🏗️ 아키텍처

최종 일관성

Eventual Consistency

분산 시스템에서 업데이트가 중단되면 결국(eventually) 모든 복제본이 동일한 값으로 수렴하는 일관성 모델입니다. 강한 일관성(Strong Consistency)을 완화하여 가용성(Availability)과 확장성(Scalability)을 확보하는 트레이드오프 전략입니다.

📐 CAP 정리 (CAP Theorem)

분산 시스템은 네트워크 분할(Partition) 상황에서 일관성(C)과 가용성(A) 중 하나를 선택해야 합니다.

C - Consistency
모든 노드가 같은 데이터
A - Availability
항상 응답 가능
P - Partition Tolerance
네트워크 분할 허용
3개 중 2개만
동시 보장 가능
CP 시스템
MongoDB, HBase
일관성 우선, 가용성 포기
AP 시스템
DynamoDB, Cassandra
가용성 우선, 최종 일관성
CA 시스템
단일 노드 RDBMS
분산 안 함 (비현실적)

⚖️ ACID vs BASE

ACID (전통적 RDBMS)

A - Atomicity 전부 성공 or 전부 실패
C - Consistency 항상 유효한 상태 유지
I - Isolation 트랜잭션 간 격리
D - Durability 커밋된 데이터 영구 보존
강한 일관성, 수직 확장, 단일 노드에 적합

BASE (NoSQL/분산 시스템)

BA - Basically Available 기본적 가용성 보장
S - Soft State 상태가 시간에 따라 변할 수 있음
E - Eventually Consistent 결국 일관성 달성
최종 일관성, 수평 확장, 대규모 분산에 적합

📊 일관성 스펙트럼

일관성은 이진적이지 않습니다. 강한 일관성과 최종 일관성 사이에 다양한 레벨이 존재합니다.

강한 일관성 (Strong) 인과적 일관성 (Causal) 최종 일관성 (Eventual)
Linearizability

가장 강력. 모든 읽기가 최신 쓰기를 반영. 단일 복사본처럼 동작.

Read-your-writes

자신이 쓴 데이터는 즉시 읽을 수 있음. 다른 사용자는 지연 가능.

Monotonic Reads

한번 읽은 값보다 이전 값을 읽지 않음. 시간이 역행하지 않음.

✅ 최종 일관성 적용 판단

적합한 경우
  • 소셜 미디어 좋아요/조회수
  • 상품 리뷰/평점
  • 사용자 프로필 업데이트
  • 검색 인덱스 갱신
  • 로그/분석 데이터 수집
  • CDN 캐시 무효화
  • DNS 레코드 전파
부적합한 경우
  • 은행 계좌 잔액
  • 재고 수량 (중복 판매 위험)
  • 예약/좌석 선택
  • 경매 입찰
  • 인증/권한 변경
  • 결제/송금 트랜잭션
  • 의료 기록 업데이트

💻 실제 구현 코드

# AWS DynamoDB: 읽기 일관성 옵션
import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Users')

# 1. Eventually Consistent Read (기본값)
# - 빠름 (리전 내 아무 복제본에서 읽기)
# - 최신 쓰기가 반영 안 될 수 있음 (보통 1초 이내 동기화)
# - 읽기 용량 유닛: 1 RCU = 4KB
response = table.get_item(
    Key={'user_id': 'user-123'},
    ConsistentRead=False  # 기본값
)
user = response.get('Item')
print(f"Eventually Consistent: {user}")

# 2. Strongly Consistent Read
# - 느림 (Primary 노드에서만 읽기)
# - 최신 쓰기가 항상 반영됨
# - 읽기 용량 유닛: 2배 소비 (1 RCU = 2KB)
# - Global Secondary Index에서는 사용 불가
response = table.get_item(
    Key={'user_id': 'user-123'},
    ConsistentRead=True  # 강한 일관성
)
user = response.get('Item')
print(f"Strongly Consistent: {user}")

