개요
이전에 메일 인증 + Redis를 활용해서 사용자 개인 계정과 관련된 보안적 측면을 향상시켰다
그런데 테스트를 하다보니 관련된 API의 성능이 매우 떨어짐을 확인할 수 있었다
인증 메일 전송을 포함한 아이디 찾기 관련 API가 무려 4초나 걸리는 것을 확인할 수 있다
현재 플랫폼에서는 구글 계정 + SMTP 프로토콜을 활용해서 메일을 전송하고 있다
SMTP 프로토콜은 기본적으로 TCP Based로 동작하기 때문에 TCP Connection을 수립하고 Endpoint간에 요청을 주고받는다
현 프로젝트상에서 이러한 프로토콜의 성능까지 제어할 수 없다고 생각해서 다른 방안을 모색해야 한다
public void provideAuthCode(final AuthForRetrieveLoginIdCommand command) {
final Member member = memberRepository.getByNameAndEmail(command.name(), command.email());
final String key = generateAuthKey(member.getEmail());
final String authCode = mailAuthenticationProcessor.storeAuthCode(key);
emailSender.sendAuthCodeForLoginId(member.getEmail().getValue(), authCode);
}
메일 전송이 포함된 로직이고 각 Step별로 로그를 찍어봄으로써 어디가 병목인지 확인해보자
역시 예상한대로 메일 발송이 거의 모든 시간을 잡아먹고 있다
그리고 이 문제의 핵심은 진입점에서 요청에 대해서 할당된 하나의 쓰레드가 일련의 로직을 전부 담당하고 있다는 것이다
이를 해결하기 위해서는 메일 전송은 별도의 쓰레드에게 위임하고 기존 로직을 처리하는 쓰레드는 다른 일을 처리하도록 유도하자
비동기로 메일 전송하기
결국 요청 진입점에서 할당된 쓰레드가 모든 작업을 담당하고 병목 지점인 메일 전송까지 진행하기 때문에 전체적인 API의 성능이 저하되는 것이다
이를 해결하기 위해서 별도의 쓰레드가 메일 전송 로직을 담당해야 한다
그러면 직접 Thread를 생성하고 메일 전송 로직을 담은 Runnable을 넘겨주고 ...
물론 이렇게 구현할 수 있긴 하겠지만 이러한 방법은 여러 단점이 존재한다
- 매번 Thread를 생성하고 Runnable을 넘겨주다 보면 생성한 모든 Thread를 관리하기 힘들어진다
- 그러면 Thread를 관리하기 위해서 ExecutorService를 활용?
- Thread 관리는 해결했지만 이제는 비동기적인 로직을 submit을 통해서 ExecutorService에게 매번 넘겨준다?
결국 이렇게 해결하면 기존 코드를 불가피하게 변경할 수 밖에 없다
- SOLID의 OCP를 위반한다고 볼 수 있다
하지만 Spring을 사용한다면 @Async를 통해서 기존 로직의 변경 없이 AOP 메커니즘으로 비동기 처리를 적용할 수 있다
@EnableAsync + @Async
@Configuration
@EnableAsync
public class AsyncConfiguration {
}
@Async
@Override
public void sendAuthCodeForLoginId(final String targetEmail, final String authCode) {
final Context context = new Context();
context.setVariable("authCode", authCode);
final String mailBody = templateEngine.process(EmailMetadata.AUTH_TEMPLATE, context);
sendMail(
EmailMetadata.LOGIN_ID_AUTH_CODE_TITLE,
targetEmail,
mailBody
);
}
@Async
@Override
public void sendAuthCodeForPassword(final String targetEmail, final String authCode) {
final Context context = new Context();
context.setVariable("authCode", authCode);
final String mailBody = templateEngine.process(EmailMetadata.AUTH_TEMPLATE, context);
sendMail(
EmailMetadata.PASSWORD_AUTH_CODE_TITLE,
targetEmail,
mailBody
);
}
이렇게 @EnableAsync를 적용하고 비동기 처리를 원하는 로직에 @Async를 붙여주면 된다
깔끔하게 메일 전송 로직을 별도의 쓰레드에게 위임한 것을 확인할 수 있고 API의 성능도 대폭 향상되었다
동기 처리 | 비동기 처리 |
약 4000ms | 약 16ms |
SimpleAsyncTaskExecutor
위와 같이 @EnableAsync + @Async만 붙여주면 모든게 해결된걸까? 그렇지 않다
@EnableAsync의 docs를 읽어보면 다음과 같이 정리할 수 있다
- TaskExecutor 빈을 찾거나 taskExecutor이라는 이름을 가진 빈을 찾는다
- 둘다 못찾으면 SimpleAsyncTaskExecutor를 활용한다
추가적으로 return type이 void인 메소드에 대해서 @Async를 적용했을 경우 호출한 부분에 예외를 전송할 수 없고 로그에만 기록된다고 나와있다
SimpleAsyncTaskExecutor의 docs에는 다음과 같이 기록되어 있다
- 각 Task마다 새로운 쓰레드를 생성하고 비동기적으로 실행시킨다
- 쓰레드를 재사용하지 않는다
정리해보면 @EnableAsync + @Async만 붙이게 되면 기본적으로 SimpleAsyncTaskExecutor를 활용하게 된다
그런데 SimpleAsyncTaskExecutor는 각 Task마다 쓰레드를 새로 생성하고 재사용하지 않는다
우리가 일반적으로 생각하는 ThreadPool의 개념은 N개의 쓰레드를 내부적으로 관리하고 재사용함으로써 매번 Thread를 생성하고 버리는 불필요한 리소스를 줄여준다
하지만 SimpleAsyncTaskExecutor는 이와 완전히 반대로 동작하게 된다
메일 전송을 위한 별도의 ThreadPool 정의
@EnableAsync + @Async만 붙이고 동작시키면 SimpleAsyncTaskExecutor가 적용되고 이는 쓰레드를 매번 생성만하고 재활용하지 않기 때문에 리소스를 낭비한다고 볼 수 있다
따라서 우리는 메일 전송을 위한 별도의 ThreadPool을 재정의할 필요가 있다
@Slf4j
@Configuration
@EnableAsync
public class AsyncConfiguration implements AsyncConfigurer {
@Bean(name = "emailAsyncExecutor")
public Executor getAsyncExecutor() {
final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(25);
executor.setQueueCapacity(30);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setAwaitTerminationSeconds(60);
executor.setThreadNamePrefix("Asynchronous Mail Sender Thread-");
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error(
"Asynchronous method thrown exception... -> Method = {}, Params = {}",
method,
params,
ex
);
}
}
- ThreadPool 관련 핵심 필드에 대한 설명은 위 포스팅에서 확인할 수 있다
@Async("emailAsyncExecutor")
@Override
public void sendAuthCodeForLoginId(final String targetEmail, final String authCode) {
final Context context = new Context();
context.setVariable("authCode", authCode);
final String mailBody = templateEngine.process(EmailMetadata.AUTH_TEMPLATE, context);
sendMail(
EmailMetadata.LOGIN_ID_AUTH_CODE_TITLE,
targetEmail,
mailBody
);
}
@Async("emailAsyncExecutor")
@Override
public void sendAuthCodeForPassword(final String targetEmail, final String authCode) {
final Context context = new Context();
context.setVariable("authCode", authCode);
final String mailBody = templateEngine.process(EmailMetadata.AUTH_TEMPLATE, context);
sendMail(
EmailMetadata.PASSWORD_AUTH_CODE_TITLE,
targetEmail,
mailBody
);
}
- 재정의한 ThreadPool이 정상적으로 동작함을 확인할 수 있다
문제점
그런데 메일 발송 자체에 오류가 발생해서 메일이 전송되지 않으면 어떻게 대처?
이에 대한 대처는 크게 2가지 정도 생각할 수 있다
- 메일 발송에 대한 모든 이력을 로그로 남기고 추후 배치 처리를 통해서 일괄 재전송
- 그런데 현재 프로젝트에서 아이디 찾기/비밀번호 재설정에 대해서 메일로 보내는 인증번호의 유효기간은 10분 정도이다
- 그렇기 때문에 이러한 짧은 시간동안 발생되는 Action에 대해서 모든 실패 이력에 대해서 배치 처리를 적용해서 일괄 재전송하는 방법은 다소 적절하지 않아 보인다
- 마찬가지로 성공/실패에 대한 로그 이력은 저장하는 대신 관리자가 수동으로 재전송 및 문의에 대한 응답으로 해결
SMTP 서비스에 장애가 발생하게 되면 사실 플랫폼 서버단에서는 크게 대응할 방법이 없다고 생각한다
많은 서비스에서 진행되는 메일 전송을 살펴봐도 "n분이 지나도 이메일이 오지 않으면 다시 요청해주세요" 와 같은 문구가 대부분임을 확인할 수 있다
따라서 이와 같은 문제는 운영적으로 푸는게 가장 깔끔하다고 생각한다