메시지는 보냈는데 DB는 롤백됐다면? — 아웃박스 패턴과 재시도 토픽으로 만드는 자가 치유 파이프라인

12 min read
KafkaOutbox PatternSpring BootKurlyEvent-Driven
메시지는 보냈는데 DB는 롤백됐다면? — 아웃박스 패턴과 재시도 토픽으로 만드는 자가 치유 파이프라인

택배를 보냈는데 송장번호만 날아가고 물건은 창고에 그대로 있는 상황을 떠올려 보면 된다. 배송 시스템에서는 "발송 완료"인데, 실제로는 아무것도 움직이지 않았다. "DB 쓰기"와 "메시지 발행"이라는 두 작업이 하나의 단위로 묶이지 않으면 바로 이런 불일치가 생긴다. 컬리의 입고 시스템이 정확히 이 문제를 겪었고, 아웃박스 패턴과 재시도 토픽으로 해결했다.


1P에서 3PL로, 달라진 게임의 규칙

컬리는 원래 1P(직매입) 모델 중심이었다. 입고 예정 정보 대부분이 자사 발주 데이터에서 생성되므로 외부 데이터를 수신할 일이 거의 없었다. 하지만 3PL(제3자 물류) 사업을 확장하면서 판이 바뀌었다. 입고 예정 정보가 외부의 여러 채널에서 다양한 규격과 형태로 밀려들어 온다.

외부 채널에서 데이터를 받는 경로는 두 가지다.

  1. 컬리가 제공하는 입고 예정 정보 생성 API를 파트너사가 직접 호출
  2. 파트너사가 제공하는 입고 예정 정보 조회 API를 컬리가 주기적으로 폴링

두 경로 모두 인터페이스 DB에 데이터를 적재하고, Kafka 메시지를 발행해 리스너에서 컬리 규격으로 변환하는 흐름이다. 문제는 실패 지점이 두 군데라는 것이다.

  • 프로듀서 측: Kafka 메시지는 발행됐는데 DB 커밋이 실패하면, 인터페이스 테이블과 메시지 사이의 원자성이 깨진다.
  • 컨슈머 측: 메시지를 읽어 변환까지 성공했으나, 아직 파트너 정보나 상품 정보가 동기화되지 않아 입고 예정 정보 생성에 실패할 수 있다.

우체통에 편지를 넣어두면, 우체부가 수거한다

[💡 잠깐! 이 용어는?] 아웃박스 패턴(Outbox Pattern): "DB 쓰기"와 "메시지 발행" 두 작업의 원자성을 보장하는 아키텍처. 메시지를 직접 발행하지 않고, 같은 트랜잭션 내에서 아웃박스 테이블에 메시지 내용을 저장한 뒤, 별도 폴러가 이를 읽어 실제 메시지를 발행한다.

아웃박스 패턴의 핵심은 비유 그 자체다. 우체통(Outbox)에 편지를 넣어두면 우체부(Poller)가 정기적으로 수거해 배달하는 구조다. 편지를 넣는 행위와 실제 배달이 분리되어 있으므로, 우체부가 잠시 아파도 편지가 사라지지 않는다.

구체적인 흐름은 이렇다.

  1. 수신 데이터를 인터페이스 테이블에 적재한다.
  2. Kafka 메시지를 바로 발행하지 않고, 발행할 내용을 아웃박스 테이블에 저장만 해둔다.
  3. 별도 폴러(Poller)가 주기적으로 미발행 레코드를 읽어 Kafka 메시지를 발행한다.
  4. 발행에 성공하면 아웃박스 테이블에 완료 마킹을 한다.

1번과 2번이 같은 DB 트랜잭션 안에서 일어난다는 게 핵심이다. 트랜잭션이 커밋되면 둘 다 저장되고, 롤백되면 둘 다 사라진다. "All or Nothing"이 보장된다.

Namastack Outbox for Spring Boot

컬리는 바퀴를 직접 만들지 않았다. 2025년 10월 릴리즈된 Namastack Outbox for Spring Boot 라이브러리를 채택해 아웃박스 테이블 관리, 폴링 스케줄링, 재시도 횟수/상태 관리를 위임했다.

build.gradle — 의존성 추가
api("io.namastack:namastack-outbox-starter-jpa:0.3.0")
application.yml — 아웃박스 설정
outbox:
  poll-interval: 2000
  batch-size: 10
  processing:
    stop-on-first-failure: true
    publish-after-save: true
    delete-completed-records: false
    executor-core-pool-size: 4
    executor-max-pool-size: 8
  retry:
    max-retries: 3
    policy: "exponential"
    exponential:
      initial-delay: 2000
      max-delay: 60000
      multiplier: 2.0

코드 레벨에서 바뀌는 것들

Producer — 직접 발행에서 아웃박스 저장으로

