아웃박스 패턴(Outbox Pattern): 분산 시스템에서 데이터 일관성 보장하기
아웃박스 패턴이란?
아웃박스 패턴(Outbox Pattern)은 분산 시스템에서 데이터베이스 트랜잭션과 메시지 발행을 원자적으로(atomically) 처리하기 위한 아키텍처 패턴입니다. 이 패턴은 마이크로서비스 환경에서 가장 중요한 과제 중 하나인 "데이터 일관성과 메시지 전달의 신뢰성"을 해결합니다.
패턴의 이름은 이메일 클라이언트의 "보낼 편지함(Outbox)"에서 유래했습니다. 이메일을 작성하면 먼저 보낼 편지함에 저장되고, 백그라운드 프로세스가 이를 실제로 전송합니다. 마찬가지로 아웃박스 패턴에서는 비즈니스 로직 실행 중 발생하는 이벤트를 즉시 메시지 브로커로 보내는 대신, 데이터베이스의 "Outbox" 테이블에 저장합니다. 그런 다음 별도의 프로세스가 이 테이블을 폴링하거나 CDC(Change Data Capture)를 통해 이벤트를 읽어 메시지 브로커로 발행합니다.
아웃박스 패턴이 해결하는 문제
분산 시스템에서 흔히 마주치는 상황을 생각해봅시다. 주문 서비스에서 주문을 생성하고, 동시에 다른 서비스들에게 "주문이 생성되었다"는 이벤트를 알려야 합니다.
전통적인 접근 방식은 이렇습니다: 먼저 데이터베이스에 주문을 저장하고, 그 다음 Kafka나 RabbitMQ에 이벤트를 발행합니다. 하지만 이 방식에는 심각한 문제가 있습니다.
"이중 쓰기 문제(Dual Write Problem)"가 핵심입니다. 데이터베이스 쓰기와 메시지 발행은 두 개의 독립적인 작업이므로, 하나는 성공하고 다른 하나는 실패할 수 있습니다. 데이터베이스에는 주문이 저장되었지만 메시지 발행이 실패하면, 다른 서비스들은 주문 생성을 알지 못합니다. 반대로 메시지는 발행되었지만 데이터베이스 트랜잭션이 롤백되면, 존재하지 않는 주문에 대한 이벤트가 전파됩니다.
분산 트랜잭션의 복잡성도 문제입니다. 2PC(Two-Phase Commit) 같은 분산 트랜잭션 프로토콜은 이론적으로는 작동하지만, 성능 오버헤드가 크고, 가용성을 저하시키며, 현대적인 클라우드 환경과 잘 맞지 않습니다. 많은 메시지 브로커는 분산 트랜잭션을 지원하지 않습니다.
메시지 손실 위험도 있습니다. 애플리케이션이 크래시되거나 네트워크 장애가 발생하면, 데이터베이스에 커밋된 변경사항에 대응하는 메시지가 영원히 발행되지 않을 수 있습니다.
아웃박스 패턴은 이러한 문제들을 우아하게 해결합니다. 비즈니스 데이터와 이벤트를 같은 데이터베이스 트랜잭션 내에서 저장함으로써 원자성을 보장하고, 별도의 신뢰성 있는 프로세스가 이벤트 발행을 책임집니다.
아웃박스 패턴의 작동 원리
아웃박스 패턴은 다음과 같이 동작합니다.
1단계: 트랜잭션 내 저장 - 비즈니스 로직이 실행될 때, 비즈니스 데이터(예: 주문)와 발행할 이벤트를 모두 같은 로컬 데이터베이스 트랜잭션 내에서 저장합니다. 이벤트는 "Outbox"라는 별도 테이블에 저장됩니다. 이 테이블은 일반적으로 이벤트 ID, 이벤트 타입, 페이로드(JSON 등), 생성 시간, 처리 상태 같은 컬럼을 가집니다.
2단계: 트랜잭션 커밋 - 데이터베이스 트랜잭션이 커밋되면, 비즈니스 데이터와 이벤트가 원자적으로 영속화됩니다. 둘 다 성공하거나 둘 다 실패합니다.
3단계: 이벤트 발행 - 별도의 프로세스(Message Relay 또는 Publisher)가 Outbox 테이블을 모니터링하고, 아직 발행되지 않은 이벤트를 찾아 메시지 브로커로 발행합니다.
4단계: 이벤트 마킹 또는 삭제 - 이벤트가 성공적으로 발행되면, 해당 레코드를 삭제하거나 "발행됨" 상태로 표시합니다.
핵심은 비즈니스 데이터와 이벤트가 하나의 ACID 트랜잭션 안에 있다는 점입니다. 데이터베이스가 일관성을 보장하므로, 별도의 분산 트랜잭션 프로토콜이 필요 없습니다.
구현 방법: 폴링 vs CDC
아웃박스 이벤트를 발행하는 방법은 크게 두 가지입니다.
"폴링 기반 발행자(Polling Publisher)"는 가장 간단한 접근입니다. 주기적으로(예: 매 초) Outbox 테이블을 쿼리하여 미발행 이벤트를 찾습니다. 찾은 이벤트를 메시지 브로커로 발행하고, 성공하면 해당 레코드를 삭제하거나 업데이트합니다.
장점은 구현이 단순하고, 모든 데이터베이스에서 동작하며, 특별한 권한이나 설정이 필요 없다는 점입니다. 단점은 폴링 간격만큼의 지연이 발생하고, 빈번한 폴링은 데이터베이스에 부하를 주며, 이벤트가 없어도 계속 쿼리를 실행한다는 점입니다.
폴링 최적화를 위해 인덱스를 적절히 설정하고, 배치 처리를 통해 여러 이벤트를 한 번에 처리하며, 지수 백오프를 적용하여 이벤트가 없을 때 폴링 빈도를 낮출 수 있습니다.
"CDC 기반 발행자(CDC-based Publisher)"는 더 현대적이고 효율적입니다. 데이터베이스의 트랜잭션 로그(예: PostgreSQL의 WAL, MySQL의 binlog)를 읽어 Outbox 테이블의 변경사항을 실시간으로 캡처합니다. Debezium 같은 CDC 플랫폼이 이를 지원합니다.
장점은 거의 실시간에 가까운 지연 시간, 데이터베이스 쿼리 부하가 없음, 효율적인 리소스 사용입니다. 단점은 초기 설정이 복잡하고, 데이터베이스 로그 접근 권한이 필요하며, 모든 데이터베이스가 CDC를 지원하는 것은 아니라는 점입니다.
CDC 방식에서는 Debezium이 Outbox 테이블의 INSERT를 감지하면 자동으로 Kafka 토픽으로 이벤트를 발행합니다. Debezium Outbox Event Router 같은 SMT(Single Message Transform)를 사용하면 Outbox 레코드를 적절한 도메인 이벤트로 변환할 수 있습니다.
실전 구현: Spring Boot 예제
Spring Boot와 PostgreSQL을 사용한 구체적인 구현을 살펴보겠습니다.
먼저 Outbox 엔티티를 정의합니다. 이벤트 ID를 고유 식별자로 사용하고, 집약 타입(aggregate type)과 집약 ID를 저장하여 어떤 엔티티와 관련된 이벤트인지 추적합니다. 이벤트 타입은 도메인 이벤트의 종류를 나타내고, 페이로드는 JSON 형태로 이벤트 데이터를 담습니다.
비즈니스 로직에서는 주문을 생성할 때 Order 엔티티와 OutboxEvent를 모두 생성하고 저장합니다. Spring의 @Transactional 어노테이션이 두 작업을 하나의 트랜잭션으로 묶어줍니다.
폴링 기반 발행자는 @Scheduled 어노테이션을 사용하여 주기적으로 실행되는 메서드를 구현합니다. 미발행 이벤트를 조회하고, Kafka로 전송한 후, 성공하면 해당 레코드를 삭제합니다. 여기서 중요한 것은 각 이벤트를 별도 트랜잭션으로 처리하여, 하나의 발행 실패가 전체를 막지 않도록 하는 것입니다.
CDC 기반으로 구현하려면 Debezium을 설정합니다. PostgreSQL 데이터베이스에 대한 Debezium 커넥터를 Kafka Connect에 등록하고, Outbox 테이블을 모니터링하도록 설정합니다. Outbox Event Router SMT를 활성화하면 Outbox 레코드가 적절한 Kafka 토픽으로 자동 변환됩니다.
멱등성과 중복 처리
아웃박스 패턴은 "최소 한 번(At-Least-Once)" 전달을 보장합니다. 즉, 이벤트가 최소 한 번은 발행되지만, 네트워크 장애나 발행자 재시작 등으로 인해 중복 발행될 수 있습니다.
중복 이벤트 발생 시나리오를 살펴보면, 발행자가 메시지를 브로커에 전송했지만 ACK를 받기 전에 크래시되는 경우가 있습니다. 재시작 후 같은 이벤트를 다시 발행하게 됩니다. 또한 Outbox 테이블 업데이트가 실패했지만 메시지는 이미 전송된 경우도 있습니다.
이벤트 소비자의 멱등성 보장이 필수적입니다. 소비자는 동일한 이벤트를 여러 번 받아도 같은 결과를 내야 합니다. 이를 위해 이벤트 ID를 추적하는 방법, 자연스럽게 멱등한 연산 사용(예: SET vs INCREMENT), 낙관적 잠금이나 버전 관리를 활용할 수 있습니다.
이벤트 ID 기반 중복 제거는 가장 확실한 방법입니다. 소비자가 처리한 이벤트 ID를 별도 테이블에 저장하고, 새 이벤트를 받으면 먼저 이 테이블을 확인합니다. 이미 처리된 이벤트라면 무시합니다. 이벤트 처리와 ID 저장을 하나의 트랜잭션으로 묶으면 원자성이 보장됩니다.
이벤트 순서 보장
분산 시스템에서 이벤트 순서는 복잡한 주제입니다.
단일 집약(Aggregate) 내 순서는 비교적 간단합니다. 같은 주문에 대한 이벤트들은 순서대로 처리되어야 합니다. 이를 위해 Kafka의 파티션 키로 집약 ID(예: 주문 ID)를 사용합니다. 같은 키를 가진 메시지는 항상 같은 파티션으로 가므로 순서가 보장됩니다.
전역 순서는 훨씬 어렵습니다. 모든 이벤트의 전역적인 순서를 보장하려면 단일 파티션을 사용해야 하는데, 이는 확장성을 크게 제한합니다. 대부분의 경우 전역 순서는 필요하지 않으며, 집약 단위 순서로 충분합니다.
타임스탬프와 인과성도 고려해야 합니다. Outbox 테이블에 타임스탬프를 저장하되, 시계 동기화 문제를 인지해야 합니다. 벡터 클럭이나 논리적 타임스탬프를 사용하여 인과 관계를 추적할 수 있습니다.
순서 위반 처리 전략으로는 버전 번호를 사용하여 순서가 맞지 않는 이벤트를 감지하고 재정렬하거나, 임시 버퍼에 저장했다가 올바른 순서가 되면 처리하는 방법이 있습니다.
아웃박스 테이블 관리
시간이 지나면 Outbox 테이블이 계속 커집니다. 효과적인 관리가 필요합니다.
레코드 삭제 전략은 여러 가지입니다. 발행 후 즉시 삭제하는 것이 가장 간단하지만, 재처리나 감사가 어렵습니다. 일정 기간 보관 후 삭제하는 방법은 최근 이벤트를 추적하고 디버깅할 수 있게 해줍니다. 아카이빙 후 삭제는 이벤트를 장기 저장소(예: S3)로 이동한 후 Outbox에서 제거합니다.
파티셔닝을 활용하면 대용량 테이블 관리가 쉬워집니다. 날짜별로 파티션을 나누고, 오래된 파티션을 통째로 삭제하거나 아카이브할 수 있습니다. PostgreSQL의 테이블 파티셔닝이나 MySQL의 파티션 기능을 활용합니다.
인덱스 최적화도 중요합니다. 발행되지 않은 이벤트를 빠르게 찾기 위해 처리 상태와 생성 시간에 인덱스를 생성합니다. 하지만 너무 많은 인덱스는 쓰기 성능을 저하시키므로 균형이 필요합니다.
정기적인 정리 작업을 스케줄링합니다. 매일 또는 매주 배치 작업으로 오래된 레코드를 삭제하거나 아카이브합니다. 피크 시간대를 피하고, 작은 배치로 나누어 처리하여 데이터베이스 부하를 분산시킵니다.
장애 처리와 복원력
아웃박스 패턴의 강점 중 하나는 장애에 대한 복원력입니다.
발행자 장애가 발생해도 이벤트는 Outbox 테이블에 안전하게 저장되어 있습니다. 발행자가 재시작되면 미발행 이벤트를 찾아 다시 처리합니다. 여러 발행자 인스턴스를 실행하여 고가용성을 확보할 수 있습니다.
메시지 브로커 장애 시에는 발행자가 재시도 로직을 통해 일시적 장애를 처리합니다. 지수 백오프와 최대 재시도 횟수를 설정하여 무한 루프를 방지합니다. 메시지 브로커가 복구되면 자동으로 이벤트 발행이 재개됩니다.
데이터베이스 장애는 전체 시스템에 영향을 주지만, 데이터베이스가 복구되면 모든 커밋된 트랜잭션은 보존됩니다. 데이터베이스 복제를 활용하여 고가용성을 확보합니다.
독 메시지(Poison Message) 처리도 필요합니다. 반복적으로 발행 실패하는 이벤트(예: 잘못된 포맷)는 별도 테이블로 이동하고 경고를 발생시킵니다. 무한 재시도로 시스템이 멈추는 것을 방지합니다.
모니터링과 알림을 통해 Outbox 테이블의 크기, 미발행 이벤트 수, 가장 오래된 미발행 이벤트의 나이를 추적합니다. 임계값을 초과하면 알림을 발송하여 문제를 조기에 감지합니다.
성능 최적화
아웃박스 패턴은 추가적인 데이터베이스 쓰기와 읽기를 수반하므로 성능 최적화가 중요합니다.
배치 처리를 통해 여러 이벤트를 한 번에 발행하면 네트워크 오버헤드가 줄어듭니다. JDBC 배치 업데이트를 사용하여 여러 레코드를 한 번에 삭제하거나 업데이트합니다.
비동기 커밋을 활용하여 메시지 브로커로의 발행이 블로킹되지 않도록 합니다. 발행 작업을 별도 스레드 풀에서 처리하여 폴링 주기가 지연되지 않도록 합니다.
연결 풀 최적화로 데이터베이스와 메시지 브로커에 대한 연결을 재사용합니다. 적절한 풀 크기를 설정하여 리소스를 효율적으로 사용합니다.
압축을 활용하여 큰 페이로드를 가진 이벤트의 저장 공간과 전송 크기를 줄입니다. JSON 대신 Protobuf나 Avro 같은 바이너리 포맷을 고려할 수 있습니다.
샤딩과 파티셔닝을 통해 대용량 환경에서 확장성을 확보합니다. 테넌트별, 지역별로 데이터베이스를 분리하고, 각각 독립적인 발행자를 운영합니다.
아웃박스 vs 트랜잭셔널 아웃박스 vs 로그 테일링
비슷해 보이는 패턴들을 구분해봅시다.
아웃박스 패턴은 앞서 설명한 대로 별도 테이블에 이벤트를 저장하고 발행하는 패턴입니다.
트랜잭셔널 아웃박스는 아웃박스 패턴의 다른 이름으로, 트랜잭션 내에서 이벤트를 저장한다는 점을 강조합니다.
"로그 테일링(Transaction Log Tailing)"은 Outbox 테이블을 사용하지 않고 직접 데이터베이스 트랜잭션 로그를 읽어 이벤트를 발행합니다. 비즈니스 테이블의 변경사항 자체를 이벤트로 간주합니다. Debezium이 이 방식을 사용할 수 있습니다. 장점은 Outbox 테이블 관리가 필요 없고 더 단순하다는 점이지만, 단점은 모든 테이블 변경이 이벤트가 되므로 세밀한 제어가 어렵고, 도메인 이벤트와 데이터 변경이 1:1 대응되지 않을 수 있다는 점입니다.
인박스 패턴과의 조합
아웃박스 패턴은 이벤트 발행 측의 문제를 해결하지만, 소비 측도 중요합니다.
"인박스 패턴(Inbox Pattern)"은 아웃박스의 반대편입니다. 이벤트 소비자가 메시지를 받으면 먼저 "Inbox" 테이블에 저장하고, 비즈니스 로직을 실행한 후, 처리 완료를 표시합니다. 이를 통해 멱등성과 정확히 한 번(Exactly-Once) 처리를 보장합니다.
아웃박스 + 인박스 조합은 엔드-투-엔드 신뢰성을 제공합니다. 발행자는 아웃박스로 최소 한 번 전달을 보장하고, 소비자는 인박스로 중복을 제거하여 정확히 한 번 처리를 달성합니다.
구체적으로 소비자는 메시지를 받으면 Inbox 테이블에 이벤트 ID와 함께 저장합니다. 이미 존재하는 ID라면 중복이므로 무시합니다. 새 이벤트라면 비즈니스 로직을 실행하고 결과를 저장합니다. 이벤트 저장, 비즈니스 로직 실행, 처리 완료 표시를 모두 하나의 트랜잭션으로 묶습니다.
사가 패턴과의 통합
아웃박스 패턴은 사가(Saga) 패턴과 자연스럽게 결합됩니다.
코레오그래피 기반 사가에서 각 서비스는 자신의 작업을 완료한 후 아웃박스를 통해 이벤트를 발행합니다. 다음 서비스가 이 이벤트를 받아 자신의 작업을 수행하고 또 이벤트를 발행합니다. 각 단계마다 아웃박스가 이벤트 발행의 신뢰성을 보장합니다.
오케스트레이션 기반 사가에서는 사가 오케스트레이터가 아웃박스를 통해 커맨드를 발행하고, 각 서비스는 아웃박스를 통해 응답 이벤트를 보냅니다. 오케스트레이터는 인박스로 응답을 받아 중복을 처리합니다.
보상 트랜잭션도 아웃박스를 통해 발행됩니다. 사가가 실패하면 각 서비스는 보상 이벤트를 아웃박스에 저장하고, 신뢰성 있게 발행하여 롤백을 수행합니다.
이벤트 소싱과의 관계
이벤트 소싱(Event Sourcing)과 아웃박스 패턴은 다른 개념이지만 함께 사용될 수 있습니다.
이벤트 소싱은 모든 상태 변경을 이벤트로 저장하고, 현재 상태를 이벤트 스트림에서 재구성하는 패턴입니다. 이벤트 저장소 자체가 진실의 원천(Source of Truth)입니다.
아웃박스 + 이벤트 소싱을 결합하면, 이벤트 저장소에 이벤트를 저장하는 것과 외부로 발행하는 것을 분리할 수 있습니다. 이벤트 저장소(예: 이벤트 테이블)가 Outbox 역할을 하며, 발행자가 이를 읽어 메시지 브로커로 전송합니다.
Axon Framework 같은 이벤트 소싱 프레임워크는 내부적으로 아웃박스와 유사한 메커니즘을 사용하여 이벤트를 외부 시스템으로 발행합니다.
클라우드 환경에서의 아웃박스
클라우드 네이티브 환경에서 아웃박스 패턴을 구현하는 방법도 다양합니다.
AWS에서는 RDS + Lambda를 활용할 수 있습니다. RDS의 Outbox 테이블을 Lambda 함수가 폴링하거나, RDS Proxy와 EventBridge를 조합하여 이벤트를 발행합니다. DynamoDB Streams를 활용하면 CDC 방식으로 구현할 수 있습니다.
GCP에서는 Cloud SQL + Cloud Functions 또는 Cloud Run을 사용합니다. Pub/Sub를 메시지 브로커로 활용하고, Dataflow를 통해 CDC 파이프라인을 구축할 수 있습니다.
Azure에서는 Azure SQL + Azure Functions를 조합하거나, Cosmos DB의 Change Feed를 활용합니다. Service Bus나 Event Hubs를 메시지 브로커로 사용합니다.
Kubernetes 환경에서는 Debezium을 Kafka Connect로 배포하고, StatefulSet으로 안정적인 발행자를 운영합니다. Helm 차트를 활용하여 표준화된 배포를 구현합니다.
모범 사례
아웃박스 패턴을 성공적으로 적용하기 위한 몇 가지 원칙이 있습니다.
작게 시작하기: 모든 서비스에 한 번에 적용하지 말고, 중요도가 높고 복잡도가 낮은 서비스부터 시작합니다.
모니터링 우선: 아웃박스를 도입하기 전에 포괄적인 모니터링과 알림을 구축합니다. 이벤트 발행 지연, 실패율, Outbox 테이블 크기를 추적합니다.
명확한 이벤트 스키마: 이벤트 페이로드의 스키마를 명확히 정의하고, Schema Registry를 활용하여 버전 관리와 호환성을 보장합니다.
문서화: 어떤 이벤트가 언제 발행되는지, 소비자는 누구인지, 재시도 정책은 무엇인지 명확히 문서화합니다.
테스트: 단위 테스트로 비즈니스 로직과 이벤트 저장을 검증하고, 통합 테스트로 발행자의 동작을 확인하며, 카오스 테스트로 장애 시나리오를 검증합니다.
점진적 롤아웃: 카나리 배포나 기능 플래그를 활용하여 점진적으로 아웃박스를 활성화하고, 문제 발생 시 신속하게 롤백할 수 있도록 준비합니다.
결론
아웃박스 패턴은 마이크로서비스 아키텍처에서 데이터 일관성과 메시지 전달 신뢰성을 보장하는 핵심 패턴입니다. 이중 쓰기 문제를 우아하게 해결하고, 분산 트랜잭션의 복잡성을 피하면서도 강력한 보장을 제공합니다.
폴링과 CDC 두 가지 구현 방식은 각각의 장단점이 있으며, 시스템의 요구사항과 제약사항에 따라 선택할 수 있습니다. 간단한 시스템에서는 폴링으로 시작하고, 규모가 커지면 CDC로 전환하는 것도 좋은 전략입니다.
아웃박스 패턴은 추가적인 복잡성을 도입하지만, 얻는 이점이 훨씬 큽니다. 이벤트 손실 없이 안정적인 시스템을 구축하고, 장애에 대한 복원력을 확보하며, 비즈니스 로직을 단순하게 유지할 수 있습니다.
분산 시스템에서 이벤트 기반 아키텍처를 구축한다면, 아웃박스 패턴은 선택이 아닌 필수입니다. 처음에는 복잡해 보일 수 있지만, 한번 구축하면 신뢰성 있는 시스템의 든든한 기반이 되어줄 것입니다.
댓글