대부분의 시스템은 초기에 단일 데이터베이스를 중심으로 모놀리식 기반 설계부터 시작된다.
서비스가 분리되고 도메인 경계가 명확해지며 트래픽이 늘어날수록 각 도메인이 자신의 데이터베이스를 소유해야 하는 시점이 찾아온다.
하지만 실제로는 분리된 두 서비스가 같은 데이터베이스를 바라보는 상황이 자주 발생한다.
이 구조는 단기적으로는 편리하지만, 시간이 지날수록 결합도 증가나 장애전이, 트랜잭션 경계 혼란 등 여러 문제가 드러나게 된다.
결국 분리된 서비스가 아닌 분리된 어플리케이션을 가진 모놀리식 구조처럼 된다는 것이다. (끔찍한 혼종..)
이러한 문제를 근본적으로 해결하려면, 서비스 간 통신을 데이터베이스 중심이 아닌 메시징 중심으로 분리해야 한다.
즉, 인프라 아키텍처의 중심에 메시지 브로커를 두고 각 서비스가 데이터를 공유하는 대신 이벤트로 교환하는 구조가 되어야 한다는 뜻이다.
1. 이벤트 발행, 어디서부터 시작해야 할까?
메시지 브로커를 도입했다고 해서 문제가 자동으로 해결되는 것은 아니다.
이벤트를 언제, 어떤식으로, 어떻게 발행할 것인가가 새로운 과제가 된다.
만약 서비스가 데이터베이스에 주문을 저장한 후 kafka로 이벤트를 발행한다면, 이 두 동작 사이에 실패가 발생할 수 있다.
예를 들어, 커머스시스템과 물류시스템이 분리되어 있고 아래와 같은 프로세스로 수행된다고 하자. (원래라면 더 세부적인 도메인 별로 서비스가 쪼개져 있겠지만 이해를 쉽게 두개로만 분리해 보자.)
- 커머스 시스템의 주문 테이블에 INSERT 성공
- Kafka에 주문 TOPIC Produce
- 물류 시스템에서 주문 TOPIC Consume
- 물류 시스템 주문 처리

하지만 위 프로세스를 수행하던 도중 네트워크 문제로 주문 TOPIC을 발행하지 못하거나 소비하지 못한다면 어떻게 될까?
주문 테이블에는 데이터가 존재하지만 물류 시스템은 이벤트를 받지 못하는 불일치 상태가 된다.
먼저, 주문 TOPIC을 발행하지 못하는 상황은 이벤트 유실 문제라고 한다.
이벤트 유실 문제는 Transactional Outbox 패턴 을 사용하여 해결할 수 있다.
2. Transactional Outbox Pattern이란?
Outbox 패턴은 비즈니스 데이터와 이벤트 데이터를 하나의 트랜잭션으로 함께 커밋하기 위한 설계 패턴이다.
"이벤트를 바로 브로커로 발행하지 말고, DB 트랜잭션 안에 Outbox 테이블을 두어 이벤트를 먼저 기록하자."
주문 트랜잭션 안에 Outbox 테이블로의 save를 한다면 이벤트 발행 자체는 나중에 별도 프로세스가 수행하더라도, 데이터는 db에 안전하게 저장되어 있기 때문에 언제든 재시도할 수 있다.
이 때 중요한 것은 이 주문 Outbox 테이블과 주문 테이블은 같은 Database에 속해있어야 한다. 이는 트랜잭션을 적용하기 위함인데, 타 데이터베이스 끼리의 트랜잭션은 전파되지 않기 때문이다.

Outbox 패턴의 목적은 데이터베이스와 메시지 브로커 간의 원자성을 보장하고, 이벤트 유실을 방지하며 재시도 가능한 구조를 확보하는것에 있다.
그러면 Outbox 패턴만 사용하면 이벤트 기반이 완성되느냐?.. 그것도 아니다.
Outbox는 발행(Producer) 측면에서의 안정성만을 책임질 뿐, 소비(Consumer) 측의 실패는 다루지 않는다.
바로 그 지점을 보완해주는 것이 DLQ(Dead Letter Queue)이다.
3. Dead Letter Queue(Topic)이란?
DLQ는 메시지를 소비 또는 하는 과정에서 실패가 발생했을 때, 실패한 메시지를 안전하게 격리하여 보관하는 장치다.
예를 들어 커머스의 주문 서비스가 Kafka 이벤트를 Consume하면서 비즈니스 로직 실패 또는 네트워크 오류가 발생했을 때 메시지를 버리게 되면 데이터 유실이 발생한다.
DLQ는 이런 실패 메시지를 별도의 토픽에 저장하고, 개발자는 이를 기반으로 실패 원인을 분석하거나 재처리할 수 있어야 한다.
요약하자면 Outbox가 발행 실패에 대비하는 장치라면, DLQ는 소비 실패에 대비하는 장치이다.
Kafka Connect에서는 처리할 수 없는 메시지를 DLQ로 보내도록 설정할 수 있는데, Dead Letter로 보내진 메시지는 무시되거나 수정 및 재처리될 수 있다.

