전략 패턴 도입 계기

이제 우리가 지금 당장 사용할 수 있는 메일 발송 서비스는 2가지가 있다.

  • Gmail-SMTP
  • AWS SES

현재까지 개발된 상황을 보면 Gmail-smtp를 사용하면 비동기가 필수적이고, 
AWS SES를 사용하면 동기만으로도 충분히 빠른 속도를 보인다. (개수가 10000개 내외일때)

이런 상황에서 앞으로 겪을 수 있는 상황을 고려했을 때,
사용하는 메일 서비스를 바꾸는 상황이 올 수도 있을 거란 판단이 들었다.

  • 프리티어 이용을 위한 AWS 계정 변경 시, SES 프로덕션 모드 변경 재신청
  • 이용자 수가 500명 이하인데 AWS SES 비용이 많이 청구되는 경우

그래서 메일 서비스 발송 방식을 쉽게 추가 및 변경하기 위해서 전략 패턴을 도입하기로 결정했다.


전략 패턴 도입

1. 공통 행위 추상화

먼저 추상화할 공통 행위를 꼽았다.

  1. 메일 내용 구성 및 발송
  2. RateLimiter로 초당 요청량 제어

선별한 공통 행위를 다음과 같이 인터페이스로 추상화하였다.

public interface MailSenderStrategy {
    void sendQuizMail(MailDto mailDto);

    boolean tryConsume(Long num);
}

 

2. 각 서비스 별 구현체 생성

public class JavaMailSenderStrategy implements MailSenderStrategy{
    private final JavaMailService javaMailService;
    private final Bucket bucket; //Gmail 초당 요청량에 맞게 설정한 Bucket

    @Override
    public void sendQuizMail(MailDto mailDto) {
        javaMailService.sendQuizEmail(mailDto.getSubscription(), mailDto.getQuiz());  // 커스텀 메서드로 정의
    }

    @Override
    public boolean tryConsume(Long num){
        return bucket.tryConsume(num);
    }
}
public class SesMailSenderStrategy implements MailSenderStrategy{

    private final SesMailService sesMailService;
    private final Bucket bucket; //SES 초당 요청량에 맞춘 Bucket

    @Override
    public void sendQuizMail(MailDto mailDto) {
        sesMailService.sendQuizEmail(mailDto.getSubscription(), mailDto.getQuiz());
    }

    @Override
    public boolean tryConsume(Long num){
        return bucket.tryConsume(num);
    }
}

 

3. Context 클래스를 통해 적용

strategyMap에 있는 메일 전략이 맞는지 아닌지 확인한 후,
전략키에 해당하는 구현체의 메서드를 호출한다.

public class MailSenderContext {
    private final Map<String, MailSenderStrategy> strategyMap;

    public void send(MailDto dto, String strategyKey) {
        MailSenderStrategy strategy = getValidStrategy(strategyKey);
        strategy.sendQuizMail(dto);
    }

    public boolean tryConsume(String strategyKey, Long num) {
        MailSenderStrategy strategy = getValidStrategy(strategyKey);
        return strategy.tryConsume(num);
    }

    private MailSenderStrategy getValidStrategy(String strategyKey) {
        MailSenderStrategy strategy = strategyMap.get(strategyKey);
        if (strategy == null) {
            throw new IllegalArgumentException("메일 전략이 존재하지 않습니다: " + strategyKey);
        }
        return strategy;
    }
}

 

처음에는 메일 발송 부분만 추상화하였었는데
튜터님께서 이왕 전략 패턴을 쓰는거 RateLimiter 관련된 부분까지 함께 관리하면 더 좋을 것 같다고 하셔서
그 부분을 추가했다.

일관된 발송 주체(주소)를 유지하기 위해서 런타임 내에서 발송 방식이 변경되도록 구현하지는 않았다.
(SES로 발송이 실패하면 Gmail로 보내도록 시도하는 FallBack 구조)

내일 배움 캠프에서 2차 모의 면접을 진행하였다.
나는 업무를 하다가 1차 신청을 놓쳤기 때문에 2차 모의 면접을 입문 단계로 신청했다.

주어진 사전 질문은 4개이다.


1. 객체지향 프로그래밍의 4가지 특징을 설명해 주세요.

캡슐화는 객체의 속성을 보호하기 위해 다른 객체와 소통할 때 필요한 정보만을 추상적으로 노출시킵니다.
추상화는 다른 객체와의 상호작용에 꼭 필요한 핵심 기능이나 공통된 기능들을 모아 상위 타입으로 만듭니다.
상속은 이러한 추상화된 상위 타입을 기반으로, 기능을 추가하거나 재정의하여 새로운 클래스를 정의합니다.
마지막으로 다형성은 공통된 인터페이스를 상속받은 객체의 기능이 여러 타입으로 표현될 수 있는 성질을 말합니다.

 

1-1) 4가지 원칙을 준수해서 프로젝트에 적용하거나 리팩터링 한 경험이 있나요?

(임기응변)
직접 구현한 것은 많지 않고, Repository를 구현할 때 추상화 개념을 많이 사용한 것 같습니다. 

지금까지 해온 게 많은데 생각해 놓은 게 없어서 설명하기 좋은 예시 딱 하나를 골라 말하기가 어려웠다.

 

1-2) Spring 프레임워크에서 캡슐화나 상속이 적용되어 있는 것 같다고 떠오르는 부분

지금 당장 떠오르는 건 예외 코드입니다.
RuntimeException을 부모로 하고 이를 상속받아 다양한 세부적인 예외 처리하는 방식이
상속과 다형성 부분에 해당하는 것 같습니다.

 

2) 조합에 대해서 들어보았는가

처음 들어봤습니다.

진짜 처음 들어봤다. (공부할 거 +1)

 

💡피드백

객체지향 프로그래밍의 여러 원칙이나 방법론적인 관점에 대해 좀 더 복습하는게 좋을 것 같다.

2. Get 메서드와 Post 메서드의 차이

GET은 정보를 요청할 때 사용하는 메서드이고
POST는 정보를 생성하기 위해 서버에 데이터를 보내는 데 사용하는 메서드입니다.

POST는 정보 생성을 위한 내용을 Body에 데이터를 담아 보내지만,
Get은 Body를 사용하지 않고 쿼리 스트링에 필요한 정보를 담는다는 차이가 있습니다.

