jjunhub

이메일 여러 개 동시 전송하기 - 2편 본문

Architecture

이메일 여러 개 동시 전송하기 - 2편

jjunhub 2025. 5. 12. 23:24

부제 : Transaction Outbox 패턴으로 Rate Limit 조절하기                                                                                               

                                                                                                                                                                                                                                                                                                                                                                                                                                                 

개요

최근 면접을 보러 갔는데 면접관님께서 Rate Limit이 존재하는 외부 서비스와의 연동이 되어있는 상황에서 외부 서비스로의 요청이 Rate Limit을 초과하는 일이 발생했을 때, 어떻게 해결할 수 있을지를 물어보셨다. 이에 대해서 아래와 같이 답했다.

대규모라면 카프카와 같은 구조를 도입해서, 이를 해결할 수 있을 것 같은데 카프카를 도입하는 리소스가 너무 클 것 같아서 작은 규모에서는 미니 카프카와 같은 구조를 설계하겠습니다. 외부 서비스로 보내야하는 요청을 Timestamp를 통해서 DB에 적재하고, DB에 주기적으로 Polling 요청을 통해서 이를 N개씩 꺼내서 외부 서비스로 요청을 보내면 될 것 같습니다.

 

면접관님께서는 오 그거 괜찮은데요? 라고 답변을 해주셨는데, 실제로도 괜찮을 지 궁금해서 글을 쓰게 되었다. 마침 외부 서비스와의 연동이 존재했던 이메일 전송 글을 이어서 개선해보는 방식으로 테스트를 진행해볼 예정이다.

 

이전 글 : 2025.01.09 - [Performance Improvement] - 이메일 여러 개 동시 전송하기 - 1편

 

이메일 여러 개 동시 전송하기 - 1편

부제 : 이메일 비동기 전송으로 쓰레드 풀 리소스 절약하기 ..

jjunhub.tistory.com

 

기존의 문제 상황 & 해결 방법

이전 글에서는 10초동안 1초에 10명씩 총 100명이 이메일 전송을 진행하는 메소드를 호출하는 경우, 약 11프로의 요청이 FAIL이 발생하였었다. 이 실패는 동기 방식으로 외부 서비스와의 연결을 맺고 있기 때문에 발생하는 Thread Connection 부족이 원인이었다.

 

따라서 이를 @Async를 통해서 외부 서비스에 비동기로 요청을 보내고, 곧바로 thread를 Connection Pool에 반환하는 방식을 통해 Thread Connection가 부족한 문제를 해결하여 100명이 모두 200 응답을 받아내어 성공했다.고 생각했다..

 

하지만 실제로 100명 중 70명만이 이메일을 받았다. 해당 상황에서의 오류 로그는 아래와 같다.

더보기

 

2024-10-24T08:05:57.552+09:00 ERROR 21832 --- [woohakdong-server] [         task-1] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void woohakdong.server.api.service.email.EmailService.sendEmailForGroupJoin(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String)

org.springframework.mail.MailAuthenticationException: Authentication failed
	at org.springframework.mail.javamail.JavaMailSenderImpl.doSend(JavaMailSenderImpl.java:402) ~[spring-context-support-6.1.13.jar:6.1.13]
	at org.springframework.mail.javamail.JavaMailSenderImpl.send(JavaMailSenderImpl.java:350) ~[spring-context-support-6.1.13.jar:6.1.13]
	at org.springframework.mail.javamail.JavaMailSender.send(JavaMailSender.java:101) ~[spring-context-support-6.1.13.jar:6.1.13]
	at woohakdong.server.api.service.email.EmailClientImpl.sendEmailForGroupJoin(EmailClientImpl.java:54) ~[main/:na]
	at woohakdong.server.api.service.email.EmailService.sendEmailForGroupJoin(EmailService.java:24) ~[main/:na]
	at jdk.internal.reflect.GeneratedMethodAccessor110.invoke(Unknown Source) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:379) ~[spring-tx-6.1.13.jar:6.1.13]
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.1.13.jar:6.1.13]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.retry.interceptor.RetryOperationsInterceptor$1.doWithRetry(RetryOperationsInterceptor.java:114) ~[spring-retry-2.0.9.jar:na]
	at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:344) ~[spring-retry-2.0.9.jar:na]
	at org.springframework.retry.support.RetryTemplate.execute(RetryTemplate.java:233) ~[spring-retry-2.0.9.jar:na]
	at org.springframework.retry.interceptor.RetryOperationsInterceptor.invoke(RetryOperationsInterceptor.java:135) ~[spring-retry-2.0.9.jar:na]
	at org.springframework.retry.annotation.AnnotationAwareRetryOperationsInterceptor.invoke(AnnotationAwareRetryOperationsInterceptor.java:162) ~[spring-retry-2.0.9.jar:na]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:768) ~[spring-aop-6.1.13.jar:6.1.13]
	at org.springframework.aop.interceptor.AsyncExecutionInterceptor.lambda$invoke$0(AsyncExecutionInterceptor.java:113) ~[spring-aop-6.1.13.jar:6.1.13]
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264) ~[na:na]
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136) ~[na:na]
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635) ~[na:na]
	at java.base/java.lang.Thread.run(Thread.java:842) ~[na:na]
