티스토리 뷰
배경
1편에서는 Gmail SMTP의 하루 발송량 제한인 500건을 넘지 않기 위해 BCC를 도입하여 발송량 자체를 줄였다.
2편에서는 서킷브레이커 패턴을 활용해 Gmail SMTP의 하루 발송량을 초과하더라도 정상적으로 이메일을 발송할 수 있는 시스템을 구현하였다.
이번 편에서는 이메일을 최소 한번 보내도록 보장하기 위해 어떻게 구현했는지에 관해 작성할 예정이다.
현재 시스템의 문제점
현재 이메일을 보낼때 JavaMailSender를 통해 비동기로 보내고 있다. 이 구조에서는 이메일이 보내지지 않을 수 있는데 이는 다음 이유때문이다.
- 이메일을 보낼때 SMTP 서버에게 데이터를 보내야 한다. 이때 네트워크를 타기 때문에 패킷이 유실되거나, 네트워크 지연, 네트워크 다운등의 이유로 데이터를 보내지 못할 수 있다.
- 비동기 스레드가 가득 찬다면, 이메일을 보낼 스레드가 부족해져 이메일 전송이 안될 수 있다.
- 비동기 스레드로 이메일을 보내고 있지만, 이때 애플리케이션이 종료된다면 최종적으로 이메일이 보내지지 않을 수 있다.
각각을 더 자세히 살펴보자.
(1) 네트워크는 신뢰할 수 없는 매체이다.
데이터를 네트워크로 전송할 때, TCP를 사용하더라도 항상 목적지에 도착한다고 보장할 수 없다.
우리는 "TCP는 신뢰성 있는 프로토콜"이라고 배웠다. 그렇다면 왜 TCP를 사용해도 데이터 전송이 실패할 수 있을까?
TCP는 ACK를 받지 못하면 데이터를 재전송한다. 이를 통해 최소 한 번의 전송을 보장하려 하지만, 재전송 역시 무한정 할 수 없다.
상대 서버가 다운되었거나, ACK가 지속적으로 유실되거나, 전송 데이터가 손실되는 상황에서 TCP는 정해진 최대 횟수만큼만 재전송을 시도한다. 최대 재시도 후에도 ACK를 받지 못하면 연결을 종료하고 애플리케이션에 에러를 반환한다.
결국 TCP를 사용하더라도 데이터가 목적지에 도달하지 못하는 상황은 발생할 수 있다.
(2) 비동기 스레드가 가득찬다면
현재 우리는 Spring의 @Async 어노테이션을 이용해 이메일을 전송하고 있다. @Async는 다음과 같이 동작한다.
작업이 들어올 때마다 corePoolSize까지 스레드를 할당한다. 모든 코어 스레드가 작업 중일 때 새로운 이메일 전송 요청이 들어오면, 해당 작업은 큐에서 대기한다. 큐도 설정한 크기만큼 가득 차면, maxPoolSize까지 스레드를 추가로 생성하여 작업을 처리한다.
그렇다면 maxPoolSize까지 모든 스레드가 사용 중이고 큐도 가득 찬 상태에서 이메일을 보내려 하면 어떻게 될까? 이를 위해 Spring에서는 다음과 같은 거절 정책을 제공한다.
- AbortPolicy: 새로운 작업 제출 시 RejectedExecutionException을 발생시킴 (기본 정책)
- DiscardPolicy: 새로운 작업을 조용히 버림
- CallerRunsPolicy: 작업을 제출한 스레드가 직접 작업을 실행함
- 사용자 정의: 개발자가 직접 정의한 거절 정책
현재 이벤트 생성 시 이메일을 전송하는데, 기본 정책인 AbortPolicy를 사용하면 스레드 풀 포화 시 RejectedExecutionException이 발생하여 이벤트 생성 자체가 실패할 수 있다.
이를 해결하기 위해 커스텀 정책을 만들어 트랜잭션 커밋 후 해당 스레드에서 직접 이메일을 전송할 수도 있다. 하지만 이메일 전송은 네트워크 I/O를 수반하기 때문에 시간이 오래 걸리고, 그만큼 사용자의 응답 시간이 느려질 수 있다.
(3) 이메일을 보내지 않았는데 애플리케이션이 종료된다면
애플리케이션은 다양한 이유로 종료될 수 있다. OOM으로 인한 강제 종료, 새로운 버전 배포를 위한 정상 종료 등이 그 예다.
이때 이메일 전송 작업이 아직 완료되지 않았다면, 해당 작업은 버려져 이메일이 전송되지 않는다. 물론 Spring에서는 @Async 작업이 모두 완료될 때까지 우아하게 종료(graceful shutdown)할 수 있도록 설정을 제공하지만, 이 역시 무한정 기다릴 수는 없다.
이러한 이유들 때문에 현재 시스템에서는 이메일을 최소 한번은 보내도록 보장이 되어 있지 않는다. 우리는 이벤트 리마인더 서비스인 만큼 이메일을 확실히 보내도록 보장해야 했다. 그렇다면 어떻게 최소 한번은 보내도록 보장할 수 있을까?
이메일 최소 한번 보내기
이 문제를 해결하기 위한 방법은, 이메일 전송 전에 이메일 발송 내역을 먼저 저장을 하고 커밋된 이후에 이메일을 발송시키면된다. 그리고 별도의 워커를 두어 이메일 발송이 실패한 내역에 대해서 이메일을 재전송하면 된다.
이 방식을 Transactional Outbox 패턴이라고 부른다.
이를 도식화 해본다면 다음과 같다.

