빙응의 공부 블로그

[UniP] 인증 메일 발송 비동기 처리하기 본문

Project/UniP

[UniP] 인증 메일 발송 비동기 처리하기

빙응이 2024. 11. 11. 19:20

이 글은 비동기 처리에 대해 다루겠습니다.

📝배경

이번에 졸업 작품 프로젝트에서 메인서버를 맡아 개발하게 되었습니다.

 

그래서 메일 인증을 위해 Redis와 메일을 보내는 로직을 만들었습니다. 

그런데 유독 혼자만 API 응답 속도가 느린 것을 확인했습니다. 

 

해당 로직은 이메일 기반으로 인증 코드를 보내는 로직입니다. 오직 이메일을 보내는 것만으로 무려 3.51초가 나온 것을 알 수 있었습니다. 

 

해당 로직이 3.51초가 걸린 것인데... 어느 부분이 문제인지 한번 전부 시간 체크를 해보겠습니다. 

    /* 이메일 작성 및 인증 코드 저장 */
    public void sendVerificationEmail(String email) {
        if(emailBlackListRepository.existsByEmail(email)){
            throw new CustomException(MailErrorCode.ALREADY_EMAIL);
        }
        //Redis에서 기존 인증 코드 삭제
        universityVerificationRepository.deleteByEmail(email);

        String authNumber = makeRandomNum(); // 난수 생성
        
        //이메일 전송 로직
        emailSender.sendEmail(email, authNumber);

        // Redis에 인증 코드 저장
        UniversityVerification verification = UniversityVerification.builder()
            .email(email)
            .authCode(authNumber)
            .expiration(180L)
            .build();
        universityVerificationRepository.save(verification); // Redis에 저장
    }

 

                                         //블랙 리스트 확인
u.u.d.u.s.UniversityVerificationService  : Check email blacklist took 13 ms
                                         //Redis에서 기존 인증 제거
u.u.d.u.s.UniversityVerificationService  : Delete existing verification code from Redis took 31 ms
                                         //인증 코드 생성
u.u.d.u.s.UniversityVerificationService  : Generate random number took 0 ms
                                         //이메일 전달
u.u.d.u.s.UniversityVerificationService  : Send email took 3498 ms
                                         //레디스에 저장
u.u.d.u.s.UniversityVerificationService  : Save verification code to Redis took 70 ms
u.u.d.u.s.UniversityVerificationService  : Total execution time: 3613 ms

해당 로직 기준으로 시간을 측정했을 때 이메일 로직으로만 총 3.6초에서 3.5초가 나왔습니다. 

 

퍼센트로 계산하면 총 시간 3.6초에서 96.8%를 이메일 전달 로직이 차지했습니다.
즉 메일 전송이 모든 시간을 잡아먹고 
문제의 핵심은 할당된 하나의 워크 쓰레드가 모든 작업을 담당해서 였습니다.

 

 

📝 개선 아이디어 도출

현재 저희의 프로젝트에서 인증 메일 전송 로직은 구글 계정과 SMTP 프로토콜을 활용해서 메일을 전송하고 있습니다.

 

SMTPTCP 기반으로 동작해 TCP Connection를 수립하고 서로 요청을 주고 받기에 프로토콜의 성능을 제어할 수 없다고 생각했습니다. 또한 이메일 서버가 외부(구글)에 있어 네트워크 환경, 서버 상태 등에 따라 응답 시간이 달라질 수 있기에 현재 우리 프로젝트에서 해당 로직을 변경하는 것은 무리가 있다고 판단했습니다.

 

이를 해결하기 위한 아이디어를 도출했습니다.

  1. 비동기 처리
  2. 이메일 전송 큐 시스템 도입

 

아이디어를 기반으로 현재 저희가 만드는 기능과 결합하여 생각하여 의견을 도출했습니다.

  • 이메일 시스템에서 이메일 전송 실패 시 재시도 로직이 필요한가?
    • soultion : 인증 메일의 생명 주기는 최대 10분정도이다. 이메일을 재시도하는 대신 사용자가 다시 요청하는 방식이 더 적합할 수 있다. 이메일 전송 자체가 네트워크와 외부 서버 상태에 의존하기 때문에, 재시도보다는 사용자가 재요청하는 방삭이 간단하다.
  • 이메일 시스템을 우리 프로젝트에서 자주 사용하는가?
    • solution : 우리 프로젝트 이메일 시스템은 첫 가입 시에만 적용하기에 많이 사용하지 않는다. 따라서 이메일 시스템에 대한 부하는 상대적으로 적다.
비동기 처리

이메일 인증은 즉시성이 중요한 작업이 아니기에, 약간의 지연이 있어도 사용자는 큰 영향이 안간다고 판단하여 비동기 처리 방식은 유효

이메일 전송 큐

이메일 전송 큐 시스템은 많은 트래픽을 처리할 때 효과적이지만 사용빈도가 적다면 굳이 복잡한 시스템을 도입할 이유가 없다 판단하였습니다.

 

