과정을 즐기자

이메일 전송 비동기로 처리하기 [Spring Boot] 본문

Spring

이메일 전송 비동기로 처리하기 [Spring Boot]

320Hwany 2023. 6. 10. 15:05

현재 '원하는 분야의 CS 면접 질문을 매일 아침 이메일로 전송해주는 서비스'를 운영하고 있습니다.   

매일 아침 9시에 각 회원이 선택한 분야, 문제 수를 보내주는데 회원이 10명이 조금 넘을 때 30초가 넘게 걸렸습니다. 

이렇게 오래걸린 이유는 이메일을 보내는 작업은 시간이 많이 드는 작업이기도 하지만 회원의 요청을 순차적으로 

처리했기 때문입니다.

 

이번 글에서는 이메일을 보내는 작업을 비동기로 처리하여 30초가 넘게 걸리는 작업을 7.4초 정도로 줄인 과정 에 대해 

작성해보려고 합니다.

 

Before

SendRandomQuizzes

public void sendRandomQuizzes() {
    List<QuizFilterSearch> quizFilterSearchList = 
    			quizFilterRepository.findAllQuizFilterSearch();
                
    for (QuizFilterSearch quizFilterSearch : quizFilterSearchList) {
       List<Quiz> filteredQuizzes = getFilteredQuizzes(quizFilterSearch);

        Collections.shuffle(filteredQuizzes);
        List<Quiz> randomQuizList = filteredQuizzes.stream()
                .limit(quizFilterSearch.getNumOfProblem())
                .toList();
        emailQuizSender.sendQuizzes(randomQuizzes, quizFilterSearch.getEmail());
}

 

수정하기 전 코드는 QuizFilterRepository에서 각 회원의 원하는 분야, 문제수 정보를 가져옵니다.  

이 정보를 바탕으로 퀴즈를 가져오고 랜덤으로 섞은 후 이메일을 전송합니다.  

이때 문제점은 반복문을 도는 작업이 순차적으로 처리되기 때문에 한 회원의 이메일 전송이 완료될 때까지

다른 회원에게는 전송되지 않습니다. 지금은 회원이 10명정도라 30초정도 걸리는 작업이지만

회원이 100명, 1000명으로 늘어나면 몇분, 몇십분이 걸리는 작업이 되기 때문에 이러한 방식으로 처리하는데는 한계가 있습니다.

 

After

 

이제 이메일 전송을 비동기로 처리하기 위한 설정을 해보겠습니다. 

우선 비동기 처리를 하기 위해 AsyncConfig 클래스를 만들어 설정했습니다.  

AsyncConfig

@EnableAsync  // 비동기 처리 활성화
@Configuration
public class AsyncConfig {

    @Bean
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);  // 코어 쓰레드 풀, 동시에 실행할 수 있는 최소 쓰레드 수
        executor.setMaxPoolSize(50); // 최대 쓰레드 풀, 동시에 실행할 수 있는 최대 쓰레드 수
        executor.setQueueCapacity(100); // 쓰레드 풀을 넘는 요청이 들어올 때 처리하는 대기열
        executor.setThreadNamePrefix("Email Send Async Task-");
        executor.initialize(); // 설정한 속성들 적용
        return executor;
    }
}

 

ScheduledTasks

@Slf4j
@RequiredArgsConstructor
@Component
public class ScheduledTasks {

    private final QuizQuery quizQuery;
    
    ...
    
    @Async
    @Scheduled(cron = NINE_AM)
    public void sendQuizAt9AM() {
        log.info("NINE_AM sendQuizAt9AM");
        quizQuery.sendRandomQuizzes();
    }
    
    ...
}

다음으로 sendQuizAt9AM() 메소드가 오전 9시에 실행되게 설정합니다. 

이때 quizQuery.sendRandomQuizzes() 메소드가 비동기로 실행될 수 있게 @Async를 붙여줍니다.

 

SendRandomQuizzes

@Async
public void sendRandomQuizzes() {
    List<QuizFilterSearch> quizFilterSearchList =
    				quizFilterRepository.findAllQuizFilterSearch();

    List<CompletableFuture<Void>> emailSendingFutures = quizFilterSearchList.stream()
            .map(quizFilterSearch -> CompletableFuture.runAsync(() -> {
                List<Quiz> filteredQuizzes = getFilteredQuizzes(quizFilterSearch);

                Collections.shuffle(filteredQuizzes);
                List<Quiz> randomQuizzes = filteredQuizzes.stream()
                        .limit(quizFilterSearch.getNumOfProblem())
                        .toList();

                emailQuizSender.sendQuizzes(randomQuizzes, quizFilterSearch.getEmail());
            }))
            .toList();

    CompletableFuture
    		.allOf(emailSendingFutures.toArray(new CompletableFuture[0])).join();
}

먼저 QuizFilterRepository에서 모든 회원의 원하는 분야, 문제에 대한 정보를 가져옵니다.  

가져온 후 CompletableFuture.runAsync()로 비동기 작업 실행 콜합니다.  

구체적인 작업은 퀴즈 필터 정보를 바탕으로 퀴즈를 가져온 후 랜덤으로 가져오고 이메일을 전송합니다.  

CompletableFuture.allOf(..).join()으로 모든 작업이 완료될 때까지 기다립니다.  

 

이제 이메일 전송시 얼마의 시간이 걸리는 지 확인해보겠습니다.  

 

이렇게 비동기 방식으로 처리하여 30초가 넘게 걸리던 작업이 7.485초로 줄었습니다!

회원 수가 증가할 수록 이러한 차이는 더 커질 것 입니다.  

정리 

 

스프링은 기본적으로 스프링 빈을 싱글톤으로 만들어 멀티 쓰레드 환경에서 효율적으로 처리할 수 있게 하였습니다.

하지만 이러한 작업은 QuizQuery가 싱글톤이어서 여러 쓰레드가 같이 사용할 수는 있긴 하지만 한 쓰레드가

시간이 오래걸리는 특정 메소드를 실행할 때 (여기서는 sendRandomQuizzes) 문제가 발생할 수 있습니다.   

이러한 특정 메소드의 작업을 여러 쓰레드가 비동기적으로 처리할 수 있도록 하면 처리시간을 줄일 수 있습니다.  

 

하지만 비동기로 처리할 때는 주의할 점이 있습니다. 

멀티 쓰레드인 만큼 싱글톤 객체가 특정 상태를 가진다면 동시성 문제를 처리해야합니다.

또한 이번 문제의 경우에는 수정하는 작업이 아니라 조회하는 경우였기 때문에 비동기로 처리하여도 문제없이

처리할 수 있었습니다.