대략적인 아이디어는 다음과 같다.
- 이벤트 생성 API를 클라이언트가 호출한다.
- 서버는 요청한 이벤트를 데이터베이스에 저장한다. 동시에 이메일 발송 내역을 저장한다. 이때 두 작업은 같은 트랜잭션에 묶는다.
- 트랜잭션이 커밋되면 이메일 발송을 비동기 스레드로 시작한다. 만약 이메일 발송에 성공했다면 이메일 발송 내역을 삭제한다.
- 스케줄링이 돌면서 실패한 이메일 발송 내역을 조회하며 이메일을 재전송한다.
2번에서 이벤트 저장과 이메일 발송 내역을 한 트랜잭션으로 묶는 이유는 도메인 로직과 이메일 발송 내역에 대한 정합성을 유지하기 위해서이다. 둘중 하나라도 실패를 한다면 롤백을 함으로써 추후 이메일 전송에 실패했을 경우 워커가 이메일 발송 내역을 조회해 재시도를 하게 하기 위해서이다.
그러면 이벤트 저장과 이메일 발송 내역 저장을 하나의 구간으로 묶고, 이메일 전송을 하나의 구간으로 바라왔을때 발생 가능한 4가지 CASE가 존재한다.
이벤트 저장과 이메일 발송 내역 저장을 1번 구간, 이메일 전송을 2번 구간이라고 했을때
- 1번 성공, 2번 성공
이벤트 저장과 이메일 발송 내역 저장이 성공적으로 실행되어 트랜잭션이 commit되고, 이후에 이메일이 전송되어 정상적으로 이메일이 전송된다. - 1번 실패, 2번 성공
이벤트 저장과 이메일 발송 내역 저장이 실패한 경우이다. 이 경우에는 이메일 전송이 되지 않는데 그 이유는 이메일 전송은 트랜잭션이 커밋된 이후에만 실행되도록 구현했기 때문이다. 즉, 해당 케이스는 발생 가능성이 없다고 봐야한다. - 1번 성공, 2번 실패
이벤트 저장과 이메일 발송 내역 저장이 성공적으로 실행되어 트랜잭션이 commit되고, 이후에 이메일이 전송되지만, 이메일 전송에 실패한 경우이다. 해당 경우에는 이메일 발송 내역이 DB에 저장되어 있기 때문에 추후 워커에 의해 이메일 재전송이 일어나 최종적으로는 이메일 전송에 성공하게 된다. - 1번 실패, 2번 실패
이벤트 저장과 이메일 발송 내역 저장이 실패하고, 이메일 전송 또한 실패한 경우이다. 로직은 실패하였지만, 데이터 정합성은 깨지지 않았기 때문에 의도한 상황으로 봐도 무방하다.
실제 구현
먼저 이벤트를 생성하는 API를 클라이언트가 호출한다면 이벤트를 생성한다.
@Transactional
public Event createEvent(
final Long organizationId,
final LoginMember loginMember,
final EventCreateRequest eventCreateRequest,
final LocalDateTime currentDateTime
) {
Event event = Event.create(
eventCreateRequest.title(),
eventCreateRequest.description(),
eventCreateRequest.place(),
organization,
eventOperationPeriod,
eventCreateRequest.maxCapacity(),
getOrganizationMemberByIds(loginMemberIncludedIds),
createQuestions(eventCreateRequest.questions()),
eventCreateRequest.isApprovalRequired()
);
Event savedEvent = eventRepository.save(event);
emailNotifier.remind(reminderEmail);
return savedEvent;
}
이벤트를 생성하고 emailNotifier를 통해 이메일 전송을 하게된다. 여기서 왜 이메일 발송 내역을 저장하지 않냐고 할 수 있는데 해당 부분은 다음과 같이 emailNotifier 내부에서 진행하고 있다.
@RequiredArgsConstructor
public class OutboxEmailSender implements EmailSender {
private final EmailOutboxRepository emailOutboxRepository;
private final EmailOutboxRecipientRepository emailOutboxRecipientRepository;
private final EmailSender delegate;
@Override
@Transactional(propagation = Propagation.MANDATORY)
public void sendEmails(final List<String> recipientEmails, final String subject, final String body) {
EmailOutbox outbox = EmailOutbox.createNow(subject, body);
List<EmailOutboxRecipient> recipients = recipientEmails.stream()
.map(email -> EmailOutboxRecipient.create(outbox, email))
.toList();
emailOutboxRepository.save(outbox);
emailOutboxRecipientRepository.saveAll(recipients);
registerAfterCommitSend(recipientEmails, subject, body);
}
private void registerAfterCommitSend(final List<String> recipientEmails, final String subject, final String body) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
delegate.sendEmails(recipientEmails, subject, body);
}
});
}
}
위 코드를 보면 이메일 발송 내역을 Outbox 테이블에 저장하는 것을 확인할 수 있다. 이 부분을 별도로 분리한 이유는 도메인 로직이 아닌 기술적 관심사라고 판단했기 때문이다. Outbox 패턴은 이메일 전송 실패 시 최종적으로 성공을 보장하기 위한 기술이므로 인프라 레이어로 분리하였다.
트랜잭션 전파 옵션을 MANDATORY로 설정한 이유는 이메일 발송 내역 저장이 항상 특정 트랜잭션 내에서 수행되어야 하기 때문이다. 만약 트랜잭션 없이 호출되면 의도적으로 예외가 발생하도록 하여 잘못된 사용을 방지한다.
Outbox 테이블에 저장이 성공하고 커밋되면, 그제서야 실제 이메일 전송을 비동기로 수행한다. 이메일 전송에 성공하면 Outbox 튜플을 삭제하고, 실패하면 스케줄러가 1분마다 실패한 Outbox를 조회하여 재전송한다.
또 다른 문제
하지만 이런식으로 구현하면 또 다시 문제에 봉착하게 된다. 스케줄러가 "전송 실패한 Outbox"를 구분할 수 없기 때문이다. 현재 구조에서는 이메일 전송 성공 시에만 Outbox 튜플을 삭제하므로, 아직 전송 중인 경우에도 튜플이 남아있다. 따라서 스케줄러 입장에서는 해당 Outbox가 비동기 스레드를 통해 전송 중인지, 아니면 실패한 것인지 알 수 없다.
이로 인해 전송 중이지만 아직 삭제되지 않은 Outbox를 스케줄러가 조회하여 재전송하면 중복 전송이 발생한다.
물론 이메일 전송에 성공했으나 응답이 유실되어 Outbox 삭제에 실패하는 경우에도 중복 전송이 발생할 수 있다. 하지만 이는 네트워크 문제로 발생하는 상황으로 확률이 낮은 반면, 전송 중인 Outbox를 재전송하는 경우는 스케줄링 주기마다 빈번하게 발생한다. 따라서 후자의 중복 전송을 방지하는 것이 우선 과제였다.
해결 시도 1: 상태 컬럼 추가
이 문제를 해결하기 위해 Outbox 테이블에 상태 컬럼을 추가하면 된다. 이메일 전송 중일 때는 SENDING, 전송 실패 시에는 FAIL, 전송 성공 시에는 SUCCESS로 상태를 업데이트하는 방식이다. 스케줄러는 FAIL 상태인 Outbox만 조회하여 재전송을 수행한다.
하지만 이 방식에도 문제가 있다. SENDING 상태에서 이메일 전송 중 애플리케이션이 종료되면, 상태가 FAIL로 업데이트되지 않아 실제로는 전송 중이 아닌데도 SENDING 상태로 남게 된다. 이를 해결하려면 SENDING 상태의 Outbox가 실제로 전송 중인지, 아니면 전송 실패로 인해 멈춰있는 것인지 구별해야 한다. 결국 이는 처음에 직면했던 "상태 구분 불가" 문제로 다시 돌아가게 된다.
해결 시도 2: Worker 전용 처리 방식
그럼 다음과 같이 아키텍처를 바꾸면 어떨까?

