스터디 노트

Chapter 06

키-값 저장소 설계

분산 KV 저장소 설계. CAP 정리, 데이터 분할, 복제, 정합성(일관성 모델), 장애 감지, Merkle 트리, 데이터 센터 간 복제.

이 챕터의 답

분산 KV는 5개 핵심 부품으로 만든다

단일 노드 KV(메모리 해시 테이블)는 단순하지만 RAM·디스크·가용성 한계가 금방 드러난다. 분산 KV는 이를 해결하기 위해 5개의 부품을 조합한다. 각 부품이 푸는 문제가 다르다.

  1. 분할 (Sharding) — 데이터를 여러 노드에 흩뿌려 용량/QPS 확장. 5장의 컨시스턴트 해싱이 답.
  2. 복제 (Replication) — 같은 데이터를 N개 노드에 둬서 가용성·내구성 확보.
  3. 일관성 (CAP + 정족수) — N/R/W 설정으로 strong vs eventual 선택.
  4. 비일관성 해소 — 동시 쓰기 감지(벡터 시계)와 노드 간 차이 검출(Merkle 트리).
  5. 장애 처리 — gossip protocol, hinted handoff, sloppy quorum.

아래는 ① 왜 분산이 필요한지 → ②~⑥ 각 부품 → ⑦ 쓰기/읽기 경로 → ⑧ 실무 → ⑨ 정리.

📚 이 챕터를 보기 전에

사전 지식 점검 — 어디서 막히는지부터

6장이 어렵게 느껴지는 건 정상이다. 다섯 가지 다른 종류의 지식이 동시에 들어오기 때문이다 — 분산 사고, 트랜잭션 개념, 시간/순서 추론, 장애 시나리오, 디스크 I/O 모델. 어디서 막히는지에 따라 돌아가야 할 곳이 다르다.

막히는 곳별 진단표

막히는 곳부족한 배경먼저 보면 좋음
"분산이 왜 어렵지"분산의 본질적 어려움1장 (확장성)
CAP·일관성·정족수트랜잭션·동시성DDIA 5·9장
벡터 시계·Merkle시간/순서·해시 자료구조Lamport, Git
Gossip·hinted handoff장애·복구 시나리오 사고4장 분산 환경
WAL·LSM-tree디스크 I/O 모델본문 7장 직접

공통 필수 4가지

  1. RAM vs 디스크 — 속도 10만 배 차이, 휘발성 vs 영속성, 디스크는 순차 vs 랜덤에서 또 100배 차이. 이 직관이 없으면 "왜 WAL?", "왜 LSM-tree?"가 안 와닿는다.
  2. 해시 테이블 — key → hash → 배열 인덱스 → value, O(1) 룩업. 분산 KV는 이걸 여러 서버로 흩뿌리는 것.
  3. 5장 컨시스턴트 해싱 — 6장 부품 ① 분할의 직접 기반. 5장 안 보고 오면 막힌다.
  4. 분산의 3대 어려움 — ① 네트워크 분할(패킷 사라짐) ② 노드 장애(죽었는지 느린지 구분 안 됨) ③ 시계 동기화(노드마다 시계가 다름). 이 셋이 CAP·정족수·벡터 시계의 동기를 만든다.

추천 학습 순서 (이 사이트 기준)

1장 (확장성) 2장 (규모 추정) 4장 (처리율 제한 — Redis·분산 동기화 첫 경험) 5장 (컨시스턴트 해싱 — 필수) 6장

깊은 이해를 원하면

  • "데이터 중심 애플리케이션 설계" (DDIA) — Martin Kleppmann. 분산 시스템 학습의 표준 교재. 5·9장이 6장 내용을 1.5~2배 깊이로 다룬다. 6장이 어렵다면 DDIA 5·9장을 먼저 보면 무릎이 탁 침.
  • Amazon Dynamo 논문 (2007) — 20쪽 짧은 논문. 6장 부품 ①~⑤가 다 들어있는 원전. 한국어 요약본도 검색 가능.
  • Jepsen 블로그 (jepsen.io) — 실제 분산 DB의 일관성을 깨뜨려보는 사례 모음. 추상 개념의 실전 직관 형성에 좋다.

막히면 죄책감 없이 위 자료로 돌아갔다 와도 된다. 6장은 한 번에 다 이해되는 챕터가 아니라 여러 번 왔다갔다하며 익혀지는 챕터다.

1. 왜 분산 KV가 필요한가

단일 노드 KV는 메모리 해시 테이블(O(1) 읽기/쓰기) — 빠르고 단순하다. 그러나 한 대 서버의 한계는 명확하다.

  • 용량 한계 — RAM은 보통 수십~수백 GB가 한도라 TB·PB 데이터는 담을 수 없다.
  • QPS 한계 — 단일 CPU·NIC가 처리할 수 있는 초당 요청 수에 상한이 있다.
  • 가용성 한계 — 서버 1대가 죽으면 전체가 멈춰서 99.99% 가용성을 맞추기 어렵다.
  • 지리적 한계 — 한 지역에만 있어 글로벌 서비스에 적합하지 않다.

분산 KV는 이 네 가지를 동시에 푼다. 용량은 분할로, QPS는 분할 + 복제로, 가용성은 복제로, 지리적 한계는 다중 데이터센터 복제로 해결한다. 단, 각 해법이 새로운 문제를 만든다 — 그래서 부품이 5개나 필요하다.

