Home 비동기 처리를 통해 이메일 전송 API의 성능 개선하기
Post
Cancel

비동기 처리를 통해 이메일 전송 API의 성능 개선하기

배경

image.png 기존 API 호출 시간

기존 이메일 인증 기능에서는 동기 처리 방식으로 인해 응답 시간이 길어지는 문제가 있었다. 이로 인해 사용자 경험이 저하되고 서버의 부담이 가중될 우려가 있었다. Amazon SES를 사용하여 인증 이메일을 전송하는 동안 API 요청이 차단되었고, 인증 코드 생성 및 Redis 저장 작업도 동기적으로 처리되어 전반적인 성능에 부정적인 영향을 미쳤다.

성능 문제 분석

  1. 이메일 전송의 동기 처리
    • Amazon SES API의 응답을 기다리는 동안 API 호출이 차단되었다.
    • 이메일 전송에 약 1초 이상의 시간이 소요되며, 이는 전체 API 응답 시간을 증가시키는 주요 요인이었다.
  2. 순차적인 인증 코드 처리
    • 인증 코드 생성과 Redis 저장 작업이 순차적으로 이루어지면서 블로킹이 발생하였다.
    • API 요청 하나가 처리되는 동안 다른 요청이 대기 상태에 놓이는 문제가 발생하였다.
  3. 서버 자원의 비효율적 사용
    • 동기 처리로 인해 서버 스레드가 불필요하게 점유되었다.

개선 사항

이메일 전송 비동기 처리

비동기 처리를 위해 Spring의 @Async 어노테이션을 사용하였다. @Async는 메서드 실행을 별도의 스레드에서 처리하도록 지원하는 기능으로, 이를 통해 이메일 전송 작업을 API 요청의 응답과 분리하여 동작하게 할 수 있다.

비동기 처리의 원리

  • @Async는 Spring의 AOP(Aspect-Oriented Programming) 기반으로 동작하며, 별도의 스레드 풀을 사용해 작업을 처리한다. 이는 호출자와 작업이 서로 다른 스레드에서 실행됨을 의미한다.
    • 호출 메서드는 기본적으로 메인 스레드에서 실행되고, @Async로 지정된 메서드는 스레드 풀 내의 다른 스레드에서 실행된다.
    • 결과적으로, 두 작업이 병렬적으로 실행되어 메인 스레드가 작업 완료를 기다릴 필요가 없다.
  • 비동기 처리를 활성화하려면 @EnableAsync 어노테이션을 사용해야 한다. 이 어노테이션은 Spring이 비동기 메서드를 실행하기 위해 필요한 설정을 자동으로 처리한다.

    1
    2
    
    @EnableAsync
    class ServerMeetingApplication
    
  • 스레드 풀은 ThreadPoolTaskExecutor를 기본 구현체로 사용하며, 이를 명시적으로 설정하면 비동기 작업의 성능과 안정성을 더욱 향상시킬 수 있다.
    • Core Pool Size: 스레드 풀에서 기본적으로 유지되는 스레드 개수.
    • Max Pool Size: 필요한 경우 생성될 수 있는 최대 스레드 개수.
    • Queue Capacity: 처리 대기 중인 작업의 허용 개수.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    @Configuration
    class AsyncConfig : AsyncConfigurer {
        override fun getAsyncExecutor(): Executor {
            val executor = ThreadPoolTaskExecutor()
            executor.corePoolSize = 5
            executor.maxPoolSize = 10
            executor.queueCapacity = 50
            executor.setThreadNamePrefix("CustomAsyncExecutor-")
            executor.initialize()
            return executor
        }
    }
    

메일 전송 플로우차트