📌 결론

따라서 비동기 처리로 서버 응답 속도를 빠르게 하고 인증 메일 전달에 실패할 경우 운영적으로 사용자에게 다시 요청을 유도하는 방식이 좋을 것이라는 생각을 하게 되었습니다.

 

 

📝비동기로 메일 전송

구현할 비동기 처리는 임의의 로직에 별도의 쓰레드를 할당해서
메일 전송 로직을 담당하고 기존 쓰레드는 응답을 반환하는 것입니다. 
하지만 에러사항이 있습니다.

📌 에러사항

  1.  현재 프로젝트는 거의 마무리 단계이기에 기존 코드를 변경해야 한다. 
    • SOLID : OCP 위반
  2. 그렇기에 기존 로직 변경 기존 흐름을 감싸는 비동기 로직을 추가하는 방식으로 해결해야한다.

 

✔ 해결방법

해당 문제는 스프링의 AOP를 사용하면 해결할 수 있습니다.
AOP의 @Async 어노테이션은 Spring의 비동기 작업을 처리하기 위한 기본적인 방법입니다. 

 

 

@EnableAsync

먼저 AsyncConfiguration을 정의하여 @EnableAsync 어노테이션을 활성화해줍니다.

해당 어노테이션은 Spring이 비동기 작업을 처리할 수 있도록 비동기 관련 설정을 자동으로 가져옵니다. 

@Configuration
@EnableAsync
public class AsyncConfiguration {
}

 

 

@Async

그 다음 비동기 처리를 하고 싶은 메소드에 @Async 어노테이션을 붙이면 됩니다.

해당 어노테이션은 다음과 같은 실행 과정을 거칩니다.

  1. @Async 어노테이션이 붙은 메서드가 호출되면, 스프링은 해당 호출을 가로채서 비동기 실행을 처리하기 위한 프록시 객체를 생성한다.
  2. 해당 메서드는 TaskExecutor에 의해 스레드풀에 작업으로 등록한다.
  3. 해당 메서드는 호출자와 별도로 동작하며, 기존 스레드는 블러킹 없이 즉시 리턴한다. 
@Component
@RequiredArgsConstructor
@Slf4j
public class EmailSender {
    @Value("${spring.mail.username}")
    private String serviceName;

    private final JavaMailSender javaMailSender;
    private final String DEFAULT_SUBJECT = "UniP 학교 인증 메일";
    private final String DEFAULT_CONTENT =
        "이메일을 인증하기 위한 절차입니다." +
        "<br><br>" +
        "회원 가입 폼에 해당 번호를 입력해주세요." + "<br>"
        +"인증번호:";

    /* 이메일 전송 */
    @Async
    public void sendEmail(String toMail, String authNumber) {
        MimeMessage message = javaMailSender.createMimeMessage();

        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(serviceName); // 발신자 이메일 설정
            helper.setTo(toMail);        // 수신자 이메일 설정
            helper.setSubject(DEFAULT_SUBJECT);  // 이메일 제목 설정
            helper.setText(DEFAULT_CONTENT + authNumber , true); // 이메일 본문 설정 (HTML 형식)

            javaMailSender.send(message);
            log.info("send email to: {}", toMail);
        } catch (MessagingException e) {
            log.error("Failed to send email to: {}. Error: {}", toMail, e.getMessage());
            throw new CustomException(MailErrorCode.FAILED_MAIL_SEND);
        }
    }
}

 

 

다시한번 로그를 찍어서 시간을 구해보자 

[nio-8080-exec-4] u.u.d.u.s.UniversityVerificationService  : Redis deleteByEmail took: 53 milliseconds
[nio-8080-exec-4] u.u.d.u.s.UniversityVerificationService  : makeRandomNum took: 0 milliseconds
[nio-8080-exec-4] u.u.d.u.s.UniversityVerificationService  : sendEmail took: 0 milliseconds
[nio-8080-exec-4] u.u.d.u.s.UniversityVerificationService  : universityVerificationRepository.save took: 56 milliseconds
[nio-8080-exec-4] u.u.d.u.s.UniversityVerificationService  : Total sendVerificationEmail took: 122 milliseconds
[         task-4] u.u.d.u.utils.EmailSender                : send email to: playgrounm@gmail.com

맨 앞 []는 쓰레드를 의미한다.  

비동기로 인해 sendEmail은 다른 쓰레드를 할당하고 리턴해서 작업을 진행한다.

 

동기 처리 비동기 처리 
3613ms                                          ->                                            122ms

 

 

 

📝남은 문제

비동기 처리로 인해 응답 속도가 혁신적으로 감소했습니다. 
그런데 비동기 처리에는 문제가 없을까요?

 

고려해야하는 문제가 있습니다.

📌 비동기 스레드 생성 방식

  • @Async 어노테이션을 사용할 때 호출 메서드는 TaskExecutor에 의해 스레드풀에 등록된다 했습니다. 
  • 그런데 이 과정은 TaskExecutor이 설정했는 지 검사하고 없다면 SimpleAsyncTaskExecutor를 사용합니다.

 🧷 문제점 : SimpleAsyncTaskExecutor