2. 부품 ① — 데이터 분할

키를 여러 노드에 나눠 저장한다. 답은 5장의 컨시스턴트 해싱이다.

단순 모듈로(hash(key) % N)는 N이 변할 때 거의 모든 데이터가 이동한다 — 분산 KV에선 치명적이다. 컨시스턴트 해싱은 노드를 추가하거나 제거할 때 1/N의 키만 이동시키고, 가상 노드 (서버당 100~200개)를 두면 부하 균등화까지 함께 챙긴다.

💬 학습자 질문에서 출발한 보강

"키로 값을 찾는 흐름을 누가 어떻게 계산하나?"

그래서 GET은 어떻게 흘러가나

5장은 알고리즘 자체를 다뤘으니, 6장에선 그 알고리즘이 실제 GET 요청에서 어떻게 쓰이는지를 짚자. 분산 KV의 기본 흐름은 다음과 같다.

②번 라우팅을 누가 수행하느냐로 시스템이 갈린다. 세 가지 패턴이 표준이다.

패턴 A — 클라이언트 측 라우팅 (Memcached ketama)

클라이언트 라이브러리가 직접 hash를 계산해 정확한 서버에 곧장 요청한다. 홉이 1번이라 가장 빠르다. 단, 클라이언트가 모든 서버 목록과 hash 함수를 알고 있어야 하며, 서버 추가/제거 시 클라이언트들에 알려줄 수단이 필요하다.

패턴 B — 코디네이터/프록시 (Cassandra, DynamoDB)

클라이언트는 아무 노드에나 요청을 던진다. 그 노드(코디 네이터)가 hash를 계산해 진짜 담당 서버로 포워딩한 뒤, 응답을 클라이언트 에게 돌려준다. 클라이언트는 단순해지지만 한 홉이 추가된다.

패턴 C — 스마트 라우터 (Redis Cluster)

클라이언트가 임의의 서버에 요청 → 잘못된 서버라면 서버가 MOVED 5461 10.0.0.5:6379 같은 응답으로 진짜 위치를 알려줌 → 클라이언트가 그 매핑을 캐시해 다음부턴 직접 정확한 서버로 요청한다. A와 B의 절충 — 처음엔 한 홉 더 들지만 곧 1홉으로 안정된다.

어느 패턴이 정답인가

  • 클라이언트 직접 — 응답 지연이 가장 짧지만 클라이언트 관리가 까다롭다. 라이브러리 의존성이 강한 캐시 (Memcached)에 적합.
  • 코디네이터 — 클라이언트가 단순해 운영 부담이 작다. 대규모 분산 DB가 보통 이 방식 (Cassandra, DynamoDB).
  • 스마트 라우터 — 둘의 장점을 섞은 절충이지만 복잡하다. 인메모리 캐시 클러스터(Redis Cluster)에 자주 쓰인다.

어느 패턴이든 핵심은 같다 — "키 → hash → 담당 서버 → value" 흐름. 컨시스턴트 해싱은 그 중 "hash → 담당 서버" 단계가 N 변화에도 안정적으로 동작하게 만드는 알고리즘이다.

3. 부품 ② — 데이터 복제

같은 데이터를 N개 노드에 둬서 가용성과 내구성을 확보한다. N을 replication factor라 부르며 보통 3을 쓴다.

어디에 복제하나 — 시계방향 다음 N-1개 노드

컨시스턴트 해싱과 자연스럽게 결합한다. 키의 시계방향 첫 노드(primary)와 그 다음 시계방향 N-1개 노드에 같은 데이터를 함께 저장한다. 가상 노드를 쓸 때는 같은 물리 노드에 중복 복제되지 않도록 건너뛰며 N개의 서로 다른 물리 노드를 고른다.

왜 N=3이 표준인가

  • 두 노드가 동시에 다운돼도 1개가 살아남는다 — 99.99% 가용성을 맞추기에 충분하다.
  • 네트워크 비용 균형 — N=4·5는 쓰기 비용이 더 들어서 추가 ROI가 낮다.
  • 일관성 합의 비용 — N=3에서 R=2, W=2로 두면 R+W>N이 성립해 강한 일관성도 가능하다.

결제 트랜잭션 로그처럼 더 강한 내구성이 필요하거나 글로벌 서비스라면 N=5나 다중 데이터센터 복제를 쓰기도 한다.

💬 학습자 질문에서 출발한 보강

"복제하면 저장공간이 더 많이 필요하지 않나?"

복제의 가장 큰 비용 — 저장 공간이 N배

복제는 공짜가 아니다. N=3이라면 같은 데이터를 3번 저장한다는 뜻이라 데이터 크기 × N만큼의 저장 공간이 든다. 1TB 데이터셋이 클러스터 전체로는 3TB가 되고, 쓰기 비용·네트워크 비용도 모두 같이 3배가 된다.

N저장 비용가용성적합
1 (복제 없음)1대 다운 = 데이터 손실휘발성 캐시
21대 다운은 OK, 동시 2대 위험비용 민감한 워크로드
32대 동시 다운에도 OK표준
54대 동시 다운에도 OK결제·금융 같은 고가용

N을 무작정 늘릴 수 없는 이유가 여기 있다. N=3이 표준인 이유는 가용성·내구성과 비용의 균형점이기 때문이다.

비용을 줄이는 우회 — Erasure Coding