기존에는 서비스 로직 내에서 kafkaProducePort.send(message)를 직접 호출했다. 변경 후에는 OutboxRecordRepository에 레코드를 저장하는 것으로 대체한다.

ExternalInboundExpectationSaveService.java — 아웃박스 적용
@Override
@Transactional
public void process(Request request) {
    inboundExpectationInterfaceMasterPort.save(request.toMaster());
    inboundExpectationInterfaceDetailPort.saveAll(request.toDetail());
 
    InboundExpectationMessage message = InboundExpectationMessage.from(request);
 
    try {
        OutboxRecord outboxRecord = new OutboxRecord.Builder()
            .aggregateId(message.inboundId())
            .eventType("INBOUND_EXPECTATION_SAVE")
            .payload(objectMapper.writeValueAsString(message))
            .build(clock);
        outboxRecordRepository.save(outboxRecord);
    } catch (Exception e) {
        throw new InboundServiceException(
            "Failed to serialize Inbound Expectation Created Event", e);
    }
}

@Transactional로 묶인 process 메서드 내에서 인터페이스 테이블 저장과 아웃박스 레코드 저장이 같은 트랜잭션에서 일어나므로 원자성이 보장된다. 비유하면 계좌 이체에서 출금과 입금이 하나의 트랜잭션으로 묶이는 것과 같은 원리다.

[💡 잠깐! 이 용어는?] 멱등성(Idempotency): 같은 연산을 여러 번 수행해도 결과가 달라지지 않는 성질. 아웃박스 패턴에서 인스턴스 다운 시 메시지가 중복 발행될 수 있으므로, Kafka 리스너에서 멱등성을 보장하는 설계가 필수다.

Poller — OutboxRecordProcessor 구현

폴러는 주기적으로 미발행 레코드를 읽어 실제 Kafka 메시지를 발행한다.

KafkaProduceOutboxProcessor.java — 폴러에서 메시지 발행
@Component
@Slf4j
public class KafkaProduceOutboxProcessor implements OutboxRecordProcessor {
    private final ObjectMapper objectMapper;
    private final KafkaProducePort kafkaProducePort;
 
    @Override
    public void process(@NonNull OutboxRecord outboxRecord) {
        switch (outboxRecord.getEventType()) {
            case "INBOUND_EXPECTATION_SAVE":
                this.sendInboundExpectationMessage(outboxRecord);
                break;
            default:
                log.warn("Unsupported OutboxRecord event type: {}",
                    outboxRecord.getEventType());
        }
    }
 
    private void sendInboundExpectationMessage(OutboxRecord outboxRecord) {
        try {
            InboundExpectationMessage message = objectMapper.readValue(
                outboxRecord.getPayload(), InboundExpectationMessage.class);
            kafkaProducePort.send(message);
        } catch (Exception e) {
            log.error("Failed to process OutboxRecord. outboxId: {}, error: {}",
                outboxRecord.getId(), e.getMessage(), e);
        }
    }
}

재시도 토픽 — 컨슈머가 스스로 낫는 구조

프로듀서 측은 아웃박스로 해결했다. 이제 컨슈머 측 문제를 보자. 아직 파트너 정보가 등록되지 않았거나 상품 정보가 동기화되지 않은 상태에서 입고 예정 정보가 도착할 수 있다. 이런 경우 일정 시간 후 재시도하면 성공할 가능성이 매우 높다.

입고 예정 정보는 보통 1~2일 전에 미리 생성된다. 이 시간이 곧 데드라인이다. 컬리는 10분 간격으로 최대 24시간(총 144회) 재시도하는 전략을 세웠다.

[💡 잠깐! 이 용어는?] DLT(Dead Letter Topic): 재시도를 모두 소진한 뒤에도 처리에 실패한 메시지가 최종적으로 보내지는 토픽. 운영자에게 "이건 수동 개입이 필요하다"는 신호를 보내는 역할이다.

Spring Kafka @RetryableTopic

Spring Kafka가 공식으로 제공하는 @RetryableTopic 어노테이션 하나로 재시도 토픽, 지연 시간, DLT를 설정할 수 있다.

SomethingConsumer.java — RetryableTopic 적용
@RetryableTopic(
    attempts = "145",
    backoff = @Backoff(delay = 600000L),
    sameIntervalTopicReuseStrategy = SameIntervalTopicReuseStrategy.SINGLE_TOPIC,
    kafkaTemplate = "retryKafkaTemplate"
)
@KafkaListener(
    topics = "${spring.kafka.topics.main-topic-name}",
    containerFactory = "containerFactory"
)
public void onMessage(SomethingRequestDTO somethingRequestDTO,
                      Acknowledgment acknowledgment,
                      @Header(KafkaHeaders.RECEIVED_TOPIC) String currentTopicName) {
    TopicNameSet topicNameSet = TopicNameSet.of(mainTopicName, currentTopicName);
    somethingConvertUseCase.convert(somethingRequestDTO.toDomain(), topicNameSet);
    acknowledgment.acknowledge();
}
 
