MSA 환경에서 이벤트 데이터 정합성 문제를 해결하기 위한 아웃박스 패턴 적용

현재 상황

크라우드펀딩 플랫폼에서 Reward Service의 일부는 Kafka 기반 이벤트 드리븐 아키텍처로 구성되어 있고 이벤트를 발행하는 부분은 다음과 같은 구조로 발행을 하고 있다.

[Project Service] -> [Reward Service] -> [Project Service]

Project Service가 PROJECT_CREATED 이벤트를 발행하면 Reward Service가 리워드 생성 시도를 하고, 이때 성공 실패 여부를 Project Service에게 이벤트를 발행한다

 

현재 이벤트 플로우

  1. 이벤트 수신 (Consumer 역할)
    • Project Service로부터 PROJECT_CREATED 이벤트를 수신
    • 프로젝트에 속한 리워드 일괄 생성
  2. 비즈니스 로직 처리
    • 리워드 엔티티를 DB에 저장
    • 재고 정보 초기화
  3. 결과 이벤트 발행 (Producer 역할)
    • 성공 시: REWARD_CREATION_RESULT (success) 발행
    • 실패 시: REWARD_CREATION_RESULT (failure) 발행

리스너 코드

@KafkaListener(...)
public void consumeProjectEvent(ProjectCreatedEvent event, Acknowledgment ack) {
    try {
        rewardService.createRewardsForProject(...);  // DB 작업
        eventPublisher.rewardCreationResult(...);    // Kafka 발행
    } catch (Exception e) {
        eventPublisher.rewardCreationResult(...);    // 실패 이벤트 발행
    } finally {
        ack.acknowledge();
    }
}

 

퍼블리셔 코드

kafkaTemplate.send(topic, key, event)
    .whenComplete((result, ex) -> {...});

발견된 문제점

문제 1: Dual Write 문제

현재 코드는 데이터베이스 저장Kafka 이벤트 발행별도의 작업으로 분리되어 있는데, 이는 트랜잭션의 원자성을 위반한다.

rewardService.createRewardsForProject(...);  // DB 저장
eventPublisher.rewardCreationResult(...);    // Kafka 발행
 

이 두 작업은 둘 다 성공하거나 둘 다 실패해야 하는데, 한쪽만 성공할 수 있기 때문에 원자성이 보장되지 않는다.

 

문제 2: 비동기 Kafka 발행으로 인한 에러 처리 불가

kafkaTemplate.send(...).whenComplete((result, ex) -> {
    if (ex == null) {
        log.info("성공");
    } else {
        log.error("실패", ex);
    }
});
 

kafkaTemplate.send()는 비동기로 동작하기 때문에, 데이터베이스 커밋과 Kafka 발행의 성공/실패가 하나의 흐름으로 묶이지 않는다. 따라서 whenComplete()에서 발행 실패를 로그로 남길 수는 있지만, 이미 DB 트랜잭션이 커밋된 뒤엔 되돌리거나 동일 트랜잭션에서 복구 처리하기 어렵다.

또한 listener에서 offset을 먼저 커밋해버리면, 이후 발행이 실패해도 메시지를 다시 받을 수 없어 복구가 더 어려워진다.

 

문제3: ack 전략으로 인한 메시지 유실 가능성

현재처럼 finally에서 무조건 커밋 하는 경우, 트랜잭션이 롤백되면 Outbox 기록도 함께 롤백될 수 있다.

그런데 offset은 이미 커밋됐기 때문에 같은 메시지를 다시 받을 수 없어, 이벤트가 유실될 수 있다.

따라서 커밋 시점은 Outbox 기록이 영속화된 뒤로 조정하거나, 실패 기록을 별도 트랜잭션으로 남기는 전략이 필요하다.

 

해결 방법

1. 동기 발행 + 재시도 로직

sendResultEventSync(RewardCreationResultEvent.success(projectId));

private void sendResultEventSync(RewardCreationResultEvent event) {
    kafkaTemplate.send(rewardResultTopic, event.payload().projectId(), event)
            .get(5, TimeUnit.SECONDS);
}