따라서 Get을 사용할 때는 쿼리 스트링에 담긴 내용이 노출되기 때문에 민감한 데이터를 넘길 때 주의가 필요합니다.

 

1) 2가지 방식을 어떤 식으로 적절하게 분류해서 사용할 수 있을까요?

이미 GET으로는 데이터를 조회하고, POST로는 데이터를 생성할 때 사용한다고 설명한 것 같은데 또 분류를 하라고 하시니까
질문이 이해가 잘 되지 않았다.
정중하게 분류에 대해 이해가 잘 되지 않는다고 추가 설명을 요청드렸다.

방금 전의 답변은 RestAPI와 혼용된 설명,
일반적인 HTTP 통신 방식 기반으로 api를 만든다고 할 때,
어떤 식으로 이 두 방식을 나눠서 구현할 수 있을까?

 

HTTP 통신 방식 기반에서 뭐가 다른지 여전히 모르겠어서 아는 것에 대해 설명하고 넘어갔다.

 


💡피드백

현재 답변이 80% 정도 부합하는데 HTTP 기반으로 좀 더 공부해서 나머지 부분을 보충하면 좋을 것 같다.

3. 인증에서 세션과 토큰 방식의 장단점은 무엇인가

세션 인증 방식은 인증에 필요한 정보를 세션 저장소에 저장하고, 세션 ID를 쿠키 형태로 클라이언트에게 전달합니다.
세션 ID가 탈취되어도 중요한 정보가 담겨있지 않기 때문에 보안에 뛰어나다는 장점이 있지만, 메모리를 사용하기 때문에 사용자가 많아질수록 메모리 리소스 부족으로 인한 장애가 발생할 수 있다는 단점이 있습니다.

토큰 인증 방식은 세션과는 다르게 인증에 필요한 정보가 페이로드에 담기고 이를 클라이언트가 관리하기 때문에 서버의 부담이 감소합니다. 또한 고유 시크릿 키를 통해 위조 여부를 쉽게 알 수 있다는 장점이 있습니다.
하지만, 페이로드는 암호화된 것이 아니기 때문에 민감한 정보를 다루지 못하고 탈취 시 대처가 어려우며,
토큰 크기가 커질수록 트래픽 부하가 증가한다는 단점이 있습니다.

 

1) 세션이 담긴 쿠키 탈취 시, 토큰 탈취와 같은 위험성이 있는 것 아닌가?

그러게요? 생각해 보니 그렇네요? (실제로 이렇게 대답한 것은 아니고 공부를 더 해오겠다고 답변했다.)

그렇지만 관리자가 해당 세션을 무효화시켜서 접근을 차단할 수 있다!
라고 얘기했어야 하는데 답변을 못했다.
(배웠던 거 까먹는 게 젤 억울해)

 

2) 어떤 인증 방식이 어떤 서비스에 더 적절한가?

단일 IP에서의 로그인을 지원하거나 관리자 로그인이 필요한 서비스라면 세션 방식이 더 적합하고,
다중 IP로 여러 번 로그인이 가능한 서비스라면 토큰 방식이 더 적합합니다.

 

3) 단일 서버에서 다중 서버 사용으로 변경되는 경우 발생하는 문제와 해결 방법

단일 서버에서 다중 서버로 사용하게 되면 세션 저장소가 공유되지 않아 
기존에 세션 ID를 발급한 서버와 다른 서버로 인증을 시도하게 되면 기존에 발급 받은
세션 ID에 해당하는 정보를 찾을 수 없게 됩니다.

따라서, 이런 경우 외부 저장소를 사용하여 여러 서버가 동일한 저장소를 사용하도록 해야합니다.

 

💡피드백

현업에서는 세션을 사용하거나 세션과 토큰을 혼용하는 방식을 주로 사용하고 있기 때문에
세션 클러스터링 같은 방식을 좀 더 찾아보면 좋을 것 같다.

4. "http://www.naver.com"에 접속할 때 생기는 과정을 단계별로 설명

사용자가 브라우저에 도메인 주소를 입력하면 DNS(도메인 네임 시스템)를 통해 도메인 이름을 IP 주소로 변환합니다.
브라우저는 변환된 IP 주소를 가진 서버에 TCP 연결을 시도합니다.
이때 3-way handshake 과정을 통해서 사용자와 네이버가 안정적으로 연결됩니다.
연결이 완료되면 브라우저가 네이버 서버에 HTTP 요청을 보내고
네이버 서버는 사용자 요청에 따라 필요한 리소스를 응답으로 전송합니다.
마지막으로 브라우저는 응답받은 HTML을 파싱 한 후 렌더링하여 보여줍니다.

 

1) 도메인 주소가 IP 주소로 변환될 때의 과정을 자세히 설명해 주세요

더 자세하게는 모르겠다.

 

2) HTTP와 HTTPS의 차이

HTTPS는 SSL/TSL 암호화 방식을 적용하여 HTTP보다 보안이 더 강화되었다는 차이가 있습니다.

 

💡피드백

HTTP의 기본적인 특징과, 암호화 방식에 대해 자세히 알면 좋을 것 같다.
도메인 주소가 IP로 변환될 때의 과정을 네트워크 기기나 7계층 레이어와 함께 설명하면 좋을 것 같다.

💡전체적인 피드백

목소리 톤이 일관된 답변으로 침착해보여서 좋았다.
면접관에 따라 다르겠지만 좀 더 강약 조절을 주어 중요한 내용을 말할 때 포인트를 주면 인상이 강하게 남을 것 같다.

아는 것 내에서 답변을 하려는 모습이 좋았다.

 

후기

면접 스크립트를 많이 작성해야 겠다는 생각을 했다.
아는 내용임에도 머릿속으로 내용을 한 번 정리한 적이 없으니까
답변을 횡설수설하게 하는 부분이 있었던 것 같다.

말의 끝맺음을 자꾸 ~로 알고 있습니다. ~같습니다. 라는 식으로 습관적인 마무리를 하는데
이를 자제하면서 말을 끝맺기 위한 적절한 어휘를 고르다가 더듬는 부분이 있다고 스스로 느꼈다.

면접 스크립트를 작성하면서 말하는 연습도 함께 병행해야겠다고 느꼈다.