큰 시스템은 모든 데이터를 단순 복제로 두지 않는다. Erasure Coding(EC, Reed-Solomon 등)을 섞어 쓰면 같은 내구성을 더 적은 저장 비용으로 달성할 수 있다.

  • 단순 복제 N=3 — 1TB → 3TB. 2대 동시 다운 견딤.
  • EC 6+3 — 1TB를 6조각 + 3패리티로 나눠 1.5TB에 저장. 3조각 손실까지 복구 가능. 같은 내구성을 절반 저장 비용으로.

대신 단점도 분명하다.

  • 인코딩·디코딩에 CPU 비용이 든다.
  • 복원 시 여러 노드의 조각을 모아야 해서 읽기 지연이 늘어난다.
  • 작은 객체엔 오버헤드가 커서 부적합하다.

그래서 EC는 큰 객체 저장소(HDFS, S3, Ceph)에서 주로 쓴다. KV 워크로드의 작은 값엔 단순 복제가 더 자연스럽다.

실무 패턴 — Tiered Replication

하나의 시스템 안에서도 데이터 성격에 따라 다른 복제 전략을 섞는다.

  • Hot (자주 읽힘) — 단순 N=3. 응답 지연 최소.
  • Warm — N=2 또는 EC. 비용·성능 절충.
  • Cold (백업·아카이브) — EC 또는 더 적극적인 압축. S3 Glacier가 대표 예다.

한 줄 요약: "복제는 비용을 곱한다. 그 비용을 어디까지 감수할지가 시스템마다 다르다."

4. 부품 ③ — 일관성 (CAP + 정족수)

데이터를 복제하는 순간 "여러 복제본 사이 일관성을 어디까지 보장할 것인가"라는 새 문제가 떠오른다. 두 노드가 같은 키에 잠시라도 다른 값을 가질 수 있는데 — 이걸 어떻게 다룰지가 분산 KV 설계의 가장 어려운 결정이다. CAP 정리가 이 트레이드오프를 정리하는 출발점이다.

CAP 정리 — 셋 중 둘만

분산 시스템은 다음 셋을 동시에 만족할 수 없다는 정리(Eric Brewer, 2000)다.

  • Consistency (일관성) — 모든 노드가 같은 시점에 같은 데이터를 본다. 이 맥락에선 정확히는 선형성(linearizability)에 가까운 강한 일관성을 의미한다 (RDB의 ACID 일관성과는 다름).
  • Availability (가용성) — 살아 있는 모든 노드가 모든 요청에 (timeout 없이) 응답을 돌려준다. 그 응답이 stale일 수는 있다.
  • Partition Tolerance (분할 허용) — 네트워크가 둘 이상 으로 갈라져도 시스템이 멈추지 않는다.

① CAP 정리 — 셋 중 둘만 가능

C 일관성 · A 가용성 · P 분할 허용
C일관성A가용성P분할 허용
세 꼭짓점 중 2개를 선택해보세요 (3번째는 포기).
A + P 선택
AP — Cassandra, DynamoDB, Riak, Couchbase

분할 시에도 양쪽 모두 응답. 대신 잠시 stale 데이터 가능. 사용자 대면 SNS·쇼핑몰처럼 응답성 우선.

💡 실무 진실: 분산 시스템은 네트워크 분할이 불가피하므로 P는 사실상 강제. 진짜 트레이드오프는 분할 발생 시 C vs A다.

실무에서 중요한 점: 분산 시스템에선 네트워크 분할이 불가피하다 (NIC 장애·라우터 다운·데이터센터 간 단절). 그래서 P는 사실상 강제되고, 진짜 트레이드오프는 분할이 일어났을 때 C와 A 중 무엇을 우선할 것인가로 좁혀진다.

분할이 일어나면 실제로 무슨 일이 벌어지나

추상적인 정의보다 시나리오로 보는 게 빠르다. 데이터센터 두 곳(DC1, DC2)에 같은 키 X의 복제본이 있고, 두 데이터센터 사이 네트워크가 끊어진 상황을 가정하자.

상황CP 시스템 (예: etcd, HBase)AP 시스템 (예: Cassandra, Dynamo)
사용자 1이 DC1에 쓰기 X=10DC2와 sync 불가 → 거부 또는 timeoutDC1에만 X=10 저장하고 응답 OK
사용자 2가 DC2에서 읽기 XDC1 확인 못 하니 응답 거부 (또는 stale 인지하고 거부)DC2 로컬 값 반환 (stale 가능)
분할 복구 후다시 정상 동작 (그동안 거부된 요청들은 클라이언트가 재시도)DC1·DC2 간 충돌 해소 — 벡터 시계나 LWW로 병합

한 줄 요약: CP는 분할 시 응답을 거부해 일관성을 지키고, AP는 분할 시 stale 가능성을 감수하고 응답을 돌려준다. 둘 다 데이터를 영구히 잃는 건 아니다. 정상 시점이 오면 다시 같은 값으로 수렴한다.

PACELC — CAP의 실용적 확장

CAP는 "분할 시"의 트레이드오프만 본다. 그런데 실무에선 분할이 없을 때도 지연(Latency) vs 일관성(Consistency)의 트레이드오프가 남는다 — 강한 일관성을 위해 노드들이 합의하면 응답이 느려지고, 빠르게 응답하려면 일부 노드 응답만 쓰니 stale 가능성이 생긴다.

이 점을 보강한 게 PACELC다 (Daniel Abadi, 2010): 분할 시(P)엔 A vs C, 평소(Else)엔 L vs C. 분류는 두 글자로 표기한다.

