회원가입 이메일 발송: 트랜잭션 분리와 아웃박스 패턴 트러블슈팅

콜 파머가 될 남자·2025년 12월 13일

성장하기

목록 보기
11/16
post-thumbnail

회원가입 성공 시 가입 축하 메일을 발송하는 로직을 구현했다. 초기에는 단순한 동기 호출로 시작했지만, 시스템의 결합도를 낮추고 데이터 정합성을 보장하기 위해 점진적으로 구조를 고도화했다.

  1. 초기: 이벤트 발행 없는 강결합 로직
  2. 개선: '회원가입'과 '메일발송'의 결합을 끊기 위해 Kafka 도입

현재 프로젝트가 거창한 MSA 환경은 아니다. 하지만 아래와 같은 케이스를 방지하고 데이터 정합성을 보장하기 위해 트랜잭션 아웃박스패턴을 흉내(?) 내며 겪었던 이슈와 해결 과정을 정리해 본다.

[목표 요구사항]

  • 회원가입이 DB에 커밋되면 메일 발송도 (결국엔) 성공해야 한다.
  • 회원가입은 성공했으나 메일 발송이 실패했다고 해서 회원가입이 롤백되면 안 된다.
  • 회원가입 트랜잭션이 실패하면 메일도 발송되지 않아야 한다. (중복 검사 실패 등)

초기 구현: 이벤트 발행 X

가장 직관적이고 단순한 형태다.

@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 커밋 단계에서 에러가 발생해 롤백된다면? '회원은 없는데 가입 축하 메일은 날아간' 유령 상태가 발생한다.


Kafka를 활용한 아웃박스 패턴 리팩토링

이를 해결하기 위해 메일 전송 요청을 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());
		}
	}
}

Publisher 구현과 상태 관리

메일 이벤트의 상태를 세분화했다.

  • 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 영속성 컨텍스트의 동작 방식에 있었다.

회원가입부터 메일발송의 과정은 다음과같다.

  1. pendingMails를 조회한다. 이때 엔티티들은 영속성 컨텍스트의 관리를 받는다.
  2. sendAsync의 콜백은 별도의 Kafka Producer 스레드에서 실행된다.
  3. Spring의 EntityManager는 스레드 로컬에 바인딩된다. 즉, 스케줄러 스레드의 영속성 컨텍스트와 콜백 스레드의 컨텍스트는 서로 다르다.
  4. 콜백 스레드 입장에서 pendingMail 객체는 다른 스레드에서 넘어온, 영속성 컨텍스트가 관리하지 않는 준영속객체일 뿐이다.
  5. 준영속 엔티티의 값을 아무리 변경해도, 변경 감지가 동작하지 않아 DB에 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 커넥션 점유 시간" 사이에서 고민이 있었다.

  • 스케줄러 전체 트랜잭션: 조회 쿼리 1번으로 끝낼 수 있지만, Kafka I/O 시간 동안 DB 커넥션을 물고 있어야 한다. 병목 지점이 될 수 있다.
  • 짧은 트랜잭션 + 재조회: 쿼리가 한 번(SELECT) 더 나가지만, 트랜잭션 유지 시간이 극도로 짧아진다.

DB 커넥션은 서비스에서 비싼 자원 중 하나다. 따라서 쿼리가 한 번 더 발생하더라도, 트랜잭션의 범위를 최소화하여 DB 자원을 효율적으로 쓰는 방식을 선택했다.

[추후 개선 예정]

  1. Direct Update: 현재는 findById(SELECT) -> Dirty Checking(UPDATE) 과정이다. 이를 JPQL 등을 사용해 바로 UPDATE 쿼리를 날린다면 SELECT 비용까지 절감할 수 있다.
  2. Kafka Key: 현재 키 값을 지정하지 않아 라운드 로빈으로 파티션에 들어간다. 순서 보장이나 중복 제거를 위해 Key 전략을 고민해 볼 예정이다.
profile
꾸준함 빼면 시체

0개의 댓글