외부 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초
}
}
문제점
- API 응답이 이메일 발송 완료까지 블로킹
- SMTP 서버 응답이 느리면 사용자 대기 시간 증가
- 동시 요청 시 처리량 감소
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 percentile | 3.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 // 대기 작업 큐 크기
동작 원리
- 요청 2개 이하: CorePoolSize 스레드 사용
- 요청 2~102개: 큐에 대기 (100개)
- 동시에 실행·대기 중인 작업이 102개를 넘으면 MaxPoolSize까지 워커 증가
- 워커 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 설정)
- 비동기 처리 시 예외 처리의 중요성