이것은 TaskExecutor의 구현체로 새로운 스레드를 생성하는 방식입니다. 매 요청마다 새로운 스레드를 생성해 사용하고 재사용하지 않기 때문에 매번 불필요한 리소스가 생깁니다. 

 

그렇기에 SimpleAsyncTaskExecutor는 대체로 성능에 부정적인 영향을 미칠 수 있으므로, 이를 대체할 스레드 풀을 사용해야 합니다.

 

🧷 해결책 : ThreadPoolTaskExecutor

ThreadPoolTaskExecutor는 스레드 풀을 사용하여 작업을 처리하기 때문에 매번 새로운 스레드를 생성하지 않고, 미리 생성된 스레드를 재사용할 수 있습니다. 이를 통해 성능을 향상시킬 수 있습니다. 물론 직접 구현해야 합니다.

 

스레드 풀 설정에 대한 것은 나중에 자세히 다루겠습니다.

@Configuration
@EnableAsync
public class AsyncConfiguration implements AsyncConfigurer {
    @Bean(name = "emailAsyncExecutor")
    public Executor getAsyncExecutor() {
        final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5); // 기본 스레드 풀 크기(최소 5개)
        executor.setMaxPoolSize(25); // 최대 스레드 풀 크기
        executor.setQueueCapacity(30); // 작업 큐의 크기
        
        // 작업 거부 전략 : 스레드 풀에 여유가 없을 경우, 호출한 스레드가 실행
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setAwaitTerminationSeconds(60); // 스레드 종료 시 대기 시간 설정
        executor.setThreadNamePrefix("AsyncMail Thread-"); // 생성 스레드 이름 접두사
        return executor;
    }
}
    /* 이메일 전송 */
    @Async("emailAsyncExecutor")
    public void sendEmail(String toMail, String authNumber) {
        MimeMessage message = javaMailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(serviceName); // 발신자 이메일 설정
            helper.setTo(toMail);        // 수신자 이메일 설정
            helper.setSubject(DEFAULT_SUBJECT);  // 이메일 제목 설정
            helper.setText(DEFAULT_CONTENT + authNumber , true); // 이메일 본문 설정 (HTML 형식)

            javaMailSender.send(message);
            log.info("send email to: {}", toMail);
        } catch (MessagingException e) {
            log.error("Failed to send email to: {}. Error: {}", toMail, e.getMessage());
            throw new CustomException(MailErrorCode.FAILED_MAIL_SEND);
        }
    }

 

다음처럼 설정한 쓰레드 풀이 잘 작동하는 것을 볼 수 있다.

 


이메일 전송에 대한 요청 시간을 줄이기 위한 개선 작업을 진행한 결과, 비동기 처리를 통해 상당한 성능 향상을 얻었습니다. 기존의 동기 처리 방식에서는 이메일 전송에만 약 3.5초가 소요되었으나, 비동기 처리 후에는 이메일 전송 시간이 0ms로 측정되었고, 전체 처리 시간이 122ms로 크게 단축되었습니다.

결론

  1. 비동기 처리의 효과: 이메일 전송 작업을 비동기 방식으로 처리함으로써 메인 스레드가 이메일 전송으로 블로킹되지 않고 즉시 응답을 반환할 수 있게 되었습니다. 이는 사용자 경험을 크게 개선하는 데 기여했습니다.
  2. 스레드 풀 활용: SimpleAsyncTaskExecutor의 문제를 해결하기 위해 ThreadPoolTaskExecutor를 사용하여 스레드 풀을 구성함으로써, 매번 새로운 스레드를 생성하지 않고 이미 생성된 스레드를 재사용할 수 있게 되었습니다. 이를 통해 불필요한 리소스 낭비를 방지하고 성능을 최적화할 수 있었습니다.
  3. 이메일 전송 실패 처리: 이메일 전송 실패 처리에 대해서는, 사용자가 다시 요청할 수 있는 방식으로 처리하는 것이 효율적이라고 판단했습니다. 대부분의 현업 시스템에서는 이메일 인증 전송 실패에 대한 별도의 재시도 로직을 추가하지 않거나, 최소화하는 경우가 많기 때문에, 이메일 전송 실패 시 사용자에게 간단한 안내 메시지를 제공하고 재요청을 유도하는 방식을 선택하는 것이 합리적이라 생각했습니다.

향후 고려 사항

  • 스레드 풀 관리: 스레드 풀이 잘 관리되지 않으면 시스템 성능에 악영향을 미칠 수 있으므로, 최대한 적절한 크기로 설정하고 모니터링하는 것이 필요할 것 같습니다.

 

이번 프로젝트로 기존 코드를 바꾸지 않고 최소한의 수정으로 비동기 처리로 성능을 크게 향상시킬 수 있었으며, 프로젝트의 응답 속도를 개선하는 데 중요한 역할을 했습니다.