
회원가입 성공 시 가입 축하 메일을 발송하는 로직을 구현했다. 초기에는 단순한 동기 호출로 시작했지만, 시스템의 결합도를 낮추고 데이터 정합성을 보장하기 위해 점진적으로 구조를 고도화했다.
현재 프로젝트가 거창한 MSA 환경은 아니다. 하지만 아래와 같은 케이스를 방지하고 데이터 정합성을 보장하기 위해 트랜잭션 아웃박스패턴을 흉내(?) 내며 겪었던 이슈와 해결 과정을 정리해 본다.
[목표 요구사항]
가장 직관적이고 단순한 형태다.
@Transactional
public void create(UserCreateRequest request) {
String encodedPassword = passwordEncoder.encode(request.password());
User user = User.create(request.email(), encodedPassword, request.username());
userRepository.save(user);
// 외부 API 호출
mailService.sendEmail(request.email());
}
이 코드는 '회원가입 트랜잭션이 커밋되기 전'에 외부 API(메일 전송)를 호출한다는 치명적인 단점이 있다. 만약 메일 전송은 성공했는데, 그 직후 DB 커밋 단계에서 에러가 발생해 롤백된다면? '회원은 없는데 가입 축하 메일은 날아간' 유령 상태가 발생한다.
이를 해결하기 위해 메일 전송 요청을 DB에 먼저 저장하고, 별도의 스케줄러가 이를 처리하는 방식을 도입했다. 메일 이벤트 상태는 Enum으로 관리한다.
PENDING: 이벤트 생성 직후SENT: 이메일 발송 성공회원가입과 메일 발송 이벤트 저장을 하나의 DB 트랜잭션으로 묶는다.
이렇게 하면 회원가입이 실패(롤백)할 경우 메일 이벤트도 함께 사라지므로 정합성이 보장된다.
@Transactional
public void create(UserCreateRequest request) {
// ... User 저장 로직 ...
userRepository.save(user);
// 같은 트랜잭션 내에서 이벤트 저장
MailEventOutbox mailEvent = MailEventOutbox.from(request.email());
mailEventOutboxRepository.save(mailEvent);
}
PENDING 상태인 이벤트를 조회하여 전송 후 SENT로 변경한다. 트랜잭션 범위가 분리되어 롤백 문제는 해결되었지만, 이제 시스템 간 결합도를 더 낮추기 위해 Kafka를 도입하기로 했다.
지금보니, 구체적인 예외인 MailException을 처리하는것이 더 이상적일것 같습니다.
@Scheduled(fixedDelay = 3000)
public void processPendingMail() {
List<MailEventOutbox> pendingMails = repository.findByStatusOrderByIdAsc(MailStatus.PENDING, PageRequest.of(0, 10));
for (MailEventOutbox pendingMail : pendingMails) {
try {
mailService.sendEmail(pendingMail.getToEmail());
pendingMail.markAsSent();
} catch (Exception ex) {
log.error("MAIL FAILED: {}", pendingMail.getToEmail());
}
}
}
메일 이벤트의 상태를 세분화했다.
PENDING: 생성됨 (DB 저장 완료)PUBLISHED: Kafka Broker로 발행 완료 (Consumer 소비 전)SENT: Consumer가 메일 발송 완료DENY: 메일 서버에 문제가 발생할 경우 상태를 변경하여 무한재시도를 방지하기위한 상태값Kafka 발행 로직은 다음과 같이 구현했다. 핵심은 'DB 트랜잭션을 길게 가져가지 않는 것'이다.
// 스케줄러에 의해 주기적으로 실행
for (MailEventOutbox pendingMail : pendingMails) {
// 비동기 전송
sendAsync(TOPIC, pendingMail, (result, exception) -> {
if (exception != null) {
log.error(...);
return;
}
// 콜백: 전송 성공 시 상태 업데이트만 별도 트랜잭션으로 수행
tx.executeWithoutResult(status -> {
pendingMail.markAsPublished();
});
});
}
스케줄러 전체를 @Transactional로 묶으면 Kafka 네트워크 I/O 시간만큼 DB 커넥션을 점유하게 된다. 이는 장애의 원인이 될 수 있으므로, 콜백 내부에서 상태 업데이트만 TransactionTemplate(REQUIRES_NEW)로 짧게 수행하도록 설계했다.
Kafka Broker로 발행에 성공한 이벤트는 Published 상태로 업데이트하였다. 즉시 SENT로 변경하지 않은 이유는, Consumer에 장애가 발생하거나 트래픽이 몰려 지연이 발생할 경우, 이벤트는 성공적으로 발행했지만 계속해서 스케줄러가 DB에서 다시 조회해서 재발행하는 것을 방지하기 위함이다.
설계는 완벽해 보였으나, 테스트 과정에서 심각한 문제가 발생했다.
Kafka로 메시지는 잘 넘어가는데, DB의 상태가
PUBLISHED로 변하지 않는다.
스케줄러가 3초마다 계속PENDING상태인 이벤트를 조회해서 메일을 무한 중복 발송함.
방해금지모드 켜놔서 첫 메일만 확인 후 이후에 알게되었다는...
만약 실제 서비스일경우 사용자가 많이 화가날 수 있는 🤬 ...
이런 문제 발생에 대비하기 위해 로그를 남기고 알림시스템을 구축해놓는건가?

문제의 원인은 스레드 모델과 JPA 영속성 컨텍스트의 동작 방식에 있었다.
회원가입부터 메일발송의 과정은 다음과같다.
pendingMails를 조회한다. 이때 엔티티들은 영속성 컨텍스트의 관리를 받는다.sendAsync의 콜백은 별도의 Kafka Producer 스레드에서 실행된다.EntityManager는 스레드 로컬에 바인딩된다. 즉, 스케줄러 스레드의 영속성 컨텍스트와 콜백 스레드의 컨텍스트는 서로 다르다.pendingMail 객체는 다른 스레드에서 넘어온, 영속성 컨텍스트가 관리하지 않는 준영속객체일 뿐이다.UPDATE 쿼리가 나가지 않았던 것이다.콜백 스레드에서 새로운 트랜잭션을 열고, 엔티티를 다시 조회하여 영속 상태로 만든 뒤 수정했다.
sendAsync(TOPIC, pendingMail, (result, exception) -> {
if (exception != null) { ... }
tx.executeWithoutResult(status -> {
// ID로 다시 조회하여 영속 상태(Managed)로 만듦
MailEventOutbox managed = mailRepository.findById(outboxId)
.orElseThrow(() -> new EntityNotFoundException("..."));
managed.markAsPublished(); // 변경 감지 동작 O
});
});
트러블슈팅 과정에서 "쿼리를 한 번 더 날리는 비효율"과 "DB 커넥션 점유 시간" 사이에서 고민이 있었다.
DB 커넥션은 서비스에서 비싼 자원 중 하나다. 따라서 쿼리가 한 번 더 발생하더라도, 트랜잭션의 범위를 최소화하여 DB 자원을 효율적으로 쓰는 방식을 선택했다.
[추후 개선 예정]
findById(SELECT) -> Dirty Checking(UPDATE) 과정이다. 이를 JPQL 등을 사용해 바로 UPDATE 쿼리를 날린다면 SELECT 비용까지 절감할 수 있다.