sequenceDiagram
    participant C as Client
    participant S as Server
    participant R as Redis
    participant ses as SES

    C->>S: 인증 메일 전송 요청
    
    S->>S: 이메일 형식 검사
    
    S->>R: 일일 발송 횟수 조회
    R-->>S: 발송 횟수 반환
    S->>S: 발송 제한 확인
    
    S-->>C: 인증 만료시간 반환
    
    Note over S: 비동기 처리 시작
    
    activate S
    S->>S: 인증코드 생성
    
    S->>R: 인증코드 저장
    
    S->>R: 발송 횟수 증가
    
    S->>ses: 인증 메일 발송
    ses-->>S: 발송 결과 전달
    
    S->S: 발송 이력 기록
    deactivate S

관련 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Service
class EmailVerificationService(
    ...
) {
    fun sendVerificationEmail(
        email: String,
        requestInfo: RequestInfoDto
    ): SendVerificationEmailResponse {

        // 이메일 형식 검증
        validateEmail(email)
        // 발송 제한 확인
        validateSendCount(email)
        // 비동기 이메일 전송
        val asyncResult = asyncEmailService.sendEmailAsync(email)

        asyncResult.whenComplete { _, exception ->
            if (exception != null) {
                logger.warn("[이메일 전송 실패] email: $email, $requestInfo")
            } else {
                logger.info("[이메일 전송 성공] email: $email, $requestInfo")
            }
        }
        // 코드 만료 시각 계산
        val expirationTime = VerificationUtils.calculateExpirationTime(codeExpiry)

        return SendVerificationEmailResponse(
            expirationTime = expirationTime,
            validDuration = codeExpiry
        )
    }
}

주의사항

클래스 내부에서 @Async 메서드를 호출할 경우, AOP 프록시가 적용되지 않아 비동기로 실행되지 않는다. 이를 해결하기 위해 AsyncEmailService를 별도의 빈으로 선언하고 외부에서 호출하도록 설계하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
class AsyncEmailService(
    ...
) {
    @Async
    fun sendEmailAsync(email: String): CompletableFuture<Unit> {
        return try {
            val verificationCode = VerificationUtils.generateVerificationCode()
            saveVerificationCode(email, verificationCode)
            sendEmail(email, verificationCode)
            incrementSendCount(email)
            CompletableFuture.completedFuture(Unit)
        } catch (exception: Exception) {
            CompletableFuture.failedFuture(exception)
        }
    }
}

기타 개선 사항

로직 분리

  • 이메일 전송 관련 로직은 AsyncEmailService로 분리하여 비동기 처리와 관련된 코드를 별도로 관리하였다.
  • 인증 관련 유틸리티 함수는 VerificationUtils로, 상수는 VerificationConstants로 분리하여 코드의 가독성과 재사용성을 향상시켰다.
1
2
3
4
5
6
7
object VerificationUtils {
    fun generateRedisKey(prefix: String, email: String, isDate: Boolean = false): String

    fun generateVerificationCode(): String

    fun calculateExpirationTime(codeExpiry: Long): Long
}
1
2
3
4
5
6
7
@Component
object VerificationConstants {
    const val CODE_PREFIX = "email_verification_code:"
    const val SEND_COUNT_PREFIX = "email_send_count"
    const val VERIFY_COUNT_PREFIX = "verification_attempts:"
    const val UOS_DOMAIN = "@uos.ac.kr"
}

결론

  1. 성능 개선: 이메일 전송 비동기 처리를 통해 API 응답 시간을 90% 이상 단축시켰다.
    • 기존 방식: 1417ms
    • 개선된 방식: 136ms image.png 개선된 API 호출 시간
  2. 안정성 향상: 비동기 작업 실패 시 로깅 및 재처리 기능을 추가하여 신뢰성을 확보하였다.
  3. 코드 구조 개선: 로직 분리와 유틸리티화를 통해 코드 유지보수가 용이하도록 하였다.

참고자료

This post is licensed under CC BY 4.0 by the author.

시대팅에 이메일 인증 기능 구현하기 with Amazon SES

시대팅에 JWT 인증 시스템 구축하기