분류의미대표 시스템
PA / EL분할 시 가용성, 평소엔 지연 우선Cassandra, DynamoDB(기본), Riak
PC / EC분할 시 일관성, 평소도 일관성 우선 (지연 감수)Google Spanner, etcd, HBase
PA / EC분할 시 가용성, 평소엔 일관성MongoDB(설정에 따라)

PACELC가 CAP보다 정확하지만 학습엔 CAP만으로 충분하다. 다만 "DynamoDB는 왜 평소에도 stale 가능?" 같은 질문이 나오면 PACELC를 떠올리면 된다.

정족수 합의 — N/R/W의 의미

AP 시스템(Cassandra·Dynamo 류)은 일관성 수준을 시스템 전체에 못박지 않는다. 대신 요청 단위로 N/R/W 값을 동적으로 설정해 그 요청만의 일관성 수준을 정한다. 같은 데이터에 어떤 호출은 strong, 어떤 호출은 eventual로 부를 수 있다.

  • N — 복제본의 총 개수. 보통 3.
  • W — 쓰기가 성공으로 인정되기 위해 응답해야 할 최소 노드 수. 클라이언트는 W개 응답을 받기까지 기다린다.
  • R — 읽기 시 조회할 최소 노드 수. R개 응답에서 가장 최신 버전을 골라 반환한다 (벡터 시계나 타임스탬프 기준).

왜 R + W > N이면 강한 일관성인가 — 비둘기집 원리

이 부등식의 직관은 비둘기집 원리(Pigeonhole Principle)다.

노드가 N개일 때 쓰기 노드 집합 W의 크기를 |W|, 읽기 노드 집합 R의 크기를 |R|이라 하자. |W| + |R| > N이면 두 집합은 반드시 최소 1개 노드에서 겹친다. 만약 안 겹친다면 두 집합의 크기 합이 N 이하여야 하는데 그건 가정에 모순이다.

겹친 노드는 가장 최근 쓰기를 반드시 가지고 있다. 그래서 R개 응답 중에 그 노드가 포함되고, "가장 최신 버전 선택" 규칙에 의해 클라이언트는 항상 최신 데이터를 읽는다 — 이게 강한 일관성의 보장이다.

반대로 R + W ≤ N이면 두 집합이 안 겹칠 수 있다. 그 경우 읽기 노드들이 모두 stale일 가능성이 있어 — 결과적 일관성(eventual)이다.

② 정족수 합의 — R + W > N

복제본 3 · 읽기 2 · 쓰기 2
3
2
2
쓰기는 노란색 W개, 읽기는 녹색 R개. 겹치는 노드(주황색)가 있으면 읽기가 최신 쓰기를 본다.
N1
W
N2
W∩R
N3
R
R + W = 2 + 2 = 4, N = 3
✅ 강한 일관성

읽기와 쓰기 노드 집합이 1개 노드에서 겹친다 → 항상 최신 쓰기 반영.

자주 쓰는 조합 (N=3 기준)
  • R=1, W=3 — 빠른 읽기 (Strong)
  • R=3, W=1 — 빠른 쓰기 (Strong)
  • R=2, W=2 — 균형 (Strong) ⭐
  • R=1, W=1 — 최고 가용성 (Eventual)

자주 쓰는 정족수 조합 (N=3 기준)

RWR+W vs N특성적합한 워크로드
112 ≤ 3 (Eventual)읽기·쓰기 모두 빠름, stale 가능세션, 캐시, 로그 수집
134 > 3 (Strong)읽기 매우 빠름, 쓰기 느림읽기 폭주 + 쓰기 드문 (시계열 조회)
314 > 3 (Strong)쓰기 매우 빠름, 읽기 느림쓰기 폭주 + 읽기 드문 (이벤트 스토어)
224 > 3 (Strong)균형, 한 노드 다운에도 동작일반 OLTP — 표준

R=W=⌈(N+1)/2⌉이 가장 균형 잡힌 선택이다 (multi-paxos·Raft도 같은 quorum size). N=5라면 R=W=3, N=7이면 R=W=4.

정족수의 한계 — strong이라고 다 같은 strong은 아니다

R+W>N으로 얻는 strong은 "읽기가 stale 안 본다"는 보장이지, 전역 시간 순서(linearizability)까지는 주지 않는다.

  • 동시에 두 클라이언트가 같은 키에 다른 값을 쓰면 둘 다 W개 노드에 도달 하고 둘 다 성공으로 간주된다 — 충돌이 발생한다. 정족수만으론 누가 "먼저"인지 정의되지 않는다.
  • 그래서 동시 쓰기 감지는 벡터 시계로 별도 처리한다 — 다음 섹션에서 다룬다.
  • 진짜 선형성을 원하면 정족수 위에 합의 알고리즘(Raft, Paxos)을 얹어야 한다. etcd·Zookeeper·Spanner가 이 길을 택했다.

일관성 모델 스펙트럼 — 강함부터 약함까지

"일관성"이라는 단어 하나가 여러 다른 보장을 가리키는데, 명확히 구분하는 게 시스템 선택의 출발점이다.