@DltHandler
public void handleDeadLetter(SomethingRequestDTO somethingRequestDTO,
                             @Header(KafkaHeaders.RECEIVED_TOPIC) String dltTopicName) {
    alarmUtils.alarm(
        "[외부 채널 입고 정보 -> 재처리 최종 실패. 데드레터 알림]",
        "id: " + somethingRequestDTO.id() + "\nDeadLetter Topic Name: " + dltTopicName
    );
}

설정 시 주의할 포인트가 두 가지 있다.

  1. SINGLE_TOPIC 전략: 기본값인 MULTIPLE_TOPICS를 사용하면 재시도 토픽이 144개나 생성된다. 하나의 재시도 토픽만 운용하도록 반드시 변경해야 한다.
  2. 별도 retryKafkaTemplate: 기존 KafkaTemplateJsonSerializer로 객체를 직렬화한다. 재시도 토픽에서 같은 템플릿을 쓰면 이미 직렬화된 메시지를 다시 직렬화하는 이중 직렬화 문제가 발생한다. StringSerializer를 사용하는 별도 템플릿으로 해결한다.

기존 방식 vs 아웃박스 + 재시도 토픽

기준기존 방식 (직접 발행)아웃박스 + 재시도 토픽
원자성DB 커밋 실패 시 깨짐보장됨
프로듀서 장애 대응수동 재발행 필요자동 폴링 재시도
컨슈머 장애 대응실패 커밋 후 수동 처리10분 간격 144회 자동 재시도
운영 부담새벽 알림 → 수동 대응자가 치유, DLT 최종 알림만
코드 복잡도낮음라이브러리 활용으로 적당

운영 안정성과 자동화 측면에서 아웃박스 + 재시도 토픽 조합이 확실한 우위다. 새벽에 알림 받고 수동으로 재발행하던 시절과는 차원이 다르다.


마무리

발행부는 아웃박스 패턴으로 메시지를 안전하게 내보내는 데 집중하고, 수신부는 재시도 토픽으로 자신의 상태를 스스로 관리한다. 각자 자기 책임만 완벽히 수행하는 구조가 견고한 아키텍처의 핵심이다. Namastack Outbox와 Spring Kafka @RetryableTopic이라는 검증된 도구를 활용해 에너지를 본질적인 비즈니스 로직에 집중할 수 있었다는 점도 중요하다. 잘 만들어진 바퀴가 있다면, 다시 발명할 이유가 없다.

관심 있을 만한 포스트

분류 번호 없는 도서관 — 당근이 행동 로그의 카오스를 정리한 방법

코드 곳곳에 하드코딩되던 사용자 행동 로그를 중앙화된 이벤트센터 플랫폼으로 정리한 당근의 개발기.

Event TrackingData Platform

AI 코딩의 맹점 — Artifacts 없이 에이전트는 기억을 잃는다

PRD, ADR, TDD가 AI 코딩 워크플로우에서 왜 선택이 아닌 필수인지, 실전 구조와 함께 살펴본다.

AI 코딩Artifacts

Next-Translate 3.0 — Turbopack과 App Router를 위한 i18n 재건

1년간 공백 후 돌아온 Next-Translate 3.0이 Turbopack 지원, 비동기 params, App Router 안정화를 한 번에 처리하는 방법.

Next.jsi18n

V8 WasmGC 투기적 최적화 — 가상 메서드를 인라인으로 만드는 법

V8이 WasmGC의 가상 메서드 디스패치에 투기적 인라이닝을 도입해 Dart와 Java 앱에서 최대 8% 성능을 끌어낸 방법.

V8WebAssembly

Vinext — Vite 위에서 Next.js를 1주일 만에 다시 만든 이야기

Cloudflare가 AI와 함께 단 일주일, $1,100의 API 비용으로 Next.js 호환 프레임워크를 Vite 위에 구축한 과정.

VinextNext.js

Tsonic — TypeScript를 네이티브 바이너리로 컴파일하는 실험

TypeScript → C# → NativeAOT 파이프라인으로 네이티브 실행 파일을 만드는 Tsonic. 어떻게 동작하고, 어떤 한계가 있는지 살펴봤다.

TypeScriptNativeAOT

VS Code 팀의 AI 에이전트 병렬화 — 월간 릴리스를 주간으로 만든 워크플로우

VS Code 팀이 월간 릴리스에서 주간 릴리스로 전환한 비결. 에이전트 세션 병렬화, 자동화 파이프라인, 품질 게이트 설계 전반을 공개했다.

VS CodeAI

React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가

React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.

ReactReact Compiler