
기존 이메일 발송 로직은 동기 블로킹 방식이었습니다.
Spring boot 서버로 요청이 들어오면 메인스레드에서
Mailgun API로 이메일 발송 요청을 보내고, 응답까지 블로킹됩니다.
이 과정에서 발생하는 평균 지연시간이 1초였기 때문에,
운영환경을 고려했을 때, 사용자가 많아질 경우 문제가 될 수 있다고 판단했습니다.
따라서 응답시간을 줄이기 위해,
이메일 발송 로직 과정을 비동기 논블로킹 방식으로 변경하도록 결정했습니다
Spring boot에서 비동기 논블로킹을 사용하는 방법은 매우 간단합니다
@Async 애노테이션만 메소드에 추가해주면
해당 메소드를 비동기 방식으로 사용할 수 있습니다
Spring boot에서 @Async의 동작하는 방식은 두가지입니다.
Spring에서 제공하는 기본설정으로, 작업마다 새로운 스레드를 생성하는 방식입니다.
스레드 풀을 이용하는 방식으로 미리 생성해둔 스레드를 재사용합니다
프로젝트에서는 ThreadPoolTaskExcutor를 사용했습니다.
SimpleAsyncTaskExecutor 방식은 작업마다 새로운 스레드를 생성하기 때문에,
작업 요청이 많을 경우 스레드 오버헤드가 발생합니다.
성능 저하로 이어지며, 최악의 경우 OutOfMemory 문제가 발생할 수 있습니다
하지만 ThreadPoolTaskExcutor 방식은 미리 스레드를 생성해서 스레드 풀에 보관하고
작업요청이 들어올 때, 스레드 풀에 있는 스레드를 재사용합니다
따라서 앞선 방식보다 스레드 오버헤드가 적게 발생하기 때문에
작업량이 많은 경우 합리적인 선택입니다
다만 작업량이 적을 경우, 미리 생성된 스레드가 CPU 리소스를 차지하고 있기 때문에
성능상 장점을 가진다고 할 수는 없습니다
하지만 설정으로 스레드 풀 크기를 적절히 조절하면 되기 때문에,
ThreadPoolTaskExecutor 방식을 선택했습니다
다음 코드는 프로젝트에서 설정한 AsyncConfig 소스코드입니다
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
@Bean(name = "emailSendExecutor")
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Async emailSendExecutor ");
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
}
ThreadPoolTaskExecutor를 사용하도록 설정했습니다
@Bean 이름으로 비동기 Executor의 이름을 지정할 수 있습니다.
프로젝트에서는 'emailSendExcutor'로 설정했습니다.
ThreadPoolTaskExecutor은 3가지 핵심 설정을 할 수 있습니다.
작업 요청이 들어오면 스레드 풀에 있는 스레드를 사용해서 작업을 진행합니다
이때 필요한 스레드 수가 기본 스레드 수보다 많아지면 작업은 큐에서 대기합니다
만약 대기 큐도 꽉찼다면 추가 스레드를 생성해서 스레드 풀의 개수를 늘립니다.
스레드 풀의 스레드 개수가 최대 스레드 개수에 도달한다면
이후 작업에 대해서는 처리하지 않고, RejectedExecutionException 예외를 발생시킵니다
RejectedExecutionException 예외처리는 다음 4가지 정책으로 해결할 수 있습니다
해당 프로젝트에서는 CallerRunPolicy를 선택했습니다
성능을 조금 손해보더라도 사용자에게 이메일을 발송하는 것이
더 중요하다고 생각했기 때문입니다
하지만 CallerRunPolicy의 경우 한가지 더 고려할 사항이 있습니다.
서버가 Shutdown 상태라면 작업을 처리하지 못하고 큐에 있는 모든 데이터가 유실됩니다
따라서 유실되지 않고 작업을 처리해주기 위해
setWaitForTasksToCompleteOnShutdown(true)
위 설정을 추가했습니다.
이제 설정을 완료했습니다.
이메일 발송 메소드에 @Async 애노테이션을 추가하면 비동기로 동작합니다.
이때 원하는 Excutor를 선택하고 싶다면, 애노테이션에 해당 빈이름을 설정하면 됩니다
@Async("emailSendExecutor")
public void sendEmail(String toEmail, boolean registerCheck, String code) {
Message message = Message.builder()
.subject(SUBJECT)
.from(SENDER_EMAIL)
.to(toEmail)
.html(mailUtil
.setContextUtil(toEmail, code
, registerCheck ? MAIL_LOGIN_HTML
: MAIL_REGISTER_HTML))
.build();
MessageResponse messageResponse = mailgunClient.mailgunMessagesApi()
.sendMessage(EMAIL_DOMAIN,message);
log.info("Mailgun Message Response: {}", messageResponse);
}
앞서 설정한 emailSendExcutor를 이메일 발송로직에 추가했습니다
만약 비동기 작업 중 예외가 발생한다면 어떻게 처리할 수 있을까요?
일반적인 동기 블로킹 방식에서는 예외가 발생하면
@ExceptionHandler에서 잡아 처리할 수 있습니다.
비동기 작업중 발생한 예외도 동일하게 적용될까요?
아쉽게도 동일하게 적용되지 않습니다
앞서 @ExceptionHandler에서 잡아 처리할 수 있었던 이유는
동일한 메인 스레드에서 작업 중에 발생한 예외이기 때문입니다
하지만 비동기 작업은 메인 스레드와 다르게 별도의 스레드에서 진행합니다.
따라서 @ExceptionHandler에서 해당 예외를 잡을 수가 없습니다
비동기 예외는 'AsyncUncaughtExceptionHandler'를 통해 처리가능합니다
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
AsyncConfigurer 인터페이스에서는 비동기 예외를 처리할 수 있도록
getAsyncUncaughtExceptionHandler()를 제공해줍니다.
설정클래스에서 해당 메소드를 오버라이딩해서
별도의 Custom비동기 예외처리 클래스가 처리할 수 있도록 설정해주면 됩니다
@Slf4j
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("비동기 처리 예외 발생: {}", ex.getMessage());
}
}
CustomAsyncExceptionHandler에서 비동기 예외를 받아 처리하도록 구현했습니다.
프로젝트에서는 단순 로그처리만 구현해서,
어떤 예외가 발생했는지만 파악할 수 있도록 개발했습니다
이유는 다음과 같습니다
메인 스레드의 경우 요청한 대상에게 예외결과를 응답할 수 있지만,
비동기 예외는 별도의 스레드에서 처리해서 요청 대상에게 응답결과를 전달할 수 없습니다
보통 동기 예외의 경우 요청한 사용자에게 전달하는 방식으로 해결하였는데,
비동기 예외의 경우 위와 같은 이유로 해결할 수 없습니다
따라서 평소와는 다른 방법을 고민해야합니다.
고민끝에 다음 4가지로 정리했습니다.
알림 서비스를 개발하거나 확인할 수 있는 API를 개발하는 것도 좋은 방법입니다.
하지만 애플리케이션이 복잡해진다는 문제와 추가 개발 비용 발생 문제로
운영 대처 방법과 로그 기록 분석 방법을 조합하여 처리하기로 결정했습니다.
예외 발생했을 때, 로그 기록을 남기도록 예외 처리 핸들러를 구현했으며,
프론트엔드 측에서 아래와 같은 발송 알림을 남겨서, 운영적으로 대처하도록 해결했습니다
'3분 이상 이메일이 도착하지 않을 경우, 재요청해주세요.'
@Async 설정 이후, 잘 적용되었는지 확인하기 위해 테스트를 진행했습니다.
postman을 통해 단일 요청을 했을 때,
동기처리방식과 비동기처리 방식의 응답시간 차이를 확인했는데...