모델보장대표 시스템
Linearizable (Strong)모든 작업이 단일 시간선 위에서 일어난 것처럼 보임. 합의 알고리즘 필요.etcd, Zookeeper, Spanner
Sequential일관된 순서로 보이지만 실시간 순서일 필요는 없음.일부 분산 DB
Causal인과관계 있는 작업만 순서 보장. 동시 작업은 노드마다 다른 순서로 보일 수 있음.COPS, MongoDB causal sessions
Read-your-writes한 클라이언트는 자기 쓰기 직후 그 값을 즉시 읽음. 다른 사용자는 stale 가능.SNS 피드 자기 게시물
Monotonic Reads한 번 본 값보다 더 오래된 값을 다시 보지 않음.여러 AP 시스템의 클라이언트 옵션
Eventual모든 쓰기가 멈추고 충분한 시간이 지나면 모든 노드가 같은 값으로 수렴.Cassandra(R=W=1), Dynamo

실무 선택 가이드

  • 금융·결제·재고·예약 — 절대 stale 안 됨. Strong + 합의 알고리즘 (Spanner, CockroachDB, etcd).
  • SNS 피드·댓글·좋아요 — 잠시 stale 괜찮음. Eventual + Read-your-writes로 자기 게시물만 즉시 보이게.
  • 세션·캐시·시계열 로그 — 응답성 우선. Eventual (R=W=1)로 최대 성능.
  • 대시보드·분석 — 약간 stale 무관. Eventual로 빠른 읽기.
  • 설정·서비스 디스커버리 — 데이터 작고 일관성 절대. Linearizable (Raft) — etcd·Consul.

한 시스템 안에서도 데이터 성격에 따라 다른 모델을 쓸 수 있다는 게 정족수 기반 KV의 강점이다. 같은 Cassandra에서도 어떤 호출은 R=W=2(strong)로, 어떤 호출은 R=W=1(eventual)로 부를 수 있다.

5. 부품 ④ — 비일관성 해소

AP 시스템에선 두 노드가 같은 키에 다른 값을 가질 수 있다. 두 가지 도구로 푼다.

벡터 시계 — 동시(concurrent) 쓰기를 감지

단순 타임스탬프는 분산 환경에서 부정확하다 (노드 간 시계 동기화 한계). 벡터 시계는 각 노드가 자신의 카운터를 가진 벡터로 인과 관계를 추적해 이 한계를 우회한다.

동작은 단순하다. 노드별로 [A의 카운터, B의 카운터, C의 카운터] 같은 벡터를 유지하고, 쓰기 시 자신의 카운터를 1 증가시킨다. 동기화 시에는 두 벡터의 element-wise max를 새 벡터로 삼는다. 두 벡터를 비교했을 때 한쪽이 다른 쪽을 dominate하지 않으면 동시(concurrent) 쓰기로 판정한다 — 이게 곧 충돌이다.

③ 벡터 시계 — 동시 쓰기 감지

3노드 · 인과 관계 추적
노드 A
[0,0,0]
노드 B
[0,0,0]
노드 C
[0,0,0]
A vs B
동일
A vs C
동일
B vs C
동일
버튼을 눌러 쓰기·동기화를 시뮬레이션하세요.

💡 두 벡터가 서로 dominate하지 않으면 동시(concurrent) 쓰기. 충돌 해소가 필요. (예: A에 쓰기 → C에 쓰기 → 둘 다 보면 충돌)

충돌 해소 방식은 시스템마다 다르다. last-write-wins(LWW)로 단순히 한쪽을 버리거나, 클라이언트가 직접 병합하거나(쇼핑카트 합치기), CRDT 자료구조로 자동 병합하는 식이다.

Merkle 트리 — 노드 간 차이를 효율적으로 검출

두 노드가 같은 키 범위를 책임지는데, 누가 어떤 키를 놓쳤는지 어떻게 비교할까? 전체 데이터를 다 주고받으면 네트워크가 폭발한다. Merkle 트리가 이 문제를 푼다.

모든 키-값을 트리로 묶고 각 노드의 해시를 자식 해시들의 합으로 계산한다. 비교는 두 트리의 루트 해시부터 시작한다. 같으면 끝, 다르면 자식으로 내려가며 비교한다. 결과적으로 다른 부분만 O(log N)에 식별할 수 있다.

Cassandra와 DynamoDB가 노드 간 anti-entropy(주기적 동기화)에 이 방식을 쓰고, Git의 버전 관리도 같은 발상에서 출발했다.

6. 부품 ⑤ — 장애 처리

분산 KV에서 노드는 항상 죽는다. 100대 클러스터에서 연 하드웨어 실패율이 1~3%만 돼도 1년에 1~3대가 죽고, 클라우드라면 EC2 retirement·spot 종료 때문에 더 자주 변한다. 시스템은 이 상황을 비정상이 아니라 일상으로 받아들이고 대응해야 한다.

장애 대응은 세 단계로 나뉜다.

  1. 감지 — 누가 죽었는지 빠르게 알아낸다.
  2. 흡수 — 그 동안 들어오는 쓰기를 어디든 받아낸다.
  3. 복구 — 죽었던 노드가 살아 돌아오면 그 동안 놓친 데이터를 따라잡는다.

각각을 어떤 메커니즘으로 푸는지 차례로 본다.

1단계 감지 — Gossip Protocol

분산 환경에서 장애 감지는 생각보다 어려운 문제다. "노드 A의 응답이 없다" 는 게 A가 죽었다는 뜻인지 네트워크가 잠시 막혔다는 뜻인지 구분이 쉽지 않다. 중앙 코디네이터를 두면 그 코디네이터가 단일 장애점이 되고, 큰 클러스터에선 모든 노드를 핑하는 비용도 빠르게 커진다.