MVP 개발이 완료되면서 도전 기능 개발에 도입하며 개발 완료에 박차를 가하는 시점,
또 다시 리팩토링이 필요하게 되었다.

 

기존 카테고리 엔티티 변경

❓카테고리 변경이 필요해진 이유

기존에는 대분류를 통해 어느 직무에 해당하는 문제인지만 구분해왔었다.
백엔드 문제인가, 프론트엔드 문제인가 하는

그런데 분야의 카테고리별 정답률 계산이라는 도전 기능이 추가되면서
소분류의 카테고리를 추가할 필요가 생겼다.

백엔드 문제에서도 네트워크 분야인지, 데이터 베이스 분야인지 구분하는 카테고리 말이다.

 

✅ 카테고리 변경 방법

카테고리 관리에 주로 사용되는 계층형 엔티티를 도입했다.

  • JPA 기반
  • 부모와 자식 관계 추가
//대분류
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private QuizCategory parent;

//소분류
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<QuizCategory> children = new ArrayList<>();

대분류와 소분류로만 나누면 되고 더 깊고 자세하게 나눌 필요는 없었기 때문에 depth는 고려하지 않았다.

 

✅ 변경된 카테고리 기반 기능 구현

1) 프론트에 전송하기 위한 대분류 카테고리 조회

    @Transactional(readOnly = true)
    public List<String> getParentQuizCategoryList() {
        return quizCategoryRepository.findByParentIdIsNull() //대분류만 찾아오도록 변경
            .stream()
            .map(QuizCategory::getCategoryType)
            .toList();
    }

부모는 parent가 null이므로 이에 해당하는 데이터만 조회하도록 하였다.

 

2) 소분류 카테고리별 정답률 계산

  1. 유저 Id 기반, 해당 유저의 구독 정보에 해당하는 대분류 카테고리 조회
  2. 대분류 카테고리에 따른 소분류 카테고리 조회
  3. 각 소분류에 해당하는 유저가 푼 문제 로그 조회
  4. 조회된 데이터들 중 정답인 데이터 필터링
  5. 정답률 계산
  public CategoryUserAnswerRateResponse getUserQuizAnswerCorrectRate(AuthUser authUser) {

        //유효성 검증 로직은 생략
        
        Long userId = user.getId();

        //유저 Id에 따른 구독 정보의 대분류 카테고리 조회
        QuizCategory parentCategory = quizCategoryRepository.findQuizCategoryByUserId(userId);

        //소분류 조회 -> getChildren()에서 실제 childCategories를 조회해오기 때문에 아래에서 이를 사용할 때 N+1 문제가 발생하지 않음
        List<QuizCategory> childCategories = parentCategory.getChildren();

        Map<String, Double> rates = new HashMap<>();
        //유저가 푼 문제들 중, 소분류에 속하는 로그 다 가져와
        for (QuizCategory child : childCategories) {
            List<UserQuizAnswer> answers = userQuizAnswerRepository.findByUserIdAndQuizCategoryId(
                userId, child.getId());

            if (answers.isEmpty()) {
                rates.put(child.getCategoryType(), 0.0);
                continue;
            }

            long totalAnswers = answers.size();
            long correctAnswers = answers.stream()
                .filter(UserQuizAnswer::getIsCorrect) // 정답인 경우 필터링
                .count();

            double answerRate = (double) correctAnswers / totalAnswers * 100;
            rates.put(child.getCategoryType(), answerRate);
        }

        return CategoryUserAnswerRateResponse.builder()
            .correctRates(rates)
            .build();
    }

 

✅ 프론트 적용

팀원이 구현해주신 프론트 적용 결과!

매우 잘 나온다 헤헷

AWS SES도입

메일 발송 방식으로 AWS SES를 도입하기로 하였다.
AWS SES는 아마존에서 제공하는 메일 발송 서비스이다.

 

도입 이유

이전 테스트에서도 확인한 단점이지만 Gmail-SMTP는 연결을 재사용하지 않기 때문에 성능이 좋은 편은 아니기 때문이다.
메일 발송 개수가 100만 개까지 크게 증가한다면 결국 비동기를 도입했다 하더라도 여전히 발송에 오래 걸리기 때문이다.  

SES는 연결을 재사용하기 때문에 대용량 메일 발송에 적합하다!

+ SendGrid 같은 다른 서비스들과 비교를 해보았을 때
비용이 그나마 가성비 있었다. (자동차 보험 중 캐럿 같은 느낌 - 쓴 만큼만 낸다!)

 

✅ 구현 및 테스트 

SDK V1은 지원이 종료된다고 하기에 SDK V2 기반으로 구현하였다.
private final SpringTemplateEngine templateEngine;
private final SesV2Client sesV2Client;

public void sendQuizEmail(Subscription subscription, Quiz quiz) throws SesV2Exception {
        Context context = new Context();
        context.setVariable("toEmail", subscription.getEmail());
        context.setVariable("question", quiz.getQuestion());
        context.setVariable("quizLink", MailLinkGenerator.generateQuizLink(subscription.getSerialId(), quiz.getSerialId()));
        context.setVariable("subscriptionSettings", MailLinkGenerator.generateSubscriptionSettings(subscription.getSerialId()));
        String htmlContent = templateEngine.process("mail-template", context);

        //수신인
        Destination destination = Destination.builder()
            .toAddresses(subscription.getEmail())
            .build();

        //이메일 제목
        Content subject = Content.builder()
            .data("[CS25] " + quiz.getQuestion())
            .charset("UTF-8")
            .build();

        //html 구성
        Content htmlBody = Content.builder()
            .data(htmlContent)
            .charset("UTF-8")
            .build();

        Body body = Body.builder()
            .html(htmlBody)
            .build();

        Message message = Message.builder()
            .subject(subject)
            .body(body)
            .build();

        EmailContent emailContent = EmailContent.builder()
            .simple(message)
            .build();

        SendEmailRequest emailRequest = SendEmailRequest.builder()
            .destination(destination)
            .content(emailContent)
            .fromEmailAddress("CS25 <noreply@cs25.co.kr>")
            .build();

        sesV2Client.sendEmail(emailRequest);
    }

 

SES 기반 메일 발송 테스트 (동기)

