프로젝트/RESTAPI 추천 서비스

이메일 전송을 비동기로 처리하기 (Feat, @Async)

indeep 2024. 5. 30. 20:15

문의 사항에 답변을 다는 로직을 진행하는데 문제가 발생했다.

여기서 답변을 작성하고 답변하기를 누르면 작성까지 3~4초의 시간이 걸리게 된다.

 

 

그 이유는 컨트롤러의 로직이 아래처럼 작성되어 있기 때문이다.

// 답변 등록                                                                                                                      
@PostMapping("answers")                                                                                                       
public ResponseEntity<ApiResponse<?>> createAnswer(@RequestBody @Valid AnswerRequestDTO answerRequestDTO) throws Exception {  
    Inquiry inquiry = answerService.createAnswer(answerRequestDTO);                                                           
    if (inquiry.isEmailSendCheck())                                                                                           
        emailService.sendAnsweredMessage(inquiry.getMember().getEmail(), inquiry.getTitle());                                 
                                                                                                                              
    return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.createSuccessNoContent("문의 답변 등록이 완료되었습니다."));          
}

 

1. 답변 작성 진행

2. 답변 알림을 체크했으면 이메일 전송 로직 동작.

 

그런데 2번의 로직이 3~4초의 시간이 걸리게 된다.

 

결국 관리자는 답변하기를 누르면 응답이 전송되는지도 모르는 채로 3~4초의 시간을 그냥 대기하게 된다.

 

 

트랜잭션의 관점에서 다시 확인해 보면

 

답변 진행 작성 -> 하나의 개별 트랜잭션으로 동작하기에 성공하면 이메일 전송으로, 실패하면 예외를 발생시킨다.

이메일 전송 -> 이메일 전송은 트랜잭션으로 관리되지 않는다. DB에 값을 반영하는 것도 아니기 때문.

 

그러면 답변 진행이 완료 됐을 때 이메일 전송을 비동기로 동작시키면 관리자는 빠른 답변 작성 응답을 받을 수 있다고 생각했다. 어차피 이메일 전송은 트랜잭션이 아니기에 전송 유무가 중요하다고 판단하지는 않았다.

 

스프링에서는 간단하게 비동기 처리를 진행하도록 구성해 놨다.

메인 애플리케이션에 @EnableAsync 어노테이션을 붙여준다.

@EnableAsync                      
public class RestapiApplication {

 

그리고 실제 서비스에 @Async 어노테이션을 붙여주면 해당 로직은 비동기로 동작하게 된다.

// 답변 이메일 전송                                                                               
@Async                                                                                     
public void sendAnsweredMessage(String to, String inquiryTitle) throws Exception {         
    MimeMessage message = createAnswerMessage(to, inquiryTitle);                           
    try {                                                                 
        emailSender.send(message);                                                         
    } catch (MailException es) {                                                           
        log.error("답변 이메일 전송 중 오류 발생: 대상 이메일 - {}, 오류 메시지 - {}", to, es.getMessage(), es); 
        throw new IllegalArgumentException("이메일 전송 실패", es);                               
    }                                                                                      
}

 

테스트 결과 관리자는 답변하기를 누르면 이메일 전송 로직을 기다리지 않고 우선 답변 작성은 성공적으로 끝낸다.

그리고 이메일 전송 로직은 따로 비동기로 돌아가게 되는 것.

 

 

로직 설명

 

동기의 경우 기존의 코드를 다시 확인해 보면

@PostMapping("answers")                                                                                                     
public ResponseEntity<ApiResponse<?>> createAnswer(@RequestBody @Valid AnswerRequestDTO answerRequestDTO) throws Exception {
    Inquiry inquiry = answerService.createAnswer(answerRequestDTO);                                                         
    if (inquiry.isEmailSendCheck()) {                                                                                       
        emailService.sendAnsweredMessage(inquiry.getMember().getEmail(), inquiry.getTitle());                               
    }                                                                                                                       
                                                                                                                            
    return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.createSuccessNoContent("문의 답변 등록이 완료되었습니다."));        
}

 

1. createAnswer 메서드 동작.

2. sendAnsweredMessage 메서드 동작.

 

즉 1번이 완료되어야 2번으로 넘어가고, 2번이 완료되면 응답을 리턴한다.

2번의 로직이 3~4초가 걸리기에 전체 응답이 늦어지는 문제가 발생하는 것이다.

 

여기서 비동기를 적용시키면 1번은 동기적으로 동작하고, 2번은 비동기로 동작시키게 된다.

@Async를 통해 비동기로 설정하면 sendAnsweredMessage 메서드를 호출할 때 별도의 스레드를 할당하게 된다.

 

내 현재 로직을 하나의 그림으로 표현하면 아래처럼 구성되어 있다.

 

즉 1번 스레드는 createAnswer를 수행하고 sendAnsweredMessage를 호출한 다음에 다음 작업인 리턴을 진행.

