
문제 — 이메일 발송 기능을 동기로 구현한다면?
A. 요청에 대한 HTTP 응답까지 사용자는 대기해야 한다.
이메일 코드 발송 기능을 개발하는 과정에서 일어났던 개념에 대한 정리이다.
이메일로 인증 코드 OTP를 발송 전,
Redis에 저장하고, Gmail SMTP를 통해 이메일을 발송하는 feature를 개발중에 있었다.
이 때 SMTP 통신은 네트워크를 거치기 때문에 500ms ~ 2,000ms 이상 걸릴 수 있다.
"이메일이 다 발송될 때까지 기다렸다가 응답을 보낸다"면 사용자는 1~2초를 기다려야 한다.
클라이언트 서버 (HTTP 스레드 1개가 전부 처리)
│ │
│─ POST ───>│
│ │── Redis.save(OTP) ── 5ms
│ │── SMTP 연결 ────── ···
│ │ (1,200ms 대기)
│ │── SMTP 발송 완료
│<── 200 OK ─│
OTP는 이미 Redis에 저장됐다. 이메일이 발송되는 동안 기다릴 이유가 없었다.
해결책 — @Async로 다른 스레드에 이메일 전송 요청을 넘기기
@Async를 붙인 메서드는 호출하면 즉시 반환된다.
실제 작업은 별도의 스레드 풀에서 비동기로 처리된다.
클라이언트 HTTP 스레드 emailExecutor 스레드
│ │
│─ POST ───>│
│ │── Redis.save(OTP)
│ │── @Async 호출 ────>│── SMTP 연결 ──
│<── 200 OK ─│ │ (비동기)
│ │ │── 발송 완료
HTTP 스레드는 "이메일 발송을 emailExecutor에게 위임했다"는 사실만 알고 바로 응답한다.
SMTP 통신 속도가 HTTP 응답 시간에 영향을 주지 않는다.
핵심 개념 — 스레드(Thread)란 무엇인가
스레드는 프로그램 안에서 일을 처리하는 작업 단위다.
카페에 비유하면:
- 스레드 = 바리스타 1명
- HTTP 요청 1개 = 손님 1명의 주문
만약 바리스타 1명이 손님의 주문을 받고, 원두를 갈고, 에스프레소를 뽑고, 거품을 올리고, 포장하고, 배달까지 직접 한다면 — 그동안 다음 손님은 기다려야 한다.
이메일 발송이 바로 이 "배달"에 해당한다. HTTP 스레드가 SMTP 발송까지 담당하면, 그 스레드는 발송이 끝날 때까지 다른 요청을 처리하지 못한다.
@EnableAsync — @Async가 동작하는 스위치
@Async는 Spring AOP 프록시를 통해 동작한다.
AOP 프록시를 쉽게 설명하면: Spring이 EmailService 빈을 만들 때, 그 앞에 "투명한 껍데기"를 씌운다. 외부에서 emailService.sendOtpEmail()을 호출하면 실제로는 이 껍데기가 먼저 받아서 "다른 스레드로 넘기는 처리"를 한 뒤 실제 메서드를 실행한다.
@EnableAsync는 이 껍데기를 활성화하는 스위치다. 이것이 없으면 Spring은 @Async를 완전히 무시하고 동기로 실행한다.
// @EnableAsync가 없을 때
emailService.sendOtpEmail(email, otp); // 동기 실행 — HTTP 스레드가 SMTP 완료까지 대기
// @EnableAsync가 있을 때
emailService.sendOtpEmail(email, otp); // 비동기 실행 — 즉시 반환
주의:
@EnableAsync가 없어도 컴파일·기동 오류가 발생하지 않는다.
조용히 동기로 실행되므로 누락 시 발견이 어렵다.
5. AsyncConfig 구현
@Configuration
@EnableAsync // @Async 활성화
public class AsyncConfig {
@Bean(name = "emailExecutor")
public Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 상시 대기 스레드 수
executor.setMaxPoolSize(5); // 최대 스레드 수
executor.setQueueCapacity(50); // 대기열 크기
executor.setThreadNamePrefix("email-async-");
executor.initialize();
return executor;
}
}
설정값 해설
카페 바리스타 비유를 이어서 설명하면:
| 설정 | 값 | 의미 |
|---|---|---|
corePoolSize |
2 | 평소 2개의 스레드가 항상 대기 |
maxPoolSize |
5 | 요청이 많아지면 5까지 스레드 풀을 늘림 |
queueCapacity |
50 | 요청의 대기 큐 capacity |
threadNamePrefix |
email-async- |
로그에서 "이메일 발송 스레드"를 식별하는 이름표 |
처리 흐름:
- 요청이 들어오면
corePoolSize(2)중 여유 있는 스레드가 처리 - 2개가 다 바쁘면
queueCapacity(50)대기열에 쌓음 - 대기열도 꽉 차면
maxPoolSize(5)까지 스레드를 추가 생성
6. 서비스 레이어에서의 적용
@Service
@RequiredArgsConstructor
public class EmailService {
@Async("emailExecutor") // 빈 이름을 명시해 지정한 스레드 풀 사용
public void sendOtpEmail(String toEmail, String otp) {
try {
// JavaMailSender로 SMTP 발송
} catch (MailException e) {
log.error("OTP 이메일 발송 실패: {}", e.getMessage());
// 예외를 다시 던지지 않는다 — OTP는 이미 Redis에 저장됐으므로 발송 실패가 치명적이지 않음
}
}
}
@Async("emailExecutor")에서 빈 이름을 반드시 명시해야 한다.
이름을 생략하면 Spring 기본 SimpleAsyncTaskExecutor가 사용되는데, 이것은 요청마다 스레드를 새로 생성해 재사용하지 않는다 — 스레드 풀의 장점이 사라진다.
7. 주의사항
같은 클래스 내부에서 호출하면 비동기가 안 된다
@Async는 Spring이 설정한 "프록시""를 통해야 동작한다.
같은 클래스 내부에서 this.sendOtpEmail()을 호출하면 프록시를 우회하기 때문에 동기로 실행된다.
// 잘못된 예 — 같은 클래스 내부 호출 (프록시 우회 → 동기 실행)
public class EmailService {
public void someMethod() {
this.sendOtpEmail(email, otp); // 비동기 안 됨
}
@Async("emailExecutor")
public void sendOtpEmail(String email, String otp) { ... }
}
// 올바른 예 — 다른 빈에서 호출 (프록시 통과 → 비동기 실행)
public class AuthService {
private final EmailService emailService; // 주입받은 빈
public void sendOtp(...) {
emailService.sendOtpEmail(email, otp); // 비동기 동작
}
}
이메일 발송 실패는 OTP 저장과 분리가 필요
@Async 를 적용한 이메일 발송 로직은 별도 스레드에서 실행된다.
따라서, 내부에서 예외가 발생해도 HTTP 응답에는 전달되지 않는다.
따라서 별도의 Exception을 catch해 로그만 남기고 무시해야 한다.
OTP는 이미 Redis에 저장됐으므로 이메일 발송 실패는 재발송 안내로 처리하면 된다.
'DEV > Spring' 카테고리의 다른 글
| CORS와 프록시 — CorsConfig / SecurityConfig / Proxy (작성중) (0) | 2026.03.22 |
|---|---|
| [Spring] SecurityConfig Spring Method Security 적용하기 (0) | 2024.02.20 |
| [트러블슈팅][Spring] 의존성 주입 간 순환참조 문제 해결 방법 (0) | 2024.02.20 |
| [트러블슈팅][Spring] ApplicationRunner RuntimeException 발생 시 shutdown되는 오류 해결 (0) | 2024.02.20 |
| [트러블슈팅][Spring] .sql 파일 init이 자동으로 실행되지 않는 문제 원인 / 해결방법 (0) | 2024.02.20 |