SES는 동기 기반에서도 충분히 빠른 성능을 보였다.
위에서 언급했듯이 기존 연결을 재사용하기 때문이었다.

Gmail-SMTP에서는 반드시 필요했던 비동기 처리의 필요성이 조금은 줄어들었다...

그러나 발송해야할 메일 개수가 10000개 이상으로 증가한다면, 여전히 비동기 처리는 필요하다.

 

여담 - 샌드박스 모드

실제 발송을 테스트하기 위해서는 샌드박스 모드가 아닌 프로덕션 모드로 전환하는 과정이 필요하다.

자세한 과정을 다룬 글들은 워낙 많기 때문에 따로 정리하지는 않도록 하겠다.
샌드박스 모드 해제 경험이 다양하길래 그냥 나도 한 번 적어보려고 한다.

1차 시도

문서가 부족하다고 자료를 첨부해 달라는 메일이 왔다.

우리는 아직 배포 전이었지만 프로젝트를 설명하고 신뢰도를 높일 만한 수단을 고민하다가 다음 자료를 첨부했다.

  • 배포를 위해 구매한 도메인 주소
  • 발표를 위해 제작한 브로셔
  • 그 외에 디테일한 상황 설명

디테일한 상황 설명을 한국어로 적어서 보냈다.

글로벌 시대답게 전세계적으로 서비스를 제공하는 업체들은 보통 기본적으로 고객센터 문의에 번역 기능이 깔려있고,
그게 아니더라도 번역이 매우 쉬운 세상이다.
거기다 아마존 정도라면 한국 전용 고객센터? 가 있을 것이라고 생각했기 때문이었다.

2일이 지났지만 별다른 답장이나 모드 전환이 되지 않았다.
(24시간 이내에 해준다며...! 나 거의 바로 회신한 건데!)

 

2차 시도

내 회신 메일이 저 뒤로 밀려났나 싶어서 회신 내용이 잘 확인된 건지 알고 싶다는 2차 메일도 보냈다.

그러나... 하루가 지나도 여전히 감감무소식이었다.

 

3차 시도

빨리 테스트해봐야 하는데...
혹시나 하는 마음에 여러 글들을 검색해 보았다.

이게 웬걸, 저 승인은 캐나다? 인가 어디서 직접 한단다.

이전에 보냈던 내용을 영어로 바꿔서 다시 보냈다.
자료와 내용 모두 이전과 동일하다.

그리고 드. 디. 어 프로덕션 모드 전환에 성공하였다!
(내가 진즉 번역기를 돌리는 성의를 보였어야 했었는데... 뼈아픈 실책이었다.)

 

모드 전환을 기다리면서 여러 글도 찾아보고 튜터님들께도 여쭤봤더니

아무래도 저 사람들도 기업이다 보니 사업성을 중요하게 보는 것 같고, 돈이 안 될 것 같으면 승인이 안되거나
요청한 자료가 부실하면 매우 오래 걸리는 경우도 있다는 것 같았다. (한 달 걸린 사람이 있다는 썰도 봄)

만약 AWS SES를 써야 하는 사람이 있다면 제일 먼저!! 프로덕션 전환부터 신청하고 개발을 하던 뭘 하길 바란다.
(본인은 개발 먼저 하고 배포 직전에 모드 전환하려다 가슴 졸였다...)

 

 

 

메일 발송 개선이 어느 정도 완료된 후, 그동안 QA를 거치면서 발견된 또 다른 문제점들을 기반으로
2차 리팩토링에 들어갔다.

1) 모놀리식에서 멀티모듈

💸변경 이유

서버 유지 비용 때문이다.

AWS 프리티어를 이용 중이고 실제 유저에게 배포 중인 상황이 아님에도
요금이 꽤나 부과될 정도로 인스턴스가 사용되고 있었다.

그 중 주요 원인이 바로 무거운 프로젝트 무게 때문이었다.

우리는 내부 논의 끝에 CS 문제를 메일로 보내는 Batch 부분을 별도의 모듈로 분리하기로 하였다.

  • 문제 발송은 하루에 1번만 하면 됨
  • 메일 발송에 쓰이는 배치 모듈을 필요할 때 잠깐만 켜고 나머지 시간에는 꺼놓자!

 

✅ 변경 결과

그 결과 아래의 이미지와 같이 바뀌었다.

멀티모듈로 바뀐 구성

Batch와 백엔드 부분이 분리되며 프로그램이 조금 가벼워졌다.

공통적으로 사용되는 것은 Global로, 엔티티 관련된 요소는 Common로 분리하였다.
주요 비즈니스 로직은 Service와 문제 발송은 Batch로 분리되고
대신 공통적으로 사용되는 Global과 Common을 주입받도록 하였다.

 

아쉬운 점

메일 발송 기능이 인증 코드와 겹치면서 약간의 중복된 기능이 존재하는데 시간 관계상 이 부분을 분리하지 못한 것이 아쉬웠다.
이왕 바꾸는 거 MSA도 도전해보면 좋았겠지만 이 역시 남은 시간과 이미 개발이 완료된 부분의 구조를 바꾸는 것이 쉽지 않아
해보지 못한 것이 아쉬웠다.

저렇게 줄여도 사용 중인 다른 기능들로 인해 비용은 여전히 부담스럽게 나오긴 한다.
(그래도 저거라도 절약한게 어디인가 싶다 ㅠㅠ)

 

2)  쿼리 파라미터 노출

개발이 거의 완료되어가는 시점, 배포 직전 여러 사항을 점검하다가
쿼리 파라미터가 노출되는 부분을 확인할 수 있었다.

우리는 유저의 더욱 간편한 접근 및 사용성을 위해 회원가입을 하지 않아도 메일로 문제를 받아볼 수 있도록 하였다.
대신 누가 어떤 문제를 풀었는지 알아야 했기 때문에 
우리는 메일에 첨부되는 링크에 구독자의 Id와 출제된 문제의 Id를 그대로 입력하였다.

https://cs25.co.kr/todayQuiz?subscriptionId=1&quizId=1

이런 식으로 말이다.

 

🔒변경 이유

  • 유저가 자신의 ID가 뭔지 쉽게 파악이 가능하다.
    (우리가 어떤 방식으로 ID를 생성하는지도 알기 쉽다.)
  • 보안 이슈! - 다른 사람 계정으로 문제를 풀거나 임의로 다른 문제를 풀고자 할 수 있다.