Caused by: jakarta.mail.AuthenticationFailedException: 454-4.7.0 Too many login attempts, please try again later. For more information,
454-4.7.0 go to
454 4.7.0  <https://support.google.com/mail/answer/7126229> 41be03b00d2f7-7eaeab57e36sm7473969a12.43 - gsmtp

	at org.eclipse.angus.mail.smtp.SMTPTransport$Authenticator.authenticate(SMTPTransport.java:954) ~[jakarta.mail-2.0.3.jar:na]
	at org.eclipse.angus.mail.smtp.SMTPTransport.authenticate(SMTPTransport.java:865) ~[jakarta.mail-2.0.3.jar:na]
	at org.eclipse.angus.mail.smtp.SMTPTransport.protocolConnect(SMTPTransport.java:769) ~[jakarta.mail-2.0.3.jar:na]
	at jakarta.mail.Service.connect(Service.java:345) ~[jakarta.mail-2.0.3.jar:na]
	at org.springframework.mail.javamail.JavaMailSenderImpl.connectTransport(JavaMailSenderImpl.java:480) ~[spring-context-support-6.1.13.jar:6.1.13]
	at org.springframework.mail.javamail.JavaMailSenderImpl.doSend(JavaMailSenderImpl.java:399) ~[spring-context-support-6.1.13.jar:6.1.13]
	... 27 common frames omitted

원인은 Gmail SMTP 서버에 단시간 내에 많은 로그인 요청을 보내서, Google 측에서 이를 차단한 것으로 정해진 Rate Limit을 초과한 것으로 예측된다. 이전에는 이를 단순히 @Retry를 통해서 다시 로그인 시도 및 메일 전송을 수행하여 결과적으로는 100명이 모두 정상적으로 메일을 수신하였지만 이는 최선의 방법이 아니었다. 이를 조금 더 개발자답게 아키텍처를 잘 설계하여 해결해보자.

 

POC 설계하기

다시 돌아와서, 이제 문제를 해결할 구조를 짜보자. 구글 공식 지원 센터에 따르면 무료 계정의 경우, 하루 최대 2000개까지 보낼 수 있다고 한다. 초당 Rate Limit과 관련된 정보는 이를 악용할 여지가 있어 공개되어 있지 않지만, 전에 Rate Limit이 발생한 경험으로는 초당 60~70개까지는 커버하는듯 하다. 따라서 우리는 넉넉하게 1초에 30개씩 나누어서 이메일을 전송해볼 예정이다.

 

예상하고 있는 시나리오는 다음과 같다.

  1. 사용자가 메일을 여러 명에게 전송하는 로직이 포함된 액션을 수행한다.
  2. 전송해야하는 사람들과 내용들을 DB에 Timestamp 기반으로 적재한다.
  3. DB에서 해당 내용들을 1초마다 polling하여 안보내진 메일 중 30개씩 전송하여 성공 표시를 남긴다.

https://excalidraw.com/ 짱짱맨

 

 

참고)

이쯤에서 구글링을 하며, 비슷한 사례는 없는가 찾아보니 Transcational outbox 패턴이라는 디자인 패턴이 해당 그림과 동일하다는 것을 알게 되었다. DB에 적재되는 것까지를 Transcation 처리하여 비즈니스 로직 성공과 이메일 전송 이벤트 생성을 원자적으로 처리하고, 이메일 전송 이벤트를 Transcation이 끝난 외부에서 처리하는 것을 뜻한다. 외부에서 이러한 이벤트를 처리할 때에는 Retry 로직이나 안보내진 것들을 마킹하는 방식을 통해서 반드시 이벤트를 수행할 수 있도록 하는 것이 일반적이라고 한다. 더 자세한 내용은 아래의 참고 자료의 링크를 읽어보길 추천한다.

 

POC 구현하기

스프링으로 서버를 2개 구현하고 DB는 MySQL을 통해서 구현한 최종 아키텍처는 아래와 같다.

https://github.com/jjunhub/mini-kafka-mailer

 

