이미지 로딩 중...
AI Generated
2025. 11. 7. · 5 Views
분산 시스템 일관성 모델 완벽 가이드
분산 시스템에서 데이터 일관성을 유지하는 다양한 모델을 실무 중심으로 살펴봅니다. CAP 이론부터 강한 일관성, 최종 일관성까지 실제 코드와 함께 깊이 있게 다룹니다. 대규모 서비스 설계에 필수적인 내용을 담았습니다.
목차
- CAP 이론 - 분산 시스템 설계의 기본 원칙
- 강한 일관성 - 선형화 가능성과 분산 트랜잭션
- 최종 일관성 - 확장성을 위한 현실적 선택
- 인과적 일관성 - 논리적 순서 보장
- 쿼럼 기반 일관성 - 유연한 일관성 수준 조정
- Raft 합의 알고리즘 - 이해하기 쉬운 강한 일관성
- CRDT - 충돌 없는 자동 병합
- 세션 일관성 - 사용자 경험 최적화
1. CAP 이론 - 분산 시스템 설계의 기본 원칙
시작하며
여러분이 글로벌 서비스를 운영하다가 네트워크 장애로 일부 서버와 연결이 끊긴 상황을 겪어본 적 있나요? 이때 "서비스를 계속 제공할 것인가, 아니면 데이터 정합성을 위해 서비스를 중단할 것인가"라는 딜레마에 빠지게 됩니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 예를 들어, 은행 시스템에서는 계좌 잔액이 틀릴 수 없기 때문에 정합성을 선택하고, SNS 서비스에서는 일시적인 데이터 불일치를 감수하더라도 서비스 가용성을 유지합니다.
이러한 선택은 비즈니스 요구사항과 직결되며, 잘못된 선택은 치명적인 결과를 초래할 수 있습니다. 바로 이럴 때 필요한 것이 CAP 이론입니다.
이 이론은 분산 시스템에서 무엇을 포기하고 무엇을 얻을지 명확한 기준을 제시해줍니다.
개요
간단히 말해서, CAP 이론은 분산 시스템에서 Consistency(일관성), Availability(가용성), Partition Tolerance(분할 내성) 세 가지 속성 중 최대 두 가지만 동시에 보장할 수 있다는 이론입니다. 실무에서 이 이론이 중요한 이유는, 시스템 아키텍처를 설계할 때 명확한 트레이드오프를 이해하고 의식적인 선택을 할 수 있게 해주기 때문입니다.
네트워크 분할은 피할 수 없는 현실이므로, 결국 우리는 일관성과 가용성 중 하나를 선택해야 합니다. 예를 들어, 결제 시스템 같은 경우에는 일관성을 선택하여 잘못된 거래를 방지하는 것이 매우 유용합니다.
기존에는 ACID 속성을 만족하는 단일 데이터베이스만 사용했다면, 이제는 BASE(Basically Available, Soft state, Eventually consistent) 모델을 채택하여 확장성과 가용성을 얻을 수 있습니다. CAP 이론의 핵심 특징은 첫째, 네트워크 분할은 현실에서 반드시 발생한다는 점, 둘째, 따라서 실질적으로는 CP(일관성+분할내성)와 AP(가용성+분할내성) 중 선택해야 한다는 점, 셋째, 이 선택은 비즈니스 요구사항에 따라 달라진다는 점입니다.
이러한 특징들이 중요한 이유는 시스템 설계 초기에 명확한 방향성을 잡을 수 있게 해주기 때문입니다.
코드 예제
# CAP 이론을 시뮬레이션하는 간단한 분산 저장소
class DistributedStore:
def __init__(self, mode='CP'): # CP 또는 AP 모드 선택
self.mode = mode
self.nodes = {'node1': {}, 'node2': {}, 'node3': {}}
self.network_partitioned = False
def write(self, key, value):
# CP 모드: 모든 노드가 응답할 수 있을 때만 쓰기 수행
if self.mode == 'CP' and self.network_partitioned:
raise Exception("Network partitioned - refusing write for consistency")
# AP 모드: 가용한 노드에만 쓰기 수행 (일관성 희생)
available_nodes = self._get_available_nodes()
for node in available_nodes:
self.nodes[node][key] = value
return f"Written to {len(available_nodes)} nodes"
def read(self, key):
# CP 모드: 최신 데이터 보장을 위해 쿼럼 읽기
if self.mode == 'CP':
return self._quorum_read(key)
# AP 모드: 가용한 노드에서 즉시 읽기
return self._fast_read(key)
설명
이것이 하는 일: 위 코드는 CP 모드와 AP 모드를 선택할 수 있는 분산 저장소를 시뮬레이션합니다. 네트워크 분할 상황에서 두 모드가 어떻게 다르게 동작하는지 보여줍니다.
첫 번째로, __init__ 메서드에서 시스템 모드를 결정합니다. CP 모드를 선택하면 일관성을 우선시하고, AP 모드를 선택하면 가용성을 우선시합니다.
세 개의 노드를 딕셔너리로 관리하며, 각 노드는 독립적인 데이터 저장소를 가집니다. network_partitioned 플래그는 네트워크 분할 상황을 시뮬레이션하기 위한 것입니다.
그 다음으로, write 메서드가 실행되면서 선택한 모드에 따라 다르게 동작합니다. CP 모드에서는 네트워크가 분할된 상태면 예외를 발생시켜 쓰기를 거부합니다.
이는 일부 노드에만 데이터가 쓰여서 데이터 불일치가 발생하는 것을 방지합니다. 반면 AP 모드에서는 가용한 노드에만 쓰기를 수행하여 서비스를 계속 제공하되, 나중에 노드 간 데이터 불일치가 발생할 수 있습니다.
마지막으로, read 메서드가 쿼럼 읽기(CP) 또는 빠른 읽기(AP)를 수행하여 최종적으로 데이터를 반환합니다. CP 모드에서는 과반수 노드의 데이터를 비교하여 최신 값을 보장하고, AP 모드에서는 첫 번째로 응답한 노드의 데이터를 즉시 반환합니다.
여러분이 이 코드를 사용하면 실제 분산 시스템에서 일관성과 가용성의 트레이드오프가 어떻게 작동하는지 구체적으로 이해할 수 있습니다. 금융 시스템처럼 정확성이 중요한 경우 CP 모드를 선택하고, 소셜 미디어처럼 서비스 중단이 치명적인 경우 AP 모드를 선택하는 등 실무 의사결정에 직접 활용할 수 있습니다.
또한 이러한 이해를 바탕으로 Cassandra(AP), HBase(CP), MongoDB(설정 가능) 같은 NoSQL 데이터베이스를 선택할 때 올바른 판단을 내릴 수 있습니다.
실전 팁
💡 실무에서는 네트워크 분할이 반드시 발생하므로, P(분할 내성)는 항상 선택해야 합니다. 따라서 실질적인 선택은 CP vs AP입니다.
💡 많은 개발자가 하는 흔한 실수는 모든 데이터에 동일한 일관성 수준을 적용하는 것입니다. 실제로는 데이터 종류별로 다른 일관성 수준을 적용해야 합니다. 예를 들어, 사용자 프로필은 최종 일관성으로, 결제 정보는 강한 일관성으로 처리하세요.
💡 AP 시스템을 선택했다면 반드시 충돌 해결 전략(conflict resolution)을 구현하세요. Last-Write-Wins, Vector Clock, CRDT 등의 기법을 활용할 수 있습니다.
💡 성능 테스트 시에는 네트워크 지연과 분할 상황을 시뮬레이션하여 시스템이 선택한 모드대로 동작하는지 검증하세요. Chaos Engineering 도구를 활용하면 효과적입니다.
💡 CAP 이론은 이진 선택이 아닙니다. PACELC 이론을 함께 공부하면, 네트워크가 정상일 때도 Latency와 Consistency 간의 트레이드오프가 있음을 이해할 수 있습니다.
2. 강한 일관성 - 선형화 가능성과 분산 트랜잭션
시작하며
여러분이 온라인 쇼핑몰에서 마지막 남은 상품을 구매하려는데, 동시에 다른 사용자도 같은 상품을 구매하려 한다면 어떻게 될까요? 두 사용자 모두에게 "구매 성공"이라고 알려주면 안 됩니다.
이런 문제는 은행 계좌 이체, 재고 관리, 예약 시스템 등 실제 비즈니스의 핵심 로직에서 반드시 해결해야 합니다. 데이터 불일치는 직접적인 금전적 손실이나 고객 신뢰 하락으로 이어지기 때문입니다.
단순히 "나중에 맞춰지면 되지"라는 생각으로는 이런 크리티컬한 시나리오를 처리할 수 없습니다. 바로 이럴 때 필요한 것이 강한 일관성(Strong Consistency)입니다.
이는 모든 사용자가 항상 동일한 최신 데이터를 보도록 보장하여 비즈니스 로직의 정확성을 지켜줍니다.
개요
간단히 말해서, 강한 일관성은 모든 읽기 작업이 가장 최근에 완료된 쓰기 작업의 결과를 반환하는 것을 보장하는 일관성 모델입니다. 이는 선형화 가능성(Linearizability)이라고도 불립니다.
실무에서 이 개념이 필요한 이유는, 분산 환경에서도 단일 서버처럼 동작하는 것처럼 보이게 하여 개발자가 복잡한 동시성 문제를 걱정하지 않아도 되기 때문입니다. 예를 들어, 항공권 예약 시스템에서 마지막 좌석을 두 명에게 판매하는 것을 방지하거나, 은행 시스템에서 잔액 이상의 출금을 막는 경우에 매우 유용합니다.
기존에는 단일 데이터베이스의 ACID 트랜잭션으로 일관성을 보장했다면, 이제는 분산 환경에서 2PC(Two-Phase Commit), Paxos, Raft 같은 합의 알고리즘을 통해 강한 일관성을 구현할 수 있습니다. 강한 일관성의 핵심 특징은 첫째, 전역적인 순서(global order)가 보장된다는 점, 둘째, 쓰기 작업이 완료된 즉시 모든 읽기에 반영된다는 점, 셋째, 이를 위해 성능과 가용성을 희생한다는 점입니다.
이러한 특징들이 중요한 이유는 비즈니스 크리티컬한 데이터의 정확성을 100% 보장할 수 있게 해주기 때문입니다.
코드 예제
# 2단계 커밋(2PC)을 사용한 분산 트랜잭션 구현
class TransactionCoordinator:
def __init__(self):
self.participants = [] # 트랜잭션 참여 노드들
def execute_distributed_transaction(self, transaction_id, operations):
# Phase 1: Prepare - 모든 참여자에게 준비 요청
prepare_results = []
for participant in self.participants:
result = participant.prepare(transaction_id, operations)
prepare_results.append(result)
# 하나라도 실패하면 전체 롤백
if not all(prepare_results):
self._abort_transaction(transaction_id)
return False
# Phase 2: Commit - 모든 참여자가 준비되면 커밋 수행
try:
for participant in self.participants:
participant.commit(transaction_id)
return True
except Exception as e:
# 커밋 실패 시 롤백 시도
self._abort_transaction(transaction_id)
raise Exception(f"Transaction failed: {e}")
def _abort_transaction(self, transaction_id):
# 모든 참여자에게 롤백 지시
for participant in self.participants:
participant.rollback(transaction_id)
설명
이것이 하는 일: 위 코드는 2단계 커밋(Two-Phase Commit) 프로토콜을 구현한 트랜잭션 코디네이터입니다. 여러 분산 노드에 걸친 작업을 원자적으로 수행하여 강한 일관성을 보장합니다.
첫 번째로, Phase 1(Prepare 단계)에서 코디네이터는 모든 참여 노드에게 "이 트랜잭션을 수행할 준비가 되었는가?"를 묻습니다. 각 참여자는 자신의 로컬 상태를 확인하고 트랜잭션을 수행할 수 있으면 True를, 불가능하면 False를 반환합니다.
예를 들어, 은행 계좌 이체에서 출금 계좌에 잔액이 충분한지 확인하는 단계입니다. 이렇게 하는 이유는 실제 커밋 전에 모든 노드가 성공할 수 있는지 미리 검증하기 위함입니다.
그 다음으로, 모든 참여자의 응답을 수집한 후 all(prepare_results)로 전체 결과를 확인합니다. 단 하나의 참여자라도 준비 실패를 보고하면 즉시 _abort_transaction을 호출하여 전체 트랜잭션을 중단합니다.
이는 "모두 성공하거나 모두 실패하거나(all-or-nothing)"라는 원자성을 보장하는 핵심입니다. 만약 이 검사 없이 진행하면 일부 노드만 업데이트되어 데이터 불일치가 발생합니다.
마지막으로, Phase 2(Commit 단계)에서 모든 참여자에게 실제 커밋을 지시합니다. 참여자들은 준비 단계에서 확보한 락(lock)을 사용하여 실제 데이터를 변경하고 영구 저장합니다.
만약 이 단계에서 예외가 발생하면 롤백을 시도하지만, 이미 일부 노드가 커밋했을 수 있어 불일치 상태에 빠질 수 있습니다. 이것이 2PC의 블로킹 문제입니다.
여러분이 이 코드를 사용하면 전자상거래의 주문-재고-결제 시스템, 은행의 계좌 이체, 예약 시스템의 좌석 확정 등 원자성이 필수적인 비즈니스 로직을 안전하게 구현할 수 있습니다. 또한 데이터베이스 샤딩 환경에서 여러 샤드에 걸친 트랜잭션을 처리하거나, 마이크로서비스 간 분산 트랜잭션을 구현할 때 핵심 패턴으로 활용됩니다.
다만 네트워크 지연과 노드 장애에 취약하므로, 프로덕션 환경에서는 타임아웃과 재시도 로직을 반드시 추가해야 합니다.
실전 팁
💡 2PC는 코디네이터가 단일 장애점(SPOF)이 되므로, 프로덕션에서는 코디네이터의 고가용성을 반드시 확보하세요. Raft나 Paxos 기반 코디네이터를 사용하는 것이 안전합니다.
💡 흔한 실수는 2PC의 블로킹 문제를 간과하는 것입니다. 코디네이터가 Prepare 후 Commit 전에 장애나면 모든 참여자가 락을 잡은 채 무한 대기할 수 있습니다. 타임아웃을 설정하고 장애 복구 프로토콜을 구현하세요.
💡 성능 최적화를 위해 읽기 작업에는 쿼럼 읽기(Quorum Read)를 고려하세요. N개 노드 중 과반수(N/2+1)만 읽으면 최신 데이터를 보장할 수 있어 모든 노드를 읽는 것보다 빠릅니다.
💡 분산 트랜잭션은 비용이 크므로, SAGA 패턴을 먼저 고려하세요. SAGA는 로컬 트랜잭션의 연속으로 분산 트랜잭션을 대체하며, 보상 트랜잭션으로 롤백을 처리합니다.
💡 디버깅 시 분산 트레이싱(Jaeger, Zipkin)을 활용하여 트랜잭션의 각 단계를 추적하세요. 어느 참여자에서 지연이나 실패가 발생하는지 파악하는 데 필수적입니다.
3. 최종 일관성 - 확장성을 위한 현실적 선택
시작하며
여러분이 페이스북에 게시물을 올렸는데, 친구는 바로 보이는데 다른 친구는 몇 초 후에 보인다면 문제가 될까요? 대부분의 경우 큰 문제가 아닙니다.
결국 모든 친구가 볼 수 있으니까요. 이런 상황은 실제로 대규모 웹 서비스에서 매우 흔하며, 오히려 의도적으로 설계된 것입니다.
만약 모든 사용자가 항상 완벽히 동일한 데이터를 보도록 강제한다면, 시스템은 엄청난 성능 저하를 겪고 확장성 문제에 직면할 것입니다. 글로벌 서비스에서 수백만 명의 사용자를 처리하려면 다른 접근이 필요합니다.
바로 이럴 때 필요한 것이 최종 일관성(Eventual Consistency)입니다. 이는 일시적인 불일치를 허용하되, 충분한 시간이 지나면 모든 복제본이 동일한 상태로 수렴하도록 보장합니다.
개요
간단히 말해서, 최종 일관성은 쓰기 작업이 즉시 모든 노드에 반영되지 않지만, 새로운 업데이트가 없다면 결국 모든 노드가 동일한 값으로 수렴하는 일관성 모델입니다. 실무에서 이 개념이 중요한 이유는, 높은 가용성과 낮은 지연시간, 그리고 수평 확장성을 동시에 얻을 수 있기 때문입니다.
Amazon, Facebook, Twitter 같은 거대 서비스들은 모두 최종 일관성을 활용합니다. 예를 들어, 소셜 미디어의 좋아요 수, 조회수, 캐시된 사용자 프로필 같은 경우에 완벽한 실시간 정확성보다 빠른 응답이 더 중요하므로 매우 유용합니다.
기존에는 강한 일관성을 위해 모든 쓰기를 동기화하고 대기했다면, 이제는 비동기 복제를 통해 쓰기를 즉시 완료하고 백그라운드에서 전파할 수 있습니다. 최종 일관성의 핵심 특징은 첫째, 쓰기 작업이 즉시 반환되어 매우 빠르다는 점, 둘째, 읽기 시 다른 복제본에서 다른 값을 볼 수 있다는 점, 셋째, 시간이 지나면 결국 수렴한다는 점입니다.
이러한 특징들이 중요한 이유는 CAP 이론의 AP(가용성+분할내성)를 선택하여 대규모 확장이 가능하게 해주기 때문입니다.
코드 예제
# 최종 일관성을 구현한 분산 캐시 시스템
import time
from typing import Dict, Any
class EventuallyConsistentCache:
def __init__(self, replication_delay=0.1):
self.primary = {} # 주 저장소
self.replicas = [{}, {}, {}] # 복제본 3개
self.replication_delay = replication_delay # 복제 지연 시뮬레이션
self.pending_updates = [] # 비동기 복제 큐
def write(self, key: str, value: Any) -> bool:
# 주 저장소에 즉시 쓰기 (동기)
self.primary[key] = {'value': value, 'timestamp': time.time()}
# 복제는 비동기로 큐에 추가
self.pending_updates.append((key, value, time.time()))
return True # 즉시 성공 반환 (빠른 응답)
def read(self, key: str, read_from='replica') -> Any:
# 복제본에서 읽기 (최종 일관성)
if read_from == 'replica':
import random
replica = random.choice(self.replicas)
return replica.get(key, {}).get('value', None)
# 주 저장소에서 읽기 (강한 일관성)
return self.primary.get(key, {}).get('value', None)
def _replicate_async(self):
# 백그라운드에서 실행되는 복제 프로세스
for key, value, timestamp in self.pending_updates:
time.sleep(self.replication_delay)
for replica in self.replicas:
replica[key] = {'value': value, 'timestamp': timestamp}
self.pending_updates.clear()
설명
이것이 하는 일: 위 코드는 최종 일관성을 가진 분산 캐시 시스템을 구현합니다. 쓰기는 즉시 반환되고, 복제는 비동기로 처리되며, 읽기 시점에 따라 다른 값을 볼 수 있습니다.
첫 번째로, write 메서드는 데이터를 주 저장소에만 즉시 쓰고 바로 True를 반환합니다. 복제본으로의 전파는 pending_updates 큐에 추가만 하고 실제 복제는 나중에 처리합니다.
타임스탬프를 함께 저장하여 나중에 충돌 해결 시 "최신 쓰기 우선(Last Write Wins)" 전략을 사용할 수 있습니다. 이렇게 하는 이유는 사용자에게 즉각적인 응답을 제공하여 사용자 경험을 향상시키고, 네트워크 지연이나 복제본 장애와 무관하게 쓰기를 성공시키기 위함입니다.
그 다음으로, read 메서드가 실행되면서 읽기 소스를 선택할 수 있습니다. 기본적으로는 랜덤한 복제본에서 읽어 부하를 분산시키지만, 아직 복제가 안 된 경우 None을 반환할 수 있습니다.
이것이 "최종" 일관성의 핵심입니다 - 같은 시점에 다른 사용자가 다른 값을 볼 수 있습니다. 만약 강한 일관성이 필요하면 read_from='primary'로 주 저장소에서 직접 읽을 수 있지만, 이는 주 저장소에 부하를 집중시킵니다.
마지막으로, _replicate_async 메서드가 백그라운드 스레드나 워커에서 주기적으로 실행되어 큐에 쌓인 업데이트를 모든 복제본에 전파합니다. replication_delay로 네트워크 지연을 시뮬레이션하며, 실제 환경에서는 이 시간 동안 서로 다른 복제본이 다른 값을 가질 수 있습니다.
복제가 완료되면 큐를 비워 메모리를 확보합니다. 여러분이 이 코드를 사용하면 Redis Cluster, Cassandra, DynamoDB 같은 NoSQL 데이터베이스의 동작 방식을 이해할 수 있습니다.
실무에서는 사용자 세션 데이터, 제품 카탈로그, 콘텐츠 피드 같이 약간의 지연이 허용되는 데이터에 활용할 수 있습니다. 예를 들어, 전자상거래에서 제품 상세 정보는 최종 일관성으로, 재고 수량은 강한 일관성으로 처리하는 하이브리드 전략을 구사할 수 있습니다.
또한 Read-Your-Writes 일관성을 보장하려면 세션 어피니티(sticky session)를 사용하여 같은 사용자의 요청을 같은 복제본으로 라우팅하는 기법을 추가할 수 있습니다.
실전 팁
💡 최종 일관성 시스템에서는 반드시 충돌 해결 전략을 정의하세요. Last-Write-Wins(타임스탬프 기반), Version Vector, CRDT(Conflict-free Replicated Data Type) 등을 비즈니스 로직에 맞게 선택해야 합니다.
💡 많은 개발자가 하는 실수는 모든 읽기가 최신 값을 반환할 것이라 가정하는 것입니다. UI 설계 시 "데이터가 곧 업데이트됨"을 사용자에게 표시하거나(예: "처리 중..."), 낙관적 UI 업데이트를 사용하세요.
💡 복제 지연(replication lag)을 모니터링하는 메트릭을 반드시 구축하세요. 정상적으로는 밀리초 단위이지만, 네트워크 문제나 부하로 인해 초 단위로 늘어날 수 있으며, 이는 사용자 경험에 직접 영향을 줍니다.
💡 읽기 일관성 수준을 애플리케이션 레벨에서 조정할 수 있게 하세요. Cassandra의 QUORUM 읽기처럼, 중요한 읽기는 여러 복제본의 합의를 요구하고, 덜 중요한 읽기는 단일 복제본에서 처리하는 식으로 최적화할 수 있습니다.
💡 테스트 시 Jepsen 같은 도구로 네트워크 분할과 노드 장애를 시뮬레이션하여 최종 일관성이 실제로 보장되는지 검증하세요. 단순 유닛 테스트로는 분산 환경의 edge case를 잡기 어렵습니다.
4. 인과적 일관성 - 논리적 순서 보장
시작하며
여러분이 채팅 앱에서 "안녕하세요"라는 메시지를 보낸 후 "어떻게 지내세요?"라고 보냈는데, 상대방에게는 역순으로 표시된다면 어떨까요? 대화의 맥락이 완전히 깨져버립니다.
이런 문제는 소셜 미디어의 댓글, 협업 도구의 문서 편집, 게임의 이벤트 처리 등에서 빈번히 발생합니다. 최종 일관성만으로는 충분하지 않은 경우가 많습니다.
시간적으로 완벽히 동기화할 필요는 없지만, 원인과 결과의 순서는 반드시 지켜져야 하는 상황들이 있습니다. 예를 들어, "게시물 작성" 후 "게시물 수정"은 반드시 순서가 지켜져야 하지만, 서로 다른 사용자의 독립적인 게시물은 순서가 바뀌어도 됩니다.
바로 이럴 때 필요한 것이 인과적 일관성(Causal Consistency)입니다. 이는 원인과 결과 관계가 있는 작업들의 순서를 보장하면서도, 독립적인 작업들은 자유롭게 처리할 수 있게 해줍니다.
개요
간단히 말해서, 인과적 일관성은 원인과 결과 관계(happens-before)가 있는 이벤트들은 모든 노드에서 동일한 순서로 보이도록 보장하지만, 동시 발생(concurrent) 이벤트들은 순서를 보장하지 않는 일관성 모델입니다. 실무에서 이 개념이 필요한 이유는, 강한 일관성의 성능 오버헤드 없이도 사용자 경험에 필수적인 논리적 순서를 보장할 수 있기 때문입니다.
채팅 애플리케이션, 공동 문서 편집, 분산 버전 관리 시스템 등에서 필수적입니다. 예를 들어, Slack에서 스레드 대화가 올바른 순서로 표시되거나, Google Docs에서 여러 사람의 편집이 논리적으로 병합되는 경우에 매우 유용합니다.
기존에는 전역 타임스탬프나 중앙화된 순서 결정자가 필요했다면, 이제는 Vector Clock이나 Version Vector를 통해 분산 환경에서 효율적으로 인과 관계를 추적할 수 있습니다. 인과적 일관성의 핵심 특징은 첫째, happens-before 관계를 보존한다는 점, 둘째, 동시 발생 이벤트는 다른 노드에서 다른 순서로 보일 수 있다는 점, 셋째, 강한 일관성보다 가볍고 최종 일관성보다 강하다는 점입니다.
이러한 특징들이 중요한 이유는 사용자의 의도와 맥락을 보존하면서도 시스템의 확장성을 유지할 수 있게 해주기 때문입니다.
코드 예제
# Vector Clock을 사용한 인과적 일관성 구현
from typing import Dict, Tuple
class VectorClock:
def __init__(self, node_id: str):
self.node_id = node_id
self.clock: Dict[str, int] = {node_id: 0} # {노드ID: 카운터}
def increment(self) -> Dict[str, int]:
# 로컬 이벤트 발생 시 자신의 카운터 증가
self.clock[self.node_id] = self.clock.get(self.node_id, 0) + 1
return self.clock.copy()
def update(self, other_clock: Dict[str, int]) -> None:
# 다른 노드의 이벤트 수신 시 벡터 클락 병합
for node, count in other_clock.items():
self.clock[node] = max(self.clock.get(node, 0), count)
# 자신의 카운터도 증가
self.clock[self.node_id] = self.clock.get(self.node_id, 0) + 1
def happens_before(self, other_clock: Dict[str, int]) -> bool:
# self가 other_clock보다 먼저 발생했는지 확인
is_less_or_equal = all(
self.clock.get(node, 0) <= other_clock.get(node, 0)
for node in set(self.clock.keys()) | set(other_clock.keys())
)
is_strictly_less = any(
self.clock.get(node, 0) < other_clock.get(node, 0)
for node in set(self.clock.keys()) | set(other_clock.keys())
)
return is_less_or_equal and is_strictly_less
def is_concurrent(self, other_clock: Dict[str, int]) -> bool:
# 두 이벤트가 동시 발생인지 확인
return not self.happens_before(other_clock) and \
not VectorClock._clock_happens_before(other_clock, self.clock)
@staticmethod
def _clock_happens_before(clock1: Dict[str, int], clock2: Dict[str, int]) -> bool:
temp = VectorClock("temp")
temp.clock = clock1
return temp.happens_before(clock2)
설명
이것이 하는 일: 위 코드는 Vector Clock을 사용하여 분산 시스템에서 이벤트 간의 인과 관계를 추적합니다. 각 노드는 자신과 다른 노드들의 논리적 시간을 관리하여 어떤 이벤트가 다른 이벤트보다 먼저 발생했는지 판단할 수 있습니다.
첫 번째로, __init__ 메서드에서 각 노드는 자신의 ID와 벡터 클락을 초기화합니다. 벡터 클락은 딕셔너리 형태로 {노드ID: 카운터} 쌍을 저장합니다.
처음에는 자신의 카운터만 0으로 초기화되어 있습니다. 예를 들어, 노드 A는 {'A': 0}, 노드 B는 {'B': 0}으로 시작합니다.
이렇게 하는 이유는 각 노드가 독립적으로 자신의 이벤트를 추적하면서도 다른 노드의 상태도 알 수 있게 하기 위함입니다. 그 다음으로, increment 메서드는 로컬 이벤트가 발생할 때 호출됩니다.
예를 들어, 사용자가 메시지를 작성하거나 문서를 수정할 때입니다. 자신의 카운터만 1 증가시키고 현재 벡터 클락의 복사본을 반환합니다.
update 메서드는 다른 노드로부터 이벤트를 수신할 때 호출됩니다. 예를 들어, 다른 사용자의 메시지를 받을 때입니다.
이때 받은 벡터 클락과 자신의 벡터 클락을 각 요소별로 최댓값으로 병합(element-wise max)하고, 자신의 카운터도 증가시킵니다. 이 병합 과정이 인과 관계를 추적하는 핵심입니다.
마지막으로, happens_before 메서드가 두 벡터 클락을 비교하여 인과 관계를 판단합니다. A가 B보다 먼저 발생했으려면, A의 모든 요소가 B의 대응 요소보다 작거나 같아야 하고(is_less_or_equal), 최소한 하나는 엄격히 작아야 합니다(is_strictly_less).
만약 이 조건을 만족하지 않으면 is_concurrent로 두 이벤트가 동시 발생인지 확인합니다. 동시 발생 이벤트는 순서가 정의되지 않으므로 충돌 해결 전략이 필요합니다.
여러분이 이 코드를 사용하면 분산 채팅 애플리케이션에서 메시지 순서를 올바르게 표시하거나, 협업 문서 편집에서 여러 사용자의 변경사항을 올바르게 병합할 수 있습니다. 예를 들어, Riak이나 Cassandra에서 데이터 충돌을 감지하고 해결할 때 Vector Clock을 사용합니다.
실무에서는 메시지와 함께 벡터 클락을 전송하고, 수신 측에서 happens_before를 확인하여 올바른 순서로 표시하거나, concurrent인 경우 사용자에게 충돌을 알려 수동으로 해결하게 할 수 있습니다. 또한 분산 버전 관리 시스템(Git의 내부 동작)이나 CRDTs(Conflict-free Replicated Data Types)의 기반 기술로도 활용됩니다.
실전 팁
💡 Vector Clock의 크기는 노드 수에 비례하므로, 대규모 시스템에서는 메모리 오버헤드가 클 수 있습니다. Dotted Version Vector나 Interval Tree Clock 같은 최적화된 변형을 고려하세요.
💡 흔한 실수는 시스템 시간(wall clock)으로 인과 관계를 판단하는 것입니다. NTP 동기화가 있어도 클럭 드리프트와 네트워크 지연으로 인해 부정확합니다. 반드시 논리적 시계(Vector Clock, Lamport Clock)를 사용하세요.
💡 동시 발생 이벤트를 감지했을 때의 충돌 해결 전략을 미리 정의하세요. 자동 병합(예: CRDT), 사용자 선택(예: Git의 merge conflict), 비즈니스 규칙 적용(예: 가장 높은 금액 선택) 등이 있습니다.
💡 성능 테스트 시 동시 발생 비율을 측정하세요. 네트워크 지연이 클수록, 독립적인 작업이 많을수록 concurrent 이벤트가 증가하며, 이는 충돌 해결 비용으로 이어집니다.
💡 디버깅 시 벡터 클락을 로그에 포함하여 이벤트 순서를 시각화하세요. Lamport diagram을 그리면 분산 시스템의 인과 관계를 명확히 이해할 수 있습니다.
5. 쿼럼 기반 일관성 - 유연한 일관성 수준 조정
시작하며
여러분이 글로벌 서비스를 운영하는데, 한국 사용자는 빠른 응답이 필요하고, 금융 거래는 정확성이 필요하다면 어떻게 해야 할까요? 하나의 일관성 수준으로는 모든 요구사항을 만족시킬 수 없습니다.
이런 문제는 실제 대규모 서비스에서 매우 현실적입니다. 모든 데이터를 강한 일관성으로 처리하면 성능이 떨어지고, 모든 데이터를 최종 일관성으로 처리하면 중요한 비즈니스 로직에서 오류가 발생합니다.
서비스 내에서도 데이터 종류별로 다른 일관성 수준이 필요하며, 심지어 같은 데이터라도 읽기/쓰기 작업에 따라 요구사항이 다를 수 있습니다. 바로 이럴 때 필요한 것이 쿼럼 기반 일관성(Quorum-based Consistency)입니다.
이는 읽기와 쓰기에 참여하는 노드 수를 조절하여 일관성, 가용성, 성능 간의 트레이드오프를 동적으로 제어할 수 있게 해줍니다.
개요
간단히 말해서, 쿼럼 기반 일관성은 N개의 복제본 중 W개에 쓰기를 성공하고, R개에서 읽기를 수행하여, W + R > N 조건을 만족하면 강한 일관성을 보장하고, 그렇지 않으면 최종 일관성을 제공하는 유연한 일관성 모델입니다. 실무에서 이 개념이 중요한 이유는, 요청별로 일관성 수준을 조정할 수 있어 비즈니스 요구사항에 정확히 맞는 시스템을 구축할 수 있기 때문입니다.
Cassandra, Riak, DynamoDB 같은 NoSQL 데이터베이스는 모두 쿼럼 기반 일관성을 제공합니다. 예를 들어, 전자상거래에서 제품 카탈로그는 (W=1, R=1)로 빠르게 읽고, 주문 정보는 (W=QUORUM, R=QUORUM)으로 정확성을 보장하는 경우에 매우 유용합니다.
기존에는 시스템 전체에 하나의 일관성 수준을 적용했다면, 이제는 테이블별, 쿼리별로 다른 W, R 값을 설정하여 세밀하게 제어할 수 있습니다. 쿼럼 기반 일관성의 핵심 특징은 첫째, W와 R을 조정하여 일관성-성능 트레이드오프를 제어한다는 점, 둘째, W + R > N이면 읽기와 쓰기가 최소 하나의 노드에서 겹쳐 최신 데이터를 보장한다는 점, 셋째, 노드 장애에 강건하다는 점입니다.
이러한 특징들이 중요한 이유는 단일 시스템 내에서 다양한 데이터 특성에 맞는 최적의 전략을 적용할 수 있게 해주기 때문입니다.
코드 예제
# 쿼럼 기반 읽기/쓰기를 구현한 분산 스토리지
from typing import List, Dict, Any, Optional
import time
class QuorumBasedStorage:
def __init__(self, replication_factor=3):
self.N = replication_factor # 총 복제본 수
self.nodes = [{}for _ in range(self.N)] # N개의 노드
def write(self, key: str, value: Any, W: int = 2) -> bool:
# W개 노드에 쓰기 성공해야 성공 반환
if W > self.N:
raise ValueError(f"W({W}) cannot exceed N({self.N})")
timestamp = time.time() # 충돌 해결용 타임스탬프
successful_writes = 0
for node in self.nodes:
try:
# 각 노드에 값과 타임스탬프 저장
node[key] = {'value': value, 'timestamp': timestamp}
successful_writes += 1
if successful_writes >= W:
return True # W개 성공 시 즉시 반환
except Exception:
continue
return successful_writes >= W
def read(self, key: str, R: int = 2) -> Optional[Any]:
# R개 노드에서 읽고 최신 값 반환
if R > self.N:
raise ValueError(f"R({R}) cannot exceed N({self.N})")
read_results = []
for node in self.nodes:
if key in node:
read_results.append(node[key])
if len(read_results) >= R:
break # R개 읽으면 중단
if len(read_results) < R:
return None # 쿼럼 미충족
# 타임스탬프 기준 최신 값 반환 (Read Repair)
latest = max(read_results, key=lambda x: x['timestamp'])
# Read Repair: 오래된 값을 가진 노드 업데이트
self._read_repair(key, latest)
return latest['value']
def _read_repair(self, key: str, latest_value: Dict):
# 읽기 과정에서 발견된 불일치를 백그라운드로 수정
for node in self.nodes:
if key not in node or node[key]['timestamp'] < latest_value['timestamp']:
node[key] = latest_value
설명
이것이 하는 일: 위 코드는 쿼럼 기반의 분산 키-밸류 스토리지를 구현합니다. 쓰기와 읽기 시 참여하는 노드 수(W, R)를 조정하여 일관성과 성능의 균형을 맞춥니다.
첫 번째로, write 메서드는 W개의 노드에 성공적으로 쓰기가 완료될 때까지 진행합니다. 각 쓰기마다 타임스탬프를 함께 저장하는데, 이는 나중에 버전 충돌을 해결하기 위함입니다.
예를 들어, W=2, N=3이면 3개 노드 중 2개에만 쓰면 되므로 1개 노드가 장애나도 쓰기가 성공합니다. W=1로 설정하면 매우 빠른 쓰기를 얻지만 데이터 손실 위험이 높아지고, W=N으로 설정하면 모든 노드에 쓰므로 강한 내구성을 얻지만 성능이 떨어집니다.
이렇게 하는 이유는 비즈니스 요구사항에 따라 내구성과 성능을 조절하기 위함입니다. 그 다음으로, read 메서드가 실행되면서 R개의 노드에서 데이터를 읽고 타임스탬프를 비교합니다.
R=1이면 가장 빠른 응답을 얻지만 오래된 데이터를 읽을 수 있고, R=N이면 모든 노드를 읽어 최신 데이터를 보장하지만 느립니다. 핵심은 W + R > N 조건입니다.
예를 들어, N=3, W=2, R=2인 경우, 쓰기가 성공한 2개 노드와 읽기하는 2개 노드는 최소 1개가 겹치므로 읽기는 항상 최신 쓰기를 반영합니다. 반면 W=1, R=1이면 겹치지 않을 수 있어 오래된 값을 읽을 수 있습니다(최종 일관성).
마지막으로, _read_repair 메서드가 읽기 과정에서 발견한 불일치를 자동으로 수정합니다. R개 노드를 읽을 때 타임스탬프가 다르면 최신 값으로 오래된 노드들을 업데이트합니다.
이는 안티 엔트로피(anti-entropy) 메커니즘으로, 시간이 지나면서 모든 복제본이 수렴하도록 돕습니다. 백그라운드에서 실행되므로 읽기 지연에는 영향을 주지 않습니다.
여러분이 이 코드를 사용하면 실제 Cassandra나 DynamoDB의 튜닝 가능한 일관성(tunable consistency)을 이해하고 활용할 수 있습니다. 실무에서는 읽기가 많은 워크로드에는 (W=N, R=1)로 쓰기를 모든 노드에 수행하고 읽기를 빠르게 하고, 쓰기가 많은 워크로드에는 (W=1, R=N)으로 반대로 설정할 수 있습니다.
금융 거래처럼 정확성이 중요하면 (W=QUORUM, R=QUORUM), 로그 수집처럼 속도가 중요하면 (W=1, R=1)로 설정합니다. 또한 장애 시나리오를 고려하여, 예를 들어 N=5, W=3, R=3으로 설정하면 최대 2개 노드 장애까지 견딜 수 있으면서도 강한 일관성을 유지할 수 있습니다.
실전 팁
💡 일반적인 쿼럼 설정은 N=3, W=2, R=2입니다. 이는 1개 노드 장애를 견디면서 강한 일관성을 제공하며, 대부분의 사용 사례에 적합합니다.
💡 많은 개발자가 하는 실수는 W=1, R=1로 설정하고 강한 일관성을 기대하는 것입니다. W + R ≤ N이면 읽기와 쓰기가 겹치지 않을 수 있어 최종 일관성만 보장됩니다. 강한 일관성이 필요하면 반드시 W + R > N을 만족하세요.
💡 Read Repair만으로는 모든 복제본이 수렴하지 않을 수 있습니다. 주기적으로 안티 엔트로피 프로세스(예: Merkle tree를 사용한 비교)를 실행하여 읽지 않는 데이터도 동기화하세요.
💡 성능 최적화를 위해 Hinted Handoff를 구현하세요. 노드 장애 시 쓰기를 대신 받은 노드가, 장애 복구 후 원래 노드에 데이터를 전달하여 일관성을 회복합니다.
💡 모니터링에서 쿼럼 실패율을 추적하세요. W나 R개 노드 응답을 받지 못한 요청의 비율이 높으면 인프라 문제나 잘못된 설정을 의미합니다. Latency percentile(p99)도 함께 모니터링하여 쿼럼 대기 시간을 파악하세요.
6. Raft 합의 알고리즘 - 이해하기 쉬운 강한 일관성
시작하며
여러분이 분산 시스템을 설계하는데, Paxos 알고리즘을 이해하려다 포기한 경험이 있나요? Paxos는 너무 복잡해서 제대로 구현하기가 매우 어렵습니다.
이런 문제는 학계와 산업계에서 오랫동안 알려진 이슈였습니다. Paxos는 수학적으로는 완벽하지만, 실제로 구현하고 디버깅하기는 악명 높게 어렵습니다.
많은 엔지니어들이 합의 알고리즘의 필요성은 알지만 구현의 복잡성 때문에 포기하거나 잘못 구현하여 버그를 만들어냅니다. 분산 시스템의 핵심인 리더 선출, 로그 복제, 안전성 보장을 올바르게 구현하는 것은 매우 중요합니다.
바로 이럴 때 필요한 것이 Raft 합의 알고리즘입니다. 이는 Paxos와 동등한 강한 일관성을 보장하면서도 이해하기 쉽고 구현하기 쉽도록 설계된 합의 알고리즘입니다.
개요
간단히 말해서, Raft는 분산 시스템에서 여러 노드가 동일한 로그(상태 변경 기록)를 유지하도록 보장하는 합의 알고리즘으로, 리더 선출, 로그 복제, 안전성 보장을 명확히 분리하여 이해와 구현을 쉽게 만든 것입니다. 실무에서 이 개념이 중요한 이유는, etcd(Kubernetes의 핵심), Consul(서비스 디스커버리), CockroachDB(분산 SQL) 등 프로덕션 시스템에서 실제로 광범위하게 사용되기 때문입니다.
Raft를 이해하면 이런 시스템들의 내부 동작을 이해하고, 직접 분산 합의가 필요한 시스템을 구축할 수 있습니다. 예를 들어, 분산 락 매니저, 리더 선출이 필요한 마이크로서비스, 분산 설정 관리 시스템 같은 경우에 매우 유용합니다.
기존에는 Paxos의 복잡성 때문에 대부분의 개발자가 합의 알고리즘을 블랙박스로 사용했다면, 이제는 Raft의 명확한 구조 덕분에 직접 이해하고 커스터마이즈할 수 있습니다. Raft의 핵심 특징은 첫째, 한 번에 하나의 리더만 존재하여 일관성을 단순화한다는 점, 둘째, 로그는 항상 순차적으로 추가되며 절대 덮어쓰지 않는다는 점, 셋째, 과반수(쿼럼) 노드의 합의로 모든 결정이 이루어진다는 점입니다.
이러한 특징들이 중요한 이유는 복잡한 분산 합의 문제를 명확한 단계로 나누어 오류 가능성을 크게 줄여주기 때문입니다.
코드 예제
# Raft 알고리즘의 핵심 구조 (간소화 버전)
from enum import Enum
from typing import List, Dict, Any
import random
class NodeState(Enum):
FOLLOWER = 1 # 팔로워: 리더의 명령을 따름
CANDIDATE = 2 # 후보자: 리더 선출에 참여
LEADER = 3 # 리더: 로그 복제를 주도
class RaftNode:
def __init__(self, node_id: int, cluster_size: int):
self.node_id = node_id
self.cluster_size = cluster_size
self.state = NodeState.FOLLOWER
# Persistent state (모든 서버에 유지)
self.current_term = 0 # 현재 임기 번호
self.voted_for = None # 현재 임기에서 투표한 후보
self.log: List[Dict[str, Any]] = [] # 로그 엔트리
# Volatile state
self.commit_index = 0 # 커밋된 로그 인덱스
self.last_applied = 0 # 상태 머신에 적용된 로그 인덱스
def request_vote(self, term: int, candidate_id: int,
last_log_index: int, last_log_term: int) -> bool:
# 리더 선출: 투표 요청 처리
# 더 높은 임기이거나, 같은 임기에서 아직 투표하지 않았고
# 후보의 로그가 최소한 자신만큼 최신이면 승인
if term > self.current_term:
self.current_term = term
self.state = NodeState.FOLLOWER
self.voted_for = None
if (term == self.current_term and
(self.voted_for is None or self.voted_for == candidate_id)):
# 로그 최신성 확인
my_last_index = len(self.log) - 1
my_last_term = self.log[-1]['term'] if self.log else 0
if (last_log_term > my_last_term or
(last_log_term == my_last_term and last_log_index >= my_last_index)):
self.voted_for = candidate_id
return True
return False
def append_entries(self, term: int, leader_id: int,
prev_log_index: int, prev_log_term: int,
entries: List[Dict], leader_commit: int) -> bool:
# 로그 복제: 리더로부터 엔트리 수신
if term < self.current_term:
return False # 오래된 리더 거부
self.current_term = term
self.state = NodeState.FOLLOWER # 유효한 리더 발견
# 로그 일관성 확인
if prev_log_index >= 0:
if (len(self.log) <= prev_log_index or
self.log[prev_log_index]['term'] != prev_log_term):
return False # 로그 불일치
# 새 엔트리 추가
for i, entry in enumerate(entries):
index = prev_log_index + 1 + i
if index < len(self.log):
if self.log[index]['term'] != entry['term']:
self.log = self.log[:index] # 충돌하는 엔트리 제거
self.log.append(entry)
else:
self.log.append(entry)
# 커밋 인덱스 업데이트
if leader_commit > self.commit_index:
self.commit_index = min(leader_commit, len(self.log) - 1)
return True
설명
이것이 하는 일: 위 코드는 Raft 알고리즘의 핵심 메커니즘인 리더 선출과 로그 복제를 구현합니다. 각 노드는 팔로워, 후보자, 리더 중 하나의 상태를 가지며, 과반수 합의를 통해 일관된 로그를 유지합니다.
첫 번째로, __init__ 메서드에서 각 노드의 상태를 초기화합니다. 모든 노드는 팔로워로 시작하며, current_term은 논리적 시간으로 작동하여 오래된 메시지를 거부할 수 있게 합니다.
log는 상태 변경의 순서를 기록하는 핵심 데이터 구조입니다. 예를 들어, 분산 키-밸류 스토어에서는 각 로그 엔트리가 "SET key=value" 같은 명령을 담습니다.
이렇게 하는 이유는 모든 노드가 동일한 순서로 동일한 명령을 실행하여 복제된 상태 머신(replicated state machine)을 만들기 위함입니다. 그 다음으로, request_vote 메서드가 실행되면서 리더 선출 과정을 처리합니다.
리더가 없거나 실패하면 팔로워는 타임아웃 후 후보자가 되어 다른 노드들에게 투표를 요청합니다. 투표는 임기(term)별로 한 번만 가능하며, 후보의 로그가 자신의 로그보다 오래되지 않았을 때만 승인합니다.
이는 커밋된 로그가 손실되는 것을 방지합니다. 예를 들어, 과반수(3개 중 2개)의 투표를 받은 후보가 새 리더가 되며, 이는 Split-brain 문제를 해결합니다.
마지막으로, append_entries 메서드가 리더로부터 로그 복제를 처리합니다. 리더는 주기적으로 팔로워들에게 새 로그 엔트리를 전송하고, 팔로워는 로그 일관성을 확인합니다.
prev_log_index와 prev_log_term을 비교하여 로그가 연속적인지 검증하고, 불일치가 있으면 거부합니다. 리더는 거부를 받으면 더 이전 인덱스부터 재시도하여 결국 일관성을 맞춥니다.
leader_commit을 통해 과반수 노드에 복제된 로그가 커밋되었음을 알리고, 팔로워는 커밋된 엔트리를 상태 머신에 적용합니다. 여러분이 이 코드를 사용하면 Kubernetes의 etcd, HashiCorp Consul, TiKV 같은 프로덕션 시스템의 내부 동작을 이해할 수 있습니다.
실무에서는 분산 락(distributed lock)을 구현할 때 Raft를 사용하여 락 획득 순서를 모든 노드가 동일하게 인식하도록 할 수 있습니다. 마이크로서비스에서 설정 관리를 할 때 Raft 기반 저장소를 사용하면 설정 변경이 원자적으로 전파되고 일관성이 보장됩니다.
또한 샤딩된 데이터베이스에서 각 샤드가 Raft 그룹으로 구성되어 샤드 내 복제와 리더 선출이 자동으로 처리되도록 할 수 있습니다. 실제 구현 시에는 네트워크 지연, 노드 장애, 메시지 재전송 등을 고려한 타임아웃과 재시도 로직을 추가해야 합니다.
실전 팁
💡 Raft 클러스터 크기는 홀수로 설정하세요. 3개 노드는 1개 장애를 견디고, 5개 노드는 2개 장애를 견딥니다. 4개나 6개로 늘려도 내결함성은 동일하므로 불필요한 비용입니다.
💡 흔한 실수는 election timeout을 너무 짧게 설정하는 것입니다. 네트워크 왕복 시간의 최소 10배 이상으로 설정하여 불필요한 리더 재선출을 방지하세요. 각 노드의 timeout을 랜덤화하여 split vote를 줄이세요.
💡 로그 압축(log compaction)을 반드시 구현하세요. 로그가 무한정 증가하면 메모리가 고갈되고 재시작 시간이 길어집니다. 스냅샷을 주기적으로 생성하고 오래된 로그를 삭제하세요.
💡 성능 최적화를 위해 배치 처리를 활용하세요. 여러 클라이언트 요청을 하나의 append_entries RPC에 담아 네트워크 오버헤드를 줄일 수 있습니다. 또한 파이프라이닝으로 응답을 기다리지 않고 다음 요청을 보내세요.
💡 디버깅 시 각 노드의 term, state, log를 시각화하는 도구를 만드세요. Raft의 버그는 특정 타이밍과 장애 시나리오에서만 나타나므로, Jepsen 같은 카오스 테스팅 도구로 검증하는 것이 필수적입니다.
7. CRDT - 충돌 없는 자동 병합
시작하며
여러분이 Google Docs처럼 여러 사람이 동시에 문서를 편집하는 기능을 만든다면, 편집 충돌을 어떻게 해결할까요? "나중 쓰기 우선" 전략을 쓰면 누군가의 작업이 사라집니다.
이런 문제는 실시간 협업 도구에서 가장 어려운 과제 중 하나입니다. 전통적인 락(lock) 방식은 사용자 경험을 해치고, 충돌 감지 후 수동 해결은 복잡하며 오류가 발생하기 쉽습니다.
특히 오프라인 상태에서도 작업하고 나중에 동기화해야 하는 모바일 앱이나, 네트워크 분할이 빈번한 분산 환경에서는 더욱 어렵습니다. 사용자들이 독립적으로 작업하면서도 결국 일관된 상태로 수렴해야 합니다.
바로 이럴 때 필요한 것이 CRDT(Conflict-free Replicated Data Type)입니다. 이는 수학적 속성을 통해 충돌을 원천적으로 방지하여, 어떤 순서로 작업을 적용하더라도 모든 복제본이 동일한 최종 상태로 수렴하도록 보장합니다.
개요
간단히 말해서, CRDT는 여러 복제본에서 독립적으로 업데이트가 발생해도 충돌 해결 로직 없이 자동으로 병합되어 모든 복제본이 결국 같은 상태로 수렴하는 것을 수학적으로 보장하는 데이터 타입입니다. 실무에서 이 개념이 중요한 이유는, 복잡한 충돌 해결 로직을 직접 구현하지 않아도 되며, 네트워크 분할이나 오프라인 상황에서도 안전하게 작동하기 때문입니다.
Redis, Riak, Apple's iCloud, Figma 같은 실제 서비스에서 사용됩니다. 예를 들어, 멀티플레이어 게임의 상태 동기화, 분산 쇼핑 카트, 실시간 협업 편집기, 오프라인 우선 모바일 앱 같은 경우에 매우 유용합니다.
기존에는 중앙 서버를 통해 모든 업데이트를 순서화하거나, 락을 사용하여 동시 수정을 방지했다면, 이제는 각 클라이언트가 독립적으로 작업하고 P2P로 동기화하여 완전히 탈중앙화된 시스템을 만들 수 있습니다. CRDT의 핵심 특징은 첫째, 교환법칙(Commutative)이 성립하여 작업 순서가 바뀌어도 결과가 같다는 점, 둘째, 결합법칙(Associative)이 성립하여 그룹화 순서가 상관없다는 점, 셋째, 멱등성(Idempotent)으로 같은 작업을 여러 번 적용해도 결과가 같다는 점입니다.
이러한 수학적 속성들이 중요한 이유는 복잡한 분산 환경에서도 정확성을 보장하며, 개발자가 엣지 케이스를 모두 고려하지 않아도 되게 해주기 때문입니다.
코드 예제
# G-Counter CRDT: 증가만 가능한 분산 카운터
from typing import Dict
class GCounter:
def __init__(self, node_id: str):
self.node_id = node_id
# 각 노드별 카운터 값 저장 {node_id: count}
self.counts: Dict[str, int] = {node_id: 0}
def increment(self, amount: int = 1) -> None:
# 자신의 카운터만 증가 (충돌 없음)
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':
# 다른 복제본과 병합 (element-wise 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
# PN-Counter CRDT: 증가/감소 가능한 분산 카운터
class PNCounter:
def __init__(self, node_id: str):
self.node_id = node_id
self.increments = GCounter(node_id) # 증가분
self.decrements = GCounter(node_id) # 감소분
def increment(self, amount: int = 1) -> None:
self.increments.increment(amount)
def decrement(self, amount: int = 1) -> None:
self.decrements.increment(amount) # 감소는 감소 카운터 증가
def value(self) -> int:
return self.increments.value() - self.decrements.value()
def merge(self, other: 'PNCounter') -> 'PNCounter':
merged = PNCounter(self.node_id)
merged.increments = self.increments.merge(other.increments)
merged.decrements = self.decrements.merge(other.decrements)
return merged
설명
이것이 하는 일: 위 코드는 가장 기본적인 CRDT인 G-Counter(증가만 가능)와 PN-Counter(증가/감소 가능)를 구현합니다. 각 노드가 독립적으로 카운터를 조작하고, 나중에 병합하여 일관된 값을 얻습니다.
첫 번째로, GCounter는 각 노드가 자신만의 카운터를 가지는 구조입니다. counts 딕셔너리는 {node_id: count} 형태로, 예를 들어 {'node1': 5, 'node2': 3, 'node3': 7}처럼 저장됩니다.
increment를 호출하면 자신의 노드 ID에 해당하는 카운터만 증가시킵니다. 다른 노드의 카운터는 절대 수정하지 않으므로 쓰기 충돌이 원천적으로 발생하지 않습니다.
전체 값은 모든 노드의 카운터를 합산하여 계산합니다. 이렇게 하는 이유는 각 노드의 기여를 독립적으로 추적하여 병합 시 정보 손실 없이 합칠 수 있게 하기 위함입니다.
그 다음으로, merge 메서드가 실행되면서 두 복제본의 상태를 병합합니다. 핵심은 element-wise maximum을 사용하는 것입니다.
예를 들어, 복제본 A가 {'node1': 5, 'node2': 2}이고 복제본 B가 {'node1': 3, 'node2': 4}라면, 병합 결과는 {'node1': 5, 'node2': 4}가 됩니다. 이는 교환법칙과 결합법칙, 멱등성을 만족합니다.
A.merge(B) == B.merge(A)이고(교환), (A.merge(B)).merge(C) == A.merge(B.merge(C))이며(결합), A.merge(A) == A입니다(멱등). 따라서 네트워크 분할로 인해 메시지가 중복되거나 순서가 바뀌어도 최종 결과는 항상 같습니다.
마지막으로, PNCounter는 두 개의 G-Counter를 조합하여 증가와 감소를 모두 지원합니다. 감소 연산은 별도의 감소 카운터를 증가시키는 방식으로 구현합니다.
최종 값은 increments - decrements로 계산됩니다. 예를 들어, node1이 +10, node2가 +5, node1이 -3을 수행하면, increments는 {'node1': 10, 'node2': 5}, decrements는 {'node1': 3}이 되어 최종 값은 15 - 3 = 12입니다.
병합 시에는 각 G-Counter를 독립적으로 병합합니다. 여러분이 이 코드를 사용하면 Redis의 CRDT 모듈, Riak의 데이터 타입, 실시간 분석 대시보드의 카운터를 이해하고 구현할 수 있습니다.
실무에서는 웹사이트 조회수 카운터를 여러 데이터센터에 분산하여, 각 데이터센터가 독립적으로 카운트하고 주기적으로 병합할 수 있습니다. 쇼핑 카트에서 여러 기기(모바일, 웹)에서 상품을 추가/제거하고 나중에 동기화할 때도 활용됩니다.
또한 LWW-Element-Set(Last-Write-Wins Set), OR-Set(Observed-Remove Set), RGA(Replicated Growable Array) 같은 더 복잡한 CRDT를 학습하여 집합, 리스트, 텍스트 편집 등에 적용할 수 있습니다. Automerge나 Yjs 같은 라이브러리는 CRDT를 활용한 실시간 협업 편집을 제공합니다.
실전 팁
💡 CRDT는 만능이 아닙니다. 모든 비즈니스 로직을 CRDT로 표현할 수 없으며, 특히 비즈니스 제약(예: 계좌 잔액은 음수가 될 수 없음)을 강제하기 어렵습니다. 비즈니스 제약이 중요하면 합의 알고리즘을 사용하세요.
💡 많은 개발자가 하는 실수는 G-Counter로 재고를 관리하려는 것입니다. 감소가 필요하면 PN-Counter를 써야 하지만, PN-Counter도 음수를 방지하지 못합니다. 재고 같은 경우는 CRDT보다 강한 일관성이 필요합니다.
💡 CRDT는 메타데이터 오버헤드가 있습니다. Vector Clock, 톰스톤(삭제 표시) 등으로 인해 메모리 사용량이 늘어납니다. 주기적으로 가비지 컬렉션을 수행하여 불필요한 메타데이터를 정리하세요.
💡 성능을 위해 델타 CRDT(Delta-state CRDT)를 고려하세요. 전체 상태를 전송하는 대신 변경분(delta)만 전송하여 네트워크 대역폭을 크게 절약할 수 있습니다.
💡 텍스트 편집에는 RGA나 WOOT 같은 Sequence CRDT를 사용하세요. 단순 문자열 CRDT는 성능이 나쁘지만, 최신 알고리즘은 Google Docs 수준의 성능을 제공합니다. Yjs 라이브러리를 참고하세요.
8. 세션 일관성 - 사용자 경험 최적화
시작하며
여러분이 온라인 쇼핑몰에서 상품을 장바구니에 담았는데, 페이지를 새로고침하니 장바구니가 비어있다면 어떨까요? 시스템 내부적으로는 최종 일관성을 사용하지만, 사용자는 혼란스러울 것입니다.
이런 문제는 최종 일관성 시스템에서 흔히 발생합니다. 기술적으로는 데이터가 결국 동기화되지만, 사용자 입장에서는 자신이 방금 한 작업의 결과가 보이지 않으면 버그로 인식합니다.
"다른 사용자가 보는 데이터는 잠깐 오래되어도 괜찮지만, 내가 쓴 데이터는 내가 읽을 때 반드시 보여야 한다"는 것이 사용자의 당연한 기대입니다. 이는 순수한 최종 일관성과 강한 일관성의 중간 지점입니다.
바로 이럴 때 필요한 것이 세션 일관성(Session Consistency)입니다. 이는 동일한 클라이언트 세션 내에서는 자신이 쓴 데이터를 항상 읽을 수 있도록 보장하여, 최종 일관성의 성능 이점을 유지하면서도 사용자 경험을 크게 개선합니다.
개요
간단히 말해서, 세션 일관성은 동일한 사용자 세션 내에서 "Read Your Writes", "Monotonic Reads", "Monotonic Writes" 같은 보장을 제공하여, 사용자가 자신의 작업에 대해서는 일관된 뷰를 보도록 하는 일관성 모델입니다. 실무에서 이 개념이 중요한 이유는, 전역적으로 강한 일관성을 제공하는 비용 없이도 사용자 경험에 필요한 최소한의 일관성을 보장할 수 있기 때문입니다.
대부분의 사용자는 다른 사용자의 최신 상태보다 자신의 작업 결과를 보는 것이 훨씬 중요합니다. 예를 들어, 소셜 미디어에서 내가 올린 게시물은 즉시 내 피드에 보여야 하고, 프로필을 수정하면 내가 볼 때는 즉시 반영되어야 하는 경우에 매우 유용합니다.
기존에는 모든 읽기에 강한 일관성을 제공하거나, 아니면 사용자가 오래된 데이터를 보는 것을 감수했다면, 이제는 세션별로 필요한 보장만 제공하여 성능과 경험의 균형을 맞출 수 있습니다. 세션 일관성의 핵심 특징은 첫째, Read Your Writes(자신이 쓴 것을 읽기)를 보장한다는 점, 둘째, Monotonic Reads(시간이 거꾸로 가지 않음)를 보장한다는 점, 셋째, 다른 사용자의 읽기에는 영향을 주지 않는다는 점입니다.
이러한 특징들이 중요한 이유는 사용자별로 독립적인 일관성 보장을 제공하여 확장성을 유지하면서도 개인 사용자 경험을 보호할 수 있게 해주기 때문입니다.
코드 예제
# 세션 일관성을 구현한 분산 스토리지
from typing import Dict, Any, Optional
import time
class SessionConsistentStore:
def __init__(self):
self.replicas = [{}, {}, {}] # 3개의 복제본
# 세션별로 마지막으로 본 버전 추적
self.session_versions: Dict[str, int] = {}
self.global_version = 0 # 글로벌 버전 카운터
def write(self, session_id: str, key: str, value: Any) -> int:
# 쓰기는 주 복제본에 수행
self.global_version += 1
version = self.global_version
# 데이터와 버전을 함께 저장
data = {
'value': value,
'version': version,
'timestamp': time.time()
}
# 모든 복제본에 비동기 복제 (간소화를 위해 동기로 표현)
for replica in self.replicas:
replica[key] = data
# 세션의 최신 버전 업데이트
self.session_versions[session_id] = version
return version
def read(self, session_id: str, key: str) -> Optional[Any]:
# Read Your Writes 보장
# 세션이 마지막으로 본 버전 이상의 데이터만 반환
session_version = self.session_versions.get(session_id, 0)
# 복제본들을 순회하며 세션 버전 이상인 데이터 찾기
for replica in self.replicas:
if key in replica:
data = replica[key]
if data['version'] >= session_version:
# Monotonic Reads 보장: 읽은 버전 업데이트
self.session_versions[session_id] = max(
session_version,
data['version']
)
return data['value']
# 세션 버전을 만족하는 데이터가 없으면 대기 또는 실패
return self._wait_for_version(session_id, key, session_version)
def _wait_for_version(self, session_id: str, key: str,
required_version: int) -> Optional[Any]:
# 실제로는 복제 완료까지 대기하거나 주 복제본에서 읽기
# 여기서는 간소화를 위해 가장 최신 복제본에서 읽기
latest_data = None
latest_version = -1
for replica in self.replicas:
if key in replica:
data = replica[key]
if data['version'] > latest_version:
latest_data = data
latest_version = data['version']
if latest_data and latest_version >= required_version:
self.session_versions[session_id] = latest_version
return latest_data['value']
return None
설명
이것이 하는 일: 위 코드는 세션별로 일관성을 제공하는 분산 스토리지를 구현합니다. 각 세션은 자신이 마지막으로 본 데이터 버전을 추적하여, 그 버전 이상의 데이터만 읽도록 보장합니다.
첫 번째로, write 메서드는 데이터를 쓸 때 글로벌 버전 번호를 증가시키고 이를 데이터와 함께 저장합니다. 모든 복제본에 데이터를 전파한 후(실제로는 비동기), session_versions 딕셔너리에 해당 세션이 본 최신 버전을 기록합니다.
예를 들어, 사용자 A가 게시물을 작성하면 버전 42가 할당되고, session_versions['user_A'] = 42로 저장됩니다. 이렇게 하는 이유는 나중에 같은 세션이 읽기를 할 때 최소한 버전 42 이상의 데이터를 보장하기 위함입니다.
그 다음으로, read 메서드가 실행되면서 먼저 세션의 마지막 버전을 확인합니다. 만약 세션이 버전 42를 마지막으로 봤다면, 복제본을 순회하며 버전이 42 이상인 데이터를 찾습니다.
첫 번째 복제본이 버전 40(오래된 복제본)이면 건너뛰고, 두 번째 복제본이 버전 43이면 이를 반환합니다. 이것이 Read Your Writes 보장입니다.
또한 읽은 버전으로 세션 버전을 업데이트하여, 다음 읽기는 최소한 버전 43 이상을 보게 됩니다. 이것이 Monotonic Reads 보장입니다 - 시간이 거꾸로 가지 않습니다.
마지막으로, _wait_for_version 메서드가 세션 버전을 만족하는 데이터가 복제본에 없을 때 호출됩니다. 실제 구현에서는 복제가 완료될 때까지 잠시 대기하거나, 주 복제본에서 직접 읽거나, 클라이언트에게 재시도를 요청합니다.
여기서는 간소화하여 가장 최신 복제본의 데이터를 반환합니다. 이 메커니즘이 없으면 네트워크 분할이나 복제 지연 시 사용자가 자신이 쓴 데이터를 볼 수 없는 문제가 발생합니다.
여러분이 이 코드를 사용하면 DynamoDB의 consistent read, Cassandra의 LOCAL_QUORUM, MongoDB의 read concern 옵션을 이해할 수 있습니다. 실무에서는 로드 밸런서에 세션 어피니티(sticky session)를 설정하여 같은 사용자의 요청을 같은 복제본으로 라우팅할 수 있습니다.
이렇게 하면 복제 지연에 관계없이 사용자는 자신이 쓴 데이터를 즉시 볼 수 있습니다. 또한 클라이언트 사이드에서 버전 벡터를 쿠키나 로컬 스토리지에 저장하여, 서버가 상태를 유지하지 않아도 되게 할 수 있습니다.
소셜 미디어 피드, 이커머스 장바구니, SaaS 애플리케이션의 사용자 설정 등에서 최종 일관성의 성능과 사용자 경험을 모두 얻을 수 있습니다.
실전 팁
💡 세션 일관성은 클라이언트가 버전 정보를 유지해야 합니다. 쿠키, JWT 토큰, 또는 커스텀 HTTP 헤더에 버전을 포함하여 stateless 서버를 유지하세요.
💡 흔한 실수는 세션이 여러 디바이스에 걸쳐 있을 때 동기화하지 않는 것입니다. 모바일 앱과 웹에서 동시에 로그인한 경우, 디바이스별로 다른 버전을 추적하거나 공유 버전 스토어를 사용하세요.
💡 Read Your Writes를 보장하기 위해 무조건 주 복제본에서 읽으면 부하가 집중됩니다. 대신 복제 지연을 모니터링하고, 지연이 짧은 복제본으로 라우팅하거나, 클라이언트가 쓴 직후 짧은 시간(예: 100ms)만 주 복제본에서 읽고 이후는 복제본에서 읽는 하이브리드 전략을 사용하세요.
💡 성능 최적화를 위해 버전 정보를 압축하세요. 전체 Vector Clock 대신 간단한 단조 증가 카운터나 Lamport timestamp를 사용하면 오버헤드를 줄일 수 있습니다.
💡 디버깅 시 사용자별 읽기 패턴을 로깅하여 Monotonic Reads 위반을 감지하세요. "버전이 거꾸로 간" 케이스는 복제 로직의 버그나 클럭 드리프트를 의미합니다.