그래서 이 부분을 개선하고자 했다.

 

💡선정 방법

먼저 팀원끼리 해결 방법을 논의해보았다.
그 결과 2가지 방법이 건의되었다.

1. 암호화 복호화하기

2. UUID 쓰기

의견이 분분했는데 실무자 관점에서는 어떨 지 궁금해서 피드백을 받으러 다녀보았다.
그 결과, 암호화/복호화까지는 너무 복잡하게 간 것 같고 UUID만으로도 충분할 것 같다는 피드백을 받았다.

 

✅ 변경 결과

기존 Subscription과 quiz 엔티티에 UUID 컬럼을 추가했다.
그리고 외부에 값이 노출될 수 있는 부분을 전부 UUID를 사용하도록 변경하였다.

그 결과 현재 URL은 다음과 같다.

https://cs25.co.kr/todayQuiz?subscriptionId=a5318c72-017e...&quizId=3bb327c7-68e6...

 

트러블 슈팅 - 일부 메일 발송 실패

100개의 메일 발송에 대해 비동기로 테스트 했을 때,
100개 중 일부인 5개의 메일에 대해서 발송이 실패하는 현상이 발생하였다.

(이럴 때 쓰라고 있는게 메일 로그지!)
실패 원인을 파악하기 위해 저장된 메일 로그를 확인하였다.

 

아니 AccessKey랑 SecretKey도 잘 입력되어있고, 그래서 다른 95개의 메일은 잘 발송되었는데
어째서 저 5개만 Authentication failed라는 예외가 발생한단 말인가?!

Authentication failed 예외 발생 원인

원인은 사용 중인 외부 메일 서버의 스로틀(Throttle) 정책에 의해 발생한 것이었다.

💡 Throttle이란?
이벤트 핸들러가 너무 자주 실행되지 않도록 조절하는 기법이다.

서버에 과도한 요청이 몰리는 것을 방지하기 위해 초당 요청 수를 제한해놓은 것


그럼 왜 100개 중 5개만 실패했는가?

Gmail-SMTP의 명확한 스로틀 제한 수는 밝혀진바가 없지만, 초당 2~3건 정도로 예상된다.

그렇다면 나는 다중 스레드를 통해서 4개의 요청을 동시에 보냈는데
왜 그에 대해서 실패한 결과는 없고 후반부에 발송한 5건에 대해서만 예외가 발생한 것일까?

💡 Throttle 제한은 Hard한 제한이 아니기 때문이다.
초당 요청 수는 제한되어있지만 이를 넘어간다고 해서 바로 예외를 던지지는 않는다.

제한된 수를 넘어간 요청은 해당 서버에서 관리하는 Queue에 들어가게 된다.
Queue의 용량이 꽉 찬 상태에서 새로운 요청이 들어오는 경우 예외를 던지는 것이다.

 

해결 방법 - Rate Limiter

Rate Limiter는 요청의 간격을 제어하여 스로틀 제한에 걸리지 않는 속도로 요청을 보낼 수 있도록 도와준다.

Rate Limiter 기능을 제공하는 보편적인 라이브러리에는 3가지가 있다.

  • Guava
  • Resilience4J
  • Bucket4J

그 중에서 나는 Bucket4J를 선택해 적용하였다.

 

Bucket4J를 선택한 이유

처음에는 가장 간단하고 사용 글이 많은 Guava를 사용하고자 했다.
그런데 IntelliJ에서 경고가 떴다. "지원이 종료됩니다." 라고

찾아보니 유지보수가 종료된다는 이야기가 있었다.

그래서 Resilience4J와 Bucket4J의 차이점에 대해 알아보았다.
그리고 다음과 같은 이유로 Bucket4J를 선택하였다.

  • 다른 기능들도 제공해서 무거운 Resilience4J와 달리 RateLimiter 기능만 제공해서 가벼운 Bucket4J
  • 분산 환경과의 호환성 높음

 

Bucket4J의 원리

Bucket4J는 TokenBucket 알고리즘 기반이다.

양동이 하나에 토큰을 채운 후 토큰이 있으면 발급, 없으면 토큰이 생길 때까지 대기하는 것이다.

https://www.geeksforgeeks.org/computer-networks/token-bucket-algorithm/

동작 방식이 매우 쉽다.

 

프로젝트에 적용

MVP 개발로 인해 구조 개선이 필요했던 부분에 대한 리팩토링을 마쳤으니
이제 이전에 측정했던 성능 테스트 결과를 기반으로 개선을 해야 했다.

이전 테스트에서 이메일 발송 요청에서 병목 현상이 발생하였다.
https://devhippo.tistory.com/126

 

[내일배움캠프 76일차] 문제 풀이 링크 발송 테스트

MVP 방식 기반 1차 개발 완료MVP 기반으로 진행되는 프로젝트 개발 일정에 따라,이메일 발송 기능에 대한 초기 구현을 마쳤다.이제는 여러 부분을 테스트해 본 후, 성능 개선을 목적으로 2차 개발

devhippo.tistory.com

 

따라서, 이메일 발송 처리에 단계에서 비동기를 적용하는 작업이 필요하였다.


💡비동기 처리 도입

ThreadPoolExecutor 설정 및 적용

ThreadPoolExecutor는 자바에서 제공하는 표준 스레드 풀의 구현체이다.

java.util.concurrent.ThreadPoolExecutor

Spring은 자바에서 제공하는 ThreadPoolExecutor를 쉽게 사용할 수 있도록
ThreadPoolTaskExecutor 기능을 제공한다.

    @Bean
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("mail-step-thread-");
        executor.initialize();
        return executor;
    }

ThreadPoolTaskExecutor를 다음과 같이 설정하고 이를 아래의 이미지와 같이 Consumer Job에 적용하였다.
각 이메일 발송 Task를 스레드 풀을 통해 병렬로 실행되도록 하여, 전체 배치 처리 시간이 단축된다.


인스턴스 환경에 따른 유의사항

현재 사용 중인 EC2 인스턴스는 t3.small로, vCPU가 1개인 환경이다.
이 때문에 CorePoolSize를 3으로 설정해도 실제로는 물리적으로 1개의 스레드만 동작하게 되어,
동기 처리와 비슷한 결과를 보이게 된다.