Gossip Protocol은 이 문제를 분산 방식으로 해결한다. 중앙 코디네이터 없이 노드들이 서로 상태를 주고받아 장애를 감지한다. 각 노드는 주기적으로 임의의 다른 노드 몇 개에게 "내가 알고 있는 클러스터 상태"를 전송하고, 받은 노드는 자기 정보와 합쳐 또 다른 노드에게 보낸다.정보는 전염병처럼 퍼져 결국 모든 노드가 같은 클러스터 뷰에 수렴한다.

구체적 동작은 단순하다.

  • 각 노드는 1~2초마다 임의 노드 1~3개에게 자신·이웃의 상태(heartbeat counter, 마지막 본 시각)를 전송한다.
  • 일정 시간 heartbeat가 안 오면 "죽은 듯(suspect)"으로 표시한다.
  • 더 오래 안 오면 "확실히 죽음(failed)"으로 처리해 그 노드로 향하는 요청을 멈춘다.

장점은 단일 장애점이 없고, 노드 수가 늘어도 트래픽이 폭발적으로 증가하지 않으며, 네트워크 분할에도 견고하다는 것이다. 단점은 정보 전파에 수 초 단위 지연이 있어 즉시 반영은 어렵다는 점. Cassandra·Amazon Dynamo· Consul·Riak이 모두 이 방식을 쓴다.

2단계 흡수 — Hinted Handoff

장애를 감지했다고 끝이 아니다. 그 다운된 노드로 가야 할 쓰기 요청들이 계속 도착한다. 어떻게 처리할까?

가장 단순한 답은 "다운된 노드가 있으면 그 쓰기를 거부한다"이다. 그러면 가용성이 떨어지고 사용자 입장에선 일시 장애로 보인다. 더 나은 답이 Hinted Handoff — "그 데이터를 다른 노드에게 임시로 맡겨 두고, 원래 노드가 깨어나면 전달해달라"는 힌트와 함께 보관하는 방식이다.

흐름을 한 단계씩 풀면:

  1. 클라이언트가 키 X 쓰기를 요청한다 (담당 노드가 A·B·C인데 A가 다운).
  2. 코디네이터가 A 대신 살아있는 다른 노드 D에 데이터를 보낸다.
  3. D는 데이터를 자기 저장소에 기록하면서 "이건 A의 데이터, A가 깨어나면 전달해야 함"이라는 힌트도 함께 저장한다.
  4. 클라이언트에게 쓰기 성공으로 응답한다.
  5. A가 복구되면 D는 보관 중이던 데이터를 A에게 전송하고 자신의 사본을 정리한다.

이렇게 하면 A가 잠깐 다운돼도 쓰기를 거부하지 않으니 가용성이 유지된다. 다만 hinted 데이터가 오래 쌓이면 D의 디스크 부담이 커지므로 일정 기간(보통 몇 시간~며칠) 안에 복구되지 않으면 힌트는 폐기된다 — 그땐 다음 단계인 anti-entropy가 풀어준다.

3단계 복구 — Read Repair + Anti-entropy

Hinted Handoff는 잠깐 다운(수 분~수 시간) 시나리오에 잘 맞는다. 그런데 진짜 오래 다운되거나 hinted 데이터를 보관하던 노드까지 죽으면? 더 강력한 복구 메커니즘이 두 가지 더 있다.

Read Repair (수동적 복구)는 읽기 요청이 들어왔을 때 R개 노드에서 응답을 받는데, 그중 일부가 stale인 것이 발견되면 그 자리에서 최신 값으로 업데이트하는 방식이다.

  1. 클라이언트가 키 X 읽기를 요청한다 (R=3).
  2. 노드 A·B·C에서 응답을 받는다 (A는 v=5, B는 v=5, C는 v=3 — C가 stale).
  3. 코디네이터가 가장 최신인 v=5를 클라이언트에게 반환한다.
  4. 동시에 C에게 "v=5로 업데이트"라는 메시지를 보내 자동 동기화한다.

읽힐 때마다 점진적으로 일관성이 복구되는 lazy 방식이라, 자주 읽히는 키는 빠르게 따라잡고 안 읽히는 키는 천천히 따라잡는다.

Anti-entropy (능동적 복구)는 백그라운드에서 노드끼리 주기적으로 데이터를 비교·동기화하는 프로세스다. Read Repair만으론 안 읽히는 키가 영원히 stale로 남을 수 있어서 별도 보강이 필요하다.

비교는 5장에서 짚었던 Merkle 트리로 한다. 두 트리의 루트 해시만 비교해 같으면 끝, 다르면 자식으로 내려가며 차이 부분만 식별·전송한다. O(log N)에 차이를 찾을 수 있어 효율적이다.

보너스 — Sloppy Quorum (가용성을 끌어올리는 변형)

기본(strict) 정족수는 "쓰기는 정해진 N개 복제 노드 중 W개에 들어가야 성공"이라는 규칙이다. 그 N개 중 충분한 수가 다운되면 쓰기는 거부된다.