2번 스레드는 sendAnsweredMessage를 비동기로 수행하고 리턴을 진행.

 

테스트 수행

 

동기로 진행했을 때 총 소요시간 : 3.4초

 

 

비동기로 진행했을 때 총 소요시간 : 0.013초

 

아무래도 이메일 로직은 따로 비동기로 동작하도록 만들어서 빠른 것을 확인할 수 있다.

 

 

@Async 주의할 점

 

Async는 프록시를 만들어 동작하는 특징이 있다.

우리가 호출하고자 하는 EmailService를 직접 호출하는 것이 아니라 EmailService의 프록시 객체를 생성해서 Async 작업을 걸어주는 것이다.

@Transactional을 붙이면 주입받은 프록시 서비스를 호출해서 트랜잭션을 보장해 주는 것과 같은 원리이다.

 

 

실제로 emailService를 까보면 AsyncAnnotationAdvisor가 포함되어 있다. 

반면에 @Async를 제거하게 되면

그냥 일반적인 emailService를 바로 호출하게 된다.

 

Proxy 객체를 생성하기 위해 아래 조건은 무조건 지켜져야 한다.

1. private 메서드 금지

2. 자가 호출 금지

 

프록시 객체를 만들기 위해서는 원본 객체의 메서드에 접근해야 한다. 그렇기 때문에 private로 설정하면 접근하지 못하는 문제가 발생한다.

 

또한 프록시 객체 내부에서 자가 호출을 진행하는 경우 프록시를 우회해서 원본 객체의 메서드를 호출하기 때문에 AOP 기능이 적용되지 않는다.

 

 

ThreadPoolTaskExecutor 

 

스프링부트는 기본적으로 서블릿 컨테이너에서 스레드 풀을 사용한다. 즉 스레드를 생성해서 주는 게 아니라 미리 여러 개의 스레드를 만들어두고 제공하는 형식을 통해 리소스를 절약한다.

 

그러나 @Async를 달아놓은 메서드를 호출할 때는 스레드 풀에서 제공하는 게 아니라 defaultsimpleAsyncTaskExecutor로 동작하게 되어있다.

 

SimpleAsyncTaskExecutor 

  • 스레드를 매번 생성해서 제공한다.

 

즉 매번 스레드를 생성해서 주기에 매 요청의 시간이 늘어나는 문제가 발생하게 된다.

그래서 우리는 ThreadPoolTaskExecutor 변경해서 스레드 풀을 이용하도록 변경해줘야 한다.

 

AsyncConfig

@Configuration                                                                          
public class AsyncConfig {                                                              
                                                                                        
    private static final int CORE_POOL_SIZE = 10;                                        
    private static final int MAX_POOL_SIZE = 50;                                        
    private static final int QUEUE_CAPACITY = 20;                                       
                                                                                        
    @Bean(name = "taskExecutor")                                                        
    public Executor taskExecutor() {                                                    
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();                 
        executor.setCorePoolSize(CORE_POOL_SIZE);                                       
        executor.setMaxPoolSize(MAX_POOL_SIZE);                                         
        executor.setQueueCapacity(QUEUE_CAPACITY);                                      
        executor.setThreadNamePrefix("Async-");                                         
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();                                                          
        return executor;                                                                
    }                                                                                   
}

 

Async에서 사용할 스레드풀의 설정이다.

  • CorePoolSize = 생성해서 사용할 스레드의 개수를 의미한다.
  • MaxPoolSize = 최대 스레드 개수를 의미한다.
  • QueueCapacity = 대기하는 작업의 개수를 의미한다.

 

처음에 요청이 날아오면 CorePoolSize에 설정해 놓은 스레드 제공을 진행한다. 이후 스레드가 부족하면 QueueCapacity에 들어가서 작업은 대기한다.

 

만약에 QueueCapacity가 꽉 차게 되면 MaxPoolSize에 설정해 놓은 만큼 스레드를 새롭게 생성해서 사용한다.

그러고도 꽉 차게 되면 예외가 발생하게 된다.

 

그리고 사용하려는 메서드에 해당 테스트 이름을 걸어주면 스레드풀을 이용해서 비동기 작업을 수행한다.

@Async("taskExecutor")

 

 

 

참고 자료

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/concurrent/ThreadPoolTaskExecutor.html

 

ThreadPoolTaskExecutor (Spring Framework 6.1.8 API)

JavaBean that allows for configuring a ThreadPoolExecutor in bean style (through its "corePoolSize", "maxPoolSize", "keepAliveSeconds", "queueCapacity" properties) and exposing it as a Spring TaskExecutor. This class is also well suited for management and

docs.spring.io

https://f-lab.kr/insight/understanding-spring-async-annotation-and-thread-pool

 

스프링의 @Async 어노테이션과 스레드 풀 이해하기

스프링의 @Async 어노테이션과 스레드 풀을 이해하고, 비동기 처리의 기본 원리와 실제 애플리케이션에서의 활용 방안을 알아봅니다.

f-lab.kr

 

반응형