즉, 단순히 스레드 풀을 구성한다고 해서 항상 병렬 처리가 되는 것은 아니며,
인프라 성능에 따라 실제 병렬 처리 효과가 제한될 수 있다.
서비스 운영 시 더 나은 성능의 인스턴스로 변경할 계획이기 때문에, 그에 맞춰 스레드 수를 조정해야 한다.

 

테스트 

위와 같은 이유로 비동기 처리 도입을 통한 성능 개선 효과는 로컬 환경(멀티코어 환경)에서 먼저 검증하였다.

✅ 1차 테스트

[테스트 환경]

발송 수단 : JavaMailSender
메일 서버 : Gmail-SMTP
데이터 개수 : 50개
스레드 개수 : 2개

개선 전 - 50개의 이메일을 발송할 때, 3분 소요
개선 후 - 50개의 이메일을 발송할 때, 1분 30초 소요

스레드 개수가 2개로 설정되어 있기 때문에 비동기 처리 시, 동기 처리 보다 2배가 빨리진 결과가 나왔다.

 

✅ 2차 테스트

[테스트 환경]

발송 수단 : JavaMailSender
메일 서버 : Gmail-SMTP
데이터 개수 : 100개
스레드 개수 : 4개

처리할 데이터 개수가 2배 증가하였으나, 이를 병렬로 처리할 스레드 개수도 2배 늘어났기 때문에 실행 시간이 매우 유사한 결과를 확인할 수 있다.

프로젝트 중 맡은 부분을 비롯해서 연관된 흐름을 한 번 점검하면서
개선이 필요한 점을 찾아 리팩토링 하였다. 

1. JOB 분리하기

  • 역할 분리
  • 추후 확장성
  • 배포 시, 유연한 실행

기존에는 1개의 Job에서 producer, consumer를 모두 수행하고 있었다.
위와 같은 부분을 고려하여 mailProducerJob, mailConsumerJob, mailRetryJob 이렇게 3개의 Job으로 분리하였다.

 

2. 데이터 정합성 보장

큐에 들어간 데이터에 대해서, 큐에서 빼서 쓸 때 해당 발송이 여전히 유효한지에 대한
데이터 정합성을 보장하기 위한 로직을 추가해주어야 했다.

발송이 유효하지 않게 되는 상황

큐에 들어간 데이터가 꺼내져서 실제 발송되기 전에

  • 유저가 받고 싶은 문제의 카테고리가 변경되는 경우
  • 메일 발송 요일이 변경된 경우
  • 구독을 취소한 경우

구독 정보 생성 시, 인증 코드를 기반으로 이메일 인증을 하기 때문에 이메일 주소에 대한 검증은 불필요하다.

 

정합성 보장을 위한 기존 로직 변경

위와 같은 상황에 대하여 발송할 데이터의 정합성을 보장하기 위해 기존 로직의 실행 순서를 변경하였다.

큐에 넣기 전 문제를 출제하는 과정을 큐에서 꺼낸 후로 실행 위치를 변경하였다.
이로 인해 큐에서 데이터를 뺀 후, 그에 담긴 QuizId를 기반으로 문제를 조회하는 과정이 불필요해지며 생략되었다.

또한 구독 정보 조회 후, 아래와 같은 검증 로직을 추가하였다.
메일 발송 요일과 탈퇴 여부에 대해 검증한다.

        if (!subscription.isActive() || !subscription.isTodaySubscribed()) {
            return null;
        }

문제 출제 시, 구독 정보의 카테고리를 기반으로 출제하기 때문에 유저가 받고 싶은 문제의 카테고리가 변경되는 경우에 대한
추가적인 검증 로직은 불필요하다.


리팩토링 결과

  • 조회 횟수 감소
  • 데이터 정합성 보장으로 신뢰도 상승
  • 문제 출제를 Consumer가 수행하도록 하며, 비동기 병렬 처리 도입 시 약간의 속도 단축이 예상됨

MVP 방식 기반 1차 개발 완료

MVP 기반으로 진행되는 프로젝트 개발 일정에 따라,
이메일 발송 기능에 대한 초기 구현을 마쳤다.

이제는 여러 부분을 테스트해 본 후, 성능 개선을 목적으로 2차 개발을 진행해야 하였다.
실제 개선에 앞서 현재 어디서 병목 현상이 발생하고,  원인은 무엇이며
어떻게 개선하면 좋을지 확인하기 위해 테스트를 진행하였다.

 

💡 잘 동작하는가?

 

문제 출제부터 이메일 발송까지 프로젝트의 일부 흐름을 테스트하기 위해
부분 통합 테스트로 테스트 코드를 작성하였다.

@TestConfiguration
public class TestMailConfig {

    @Bean
    public JavaMailSender mailSender() {

        JavaMailSender mockSender = Mockito.mock(JavaMailSender.class);
        Mockito.when(mockSender.createMimeMessage())
            .thenReturn(new MimeMessage((Session) null));
        return mockSender;
    }

    @Bean
    public MailService mailService(JavaMailSender mailSender,
        SpringTemplateEngine templateEngine,
        StringRedisTemplate redisTemplate) {
        MailService target = new MailService(mailSender, templateEngine, redisTemplate);
        return Mockito.spy(target);
    }
}

실제 메일 발송을 막기 위해 JavaMailSender를 Mock 객체로 하는 테스트용 Config를 설정하였다.
Mock 객체이기 때문에 실제로 요청이 가지 않는다.

@SpringBootTest
@Import(TestMailConfig.class) //제거하면 실제 발송, 주석 처리 시 테스트만
class DailyMailSendJobTest


TestMailConfig 클래스를 Import 또는 주석처리하여 테스트 코드를 실행해 성능 측정 및 실제 발송 여부를 테스트하였다.

 

✅ 실제 발송 여부

TestMailConfig를 잠시 주석처리하고 Job을 실행했을 때 실제로 send 요청이 실행되며,
정상적으로 이메일이 발송되는 것을 확인할 수 있다.

 


성능 테스트

🔎 어디서 병목 현상이 일어나는가

문제 풀이 링크를 발송하기까지의 과정에 대해서 각 메서드들의 실행 시간을 측정하여
어디서 병목 현상이 일어나는지 확인하고자 하였다.

