본문으로 건너뛰기

외부 I/O 분리를 통한 이메일 인증 요청 경로 개선

SMTP 통신을 사용자 요청 처리 경로에서 분리하고, 메일 발송을 비동기 후처리로 구성한 과정을 정리합니다.


0. 개요​

문제 상황
  • JavaMailSender.send()가 동기 블로킹 방식
  • SMTP 서버 응답 대기 시간 (평균 2~3초)
  • 사용자는 이메일 발송 완료까지 대기
해결 방향
  • Spring Event를 발행하여 비동기 처리
  • @Async로 별도 스레드에서 이메일 발송
  • ThreadPoolTaskExecutor로 스레드 풀 관리

1. 문제 분석​

AS-IS: 동기 블로킹 방식​

@Service
public class VerificationService {

public void createAndSendCode(String email, VerificationType type) {
String code = generateRandomCode();
redisService.saveCode(email, code, type);

// 동기 블로킹 - SMTP 서버 응답 대기
emailService.sendVerificationCode(email, code, type);

// 총 응답 시간: 2.5초
}
}

문제점

  1. API 응답이 이메일 발송 완료까지 블로킹
  2. SMTP 서버 응답이 느리면 사용자 대기 시간 증가
  3. 동시 요청 시 처리량 감소

2. 해결 과정​

Step 1: Spring Event 설계​

// 이벤트 클래스
public class VerificationCodeCreatedEvent extends ApplicationEvent {
private final String email;
private final String code;
private final VerificationType type;

public VerificationCodeCreatedEvent(
Object source,
String email,
String code,
VerificationType type
) {
super(source);
this.email = email;
this.code = code;
this.type = type;
}

// getters
}

Step 2: 이벤트 발행​

@Service
public class VerificationService {
private final ApplicationEventPublisher eventPublisher;

public void createAndSendCode(String email, VerificationType type) {
String code = generateRandomCode();
redisService.saveCode(email, code, type); // Redis 저장 (동기)

// 이벤트 발행 자체는 동기이며, @Async 리스너가 별도 Executor에서 처리
eventPublisher.publishEvent(
new VerificationCodeCreatedEvent(this, email, code, type)
);

log.info("인증코드 생성 완료: email={}", email);
// 요청 스레드는 SMTP 완료를 기다리지 않고 반환
}
}

Step 3: 이벤트 리스너 (@Async)​

@Component
public class VerificationCodeEventListener {
private final EmailService emailService;

@Async("emailTaskExecutor") // 별도 스레드 풀에서 실행
@EventListener
public void handleVerificationCodeCreated(
VerificationCodeCreatedEvent event
) {
try {
emailService.sendVerificationCode(
event.getEmail(),
event.getCode(),
event.getType()
);
log.info("이메일 발송 완료: email={}", event.getEmail());
} catch (Exception e) {
log.error("이메일 발송 실패: email={}", event.getEmail(), e);
// 실패 시 재시도 로직 또는 알림 처리
}
}
}

Step 4: ThreadPoolTaskExecutor 설정​