GitHub - jjunhub/mini-kafka-mailer: Send emails reliably under external rate limits using the Transactional Outbox pattern (Spri

Send emails reliably under external rate limits using the Transactional Outbox pattern (Spring POC) - jjunhub/mini-kafka-mailer

github.com

 

위의 설계와 변경된 사항은 초마다 불러오는 이메일 발송 이벤트 개수가 30개에서 10개로 줄어든 것이다.

왜 10개로 줄어들게 됐는지는 아래의 Consumer의 스케쥴러 코드와 함께 설명하겠다.

    @Scheduled(fixedRate = 1000) // 1초마다 실행
    public void fetchPendingEmails() {
        // 이벤트 조회
        List<EmailSendEvent> events = repository.findTop10ByStatusOrderByProducedAtAsc(EmailSendEvent.Status.PENDING);
        if (events.isEmpty()) {
            log.info("No pending email events found");
            return;
        }
        log.info("Fetched {} pending email events", events.size());

        // 이벤트 상태를 PROCESSING으로 변경
        events.forEach(EmailSendEvent::markProcessing);
        repository.saveAll(events);

        // 각 이벤트 비동기 처리
        Flux.fromIterable(events)
                .delayElements(Duration.ofMillis(1000)) // 1초마다 1개씩 처리
                .flatMap(this::sendEmail, 1)
                .subscribe();
    }

    private Mono<Void> sendEmail(EmailSendEvent event) {
        return emailClient.send(event.getEmail(), "subject", "body")
                .doOnSuccess(unused -> {
                    event.markSent();
                    repository.save(event);
                    log.info("[O] Sent email to {}", event.getEmail());
                })
                .doOnError(error -> {
                    event.markFailed();
                    repository.save(event);
                    log.warn("[X] Failed to send email to {}", event.getEmail(), error);
                })
                .subscribeOn(Schedulers.boundedElastic());
    }
  1. 1초마다 Event 테이블로 접근하여, 전송할 이메일 목록들을 10개씩 가져온다.
  2. 10개 전부를 곧바로 PROCESSING 형태로 마킹한다. ( 중복으로 가져오지 않도록 )
  3. 각 스케쥴링마다 이벤트에 대해서 1초마다 처리하도록 delay를 걸어 처리한다.
  4. 처리된 Event들은 SENT 상태로 변경한다.
  5. 실패된 Event들은 FAILED 상태로 변경한다.
  6. ( TODO ) FAILED된 Event들을 따로 재시도 진행한다.

위 코드는 전역적으로 Flux를 제한걸어 처리하는 것이 아니라 1초마다 10개씩 가져오고 와서 Flux를 만들어내고, Flux마다 1초의 간격으로 처리한다. 동시에 100개의 이벤트가 등록되고, 10초간 10개씩 Event를 불러온다고 했을 때 아래와 같은 구조로 진행된다. 참고로 T{N} 은 {N}번째 스케쥴링에서 남아있는 이벤트 개수이다.

시간 총 Event 수 초당 처리 T1 T2 T3 T4 T5 T6 T7 T8 T9 T10 T11
1 10 1 10                    
2 19 2 9 10                  
3 27 3 8 9 10                
4 34 4 7 8 9 10              
5 40 5 6 7 8 9 10            
6 45 6 5 6 7 8 9 10          
7 49 7 4 5 6 7 8 9 10        
8 52 8 3 4 5 6 7 8 9 10      
9 54 9 2 3 4 5 6 7 8 9 10    
10 55 10 1 2 3 4 5 6 7 8 9 10  
11 55 10 0 1 2 3 4 5 6 7 8 9 10
...                          

 

consume하는 중간에 캡처한 것

즉, 지금 구조에서는 스케쥴링 한 번에 몇 개씩 이벤트를 불러오느냐1초마다 보내는 요청 수가 된 것이다. 이제 전송된 결과를 살펴보자.

 

producer는 2025-05-17 03:51:02.154188~2025-05-17 03:51:04.384814 내에 모든 이메일 발송 이벤트를 적재했다.

consumer는 2025-05-17 03:51:08.276282~2025-05-17 03:51:58.339967, 50초 동안 100개의 이메일 발송 이벤트를 안정적으로 나누어 처리했다. ( 이 부분에 대해서는 20초만에 끝낼 것으로 예상됐는데, 어떤 부분에서 병목으로 인해 더 걸렸는지는 더 알아봐야한다. )

 

또한 consumer 쪽에서는 위의 테이블처럼 이벤트를 처리한 것도 확인할 수 있었다.

( id 1 ~ 10이 첫번째 스케쥴링으로 가져온 것, 11~20이 두번째 스케쥴링, 21~30이 ... )

 

POC 결과

결과적으로 POC는 성공이라고 볼 수 있다.

다만 왜 저렇게 지표가 나왔는지는 좀 더 알아봐야한다. ㅎㅎ...

 

개선할 점

  1. 실패 케이스에 대한 재시도 처리
    • Failed인 Event나 PROCESSING으로 오래 남아있는 Event들에 대해서 어떻게 다시 적절하게 재시도할 지를 고민해야한다.
  2. 멀티 노드 환경으로 갈 때의 문제 사항
    • 현재는 (스케쥴링 주기 / 초마다 처리하는 이벤트 수)만큼 외부 서비스로 초마다 요청을 보내는데, 이는 스케쥴링 서버의 개수만큼 배수로 증가할 것이다. 멀티 노드 환경에서 이러한 Rate Limit을 어떻게 효과적으로 처리할 지를 고민해봐야한다.

 

느낀점

  1. 외부 서비스로 전송해야하는 요청을 DB에 저장하고, 이를 주기적으로 꺼내서 외부 서비스로 전송하니 미니 카프카가 맞는 것 같기도.
  2. 비동기가 왜 어려운 지 알 것 같기도 하고.. Webflux는 공부해볼 법하다!
  3. 이번에도 면접에서 새로운 인사이트를 얻었다!

 

참고 자료

[강남언니] 분산 시스템에서 메시지 안전하게 다루기

[AWS] Transcational outbox pattern