[테스트 조건]

발송할 데이터 개수 : 1개
측정 시간 단위 : ms
테스트 횟수 : 3회

메서드 실행 시간(ms)
메일 발송 대상 조회 327
실제 객체 조회 103
문제 출제 316
메일 내용 구성 1160
메일 발송 3500
합계 5406

Message Queue 적용으로 Queue에 데이터를 넣고, 데이터를 꺼내는데 소요되는 시간은 평균 약 100ms가 소요되었다.

테스트 결과, 메일 발송까지의 흐름에 걸리는 시간이 건 당 약 5~6 TPS
그 중 메일 발송이 건 당 약 3~4 TPS로 가장 큰 비중을 차지하였다.

 

🔎 이메일 실제 발송 테스트

테스트 코드를 실행할 때, JavaMailSender는 Mock 객체이다.
데이터 개수에 따른 실행 시간을 측정하고자 할 때,
실제로 메일 발송 요청에 걸리는 시간은 측정할 수 없다.

따라서, 현실적인 부분을 고려하여 4개의 데이터에 대한 실제 메일 발송 시간을 측정해 보았다.
(Gmail은 하루에 500건까지만 발송 가능하고, 수신자가 받는 메일의 양이 너무 많을 경우 발송되지 않는다.)

 

메일 발송 요청에 걸리는 시간은, 데이터 개수와 상관없이 유사하다.

 

💡 원인

  • 외부 SMTP 서버로 네트워크를 통해 전송하는 과정에서의 네트워크 지연 + 서버 응답 시간
  • 응답 시, 프로토콜에 따른 교환 과정 필요

 

✅ 해결 방법

  • 비동기 처리 도입
  • JavaMailSender 대신 AWS SES와 같이 대규모 메일 발송을 지원하는 서비스 이용

 


 

🔎 데이터 개수에 따른 실행 시간 측정

 

앞선 테스트 결과를 기반으로 실제 발송 요청에 걸리는 시간은 건 당 3500ms로 가정하고
이를 제외한 나머지 과정에 대하여 데이터 개수에 따른 실행 시간을 측정하였다.

데이터 개수 실행시간 (sec)
건당 평균 시간 (sec)
1 7 7
10 39 3.9
100 358 3.58
1000 3533 3.53
10000 35242 3.52

100개의 데이터 까지는 약 6분이 걸리지만,
그 이상으로 데이터가 늘어날수록 실행 시간도 매우 길어진다.
데이터 개수가 1000인 경우 1시간이 걸리고 10000개인 경우 약 10시간이 소요된다.

비동기 처리가 필요성을 다시 한번 느끼게 하는 수치이다.

 

흥미로운 점은 데이터가 1개일 때보다 개수가 늘어날수록 건당 평균 시간이 감소한다는 것이었다.

메서드 실행 시간(ms)   실행 시간(ms)
문제 출제 316 ➡️ 5
메일 내용 구성 1160 ➡️ 22

 

 

💡 원인 1) JVM은 Warm-up이 필요해요

JVM은 클래스를 최초로 사용할 때 .class 파일을 메모리에 로드하고 바이트코드 검증, 메타데이터 등록 등을 수행한다.
이후에는 메모리 기반으로 이를 재사용한다.

또한, JIT 컴파일러는 자주 사용되는 특정 메서드를 기계어로 변환한다.

이러한 과정으로 인해 초기 데이터에서만 실행 시간이 비교적 오래 걸리고 그 후에 효과적으로 단축된 것이다.
초기 데이터의 실행 시간을 단축하기 위해서는 JVM Warm-up 과정이 필요하다.

💡 원인 2) 고마워요 JPA의 Entity Manager

문제를 출제하는 메서드에서는 DB에 저장되어 있는 문제를 전부 조회하고 그중에서 문제를 선정한다.

메서드를 호출할 때마다 전체를 조회하기 때문에 성능 개선이 필요하겠다고 짐작하고 있던 부분이었는데
실제 테스트 결과, 초기 실행에만 300ms가 소요되고
그 이후에는 평균 약 5ms라는 굉장히 짧은 시간만 소요되는 것을 확인할 수 있었다.

메서드를 호출할 때마다 DB의 데이터를 전체 조회해서 가져오는데 왜 5ms 밖에 걸리지 않을까?

바로 JPA의 EntityManager 때문이다.

JPA 기반으로 DB의 데이터를 조회하면 EntityManager가 해당 데이터를 1차 캐싱한다.
따라서, 매번 DB에서 실제 조회를 하지 않고 메모리에서 값이 반환되기 때문에
메서드의 실행 시간이 짧아진 것이다.

 


성능 개선 방향성

테스트 결과를 바탕으로 성능 개선의 방향성을 정할 수 있었다.

  • 비동기 처리 적용
  • AWS 연결

 

트레이드 오프 - Warm Up

장점 단점
초반 지연 감소 시스템 복잡도 증가
대량 트래픽 처리 전 사전 안정화 외부 시스템 부하 위험 (DDoS 유사)
  배포/구동 시간 증가

단점들을 감수하고 초반 지연 속도를 감소시키는 경우, 단축되는 시간은 약 1400ms이다.

프로젝트를 언제든지 사용할 수 있도록 여러 서버로 수평확장한다고 가정했을 때,
Warm-up의 빈도 수가 증가할 수 있다는 점을 고려하면
장점에 비해 단점이 더 크다.

따라서, JVM Warm Up을 통한 성능 개선은 적용하지 않기로 하였다. 

 

https://junuuu.tistory.com/830

 

JVM Warm-up 이란

JVM Warm-up 이란?warm-up은 흔히 워밍업으로 우리가 알고 있으며 몸풀기 준비운동이라는 뜻입니다. 그렇다면 JVM의 준비운동은 어떤것을 의미할까요? 이를 이해하기 위해서는 자바언어의 컴파일 과

junuuu.tistory.com

 

MessageQueue란?

메시지 지향 미들웨어로 임시로 데이터를 저장하는 큐 형태의 버퍼 역할을 한다.
프로세스 또는 프로그램 간에 데이터를 교환할 때 사용하는 통신 방법이다.

