분산 시스템에서의 이중 쓰기 문제
Source: Dev.to
개요
듀얼‑라이트 문제는 단일 논리 연산이 두 개(또는 그 이상)의 독립적인 시스템을 업데이트해야 할 때 발생합니다—예를 들어, 데이터베이스에 데이터를 영구 저장하고 및 Kafka와 같은 메시지 브로커에 이벤트를 발행하는 경우입니다. 시스템들이 트랜잭션 코디네이터를 공유하지 않기 때문에, 원자적인 “전부 혹은 전무” 동작을 달성하는 것은 매우 어렵습니다.
분산 트랜잭션 프로토콜
- Consensus‑based protocols (Paxos, Raft) 은 상태 머신에 대해 강한 일관성을 제공하며 분산 데이터베이스 및 구성 저장소에서 사용됩니다.
- TrueTime + Paxos (Google Spanner) 은 전역 ACID 보장을 제공합니다.
- 일반적인 마이크로서비스 아키텍처에서는 이러한 무거운 프로토콜을 거의 사용하지 않아 이중‑쓰기 문제를 야기합니다.
예시 시나리오
예를 들어 user service가 다음과 같이 해야 한다고 가정해 보겠습니다:
BEGIN;
INSERT INTO users (id, name) VALUES (...);
-- send a "UserCreated" event to Kafka
COMMIT;
데이터베이스 삽입은 성공했지만 Kafka 전송이 실패하거나(또는 그 반대인 경우) 시스템은 일관성 없는 상태가 됩니다—한쪽은 변경을 반영하고 다른 쪽은 반영하지 않습니다. 이는 이중 쓰기 문제를 보여줍니다.
핵심 문제
- 전역 트랜잭션 관리자가 두 작업을 조정하지 않는다.
- 네트워크 오류, 프로세스 충돌, 또는 재시도가 부분 업데이트를 일으킬 수 있다.
- 재시도로 인해 중복 이벤트가 발생하거나 순서가 뒤바뀐 처리가 발생할 수 있다.
결과
| Failure Mode | Result |
|---|---|
| DB 쓰기 성공, 이벤트 미전송 | 다운스트림 서비스가 새 엔티티에 대해 알지 못함. |
| 이벤트 전송됨, DB 쓰기 실패 | 소비자가 존재하지 않는 데이터에 대해 동작함. |
| 부분 재시도 | 중복 이벤트 또는 다중 DB 삽입. |
Source: …
일반적인 솔루션 / 패턴
트랜잭셔널 아웃박스 패턴
- 비즈니스 데이터 와 이벤트를 동일한 데이터베이스에 단일 트랜잭션으로 기록합니다.
- 백그라운드 프로세스(또는 CDC 도구)가 “outbox” 테이블을 읽어 이벤트를 Kafka에 발행합니다.
장점
- DB와 메시지 간 강력한 일관성 보장.
- 스토리지와 메시징을 모두 제어할 때 간단함.
단점
- 운영 복잡성이 증가함.
- 소비자는 중복 이벤트 발생 가능성을 처리해야 함.
체인지 데이터 캡처 (CDC)
- CDC 도구(예: Debezium, Oracle GoldenGate)를 사용해 데이터베이스 변화를 모니터링하고 자동으로 이벤트를 발생시킵니다.
장점
- 애플리케이션 코드에 이중 쓰기 로직이 필요 없음.
- CDC 파이프라인이 신뢰할 수 있다면 강력한 일관성 제공.
단점
- 이벤트 지연 가능성.
- 안정적인 스키마와 신뢰할 수 있는 CDC 인프라가 필요함.
멱등성 & 재시도 안전 설계
- 작업을 멱등하게 설계하여 반복해도 안전하도록 합니다.
- 고유 요청 ID를 사용하고, 소비자 측에서 중복 제거를 수행합니다.
장점
- 이기종 시스템 간에도 적용 가능.
단점
- 여전히 신중한 설계가 필요하며, 순서 보장은 해결되지 않음.
Source: …
트랜잭션 아웃박스 솔루션 (상세 예시)
스키마
-- Business table
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
total NUMERIC NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
-- Outbox table
CREATE TABLE outbox (
id UUID PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP DEFAULT now(),
published BOOLEAN DEFAULT FALSE
);
애플리케이션 트랜잭션 (원자적 쓰기)
import uuid, json
import psycopg2
conn = psycopg2.connect(...)
order_id = uuid.uuid4()
event = {
"event_id": str(uuid.uuid4()),
"type": "OrderCreated",
"order_id": str(order_id)
}
try:
with conn.cursor() as cur:
# Insert business data
cur.execute(
"INSERT INTO orders (id, customer_id, total) VALUES (%s, %s, %s)",
(order_id, "some-customer-id", 123.45)
)
# Insert outbox event
cur.execute(
"""
INSERT INTO outbox (id, event_type, payload)
VALUES (%s, %s, %s)
""",
(event["event_id"], event["type"], json.dumps(event))
)
conn.commit() # ✅ 두 행이 원자적으로 영속됩니다
except Exception as e:
conn.rollback()
raise
아웃박스 프로세서 (비동기 퍼블리셔)
가벼운 워커(또는 CDC 도구)가 outbox 테이블을 지속적으로 스캔하여 published = FALSE인 행을 찾습니다. 각 행에 대해:
payload를 Kafka에 퍼블리시합니다.- 해당 행을
published = TRUE로 표시합니다.
워커가 퍼블리시 도중에 크래시가 발생하면, 이벤트는 아직 표시되지 않은 상태로 남아 재시도됩니다. 이를 통해 최소 1회 전달(at‑least‑once delivery)을 보장하면서 일관성을 잃지 않게 됩니다.
목표: 데이터베이스에 쓰기와 메시지 브로커에 이벤트를 퍼블리시할 때 발생할 수 있는 불일치를 방지하여 이중 쓰기 문제를 해결하는 것입니다.