# 3. 실제 사용 예시: 쓰기 후 읽기
def update_and_read_profile(user_id, new_name):
    # 프로필 업데이트
    table.update_item(
        Key={'user_id': user_id},
        UpdateExpression='SET #name = :name',
        ExpressionAttributeNames={'#name': 'name'},
        ExpressionAttributeValues={':name': new_name}
    )

    # 방금 쓴 데이터를 바로 읽어야 하므로 Strong Consistency 사용
    response = table.get_item(
        Key={'user_id': user_id},
        ConsistentRead=True  # 중요: 자신이 쓴 값 즉시 확인
    )
    return response.get('Item')

# 4. Query에서의 일관성
response = table.query(
    KeyConditionExpression='user_id = :uid',
    ExpressionAttributeValues={':uid': 'user-123'},
    ConsistentRead=True  # Query도 강한 일관성 가능
)
# 최종 일관성 시스템에서의 충돌 해결 전략
from datetime import datetime
from dataclasses import dataclass
from typing import List, Any
import hashlib

# 1. Last-Write-Wins (LWW) - 가장 간단
@dataclass
class LWWValue:
    value: Any
    timestamp: datetime

    def merge(self, other: 'LWWValue') -> 'LWWValue':
        """타임스탬프가 더 최신인 값을 선택"""
        return self if self.timestamp > other.timestamp else other

# 사용 예시
replica1 = LWWValue("value_A", datetime(2024, 1, 1, 10, 0))
replica2 = LWWValue("value_B", datetime(2024, 1, 1, 10, 5))
resolved = replica1.merge(replica2)  # "value_B" 선택


# 2. G-Counter CRDT (Conflict-free Replicated Data Type)
# 증가만 가능한 카운터 - 최종 일관성에서도 충돌 없이 병합
class GCounter:
    def __init__(self, node_id: str):
        self.node_id = node_id
        self.counts = {}  # {node_id: count}

    def increment(self, amount: int = 1):
        # 자신의 노드 카운트만 증가
        self.counts[self.node_id] = self.counts.get(self.node_id, 0) + amount

    def value(self) -> int:
        # 모든 노드 카운트의 합
        return sum(self.counts.values())

    def merge(self, other: 'GCounter') -> 'GCounter':
        """각 노드의 max 값을 취해 병합 - 충돌 없음"""
        merged = GCounter(self.node_id)
        all_nodes = set(self.counts.keys()) | set(other.counts.keys())
        for node in all_nodes:
            merged.counts[node] = max(
                self.counts.get(node, 0),
                other.counts.get(node, 0)
            )
        return merged

# 사용 예시: 좋아요 카운터
node_a = GCounter("node-a")
node_b = GCounter("node-b")

node_a.increment(5)   # 노드 A에서 5번 클릭
node_b.increment(3)   # 노드 B에서 3번 클릭

# 네트워크 복구 후 병합 - 순서 무관, 멱등성 보장
merged = node_a.merge(node_b)
print(f"총 좋아요: {merged.value()}")  # 8


# 3. Vector Clock - 인과관계 추적
class VectorClock:
    def __init__(self, node_id: str):
        self.node_id = node_id
        self.clock = {}

    def tick(self):
        self.clock[self.node_id] = self.clock.get(self.node_id, 0) + 1

    def compare(self, other: 'VectorClock') -> str:
        """인과관계 판단: 'before', 'after', 'concurrent'"""
        less = equal = greater = False
        all_keys = set(self.clock.keys()) | set(other.clock.keys())
        for key in all_keys:
            v1 = self.clock.get(key, 0)
            v2 = other.clock.get(key, 0)
            if v1 < v2: less = True
            elif v1 > v2: greater = True
            else: equal = True

        if less and not greater: return 'before'
        if greater and not less: return 'after'
        return 'concurrent'  # 충돌 발생!