Sloppy quorum은 그 규칙을 느슨하게 푼다. 정해진 N개 중 W개를 못 채우면 다른 살아있는 노드에 임시로 저장해도 쓰기 성공으로 처리한다(Hinted Handoff와 결합). 가용성을 끌어올리는 대신 일관성을 약간 희생하는 트레이드오프다.

  • 적합: AP 시스템에서 가용성을 최대화하고 싶을 때 (Cassandra·Dynamo의 기본 동작).
  • 부적합: 결제·금융처럼 strict quorum이 절대적인 경우.

한눈에 보는 장애 대응

7. 쓰기/읽기 경로 — WAL → MemTable → SSTable

지금까지는 분산 환경의 부품들을 봤다. 이제 노드 한 대 안에서 데이터가 어떻게 저장되는지를 본다. 분산이라기보다 단일 노드 스토리지 엔진의 영역인데, 분산 KV(Cassandra·LevelDB·RocksDB·Bigtable)의 표준 패턴이 되어버린 LSM-tree(Log-Structured Merge-tree)를 짚고 가자.

왜 이렇게 복잡한가 — 디스크의 물리적 특성

먼저 동기를 잡자. 디스크는 SSD든 HDD든 한 가지 명확한 특성이 있다. 순차 쓰기는 빠르고 랜덤 쓰기는 느리다. SSD에서도 순차 쓰기가 랜덤 쓰기보다 수십~수백 배 빠르다 (쓰기 증폭과 GC 부담 때문).

전통적인 RDBMS(PostgreSQL·MySQL InnoDB 등)는 B-tree로 데이터를 저장한다. B-tree는 정렬된 자료구조라 읽기는 빠르지만, 쓰기 시 디스크 위 정확한 위치에 in-place 업데이트를 해야 해서 랜덤 I/O가 발생한다.

LSM-tree는 정반대 발상에서 출발한다 — "쓰기는 무조건 순차로, 읽기는 약간 느려도 받아들이자". 그러면서도 읽기를 충분히 빠르게 유지하기 위해 여러 보조 구조를 둔다. 그게 WAL · MemTable · SSTable · Bloom filter · Compaction의 조합이다.

쓰기 경로 단계별

쓰기 한 건이 들어오면 다음 순서로 처리된다. 인터랙티브로 직접 따라가 보자.

④ 쓰기 경로 — WAL → MemTable → SSTable

LSM-tree 기반 (Cassandra, RocksDB, LevelDB)
📱 클라이언트
write(k, v)
📜 WAL (디스크)
순차 append, 영속화
0 엔트리
🧠 MemTable (RAM)
정렬된 자료구조
0/4 키
↓ MemTable이 가득 차면 flush
💿 SSTable들 (디스크, 불변)
아직 없음
쓰기 경로: 클라이언트 → WAL append (영속) → MemTable 삽입 → 클라이언트 ack. WAL이 영속화되어 있어 서버가 죽어도 복구 가능.
읽기 경로: MemTable → 캐시된 SSTable → 디스크 SSTable (Bloom filter로 빠른 존재 검사). 가장 최신부터 검색.

💡 디스크 쓰기는 항상 순차(sequential)이라 매우 빠르다. 랜덤 디스크 쓰기는 SSD에서도 느려 → LSM-tree가 RDB(B-tree)보다 쓰기에 강함.

  1. 클라이언트가 (key, value) 쓰기를 요청한다.
  2. 노드는 우선 WAL(Write-Ahead Log)에 append한다. 디스크 끝에 한 줄을 추가하기만 하니 순차 쓰기 — 매우 빠르다. 이 단계 에서 영속성이 확보된다.
  3. 동시에 MemTable(RAM 안의 정렬된 자료구조, 보통 skiplist 나 red-black tree)에 (key, value)를 삽입한다.
  4. 클라이언트에게 쓰기 성공 응답을 보낸다.
  5. MemTable이 일정 크기(보통 수~수십 MB)를 넘으면 백그라운드에서 디스크에 flush한다. 이미 정렬된 상태라 순차 쓰기로 SSTable (Sorted String Table) 파일이 만들어진다.
  6. 새 MemTable이 시작되어 다음 쓰기를 받는다. 이전 WAL은 SSTable이 안전히 디스크에 쓰여진 후 삭제 가능.

핵심: 디스크에 일어나는 쓰기는 WAL append MemTable flush, 두 가지뿐이다. 둘 다 순차 쓰기.

읽기 경로 단계별

읽기는 쓰기보다 살짝 복잡하다. 어느 키든 MemTable에 있을 수도, 여러 SSTable 중 하나에 있을 수도 있다. 같은 키가 여러 곳에 다른 값으로 있을 수도 있다(여러 번 갱신된 키). 그래서 가장 최신부터 거꾸로 찾는다.

  1. 클라이언트가 (key) 읽기를 요청한다.
  2. MemTable을 먼저 검색한다. 있으면 그 값을 반환한다 (가장 최신).
  3. 없으면 SSTable들을 새 것부터 오래된 순으로 검색한다.
  4. 각 SSTable마다 Bloom filter로 "이 키가 여기 있을 가능성"을 빠르게 확인한다. "확실히 없음"이면 다음 SSTable로 넘어가고, "있을 수도 있음"이면 SSTable 내부 인덱스를 디스크에서 읽어 확인한다.
  5. 첫 번째로 발견된 키-값을 반환한다 (없으면 not found).

읽기는 최악의 경우 모든 SSTable을 뒤져야 해서 B-tree보다 느릴 수 있다. 그래서 SSTable 수를 줄이는 Compaction과 키 존재 여부를 빠르게 판단하는 Bloom filter가 필수가 된다.