비동기 요청과 동기 요청 모두, 큰 차이없이 동일한 것을 확인하였습니다
처음에는 원래 응답시간 차이가 크지 않은 것이 정상인가라고 생각했습니다
하지만 다른 테스트 기록 글을 봤을 때, 큰 성능 차이가 발생했던 것을 확인했고
코드를 잘못 구현했겠구나 생각했습니다
로그를 분석하던 중 원인을 발견했습니다

비동기 설정을 했음에도 별도의 스레드가 아닌
요청한 메인 스레드에서 작업을 진행하고 있었습니다

이렇게 동작한 원인은 리턴타입때문이었습니다.
처음 이메일 발송 로직은 인증코드를 생성한뒤 이메일에 포함시켜 발송하고,
이후 리턴해서 임시 멤버를 생성할 때, 인증코드를 포함하여 저장합니다.
이때 생성한 인증코드를 반환하기 위해 리턴타입이 String이었고,
이것이 @Async가 동작하지 않는 원인이었습니다.
@Async의 리턴타입은 다음 3가지만 가능합니다
따라서 String 리턴타입이 아닌 다른 리턴타입으로 바꿀 필요가 있었습니다
하지만 이렇게 할 경우, 로직 구조를 변경할 필요가 있어서 다른 방법을 고민했습니다
이메일 발송 로직만 별도의 메소드로 빼서 내부에서 호출하는 형식으로 바꾸고,
해당 메소드에 @Async 애노테이션을 추가한 뒤
반환타입을 void로 바꾸면 조건을 만족시킨다고 생각했습니다.
public String sendEmail(String toEmail, boolean registerCheck) {
String code = mailUtil.createCodeUtil();
Message message = Message.builder()
.subject(SUBJECT)
.from(SENDER_EMAIL)
.to(toEmail)
.html(mailUtil
.setContextUtil(toEmail, code
, registerCheck ? MAIL_LOGIN_HTML
: MAIL_REGISTER_HTML))
.build();
mailgunEmailSend(message);
return code;
}
@Async("emailSendExecutor")
public void mailgunEmailSend(Message message){
MessageResponse messageResponse = mailgunClient.mailgunMessagesApi()
.sendMessage(EMAIL_DOMAIN,message);
log.info("Mailgun Message Response: {}", messageResponse);
}
따라서 위와 같은 코드로 변경했습니다
하지만 이번에도 동일하게 성능차이가 없는 결과가 나왔습니다
이번에는 @Async 공식 문서를 통해 원인을 찾았습니다
주의점이 두가지 있는데,
그중 'self-invocation인 경우 비동기 사용 불가능' 조건이 실패의 원인이었습니다
@Async 주의점
- public 메소드에서만 사용 가능
- self-invocation(자기 호출)인 경우 사용 불가능
앞서 트러블 슈팅 1번 과정에서 void 반환타입으로 바꾸기 위해
별도의 메소드로 분리한 것이 문제였습니다.
따라서 남은 선택지는 다시 하나의 메소드로 합치고, 구조적인 변화를 줘서
리턴타입을 void로 만드는 방법밖에 없습니다
이메일 서비스에 인증코드를 생성하는 메소드를 분리했습니다.
public String createCode(){
return mailUtil.createCodeUtil();
}
변수 값을 컨트롤러에서 지역변수로 관리하며,
메일 발송로직과 임시멤버생성 로직에서 동일한 인증코드로 사용하도록 리팩토링했습니다
String code = emailService.createCode(); // 추가
emailService.sendEmail(emailRequestDto.getReceiver(),
emailService.registerCheck(emailRequestDto.getReceiver()), code);
// 동일한 인증코드
emailService.createTemporaryMember(emailRequestDto.getReceiver(),
code); // 동일한 인증코드
@Async("emailSendExecutor")
public void sendEmail(String toEmail, boolean registerCheck, String code) {
Message message = Message.builder()
.subject(SUBJECT)
.from(SENDER_EMAIL)
.to(toEmail)
.html(mailUtil
.setContextUtil(toEmail, code
, registerCheck ? MAIL_LOGIN_HTML
: MAIL_REGISTER_HTML))
.build();
MessageResponse messageResponse = mailgunClient.mailgunMessagesApi()
.sendMessage(EMAIL_DOMAIN,message);
log.info("Mailgun Message Response: {}", messageResponse);
}
이제 모든 문제를 해결했습니다. 다시 성능 테스트를 진행했습니다


