최종 일관성
Eventual Consistency
분산 시스템에서 업데이트가 중단되면 결국(eventually) 모든 복제본이 동일한 값으로 수렴하는 일관성 모델입니다. 강한 일관성(Strong Consistency)을 완화하여 가용성(Availability)과 확장성(Scalability)을 확보하는 트레이드오프 전략입니다.
Eventual Consistency
분산 시스템에서 업데이트가 중단되면 결국(eventually) 모든 복제본이 동일한 값으로 수렴하는 일관성 모델입니다. 강한 일관성(Strong Consistency)을 완화하여 가용성(Availability)과 확장성(Scalability)을 확보하는 트레이드오프 전략입니다.
분산 시스템은 네트워크 분할(Partition) 상황에서 일관성(C)과 가용성(A) 중 하나를 선택해야 합니다.
일관성은 이진적이지 않습니다. 강한 일관성과 최종 일관성 사이에 다양한 레벨이 존재합니다.
가장 강력. 모든 읽기가 최신 쓰기를 반영. 단일 복사본처럼 동작.
자신이 쓴 데이터는 즉시 읽을 수 있음. 다른 사용자는 지연 가능.
한번 읽은 값보다 이전 값을 읽지 않음. 시간이 역행하지 않음.
# 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())
ConsistentRead=True와 조건부 업데이트 사용, (2) 재고는 강한 일관성을 제공하는 별도 시스템(Redis, PostgreSQL)으로 분리.
은행 잔액, 송금, 결제 등 정확성이 필수인 데이터에 최종 일관성을 적용하면 이중 출금, 마이너스 잔액 등 심각한 문제가 발생합니다. 강한 일관성 또는 분산 트랜잭션을 사용하세요.
데이터를 쓴 직후 읽으면 이전 값이 보일 수 있습니다. 사용자가 프로필을 수정했는데 새로고침하면 이전 값이 보이는 UX 문제. ConsistentRead 또는 클라이언트 캐싱으로 해결하세요.
동시에 같은 데이터를 수정하면 충돌이 발생합니다. Last-Write-Wins, Vector Clock, CRDT 등 명확한 충돌 해결 전략을 설계해야 합니다. "알아서 해결될 것"이라 가정하면 데이터 손실이 발생합니다.
배경: Amazon의 장바구니 서비스는 초당 수백만 요청을 처리해야 합니다. 블랙 프라이데이 같은 피크 시즌에도 장바구니 추가가 실패하면 매출 손실로 직결됩니다.
적용: 최종 일관성 모델을 채택하여 여러 데이터센터에 복제. 장바구니 항목 추가는 "항상 성공"하고, 충돌(같은 상품 동시 추가)은 나중에 병합. 사용자에게 약간 오래된 장바구니를 보여주는 것보다 "추가 실패"가 더 나쁨.
가용성 > 일관성인 경우. 장바구니 항목 개수가 잠시 틀려도 구매 시점에 정확하면 됨.
배경: DNS는 도메인을 IP 주소로 변환하는 전 세계적 분산 시스템입니다. 수십억 개의 DNS 쿼리가 매일 발생하며, 강한 일관성은 불가능합니다.
적용: TTL(Time-To-Live) 기반 최종 일관성. DNS 레코드 변경 시 즉시 전파되지 않고, 각 캐시 서버의 TTL 만료 후 갱신. 일반적으로 24-48시간 내 전파 완료.
DNS 변경이 즉시 반영되지 않는 이유. 마이그레이션 시 TTL을 미리 낮춰두는 기법 사용.
배경: 인기 트윗은 초당 수천 개의 좋아요를 받습니다. 모든 좋아요를 실시간으로 정확하게 집계하면 엄청난 부하가 발생합니다.
적용: 각 리전의 카운터를 비동기로 집계하여 최종 일관성 유지. 사용자마다 다른 숫자를 볼 수 있지만, 결국 수렴. "1.2M 좋아요"처럼 근사치로 표시하여 차이를 숨김.
정확한 숫자가 중요하지 않은 경우. "많다"는 느낌이 중요하지 1,234,567인지 1,234,568인지는 무관.