Compaction — SSTable이 쌓이면 정리한다

쓰기를 계속 받으면 SSTable이 끝없이 쌓인다. 같은 키가 여러 SSTable에 다른 값으로 있을 수 있고, 삭제된 키도 tombstone(삭제 마커)으로 남아 있다. 그대로 두면 읽기 성능이 떨어지고 디스크 공간도 낭비된다.

Compaction백그라운드 작업으로 여러 SSTable을 읽어 병합한 새 SSTable을 만든다. 이 과정에서:

  • 같은 키의 여러 버전 중 가장 최신만 남기고 나머지는 버린다.
  • 삭제된 키(tombstone)를 정리한다.
  • 정렬된 상태를 유지한다 (sorted merge).

전략은 여러 가지가 있다 (size-tiered, leveled 등). RocksDB는 leveled compaction을 주로 쓴다 — 레벨 0(MemTable flush 결과)에서 시작해 레벨 N까지 점진적으로 병합하며, 각 레벨이 이전 레벨보다 약 10배 크다.

Compaction은 비용이 크다 — 디스크 I/O와 CPU를 많이 쓴다. 그래서 스토리지 엔진 튜닝의 핵심 파라미터다 (얼마나 자주, 얼마나 크게 묶어서 병합할지).

Bloom filter — 디스크 I/O를 100배 줄이는 비결

읽기 경로에서 SSTable마다 디스크 인덱스를 일일이 뒤지면 매우 느리다. Bloom filter는 각 SSTable에 "이 SSTable에 어떤 키들이 있는지"를 매우 효율적으로 표현하는 확률적 자료구조를 둔다.

핵심 성질 두 가지:

  • "키가 있을 수도 있음" — 디스크에서 진짜 확인이 필요 (가끔 거짓 양성, false positive).
  • "키가 확실히 없음" — 디스크를 건드리지 않고 즉시 다음 SSTable로 넘어감 (거짓 음성 없음, false negative is impossible).

거짓 양성률은 보통 1% 정도로 튜닝되니, 100개 SSTable 중 99개는 디스크를 건드리지 않고 스킵된다. 읽기 디스크 I/O가 100배 줄어드는 셈이다.

구현은 비트 배열 + 여러 해시 함수의 조합이다. 메모리는 키당 약 10비트면 충분 — 1억 키여도 약 100MB 정도라 RAM에 모두 올린다.

B-tree vs LSM-tree — 한눈에

B-tree (RDBMS)LSM-tree (NoSQL·KV)
쓰기 패턴in-place 랜덤 쓰기순차 append + 배치 flush
쓰기 성능보통빠름 (특히 폭주에 강함)
읽기 성능빠름 (1~2회 디스크 hop)보통 (여러 SSTable 검색 가능)
공간 효율좋음약간 낭비 (compaction 필요)
적합 워크로드읽기 위주, 트랜잭션쓰기 위주, 시계열·로그·이벤트

LSM-tree는 "쓰기 폭주" 워크로드에 강하다. 시계열·로그·이벤트 스트림처럼 초당 수만 쓰기가 들어오는 곳에 잘 맞는다. 읽기 위주의 OLTP라면 RDBMS의 B-tree가 더 단순하고 빠르다.

8. 실무 — 누가 어떻게 쓰나

시스템일관성 모델활용
Amazon DynamoDB설정 가능 (eventual / strong)AWS 기본 NoSQL, 2007 Dynamo 논문 후예
Apache Cassandra설정 가능 (R/W 단위)Netflix, Apple. 거대 규모 시계열·로그
RiakEventual 기본Dynamo 직계, 벡터 시계 구현
CouchbaseStrong (단일 마스터)모바일 동기화, JSON 문서
etcd / ZookeeperStrong (Raft / ZAB)설정·서비스 디스커버리. 데이터 작고 일관성 우선
Redis ClusterEventual (master-replica)인메모리, 캐싱·세션

언제 KV가 안 맞나

  • 관계형 쿼리 — JOIN이나 복잡한 WHERE 절이 필요하면 KV가 어색하다. RDBMS가 답.
  • 트랜잭션 일관성 — 여러 키를 atomic하게 수정해야 한다면 Spanner·CockroachDB 같은 NewSQL이 더 적합하다.
  • 풀텍스트 검색 — Elasticsearch처럼 역색인을 가진 검색 엔진이 정답이다.
  • 그래프 순회 — 노드와 관계를 따라가는 쿼리는 Neo4j 같은 그래프 DB가 잘 맞는다.

9. 정리

  • 5개 부품의 조합이 분산 KV다 — 어느 하나가 빠지면 시스템이 망가진다.
  • CAP는 실무에선 C vs A의 선택이다 — P는 사실상 강제이고, 사용자 대면 서비스는 AP를 더 흔히 고른다.
  • R + W > N이면 강한 일관성이 보장된다 — 요청별로 동적 설정이 가능하다.
  • 벡터 시계로 동시 쓰기를 감지하고, Merkle 트리로 노드 간 차이를 검출한다.
  • WAL → MemTable → SSTable — 디스크 순차 쓰기로 성능을 확보하는 LSM-tree 패턴이 표준이다.

5장(컨시스턴트 해싱)이 6장의 ① 분할에 그대로 쓰이고, 6장의 ②~⑤가 그 위에 쌓이는 층이다. 두 챕터가 함께 분산 NoSQL의 청사진을 만든다.