Queue에 데이터를 입력하는 주체를 Producer, 소비하는 주체를 Consumer라고 한다.

메일 발송에 MessageQueue를 도입해야 하는 이유

지난 글에서 잠시 언급했던 것에 이어서 좀 더 설명을 보충하고자 한다.

💡프로젝트에 적합하다.

Message Queue는 바로 처리되지 않더라도 언젠가 처리되어야 할 작업들에 적용할 수 있다.

문제풀이 링크 메일 발송은 정해진 시간에 딱 맞춰 빠르게 보내지는 것보다는
몇 분 늦더라도 정확하게 도착하는 것이 중요하기 때문에 적합하다고 판단하였다.

 

💡신뢰도 보완

현재까지 구현된 알고리즘은 중간에 메일 전송에 실패하거나 서버가 다운될 때
별도의 대처 방안이 없어 메일 발송에 대한 신뢰도가 떨어진다. 

1000개의 메일을 발송할 때, 문제 출제 -> 메일 발송 이 과정을 순차적으로 반복하기 때문에
526번째 메일에서 서버가 다운된다면, 이후 메일 발송에 대한 정보는 손실되어 복구가 어렵다.

어디까지 처리되었고 어디부터 다시 처리해야 하는지를 추적할 수 없다.

MessageQueue 도입 시,
서버가 다운되더라도 Redis 서버가 정상 동작하는 한, Queue에 저장된 메시지는 유지된다.
따라서, 발생한 문제를 해결한 후 Queue에 남아있는 데이터에 대해 처리할 수 있다.

 

💡속도 개선의 가능성

앞서 몇 분 늦더라도 정확하게 도착하는 것이 더 중요하다고 언급하였지만
속도가 개선된다면, 사용자 경험 측면에서도 프로젝트 성능 측면에서도 긍정적일 것이라고 생각한다.

MessageQueue를 도입하면 비동기 처리를 도입할 수 있으므로 속도를 향상할 가능성이 높아진다.

 

단점

  • 메시지 큐 운영 및 관리를 위한 관리 비용 증가
  • 시스템 구조의 복잡성 증가
  • 오버헤드 발생 가능성

 

트레이드오프

단점과의 트레이드오프를 고려하더라도 프로젝트 특성상, 문제를 풀 수 있는 수단을 메일로 받는다는 점에서
메일 발송은 중요한 수단이기 때문에 이를 감수하더라도 MessageQueue를 도입할 가치가 있다고 판단하였다.


Redis Streams 사용

MessageQueue 기능을 제공하는 프로그램들은 Kafka, RabbitMQ 등 다양하게 있다.

그중에서 Redis Streams를 사용하여 구현하고자 한 이유는 다음과 같다.

  • 이미 Redis를 사용 중이었기 때문에 접근성이 매우 높다.
  • docker랑 CI/CD 설정을 굳이 변경하고 싶지 않다.

어떤 기능을 위한 프로젝트를 추가하기 위해서 docker와 CI/CD 설정을 변경할 때마다 자꾸 뭐가 안 돼서 함께 고생을 했었다.
따라서, 굳이 설정을 추가 및 변경하지 않고도 기능을 도입할 수 있는 Redis를 가급적이면 사용하고 싶었다.

또한, 예상되는 프로젝트 규모가 Redis가 감당할 수 있는 정도라고 생각했기 때문에 Redis Streams를 사용하였다.


MessageQueue 구현

기존에 구현된 기능들을 기반으로 MessageQueue를 적용했을 때, 구조는 위의 이미지와 같다.

Producer

Spring Batch의 MailJob이 Producer의 역할을 맡아
데이터를 Redis Streams의 Message Queue에 넣는다.

public void enqueueQuizEmail(Subscription subscription, Quiz quiz) {
    Map<String, String> data = new HashMap<>();
    data.put("email", subscription.getEmail());
    data.put("subscriptionId", subscription.getId().toString());
    data.put("quizId", quiz.getId().toString());

    redisTemplate.opsForStream().add("quiz-email-stream", data);
}

이 때, 메일 내용 구성 및 메일 발송 로그 생성에 필요한 Subscriptio, Quiz 엔티티는 
Redis의 데이터 특성상 Id가 문자열 형태로 직렬화 되어 저장된다.

 

Consumer

Consumer 역시 Spring Batch가 맡는다.
Spring Batch는 다른 글에서 자세히 다룰 것이기 때문에 간단한 구조만 설명하자면

https://terasoluna-batch.github.io/guideline/5.0.0.RELEASE/en/Ch02_SpringBatchArchitecture.html

Step은 ItemReader → ItemProcessor → ItemWriter 구조로 동작한다.

📖 ItemReader

ItemReader는 데이터를 읽어온다.
여기서는 Message Queue에 저장된 데이터를 꺼내서 읽는 역할을 수행하도록 Custom하였다.

public Map<String, String> read() {Add commentMore actions
    List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
        .read(StreamOffset.fromStart("quiz-email-stream"));

    MapRecord<String, Object, Object> msg = records.get(0);
    redisTemplate.opsForStream().delete("quiz-email-stream", msg.getId());

 

🔨 ItemProcessor

ItemProcessor에서는 메일 내용 구성과 메일 발송 로그 생성에 필요한 데이터를 실제로 조회한 후 Dto를 생성하여 넘긴다.

@Override
public MailDto process(Map<String, String> message) throws Exception {
    Long subscriptionId = Long.valueOf(message.get("subscriptionId"));
    Long quizId = Long.valueOf(message.get("quizId"));

    Subscription subscription = subscriptionRepository.findByIdOrElseThrow(subscriptionId);
    Quiz quiz = quizRepository.findById(quizId).orElseThrow(() -> new QuizException(QuizExceptionCode.NOT_FOUND_ERROR));

    return new MailDto(subscription, quiz);
}

 

✏️ ItemWriter

ItemProcessor에서 만든 MailDto로 실제 메일을 발송한다.

@Override
public void write(Chunk<? extends MailDto> items) throws Exception {
    for (MailDto mail : items) {
        try {
            mailService.sendQuizEmail(mail.subscription(), mail.quiz());
        } catch (Exception e) {
            // 에러 로깅 또는 알림 처리
            System.err.println("메일 발송 실패: " + e.getMessage());
        }
    }
}

 

 

+ Recent posts