@Configuration
@EnableAsync
public class AsyncConfig {

@Bean(name = "emailTaskExecutor")
public Executor emailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 기본 스레드 수
executor.setMaxPoolSize(5); // 최대 스레드 수
executor.setQueueCapacity(100); // 대기 큐 크기
executor.setThreadNamePrefix("email-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}

3. 성능 측정​

측정 방법​

  • JMeter: 동시 사용자 50명, 각 10회 요청
  • API: POST /api/auth/email/verification

결과​

항목Before (동기)After (비동기)개선율
평균 응답시간2.5초0.2초92% ↓
95 percentile3.2초0.3초90% ↓

응답 시간은 SMTP 처리 시간이 사라진 것이 아니라 HTTP 응답 경로에서 분리된 결과입니다. 메일 발송 완료 시간과 실패율은 별도 지표로 확인해야 합니다.


4. TO-BE 아키텍처​

[Client 요청]
↓
[Controller] ← SMTP 완료를 기다리지 않고 응답
↓
[VerificationService]
├─ Redis 저장
└─ Event 발행 ← 여기서 반환
↓
[ApplicationEventPublisher]
↓
[emailTaskExecutor] ← 별도 스레드 풀
↓
[VerificationCodeEventListener]
@Async("emailTaskExecutor")
↓
[EmailService.sendMail()] ← 사용자는 완료까지 대기하지 않음

5. 핵심 개선 포인트​

1️⃣ 이벤트 기반 아키텍처 (EDA)​

  • ✅ 이메일 발송 로직을 별도 이벤트로 분리
  • ✅ API 응답과 이메일 발송이 느슨하게 결합
  • ✅ 이메일 발송 실패가 이미 반환된 사용자 응답을 지연시키지 않음

2️⃣ @Async 비동기 처리​

  • ✅ 별도 스레드에서 이메일 발송
  • ✅ 일반적인 부하 범위에서는 요청 스레드가 SMTP 완료를 기다리지 않고 반환
  • ✅ 전용 Executor로 요청 처리 스레드와 SMTP 작업 자원 분리

3️⃣ ThreadPoolTaskExecutor 튜닝​

CorePoolSize: 2       // 항상 살아있는 스레드
MaxPoolSize: 5 // 부하 시 증가 가능한 최대 스레드
QueueCapacity: 100 // 대기 작업 큐 크기

동작 원리

  1. 요청 2개 이하: CorePoolSize 스레드 사용
  2. 요청 2~102개: 큐에 대기 (100개)
  3. 동시에 실행·대기 중인 작업이 102개를 넘으면 MaxPoolSize까지 워커 증가
  4. 워커 5개와 큐 100개가 모두 차면 다음 작업부터 CallerRunsPolicy 실행

CallerRunsPolicy가 실행되면 이벤트를 발행한 요청 스레드가 SMTP 작업을 직접 수행하므로 해당 요청은 다시 지연될 수 있습니다. 이 정책은 작업 유실을 피하는 대신 과부하를 호출자에게 전달하는 선택이므로, 실제 운영에서는 큐 적재량과 거부 횟수를 함께 관찰해야 합니다.


6. 추가 고려사항​

이메일 발송 실패 처리​

@Async("emailTaskExecutor")
@EventListener
public void handleVerificationCodeCreated(VerificationCodeCreatedEvent event) {
int maxRetries = 3;
int retryCount = 0;

while (retryCount < maxRetries) {
try {
emailService.sendVerificationCode(
event.getEmail(),
event.getCode(),
event.getType()
);
log.info("이메일 발송 성공: email={}", event.getEmail());
return;
} catch (Exception e) {
retryCount++;
log.warn("이메일 발송 실패 ({}회 재시도): email={}", retryCount, event.getEmail());

if (retryCount >= maxRetries) {
log.error("이메일 발송 최종 실패: email={}", event.getEmail(), e);
// 슬랙 알림 또는 DB 로그 저장
} else {
Thread.sleep(1000L * retryCount); // 선형 백오프 예시
}
}
}
}

7. 결론​

개선 결과
  • 인증 요청 API가 SMTP 처리 완료를 기다리지 않고 응답하도록 요청 경로와 메일 발송 책임 분리
  • 측정 환경에서 API 평균 응답 시간 2.5초 → 0.2초
  • 이메일 발송 실패가 이미 반환된 사용자 응답을 지연시키지 않음
  • 전용 Executor로 요청 처리 스레드와 SMTP 작업 자원 분리
배운 점
  • 이벤트 기반 아키텍처의 효과를 직접 체감
  • @Async의 올바른 사용법 (ThreadPoolTaskExecutor 설정)
  • 비동기 처리 시 예외 처리의 중요성

8. 참고 자료​

9. PR​

관련 PR