# Saga 패턴: 분산 트랜잭션에서의 최종 일관성
# 각 서비스는 로컬 트랜잭션 + 보상 트랜잭션으로 일관성 유지
from enum import Enum
from dataclasses import dataclass
from typing import Callable, List
import asyncio

class SagaState(Enum):
    PENDING = "pending"
    COMPLETED = "completed"
    COMPENSATING = "compensating"
    FAILED = "failed"

@dataclass
class SagaStep:
    name: str
    action: Callable           # 실행할 작업
    compensation: Callable     # 롤백 작업

class OrderSaga:
    """주문 처리 Saga 예시"""

    def __init__(self, order_id: str):
        self.order_id = order_id
        self.state = SagaState.PENDING
        self.completed_steps: List[SagaStep] = []

    async def execute(self):
        steps = [
            SagaStep(
                name="재고 확보",
                action=lambda: self._reserve_inventory(),
                compensation=lambda: self._release_inventory()
            ),
            SagaStep(
                name="결제 처리",
                action=lambda: self._process_payment(),
                compensation=lambda: self._refund_payment()
            ),
            SagaStep(
                name="배송 예약",
                action=lambda: self._schedule_delivery(),
                compensation=lambda: self._cancel_delivery()
            ),
        ]

        try:
            for step in steps:
                print(f"[Saga] 실행: {step.name}")
                await step.action()
                self.completed_steps.append(step)

            self.state = SagaState.COMPLETED
            print(f"[Saga] 완료: 주문 {self.order_id}")

        except Exception as e:
            print(f"[Saga] 실패: {e}")
            await self._compensate()

    async def _compensate(self):
        """완료된 단계를 역순으로 롤백"""
        self.state = SagaState.COMPENSATING
        for step in reversed(self.completed_steps):
            try:
                print(f"[Saga] 보상: {step.name} 롤백")
                await step.compensation()
            except Exception as e:
                print(f"[Saga] 보상 실패: {e}")
        self.state = SagaState.FAILED

    # 각 단계 구현 (실제로는 마이크로서비스 호출)
    async def _reserve_inventory(self):
        # 재고 서비스에 재고 확보 요청
        await asyncio.sleep(0.1)  # 시뮬레이션

    async def _release_inventory(self):
        # 재고 서비스에 재고 해제 요청
        await asyncio.sleep(0.1)

    async def _process_payment(self):
        # 결제 서비스에 결제 요청
        await asyncio.sleep(0.1)
        # raise Exception("결제 실패")  # 테스트용

    async def _refund_payment(self):
        # 결제 서비스에 환불 요청
        await asyncio.sleep(0.1)

    async def _schedule_delivery(self):
        # 배송 서비스에 배송 예약
        await asyncio.sleep(0.1)

    async def _cancel_delivery(self):
        # 배송 서비스에 배송 취소
        await asyncio.sleep(0.1)

# 실행
async def main():
    saga = OrderSaga("order-12345")
    await saga.execute()

asyncio.run(main())

💬 대화로 배우기

👤
주니어 개발자
쇼핑몰에서 상품 재고를 관리하는데, DynamoDB를 쓰면 최종 일관성 때문에 재고가 마이너스가 될 수 있지 않나요?
🏗️
시니어 아키텍트
맞습니다. 재고처럼 과잉 판매(overselling)가 치명적인 경우는 최종 일관성이 부적합해요. 두 가지 해결책이 있습니다: (1) DynamoDB에서 ConsistentRead=True와 조건부 업데이트 사용, (2) 재고는 강한 일관성을 제공하는 별도 시스템(Redis, PostgreSQL)으로 분리.
👤
주니어 개발자
그럼 최종 일관성은 언제 쓰는 건가요? 위험해 보이는데요.
🏗️
시니어 아키텍트
"잠시 오래된 값을 보여줘도 비즈니스에 문제없는 경우"에 사용해요. 예를 들어 인스타그램 좋아요 수가 100인데 실제로는 103이라면? 사용자는 신경 안 써요. 하지만 가용성과 확장성은 엄청나게 좋아지죠. 트레이드오프를 이해하고 적재적소에 사용하는 게 핵심입니다.

