일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- scheduling
- redis
- 결제누락
- 비동기
- health check
- Spring
- mysql
- varchar
- 레디스
- 선착순
- Docker
- 빅맥
- 성능개선
- 이메일 비동기
- M오더
- buildx
- ALB
- 페이징
- SMTP
- 동시성 문제
- 실행계획
- cache miss
- explain
- spring actuator
- url 단축기
- AWS Lambda
- ECR
- 포트원
- Lock
- JMeter
- Today
- Total
jjunhub
SMTP 환경에서 다수의 이메일 동시 전송하기 본문
부제 : 이메일 비동기 전송으로 쓰레드 풀 리소스 절약하기
개요
하나의 쓰레드에서 외부 서비스로 여러 번의 요청을 보내야할 때, 이 쓰레드가 동기로 이 요청을 기다리고 있으면 thread pool이 부족해지는 현상이 자주 발생한다. 이 현상에 대해서 비동기와 재시도를 곁들인 로직으로 해결하는 방법을 공유하고자 한다.
문제 상황
10초동안 1초에 10명씩 총 100명이 이메일 전송을 진행하는 메소드를 호출하는 경우, 다음과 같은 결과가 발생한다.
100개의 요청 중 약 11개의 요청이 FAIL이 발생한다. 그 이유로는 각 요청마다 외부 서비스인 SMTP를 이용하여, 이메일을 전송하고 그 결과를 받기까지 동기로 기다리기 때문에 Connection Pool이 부족하기 때문이다.
기존 코드
문제가 발생하는 기존 코드는 다음과 같다.
// Controller
@GetMapping("/{groupId}/test")
public void emailTest(@PathVariable Long groupId) {
orderService.sendEmailTest(groupId);
}
---
// orderService
@Transactional(readOnly = true)
public void sendEmailTest(Long groupId) {
Member member = getMemberFromJwtInformation();
Group group = groupRepository.findById(groupId).get();
Club club = group.getClub();
emailService.sendEmailForGroupJoin(member, group, club);
}
---
// emailService
public void sendEmailForGroupJoin(Member member, Group group, Club club) {
emailClient.sendEmailForGroupJoin(club, group, member);
}
문제 해결 1 - @Async
이를 해결하기 위해서는 각 쓰레드에 해당 이메일 전송 작업을 동기로 진행하는 것이 아니라, 비동기로 이메일 전송을 처리하면 해결될 것으로 생각된다. 이를 위해서 @Async를 통해서 특정 메소드를 비동기로 처리할 수 있도록 Spring Batch의 힘을 빌려보자.
( dependency 추가 )
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public static BeanDefinitionRegistryPostProcessor jobRegistryBeanPostProcessorRemover() {
return registry -> registry.removeBeanDefinition("jobRegistryBeanPostProcessor");
}
}
참고) 이렇게 Bean 등록해둔거는 스프링 3.2 버전 이상에서 Batch를 사용할 때 뜨는 WARN을 지워주기 위함이다.
// Controller
@GetMapping("/{groupId}/test")
public void emailTest(@PathVariable Long groupId) {
orderService.sendEmailTest(groupId);
}
---
// orderService
@Transactional(readOnly = true)
public void sendEmailTest(Long groupId) {
Member member = getMemberFromJwtInformation();
Group group = groupRepository.findById(groupId).get();
Club club = group.getClub();
emailService.sendEmailForGroupJoin(member, group, club);
}
---
// emailService
@Async
public void sendEmailForGroupJoin(Member member, Group group, Club club) {
emailClient.sendEmailForGroupJoin(member, group, club);
}
그리고 이렇게 @Async를 붙여서 비동기 처리를 수행해주면 끝일줄 알았다. 하지만 다음과 같은 에러가 발생하였다.
org.hibernate.LazyInitializationException: could not initialize proxy [woohakdong.server.domain.club.Club#1] - no Session
at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:165) ~[hibernate-core-6.5.3.Final.jar:6.5.3.Final]
at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:314) ~[hibernate-core-6.5.3.Final.jar:6.5.3.Final]
at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:44) ~[hibernate-core-6.5.3.Final.jar:6.5.3.Final]
at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:102) ~[hibernate-core-6.5.3.Final.jar:6.5.3.Final]
at woohakdong.server.domain.club.Club$HibernateProxy$ktX4gevd.getClubName(Unknown Source) ~[main/:na]
at woohakdong.server.api.service.email.EmailService.sendEmailForGroupJoin(EmailService.java:23) ~[main/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[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]
LazyInitializationException 는 전에 어디서 본듯한 예외이다. 이는 프록시 객체를 비동기로 조회하려 했으나, Transaction이 종료되어서 Group에서 Club 엔티티를 조회할 수가 없게 된 것이다.
즉 비동기로 처리하는 부분에는 엔티티에서 프록시 값을 찾도록 설정하지 않게, 실제 값을 넣어주는 쪽으로 코드를 수정해야한다.
// Controller
@GetMapping("/{groupId}/test")
public void emailTest(@PathVariable Long groupId) {
orderService.sendEmailTest(groupId);
}
---
// orderService
@Transactional(readOnly = true)
public void sendEmailTest(Long groupId) {
Member member = getMemberFromJwtInformation();
Group group = groupRepository.findById(groupId).get();
Club club = group.getClub();
// 모두 JPA의 영속성 영향을 받지 않도록 실제 값을 꺼내서 비동기 메소드 쪽으로 전달
emailService.sendEmailForGroupJoin(
member.getMemberName(),
member.getMemberEmail(),
club.getClubName(),
group.getGroupChatLink(),
group.getGroupChatPassword()
);
}
---
// emailService
@Async
public void sendEmailForGroupJoin(String memberName, String memberEmail, String clubName, String groupChatLink, String groupChatPassword) {
emailClient.sendEmailForGroupJoin(memberEmail, clubName, groupChatLink, groupChatPassword, memberName);
}
지금처럼 모든 값을 비동기 로직 전에 찾아서, 비동기로 값을 전달하여 로직을 수행하여 LazyLoading 이슈를 해결할 수 있다. 위 방법처럼 코드가 길어지는게 싫다면, 위의 파라미터들을 하나의 Dto로 만들어서 넘겨도 좋다.
문제 해결 1 - @Async 결과
기존과 같은 방식으로 테스트한 결과는 아래와 같다.
이렇게 평균 응답 시간이 20532ms → 25ms로 줄어든 것을 확인할 수 있다. 이제 끝인가 했는데..
실제 이메일 전송은 약 70개만 이루어졌다. 30개가 누락된 것이다!
왜 그런가 스프링 어플리케이션의 로그를 확인하였더니, 다음과 같은 에러가 발생하였다.
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
이는 GPT에게 물어보니 다음과 같은 이야기였다.
이 에러는 MailAuthenticationException으로, 이메일 인증 과정에서 실패했다는 것을 나타냅니다. 구체적으로, 에러 메시지에서 Too many login attempts, please try again later라는 메시지가 있습니다. 이는 메일 서버에서 너무 많은 로그인 시도가 발생하여 계정이 잠겼거나 제한된 상태라는 뜻입니다. 일반적으로 이는 보안 상의 이유로 인해 발생하는 제한입니다..
즉, 너무 많은 로그인 시도가 발생해서 일부의 로그인 시도들이 실패되었다는 것이다. 이를 가장 간단하게 해결하는 방법은 로그인이 성공할 때까지 반복해서 다시 로그인을 시도하는 것이다. (물론 이 방법은 최선의 방법은 아니다! )
문제 해결 2 - @Retryable
이렇게 발생한 예외는 아래의 로직을 추가하여 해결하였다. 해당 어노테이션은 내가 정의한 CustomException.class로 인해 발생하는 예외들에 대해서, 최대 3회까지 0.5초 이후에 재시도 한다는 것이다.
// emailService
@Async
@Retryable(retryFor = {CustomException.class}, maxAttempts = 3, backoff = @Backoff(delay = 500))
public void sendEmailForGroupJoin(String memberName, String memberEmail, String clubName, String groupChatLink, String groupChatPassword) {
emailClient.sendEmailForGroupJoin(memberEmail, clubName, groupChatLink, groupChatPassword, memberName);
}
문제 해결 2 - @Retryable 결과
아래처럼 총 100개의 이메일이 정상적으로 도착한 것을 확인할 수 있다.
결론.
- 외부 서비스를 통해 이메일 전송하는 과정을 기다리지 않고, 비동기로 처리하여 I/O 바운드를 제거한다.
- 이 과정 중에 실패하는 경우에 대해서 Retryable을 통해 다시 시도하도록 처리한다.
'Performance Improvement' 카테고리의 다른 글
게시글 목록 미리보기 성능 개선하기 - 3편 (0) | 2025.02.05 |
---|---|
게시글 목록 미리보기 성능 개선하기 - 2편 (0) | 2025.01.18 |
게시글 목록 미리보기 성능 개선하기 - 1편 (0) | 2025.01.10 |