Consume에 실패했을 때는 DLQ로 보내지겠지만, Consume에는 성공했지만 비즈니스 로직에 실패했다면, 이 때는 고려해야 할 부분이 더 많아진다.
비즈니스 로직을 재처리할 가치가 있다면(재처리하면 성공할 가능성이 있는 케이스), 재처리 시도 후 DLQ에 전송한다.
영구적인 오류라고 판단될 경우에도 DLQ에 전송한다.
이처럼 "트랜잭션(비즈니스 로직)이 실패하면 무조건 DLQ로" 가 아니라, 재시도 가치가 있으면 재시도하고, 그래도 안되면 DLQ로 보내되, 가치가 없으면(영구적인 오류일 때) 즉시 DLQ로 전송하는 방식이 정석적인 방법이다.
추가적으로 Kafka는 이러한 기능을 DLQ라고 부르지 않고, DLT(Dead Letter Topic)이라고 부른다.
4. Outbox 패턴과 DLQ는 '양자택일'이 아니다
위에서 설명했듯, Outbox와 DLQ는 서로 다른 문제를 해결한다. 즉, 서로 대체 관계가 아니라 보완 관계이다.
| 구분 | 다루는 영역 | 처리 |
| Outbox | Producer(발행부) | DB <-> Broker(Queue) 간 정합성 보장 |
| DLQ/DLT | Consumer(소비부) | 메시지 소비 실패, 재처리, 장애 복구 |
Outbox가 없다면 이벤트가 DB 저장 이후 사라질 수 있다.
DLQ가 없다면 이벤트는 발행되었지만 소비 과정에서 손실될 수 있다.

따라서 안정적인 이벤트 기반 아키텍처를 구성하려면 Outbox + DLQ 조합이 필수적이다.
5. Outbox는 어떻게 처리해야 할까?
위에서 설명했듯, 카프카를 사용한다면 Spring Cloud Kafka에서 제공하는 DLT 기능을 사용하면 된다.
하지만 Outbox는 어떻게 재처리를 해야 할까?
대표적으로 두 방식이 있다. 바로 Polling 방식과 CDC(Change Data Capture) 방식이다.
Polling 방식은 스케줄러가 주기적으로 돌면서 Outbox를 탐색하고 이에 대한 재처리를 수행한다.
CDC 방식은 데이터베이스에서 발생하는 변경 사항을 실시간으로 감지하고 추적하여 아웃박스 테이블에 변화가 발생했을때 이벤트를 발행한다.
두 방식의 차이점은 아래 표를 보면 알 수 있다.
| 방식 | 설명 | 장점 | 단점 |
| Polling(스케줄러) | 애플리케이션이 주기적으로 Outbox 테이블을 조회하여 메시지 재발행 | 구현이 간단, 인프라 부담이 낮음 | 지연 발생, 중복 관리 필요 |
| CDC | DB 변경 로그(binlog/WAL)를 실시간 감시하여 kafka로 자동 발행 | 실시간성, 중복 방지, 안정성 | 초기 세팅 어려움, 운영 복잡도 잇음 |
위 방식처럼 CDC는 폴링 방식의 완벽한 대안이다.
대표적인 구현체는 Debezium Outbox SMT로, Kafka에서 제공하는 connector이다.

Outbox 테이블의 행이 INSERT될 때마다 해당 데이터를 자동으로 Kafka 이벤트로 전송한다.
즉, 애플리케이션이 직접 발행 코드를 구현하지 않아도 DB 트랜잭션 로그만으로 안전하게 이벤트를 발행할 수 있다.
예를 들어, 실제 이 부분을 구현할 때 폴링 방식 같은 경우에는 주문 트랜잭션이 끝나기 직전, Outbox에 Save하고, ApplicationEventPublisher를 통해 이벤트를 publish하게 되면
@TransactionalEventListener의 AFTER_COMMIT 속성을 사용하여 트랜잭션 커밋 성공 이후에 kafka로 메시지를 쏘는 코드를 작성하게 될 텐데
CDC를 사용하게 된다면 이 부분이 필요없고 그냥 Outbox에 save만 하면 알아서 이벤트를 발행해주는 것이다.(물론 세팅 빡세게 해야겠지만..)
결과적으로 이처럼 Outbox + CDC + DLQ 조합은 이벤트를 안전하게 발행하고, 실패를 복구할 수 있는 완전한 파이프라인을 만들어 줄 수 있다.
6. 결론
아웃박스는 이벤트 발행의 정합성을 보장하고, CDC는 그 발행 과정을 자동화하며, DLQ는 이벤트 소비 과정의 실패를 제어한다.
셋이 조합될 때 비로소 이벤트 기반 시스템은 데이터를 잃지 않고, 장애를 통제할 수 있는 구조가 된다.
하지만 완벽한 아키텍처는 존재하지 않는다.
조직의 인프라 수준, 트래픽 규모, 운영 역량에 따라 어떤 조합이 최적일지는 달라진다.
중요한 것은 현재 시스템의 한계를 인식하고 상황에 맞게 균형 잡힌 선택을 하는 것이라고 생각한다.
'DEV > 개발일기 || 트러블슈팅' 카테고리의 다른 글
| 비동기로 성능개선을 한다고? - Coroutine, Semaphore, BulkHead로 안정적인 성능 만들기 (0) | 2025.10.12 |
|---|---|
| Coroutine과 ReactiveMongo 다중DB 환경에서 @Transactional을 사용해 보자 (0) | 2025.03.10 |
| 데이터 압축을 위한 Gorilla 알고리즘 적용 사례: 사내 솔루션 개발기 및 회고 (5) | 2024.09.03 |
| JPA에서 대용량 데이터를 읽거나 수정/삭제 할때, 쿼리를 어떻게 작성해야 할까? (1) | 2024.06.10 |
| 공유 자원에서의 동시성 이슈는 어떻게 해결해야 할까? (0) | 2023.12.15 |