마이크로서비스 아키텍처 · 데이터 일관성(사가·아웃박스) · 이론
이중 쓰기 문제와 그 해법
한 줄 요약
DB 에 쓰고 브로커에 발행하는 두 동작은 원자적이지 않다. 아웃박스는 그 둘을 하나의 DB 트랜잭션으로 접어 넣는 기법이다.
왜 이게 필요했나
가장 흔한 코드부터 봅시다.
def create_order(req): order = db.save(Order(req)) # 1. DB 저장 broker.publish("OrderCreated", order) # 2. 이벤트 발행 return order이 다섯 줄에 심각한 문제가 있습니다. 1번은 성공했는데 2번이 실패하면 주문은 존재하지만 아무도 모릅니다. 반대로 2번이 성공한 뒤 트랜잭션이 롤백되면 존재하지 않는 주문의 이벤트가 세상에 퍼집니다. 이것이 이중 쓰기 문제입니다.
"둘을 하나의 분산 트랜잭션으로 묶으면 되지 않나" 하는 생각이 자연스럽지만, 대부분의 브로커는 XA 트랜잭션에 참여하지 못하고, 2PC 자체가 코디네이터 장애 때 참여자를 붙잡아 두는 가용성 문제를 만듭니다. 그래서 실무는 다른 길을 갑니다.
어떻게 동작하나
아웃박스의 아이디어는 단순합니다. 이벤트를 브로커에 직접 보내지 않고, 비즈니스 데이터와 같은 트랜잭션으로 outbox 테이블에 한 행 넣습니다. DB 의 ACID 가 두 쓰기의 원자성을 보장합니다. 그다음 별도 프로세스(릴레이)가 그 테이블을 읽어 브로커로 옮깁니다.
테이블 설계에서 중요한 컬럼은 넷입니다. aggregate_id(파티션 키로 쓴다), event_type, payload, 그리고 상태 또는 발행 시각입니다. 릴레이 방식은 두 가지입니다. 주기적으로 PENDING 을 조회하는 폴링, 그리고 DB 의 트랜잭션 로그(WAL, binlog)를 읽는 CDC 입니다. CDC 는 지연이 작고 DB 부하가 적지만 운영 컴포넌트가 하나 늘어납니다.
여기서 반드시 이해할 점 하나. 아웃박스는 유실을 없애지만 중복은 없애지 못합니다. 릴레이가 브로커에 발행한 직후, 상태를 PUBLISHED 로 바꾸기 전에 죽으면, 재시작 후 같은 이벤트를 다시 발행합니다. 이것이 at-least-once 이고, 이 지점에서 소비자의 멱등성이 필수가 됩니다. 정확히 한 번은 전달이 만드는 성질이 아니라 수신 측이 만드는 성질입니다.
사가는 다른 문제를 풉니다. 여러 서비스에 걸친 하나의 비즈니스 트랜잭션을, 로컬 트랜잭션의 연쇄와 실패 시 보상 트랜잭션으로 구성합니다. 결제가 성공하고 배송이 실패하면 결제를 취소하는 보상 단계를 실행합니다. 사가는 롤백이 아니라 앞으로 가는 취소라는 점이 핵심입니다 — 이미 보낸 이메일은 되돌릴 수 없고 "취소 안내 메일"을 한 통 더 보낼 뿐입니다.
사가는 코레오그래피(각 서비스가 이벤트를 듣고 다음 행동)와 오케스트레이션(중앙 조정자가 순서를 지시) 두 형태입니다. 단계가 셋 이하면 코레오그래피가 가볍고, 넷을 넘어가면 흐름이 어디로 가는지 아무도 모르게 되어 오케스트레이션이 낫습니다.
현장에서 만나는 모습
아웃박스 테이블은 방치하면 비대해집니다. 발행 완료 행을 주기적으로 지우거나 파티션을 잘라 버려야 합니다. 그리고 status='PENDING' 조건의 부분 인덱스를 두지 않으면 릴레이 쿼리가 테이블 전체를 훑습니다.
보상 트랜잭션에서 자주 나오는 실수는 보상 자체가 실패할 수 있다는 사실을 무시하는 것입니다. 보상도 재시도되어야 하고, 따라서 보상도 멱등해야 합니다.
다음 실습에서 할 것
SQLite 로 주문과 아웃박스 테이블을 만들고, 먼저 이중 쓰기의 불일치를 재현한 뒤, 하나의 트랜잭션으로 접어 넣고, 릴레이를 Redis 로 발행하게 만들고, 릴레이가 중간에 죽었을 때 중복이 생기는 것까지 확인한 다음, 소비자에 중복 제거를 붙입니다.