-
일관성 코어 (Consistent Core) & 리스 (Lease)Development/Architecture 2026. 8. 17. 20:58
일관성 코어 (Consistent Core) & 리스 (Lease)
1. 일관성 코어 — 합의는 클러스터 크기와 함께 스케일하지 않는다
문제
1,000노드 클러스터가 Raft를 직접 돌린다면 정족수가 501이다. 쓰기마다 501개의 ack를
기다려야 하고, 하트비트가 1,000개씩 오가며, 리더 선출 때는 1,000노드가 투표전을 벌인다.
정족수 기반 합의의 비용은 노드 수에 비례해 나빠지는데, 우리가 노드를 늘리는 이유는
데이터와 처리량 때문이지 합의 참여자를 늘리고 싶어서가 아니다. 요구가 어긋나 있다.그런데 잘 보면, 큰 클러스터에서 강한 일관성이 필요한 데이터는 아주 작다. 어떤 노드가
살아있는가, 어떤 파티션을 어떤 노드가 담당하는가, 클러스터 설정값 — 메타데이터뿐이다.
실제 데이터 읽기/쓰기는 파티셔닝과 복제로 처리하면 되고, 거기엔 전 클러스터 합의가 필요
없다.해법: 작은 합의 클러스터 + 큰 데이터 클러스터
┌─────────────────────────────────────┐ │ 일관성 코어 (3~5 노드, Raft/Zab) │ ← 메타데이터, 강한 일관성 │ 멤버십 / 파티션 배정 / 설정 / 선출 │ 쓰기 빈도 낮음, 데이터 작음 └───────────────┬─────────────────────┘ │ 리스 갱신(하트비트), 감시(watch), 메타데이터 조회 ┌───────────────┴─────────────────────┐ │ 데이터 클러스터 (수백~수천 노드) │ ← 실제 데이터, 파티셔닝/복제 └─────────────────────────────────────┘실물로는 전부 아는 이름들이다. ZooKeeper(HBase, HDFS, 구세대 Kafka의 코어),
etcd(Kubernetes의 코어), Chubby(Google GFS/Bigtable의 코어, 이 패턴의 원조 논문),
그리고 Kafka KRaft의 컨트롤러 쿼럼(외부 코어 ZooKeeper를 쓰다가 같은 구조를 내부로
들여온 사례).코어가 제공하는 세 가지 기능
첫째, 강한 일관성의 작은 KV 저장소. 메타데이터를 선형화 가능(linearizable)하게 읽고 쓸
수 있어야 한다. "파티션 7의 리더가 누구인가"에 대해 두 노드가 다른 답을 받으면 안 된다.
내부는 복제 로그 + 단일 갱신 큐 + 요청 대기 목록 — 앞선 글들의 파이프라인 그대로다.둘째, 리스 기반 멤버십 관리. 데이터 노드는 코어에 자신을 등록하면서 TTL이 있는 리스를
받고, 주기적인 하트비트로 갱신한다. 갱신이 끊기면 코어는 그 노드를 죽은 것으로 간주한다
(2장에서 상세히 다룬다).셋째, 상태 감시(State Watch). 클라이언트가 "이 키가 바뀌면 알려달라"고 등록해 두면
코어가 변경 시점에 통지를 밀어준다. 컨트롤러가 "노드 등록이 사라지면 파티션 재배정을
시작한다"처럼 반응형으로 동작하는 기반이다.설계 원칙: 코어는 제어면이지 데이터 경로가 아니다
코어의 처리량은 3~5노드 합의 클러스터의 처리량이다. 데이터 요청이 매번 코어를 거치면
코어가 전체 시스템의 병목이자 단일 장애점이 된다. 그래서 실제 시스템들은 클라이언트가
파티션 배치 정보를 코어에서 한 번 읽어 캐시하고, 데이터 요청은 데이터 노드로 직행하며,
배치가 바뀌었을 때만(watch 통지 또는 요청 실패 시) 갱신한다. 코어에 담는 데이터는 메모리에
다 들어갈 만큼 작게, 쓰기 빈도는 "노드가 죽거나 설정이 바뀔 때" 수준으로 낮게 유지한다.
Kubernetes 운영에서 "etcd에 큰 오브젝트를 넣지 마라"가 철칙인 이유다.2. 스플릿 브레인과 펜싱 — 코어와 데이터 노드 사이의 유령 리더
데이터 노드 D가 파티션 7의 리더로 배정되어 있고, 코어의 리스로 그 사실이 관리되고 있다고
하자. 그런데 D와 코어 사이의 네트워크만 끊겼다 — D는 멀쩡히 살아서 클라이언트 요청을
계속 받고 있다. 리스가 만료되면 코어는 D를 죽었다고 보고 파티션 7의 리더를 E로 재배정한다.이 순간 파티션 7에 리더가 둘이 된다 — 스플릿 브레인이다. D는 자기가 강등된 것을
모르고(코어와 연락이 안 되니 통지받을 길이 없다) 계속 리더 행세를 하고, 클라이언트마다
다른 리더에 쓰면서 두 사본이 갈라진다.해법의 방향: D를 멈추게 하는 게 아니라, 세상이 D를 거절하게 만든다
핵심 통찰 — D의 행동은 고칠 수 없다. 연락이 안 되는 노드에게 "너 강등됐어"를 전할
방법은 정의상 없다. 그래서 해법의 방향이 뒤집힌다.코어가 파티션 리더를 배정할 때마다 단조 증가하는 세대 번호를 함께 발급한다. D는 세대
5의 리더였고 E는 세대 6의 리더다. 리더가 하는 모든 요청(팔로워 복제, 스토리지 쓰기)에 이
번호를 실어 보내게 하고, 받는 쪽은 자기가 본 최고 세대를 기억하며 그보다 낮은 세대의
요청을 거부한다.E가 세대 6으로 활동 시작 → 레플리카들이 "최고 세대 = 6" 기록 D(세대 5)가 복제/쓰기 시도 → "세대 5 < 6" → 거부 D는 거부당하고서야 자신의 강등을 알게 됨 → 스스로 물러남D의 쓰기는 접수까지는 될지 몰라도 커밋(복제 완료)이 불가능해진다. 이 용도의 세대 번호를
펜싱 토큰(fencing token) 이라 부른다. Chubby의 sequencer, Kafka의 leader
epoch/controller epoch, HDFS NameNode 페일오버의 fencing이 전부 이것이다.방어는 두 겹이다. 1겹: D의 자진 사퇴(리스 TTL을 스스로 감시하다 갱신 실패 시 역할 중단
— 홀더와 코어의 시계 차이 때문에 코어는 ε 여유를 두고 재배정한다). 2겹: 펜싱 토큰(D가
어떤 이유로든 안 물러나도 세상이 거부한다). 1겹은 최적화이고 2겹이 안전망이다. "노드가
성실하게 행동한다"에 기대는 장치와 "노드가 무슨 짓을 해도 안전하다"인 장치를 구분하는 것이
중요하다.3. 리스 (Lease)
문제: 분산 환경에서 "소유"는 어떻게 만료되는가
분산 환경에서 순수한 락은 치명적 결함이 있다. 락을 쥔 노드가 죽으면 락이 영원히
잠긴다. 해제해 줄 주체가 사라졌기 때문이다. 그룹 멤버십("나 살아있어"), 파티션 리더십도
본질이 같다. 소유자가 사라졌을 때 그 사실이 자동으로 무효화되는 메커니즘이 필요하다.락에 유효기간(TTL)을 붙인 것이 리스다. "이 자원은 30초 동안 네 것이다. 계속 쓰려면
만료 전에 갱신해라. 갱신이 끊기면 자동 회수한다." DHCP가 IP 주소를 나눠주는 방식 그대로다.구현: 리스는 일관성 코어의 복제된 상태다
클라이언트가 리스를 요청하면 코어의 리더는 이것을 복제 로그의 엔트리로 커밋한다. 코어
노드 전부가 "이 리스가 존재한다"에 합의한다. 갱신도, 만료에 의한 삭제도 로그를 탄다. 코어
리더가 죽어도 새 리더가 같은 로그에서 같은 리스 목록을 복원한다.여기서 미묘한 설계 결정이 하나 있다. 시간을 재는 것은 오직 코어의 리더뿐이다.
팔로워들도 리스 데이터는 갖고 있지만, 각자 시계를 보며 만료를 독자 판단하지 않는다. 코어
노드들의 시계도 서로 어긋나 있기 때문이다. 각자 판정하면 노드마다 리스 목록이 달라지는 —
합의로 지켜온 일관성이 시계 때문에 깨지는 — 사태가 난다. 그래서 만료 판정은 리더가
내리고, 그 결정(삭제)을 로그로 복제한다. 팔로워에게 만료란 시계 이벤트가 아니라 로그
엔트리다. "시간에 대한 판단조차 단일 결정자를 거쳐 로그로 흐르게 한다" — 단일 갱신 큐의
철학이 시간 영역까지 확장된 모습이다.또 하나, 리더가 경과 시간을 잴 때는 벽시계(wall clock)가 아니라 단조 시계(monotonic
clock) 를 써야 한다. 벽시계는 NTP 보정으로 뒤로 점프할 수 있고, 그 순간 모든 리스의 남은
시간이 왜곡된다. JVM이라면System.currentTimeMillis()가 아니라System.nanoTime()
기반으로 재야 한다는, 코드 리뷰에서 실제로 잡아내야 하는 디테일이다.TTL의 딜레마
짧게(1~2초): 장애 감지가 빨라 페일오버가 빠르다. 대신 잠깐의 네트워크 딸꾹질, JVM의
stop-the-world GC 한 번에도 리스가 날아간다 — 멀쩡한 노드를 죽었다고 오판하고(false
positive) 불필요한 재배정 폭풍이 인다. 길게(30초~수 분): 오판은 줄지만 진짜 죽은
노드를 그만큼 늦게 알아챈다. 실제 시스템들은 수 초 단위(ZooKeeper 세션 타임아웃, etcd
리스)에서 시작해 워크로드에 맞춰 조정한다. 정답이 없는 감시 민감도의 튜닝 문제다.GC 멈춤과 펜싱 — 자가 감시로도 못 잡는 경우
홀더가 stop-the-world GC로 30초 멈췄다 깨어나면, 자기 리스가 만료된 줄 모르고 "아직
리더"라며 행동을 재개한다. 시계가 아니라 실행 자체가 멈췄으니 자가 감시로도 못 잡는다.
그래서 최후의 방어선은 언제나 펜싱 토큰이다. 이 조합(리스 + 펜싱)이 "Redis 분산
락(Redlock)은 펜싱 없이는 안전하지 않다"는 Martin Kleppmann의 유명한 비판의 요지이기도
하다 — 리스만 있고 펜싱이 없는 분산 락은 GC 멈춤 하나에 뚫린다.코어 리더 페일오버 — 연장은 안전하고 단축은 위험하다
코어의 리더가 죽고 새 리더가 선출됐다. 새 리더는 복제 로그에서 리스 목록을 복원했지만,
각 리스의 "남은 시간"은 죽은 리더의 메모리(단조 시계 기준)에만 있었고 로그에는 없다. 새
리더는 어떻게 해야 할까. (a) 로그의 리스 생성 시각 + TTL로 만료 시점을 계산한다? (b) 모든
리스의 TTL을 지금부터 처음부터 다시 센다?답은 (b)다. 이유는 두 겹이다.
첫째, (a)는 위험한 게 아니라 사실상 불가능에 가깝다. 단조 시계는 기점(origin)이
프로세스마다 제멋대로라 노드 간 비교가 아예 무의미하다. 죽은 리더의 단조 시계 값은 새
리더에게 의미 없는 숫자다. 벽시계 타임스탬프를 쓰자니 두 리더 간 시계 오차와 역행 문제가
그대로 들어온다.둘째 — 더 본질적으로 — 안전성이 비대칭이다. (b)는 리스를 실질적으로 연장하는
행위다. 연장의 최악 시나리오는 이미 죽은 홀더를 TTL 한 사이클 늦게 알아채는 것 — 성능
손해일 뿐 정합성은 안 깨진다. 살아있는 홀더들은 어차피 하트비트로 계속 갱신하니 영향이
없다. 반면 (a)가 잘못되어 남은 시간을 실제보다 짧게 계산하면, 멀쩡히 일하던 홀더의 리스를
조기 회수하고 재배정한다 — 스플릿 브레인을 코어가 직접 만들어내는 꼴이다.원칙 한 줄: 리스는 연장은 언제나 안전하고, 단축은 언제나 위험하다. 모르겠으면
연장하라. 이 사고방식 — 불확실할 때 어느 방향의 오류가 안전한지 먼저 정하고 그쪽으로
넘어진다 — 는 commit-wait(모르면 기다린다), 리스 만료 판정의 ε 여유(모르면 늦게 회수한다)와
같은 계보다. 분산 시스템 설계 전반을 관통하는 태도다.실물들
ZooKeeper — 세션이 곧 리스다. 세션이 살아있는 동안만 존재하는 임시 노드(ephemeral
node)로 멤버십과 락을 표현한다. etcd — 리스가 일급 API다(grant로 TTL 받고,keepalive로 갱신하고, 키에 리스를 붙이면 만료 시 키 자동 삭제). Kubernetes — 이 위에
지어졌다. 컨트롤러들의 리더 선출이coordination.k8s.io/Lease오브젝트로 돌아가고,
kubelet의 노드 하트비트도 리스 갱신이다.4. 정리
일관성 코어는 "강한 일관성이 필요한 것은 메타데이터뿐"이라는 관찰에서 출발해, 합의는
작은 클러스터(3~5노드)에 맡기고 큰 데이터 클러스터는 그 결과를 따르게 하는 분업 구조다.
코어는 제어면에만 두고 데이터 경로에서 치우는 것이 계율이다.리스는 그 코어 위에서 "소유"와 "생존"을 시간 제한부 계약으로 만든 것이다. 만료 판정은
코어 리더가 단조 시계로 단독 수행해 로그로 복제하고, 페일오버 시에는 TTL을 다시 세며(연장은
안전, 단축은 위험), 홀더의 자진 사퇴(1겹)와 펜싱 토큰(2겹)으로 스플릿 브레인을 막는다.앞서 배운 패턴들이 전부 부품으로 재등장했다. 코어의 내부는 복제 로그 + 단일 갱신 큐 + 요청
대기 목록이고, 펜싱 토큰은 세대 시계이며, 만료 판정의 여유는 시계 제한 대기다. 패턴들은
낱개가 아니라 조립품이다.참고 자료
- 『30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법』 — 일관성 코어, 리스
반응형'Development > Architecture' 카테고리의 다른 글
시계 제한 대기 (Clock-Bound Wait) (0) 2026.08.17 램포트 시계 & 하이브리드 시계 (0) 2026.08.17 버전 벡터 (Version Vector) (0) 2026.08.09 단일 갱신 큐 & 요청 대기 목록 (0) 2026.07.19 복제 로그 패턴 (0) 2026.07.19