서버는 이메일을 직접 전송하지 않고 Outbox 테이블에 이메일 발송 내역만 저장한다. 실제 이메일 전송은 오직 Worker만 담당하며, 전송 성공 시 상태를 SUCCESS로 업데이트한다.
Worker는 SUCCESS가 아닌 Outbox만 조회하여 이메일을 전송한다. 이메일 전송을 Worker만 담당하므로, 서버에서 비동기 전송 중 발생하던 "전송 중/실패 구분 불가" 문제가 해결된다.
하지만 이 방식도 Worker가 여러 대로 확장되면 문제가 발생한다. 여러 Worker가 동시에 SUCCESS가 아닌 Outbox를 조회하면, 아직 전송되지 않은 동일한 레코드를 여러 Worker가 중복으로 가져가 이메일을 중복 전송하게 된다. 결국 이경우에도 해당 Outbox가 현재 전송 중인지 아닌지를 판단할 수 없어 발생하는 문제다. 여러 Worker가 동일한 레코드를 "아직 전송되지 않음"으로 판단하여 동시에 처리하기 때문이다.
근본 문제
결과적으로 모든 접근 방식이 동일한 근본 문제로 귀결된다. 바로 해당 Outbox가 현재 전송 중인지 아닌지를 판단할 수 없다는 점이다.
- 비동기 스레드 + 스케줄러 방식: 스케줄러가 해당 Outbox를 비동기 스레드에서 전송 중인지, 아니면 전송에 실패한 것인지 구분 불가
- 상태 컬럼 추가 방식: SENDING 상태가 실제로 전송 중인지, 아니면 애플리케이션 종료로 인해 멈춰있는 것인지 구분 불가
- Worker 전용 처리 방식: 여러 Worker가 해당 Outbox를 다른 Worker에서 전송 중인지, 아니면 아직 아무도 처리하지 않은 것인지 구분 불가
세 가지 방식 모두 "현재 누군가(스레드, Worker)가 이 레코드를 처리하고 있는가?"를 판단할 수 없다는 동일한 문제에 직면한다.
이 문제를 해결하기 위한 두 가지 방법이 있다.
1. 비관적 락(Pessimistic Lock) 사용
이메일 전송 중에는 다른 Worker가 해당 Outbox를 조회하지 못하도록 데이터베이스 락을 거는 방식이다. MySQL의 FOR UPDATE SKIP LOCKED 구문을 사용하면 락이 걸린 레코드는 건너뛰고 다른 레코드를 조회할 수 있다.
2. 타임스탬프 기반 판단
Outbox 테이블에 locked_at 컬럼을 추가하여 레코드 처리 시작 시각을 기록한다. Worker는 locked_at과 현재 시간을 비교하여 일정 시간(예: 5분)을 초과한 작업은 전송 실패로 판단하고 재처리한다.
비관적 락 방식은 락이 걸린 상태에서 이메일 전송이 일어나기 때문에 트랜잭션을 과도하게 오래 유지하게 된다. 이메일 전송은 네트워크 I/O를 수반하여 수 초에서 수십 초가 걸릴 수 있으며, 이 시간 동안 데이터베이스 연결을 점유하게 되어 커넥션 풀 고갈 및 전체적인 DB 성능 저하를 초래할 수 있다. 따라서 타임스탬프 기반 방식을 선택했다.
구체적인 구현
Outbox 테이블에 locked_at 컬럼을 추가하고, Worker는 다음과 같은 조건으로 처리 대상을 조회한다.
SELECT *
FROM email_outbox
WHERE locked_at IS NULL
OR locked_at < NOW() - INTERVAL 5 MINUTE
FOR UPDATE SKIP LOCKED
LIMIT 10;
locked_at이 NULL인 경우는 아직 어떤 Worker도 처리하지 않은 Outbox이고, locked_at이 5분 이상 지난 경우는 이메일 전송 도중 Worker가 비정상 종료되는 등으로 인해 처리가 중단되었다고 판단했다.
즉, 5분을 처리 임계 시간(lease timeout) 으로 두고, 이를 초과한 Outbox는 재처리 대상으로 간주했다.
여기서 FOR UPDATE SKIP LOCKED를 사용하는 이유는 Worker가 여러 대로 확장되었을 때 동일한 Outbox를 동시에 처리하는 상황을 방지하기 위함이다.
Worker는 위 쿼리로 조회한 레코드에 대해, 같은 트랜잭션 안에서 locked_at을 현재 시각으로 업데이트한다.
UPDATE email_outbox
SET locked_at = NOW()
WHERE id IN (...);
이렇게 하면 트랜잭션이 커밋되기 전까지 해당 레코드는 Row Lock이 유지되며, 커밋 이후에는 locked_at 값이 갱신되어 다른 Worker가 동일한 Outbox를 다시 가져가지 못하게 된다.
트랜잭션이 커밋된 후 Worker는 이메일 전송을 시도한다. 이메일 전송이 성공하면 해당 Outbox 레코드를 삭제하고, 실패할 경우에는 레코드를 그대로 유지하여 다음 스케줄에서 재처리되도록 하였다.
해당 방식을 이용하면 트랜잭션을 짧게 유지하면서도(조회 및 locked_at 업데이트만), 이메일 전송이라는 긴 작업은 트랜잭션 외부에서 수행할 수 있어 데이터베이스 성능에 미치는 영향을 최소화한다.
물론 이 방식에도 단점이 있다. 전송 실패 시 재시도까지 5분이라는 대기 시간이 필요하므로 즉각적인 재전송은 불가능하다. 하지만 우리 서비스는 이메일이 실시간으로 전송될 필요가 없었기 때문에 이 방식을 선택하였다.
그럼 문제를 해결한 전체적인 아키텍처는 다음과 같다.