⚠️ 주의사항

💰

금융 거래에 사용 금지

은행 잔액, 송금, 결제 등 정확성이 필수인 데이터에 최종 일관성을 적용하면 이중 출금, 마이너스 잔액 등 심각한 문제가 발생합니다. 강한 일관성 또는 분산 트랜잭션을 사용하세요.

📖

Read-after-Write 문제

데이터를 쓴 직후 읽으면 이전 값이 보일 수 있습니다. 사용자가 프로필을 수정했는데 새로고침하면 이전 값이 보이는 UX 문제. ConsistentRead 또는 클라이언트 캐싱으로 해결하세요.

⚔️

충돌 해결 전략 필수

동시에 같은 데이터를 수정하면 충돌이 발생합니다. Last-Write-Wins, Vector Clock, CRDT 등 명확한 충돌 해결 전략을 설계해야 합니다. "알아서 해결될 것"이라 가정하면 데이터 손실이 발생합니다.

📋 실제 적용 사례

Amazon DynamoDB
e-커머스

배경: Amazon의 장바구니 서비스는 초당 수백만 요청을 처리해야 합니다. 블랙 프라이데이 같은 피크 시즌에도 장바구니 추가가 실패하면 매출 손실로 직결됩니다.

적용: 최종 일관성 모델을 채택하여 여러 데이터센터에 복제. 장바구니 항목 추가는 "항상 성공"하고, 충돌(같은 상품 동시 추가)은 나중에 병합. 사용자에게 약간 오래된 장바구니를 보여주는 것보다 "추가 실패"가 더 나쁨.

핵심:

가용성 > 일관성인 경우. 장바구니 항목 개수가 잠시 틀려도 구매 시점에 정확하면 됨.

DNS (Domain Name System)
인프라

배경: DNS는 도메인을 IP 주소로 변환하는 전 세계적 분산 시스템입니다. 수십억 개의 DNS 쿼리가 매일 발생하며, 강한 일관성은 불가능합니다.

적용: TTL(Time-To-Live) 기반 최종 일관성. DNS 레코드 변경 시 즉시 전파되지 않고, 각 캐시 서버의 TTL 만료 후 갱신. 일반적으로 24-48시간 내 전파 완료.

핵심:

DNS 변경이 즉시 반영되지 않는 이유. 마이그레이션 시 TTL을 미리 낮춰두는 기법 사용.

Twitter 좋아요/리트윗 카운터
소셜 미디어

배경: 인기 트윗은 초당 수천 개의 좋아요를 받습니다. 모든 좋아요를 실시간으로 정확하게 집계하면 엄청난 부하가 발생합니다.

적용: 각 리전의 카운터를 비동기로 집계하여 최종 일관성 유지. 사용자마다 다른 숫자를 볼 수 있지만, 결국 수렴. "1.2M 좋아요"처럼 근사치로 표시하여 차이를 숨김.

핵심:

정확한 숫자가 중요하지 않은 경우. "많다"는 느낌이 중요하지 1,234,567인지 1,234,568인지는 무관.

🧠 이해도 퀴즈

Q. 최종 일관성(Eventual Consistency)이 적합한 사용 사례는?
A. 은행 계좌 잔액 조회
B. 항공권 좌석 예약
C. 소셜 미디어 게시물 조회수
D. 재고 수량 감소
정답: C

소셜 미디어 조회수는 정확성보다 가용성이 중요합니다. 조회수가 1,234,567인지 1,234,570인지는 사용자 경험에 영향을 주지 않습니다.

반면:
은행 잔액: 정확해야 함 (이중 출금 위험)
좌석 예약: 중복 예약 방지 필요
재고 수량: 과잉 판매 방지 필요

🔗 연관 용어

📚 학습 자료