분산 시스템에서의 이중 쓰기 문제

발행: (2025년 12월 29일 오후 09:05 GMT+9)
7 min read
원문: Dev.to

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 ModeResult
DB 쓰기 성공, 이벤트 미전송다운스트림 서비스가 새 엔티티에 대해 알지 못함.
이벤트 전송됨, DB 쓰기 실패소비자가 존재하지 않는 데이터에 대해 동작함.
부분 재시도중복 이벤트 또는 다중 DB 삽입.

Source:

일반적인 솔루션 / 패턴

트랜잭셔널 아웃박스 패턴

  1. 비즈니스 데이터 이벤트를 동일한 데이터베이스에 단일 트랜잭션으로 기록합니다.
  2. 백그라운드 프로세스(또는 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인 행을 찾습니다. 각 행에 대해:

  1. payload를 Kafka에 퍼블리시합니다.
  2. 해당 행을 published = TRUE로 표시합니다.

워커가 퍼블리시 도중에 크래시가 발생하면, 이벤트는 아직 표시되지 않은 상태로 남아 재시도됩니다. 이를 통해 최소 1회 전달(at‑least‑once delivery)을 보장하면서 일관성을 잃지 않게 됩니다.

목표: 데이터베이스에 쓰기와 메시지 브로커에 이벤트를 퍼블리시할 때 발생할 수 있는 불일치를 방지하여 이중 쓰기 문제를 해결하는 것입니다.

Back to Blog

관련 글

더 보기 »