정리
이번 편에서는 이메일을 최소 한 번 보내도록 보장하기 위해 Transactional Outbox 패턴을 도입했다.
기존 시스템은 네트워크 장애, 비동기 스레드 풀 포화, 애플리케이션 종료 등의 상황에서 이메일 전송이 실패할 수 있었지만 Outbox 패턴을 통해 이메일 발송 내역을 데이터베이스에 먼저 저장하고, 별도의 Worker가 주기적으로 실패한 발송 내역을 재처리하도록 구현했다. 이 과정에서 "현재 전송 중인지 실패한 것인지 구분할 수 없다"는 문제를 마주했다.
최종적으로 locked_at 컬럼과 MySQL의 FOR UPDATE SKIP LOCKED 구문을 조합하여 이 문제를 해결했다. 해당 방식은 트랜잭션을 짧게 유지하면서도(조회 및 업데이트만) 이메일 전송이라는 긴 작업은 트랜잭션 외부에서 수행할 수 있어 오랜 시간동안 트랜잭션을 붙잡고 있지 않을 수 있었다.
이제 우리 시스템은 네트워크 장애, 스레드 풀 포화, 애플리케이션 재시작 등의 상황에서도 이메일 전송을 보장할 수 있게 되었다. Worker가 주기적으로 실패한 발송 내역을 재처리하므로, 일시적인 장애 상황에서도 최종적으로는 모든 이메일이 전송된다. 즉, 최소 한번은 이메일 전송에 성공한다는 의미이다.
다음 편에서는 AWS SES의 초당 14건 전송 제한을 토큰 버킷 알고리즘으로 해결한 과정을 다룰 예정이다.
'프로젝트' 카테고리의 다른 글
| 이메일 확실히 보내기 4편 - 토큰 버킷 알고리즘 기반 유량 제어 (1) | 2025.12.15 |
|---|---|
| 이메일 확실히 보내기 2편 - 서킷브레이커 (0) | 2025.12.12 |
| 이메일 확실히 보내기 1편 - BCC (0) | 2025.12.11 |
| On-The-Fly 이미지 리사이징 도입기 (4) | 2024.09.20 |
| 성능 테스트 자동화 환경 구축기 (2) (1) | 2024.09.15 |