기존에 kafkaTemplate.send(...).whenComplete(...) 방식과 달리 kafkaTemplate.send(...) .get(...) 방식은 .get()으로 완료 대기 후 실패 시 예외를 발생하기 때문에 보상 처리가 가능하다.

 

이 방식은 구현이 단순하고 명확하게 에러처리가 가능하지만, DB 커밋 후 Kafka 실패 시 데이터 불일치 문제를 해결해 주진 않는다.

또한 애플리케이션 종료 시 이벤트가 영구적으로 손실되고, 완료를 기다리기 때문에 응답 시간이 얼마나 지연될지 모른다.

 

방법 2: 아웃박스 패턴

아웃박스 패턴은 발행할 이벤트를 DB에 저장하여 비즈니스 데이터와 하나의 트랜잭션으로 묶어 원자성을 보장하는 것이다.

@Transactional
public void createRewardsForProject(...) {
    rewardRepository.save(reward);
    outboxRepository.save(...);
}
// 둘 다 성공하거나 둘 다 실패

이 방식은 비즈니스 로직과 이벤트를 단일 트랜잭션으로 관리하기 때문에 원자성이 보장되고, 메시징 서비스가 다운되어도 정상 동작한다.

또한 주기적으로 미발행 이벤트를 처리하도록 설정할 수 있고, 한 트랜잭션에서 처리하기 때문에 멱등성 또한 자연스럽게 관리할 수 있다.

 

하지만 구현 로직이 1번 방법보다 복잡하고, 이벤트가 바로 발행되는 게 아니라 지연이 발생하고 데이터베이스 부하가 더 늘어나게 된다.

 

최종 선택: 아웃박스 패턴

클라우드 펀딩에서 리워드 생성은 재고 관리와 연관성이 크기 때문에 데이터 정합성이 가장 중요하다.

또한 메시징 서비스에 문제가 생겨도 아웃박스는 정상 동작하기 때문에 메시징 서비스 정상화 시 다시 발행할 수 있다.

 

Outbox 패턴 적용 구조 요약

Outbox 패턴 적용 이후엔 Kafka로 바로 발행하지 않고, 발행할 이벤트를 Outbox 테이블에 먼저 저장한 뒤 커밋 이후 발행하도록 바꿨다.

즉, Reward 저장과 Outbox 저장을 같은 트랜잭션으로 묶어 원자성을 보장한다.

 

핵심 흐름

 

  1. Kafka 이벤트 수신
    ProjectEventListener가 PROJECT_CREATED를 수신하고 리워드 생성을 시작한다.
  2. 비즈니스 트랜잭션 처리 (RewardService)
    리워드를 저장하고, 결과(성공/실패) 이벤트를 즉시 Kafka 발행 대신 OutboxEventPublisher로 발행 요청한다.
  3. Outbox 저장 (BEFORE_COMMIT)
    @TransactionalEventListener(BEFORE_COMMIT)에서 Outbox를 저장한다.
    • Reward 저장과 동일 트랜잭션이라 둘 다 성공하거나 둘 다 실패한다.
  4. 커밋 직후 즉시 발행 1회 시도 (AFTER_COMMIT)
    @TransactionalEventListener(AFTER_COMMIT)에서 publishImmediately()로 Kafka 발행을 한 번 시도한다.
  5. 실패 시 Polling으로 재시도
    즉시 발행에 실패해도 Outbox row는 남아있으므로, OutboxPollingScheduler가 주기적으로 조회해 publishWithRetry()로 재시도한다.

 

RewardService에서 Outbox 저장

RewardService는 Kafka로 직접 보내지 않고, 성공/실패 결과를 OutboxEventPublisher로 보냈다.

RewardService - createRewardsForProject(...)

여기서 중요한 점은, OutboxEventPublisher.publish()가 즉시 Kafka 발행을 하는 게 아니라, Outbox 엔티티 생성 + Spring 이벤트 발행까지만 책임지는 것이다.

 

OutboxEventPublisher

RewardService가 Outbox 내부 구조를 다 알게 되면 결합도가 너무 높아진다.
그래서 Outbox 생성 로직은 OutboxEventPublisher로 모아두고, Service는 결과만 전달한다.

여기서  applicationEventPublisher는 Spring에서 제공하는 이벤트 발행 인터페이스의 구현체이다.