비동기 요청 응답시간이 3배정도 빠른 것을 확인할 수 있습니다
이번에는 postman의 iteration 테스트 도구를 이용해서 지연시간없이
15번 요청하도록 테스트했습니다


비동기 요청의 평균 응답시간이 더 빠른 것을 확인할 수 있습니다
하지만 이 방식도 결국 순차적으로 요청하는 방식입니다.
동시 요청하는 경우도 정상적으로 동작하는지 확인할 필요가 있습니다
이번에는 postman의 performance 테스트 도구를 이용하여 진행했습니다
단 이메일 발송 한도 제한과, 학교 이메일 서버에 부하를 줄 가능성이 있기 때문에
너무 많은 사용자를 설정하거나 장시간 부하를 주도록 설정하지는 않았습니다
위와 같은 설정으로 성능 테스트를 진행했습니다

(요청 결과가 조금 부족한데, 중간에 이메일 수신 한도제한에 걸려서 그렇습니다...
정상적으로 요청되어도 현재와 비슷한 평균 응답시간일 것으로 예상하고 있습니다)

위 성능 테스트 결과도 비동기 요청의 결과가 압도적으로 좋은 것을 확인할 수 있습니다
이메일 발송 로직을 동기 방식에서 비동기 방식으로 변경하여,
평균 응답속도를 348ms에서 15ms로 감소시켰습니다
이제 더 빠른 속도로 사용자에게 응답하여,
이메일 발송 과정 중 사용자가 겪는 지연시간의 불편함을 해결하게 되었습니다!