Service → Publisher → TransactionalEventListener로 흘러가면서 완성된다.

 

BEFORE_COMMIT 저장 / AFTER_COMMIT 발행 분리

Outbox의 핵심은 DB저장과 이벤트 기록을 한 트랜잭션에서 해결하는 것으로 생각하고 있다.

그래서 @TransactionalEventListener를 활용해 같은 트랜잭션에서 해결하도록 구현했다.

OutboxEventListener.java

Spring의 @TransactionalEventListener는 트랜잭션의 특정 시점에 맞춰 이벤트를 처리할 수 있게 해준다.

 

위에서 쓰인 BEFORE_COMMIT은 현재 트랜잭션이 커밋되기 전 시점이다.

그렇기때문에 리워드 생성과 같은 트랜잭션이고, Outbox 저장이 같은 트랜잭션으로 묶여 Dual Write 문제가 해결된다.

 

또한 AFTER_COMMIT 커밋이 성공된 이후에 실행된다.

이벤트 발행을 커밋 이후로 두면 커밋이 실패했을 경우에도 이벤트를 발행하는 상황을 예방할 수 있다.

 

커밋 이후엔 즉시 1회 이벤트 발행, 실패 시 재시도로 설계

Outbox에 이벤트가 저장됐다고 해서 이벤트 발행이 자동으로 보장되는 게 아니기 때문에 두 단계로 처리했다.

 

1. 커밋 직후 1회 빠르게 발행 시도

정상 상황에서는 커밋 직후 곧바로 발행되도록 publishImmediately()에서 한 번 전송을 시도한다.

이때 장점은 즉시 발행이 실패해도 Outbox 기록이 DB에 남아있기 때문에 유실되지 않는다는 것이다.

즉시 발행은 성공하면 좋고, 실패해도 Polling이 이어받는다.

 

2. 실패하면 Outbox에 남겨두고 주기적으로 재시도

장애나 일시적인 네트워크 문제로 발행에 실패하면, 스케줄러가 Outbox를 조회해 publishWithRetry()로 재시도하여 결국 발행되도록 설계했다.

 

마무리

이번 리팩토링의 목표는 Kafka 기반 이벤트 흐름에서 데이터 정합성을 지키는 것이었다.
기존 구조는 DB 저장과 Kafka 발행이 분리되어 있었고, kafkaTemplate.send().whenComplete() 형태의 비동기 발행은 실패해도 트랜잭션 관점에서 복구가 어려웠다.

 

Outbox 패턴을 적용하면서 Reward 저장과 발행할 이벤트 기록을 단일 트랜잭션으로 저장하도록 바꿀 때 @TransactionalEventListener의 phase옵션을 활용하였는데, 이부분이 조금 생소해 언제 Outbox를 저장하면 단일 트랜잭션으로 묶일까? 라는 고민을 좀 많이 했던 거 같다.

 

또한 커밋 이후엔 즉시 발행에 실패했을 때 스케줄러를 활용한 재시도 정책으로 내구성을 확보했는데

이렇게 하면 데이터베이스 접근이 많아져 고민이 생겼지만, 하는 일은 전송 상태 확인으로 단순하기 때문에 큰 문제가 없을 거로 판단되어 그대로 진행했다.

 

결과적으로 메시징 시스템 장애나 순간적인 네트워크 문제 상황에서도, 이벤트가 유실되기보다는 Outbox에 남아 결국 발행되는 구조로 개선할 수 있었다.

 

느낀점

처음 이벤트를 적용했을 때는 되게 간단하고 멱등성만 어떻게 잘 해결하면 동기방식보다 좋을 거 같다고 생각했다.

하지만 실제로 적용해 보니 실패했을 때 문제가 굉장히 컸다.

실패 자체는 큰 문제가 되지 않다는 생각이 들었는데 그 이유는 실패했을 경우 아무 이상 없이 조용히 묻혀 정합성이 깨지는 게 너무 컸기 때문이다.

그래서 이를 방지하고 Outbox 패턴을 적용했다.

하지만 Outbox만 붙인다고 끝나는 게 아니라, 언제 커밋할지 같은 처리 시점까지 함께 설계해야 데이터 유실/중복 문제를 예방 수 있다는 걸 체감했다.