<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Icarus</title>
    <link>https://icarus8050.tistory.com/</link>
    <description>e-mail :icarus8050@naver.com

Github :https://github.com/icarus8050

Blog (이전 블로그) :https://blog.naver.com/icarus8050</description>
    <language>ko</language>
    <pubDate>Wed, 19 Aug 2026 06:24:35 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Icarus8050</managingEditor>
    <image>
      <title>Icarus</title>
      <url>https://tistory1.daumcdn.net/tistory/3179802/attach/de7a768f4f3442b2a9aacb9dcafc3fb6</url>
      <link>https://icarus8050.tistory.com</link>
    </image>
    <item>
      <title>일관성 코어 (Consistent Core) &amp;amp; 리스 (Lease)</title>
      <link>https://icarus8050.tistory.com/177</link>
      <description>&lt;h1&gt;일관성 코어 (Consistent Core) &amp;amp; 리스 (Lease)&lt;/h1&gt;
&lt;h2&gt;1. 일관성 코어 — 합의는 클러스터 크기와 함께 스케일하지 않는다&lt;/h2&gt;
&lt;h3&gt;문제&lt;/h3&gt;
&lt;p&gt;1,000노드 클러스터가 Raft를 직접 돌린다면 정족수가 501이다. 쓰기마다 501개의 ack를&lt;br&gt;기다려야 하고, 하트비트가 1,000개씩 오가며, 리더 선출 때는 1,000노드가 투표전을 벌인다.&lt;br&gt;&lt;strong&gt;정족수 기반 합의의 비용은 노드 수에 비례해 나빠지는데, 우리가 노드를 늘리는 이유는&lt;br&gt;데이터와 처리량 때문이지 합의 참여자를 늘리고 싶어서가 아니다.&lt;/strong&gt; 요구가 어긋나 있다.&lt;/p&gt;
&lt;p&gt;그런데 잘 보면, 큰 클러스터에서 강한 일관성이 필요한 데이터는 아주 작다. 어떤 노드가&lt;br&gt;살아있는가, 어떤 파티션을 어떤 노드가 담당하는가, 클러스터 설정값 — &lt;strong&gt;메타데이터&lt;/strong&gt;뿐이다.&lt;br&gt;실제 데이터 읽기/쓰기는 파티셔닝과 복제로 처리하면 되고, 거기엔 전 클러스터 합의가 필요&lt;br&gt;없다.&lt;/p&gt;
&lt;h3&gt;해법: 작은 합의 클러스터 + 큰 데이터 클러스터&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;┌─────────────────────────────────────┐
│  일관성 코어 (3~5 노드, Raft/Zab)      │  ← 메타데이터, 강한 일관성
│  멤버십 / 파티션 배정 / 설정 / 선출     │     쓰기 빈도 낮음, 데이터 작음
└───────────────┬─────────────────────┘
                │ 리스 갱신(하트비트), 감시(watch), 메타데이터 조회
┌───────────────┴─────────────────────┐
│  데이터 클러스터 (수백~수천 노드)        │  ← 실제 데이터, 파티셔닝/복제
└─────────────────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실물로는 전부 아는 이름들이다. &lt;strong&gt;ZooKeeper&lt;/strong&gt;(HBase, HDFS, 구세대 Kafka의 코어),&lt;br&gt;&lt;strong&gt;etcd&lt;/strong&gt;(Kubernetes의 코어), &lt;strong&gt;Chubby&lt;/strong&gt;(Google GFS/Bigtable의 코어, 이 패턴의 원조 논문),&lt;br&gt;그리고 &lt;strong&gt;Kafka KRaft의 컨트롤러 쿼럼&lt;/strong&gt;(외부 코어 ZooKeeper를 쓰다가 같은 구조를 내부로&lt;br&gt;들여온 사례).&lt;/p&gt;
&lt;h3&gt;코어가 제공하는 세 가지 기능&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;첫째, 강한 일관성의 작은 KV 저장소.&lt;/strong&gt; 메타데이터를 선형화 가능(linearizable)하게 읽고 쓸&lt;br&gt;수 있어야 한다. &amp;quot;파티션 7의 리더가 누구인가&amp;quot;에 대해 두 노드가 다른 답을 받으면 안 된다.&lt;br&gt;내부는 복제 로그 + 단일 갱신 큐 + 요청 대기 목록 — 앞선 글들의 파이프라인 그대로다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 리스 기반 멤버십 관리.&lt;/strong&gt; 데이터 노드는 코어에 자신을 등록하면서 TTL이 있는 리스를&lt;br&gt;받고, 주기적인 하트비트로 갱신한다. 갱신이 끊기면 코어는 그 노드를 죽은 것으로 간주한다&lt;br&gt;(2장에서 상세히 다룬다).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;셋째, 상태 감시(State Watch).&lt;/strong&gt; 클라이언트가 &amp;quot;이 키가 바뀌면 알려달라&amp;quot;고 등록해 두면&lt;br&gt;코어가 변경 시점에 통지를 밀어준다. 컨트롤러가 &amp;quot;노드 등록이 사라지면 파티션 재배정을&lt;br&gt;시작한다&amp;quot;처럼 반응형으로 동작하는 기반이다.&lt;/p&gt;
&lt;h3&gt;설계 원칙: 코어는 제어면이지 데이터 경로가 아니다&lt;/h3&gt;
&lt;p&gt;코어의 처리량은 3~5노드 합의 클러스터의 처리량이다. 데이터 요청이 매번 코어를 거치면&lt;br&gt;코어가 전체 시스템의 병목이자 단일 장애점이 된다. 그래서 실제 시스템들은 클라이언트가&lt;br&gt;파티션 배치 정보를 코어에서 &lt;strong&gt;한 번 읽어 캐시&lt;/strong&gt;하고, 데이터 요청은 데이터 노드로 직행하며,&lt;br&gt;배치가 바뀌었을 때만(watch 통지 또는 요청 실패 시) 갱신한다. 코어에 담는 데이터는 메모리에&lt;br&gt;다 들어갈 만큼 작게, 쓰기 빈도는 &amp;quot;노드가 죽거나 설정이 바뀔 때&amp;quot; 수준으로 낮게 유지한다.&lt;br&gt;Kubernetes 운영에서 &amp;quot;etcd에 큰 오브젝트를 넣지 마라&amp;quot;가 철칙인 이유다.&lt;/p&gt;
&lt;h2&gt;2. 스플릿 브레인과 펜싱 — 코어와 데이터 노드 사이의 유령 리더&lt;/h2&gt;
&lt;p&gt;데이터 노드 D가 파티션 7의 리더로 배정되어 있고, 코어의 리스로 그 사실이 관리되고 있다고&lt;br&gt;하자. 그런데 D와 코어 사이의 &lt;strong&gt;네트워크만&lt;/strong&gt; 끊겼다 — D는 멀쩡히 살아서 클라이언트 요청을&lt;br&gt;계속 받고 있다. 리스가 만료되면 코어는 D를 죽었다고 보고 파티션 7의 리더를 E로 재배정한다.&lt;/p&gt;
&lt;p&gt;이 순간 파티션 7에 리더가 둘이 된다 — &lt;strong&gt;스플릿 브레인&lt;/strong&gt;이다. D는 자기가 강등된 것을&lt;br&gt;모르고(코어와 연락이 안 되니 통지받을 길이 없다) 계속 리더 행세를 하고, 클라이언트마다&lt;br&gt;다른 리더에 쓰면서 두 사본이 갈라진다.&lt;/p&gt;
&lt;h3&gt;해법의 방향: D를 멈추게 하는 게 아니라, 세상이 D를 거절하게 만든다&lt;/h3&gt;
&lt;p&gt;핵심 통찰 — &lt;strong&gt;D의 행동은 고칠 수 없다.&lt;/strong&gt; 연락이 안 되는 노드에게 &amp;quot;너 강등됐어&amp;quot;를 전할&lt;br&gt;방법은 정의상 없다. 그래서 해법의 방향이 뒤집힌다.&lt;/p&gt;
&lt;p&gt;코어가 파티션 리더를 배정할 때마다 &lt;strong&gt;단조 증가하는 세대 번호&lt;/strong&gt;를 함께 발급한다. D는 세대&lt;br&gt;5의 리더였고 E는 세대 6의 리더다. 리더가 하는 모든 요청(팔로워 복제, 스토리지 쓰기)에 이&lt;br&gt;번호를 실어 보내게 하고, 받는 쪽은 자기가 본 최고 세대를 기억하며 &lt;strong&gt;그보다 낮은 세대의&lt;br&gt;요청을 거부한다.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;E가 세대 6으로 활동 시작 → 레플리카들이 &amp;quot;최고 세대 = 6&amp;quot; 기록
D(세대 5)가 복제/쓰기 시도 → &amp;quot;세대 5 &amp;lt; 6&amp;quot; → 거부
D는 거부당하고서야 자신의 강등을 알게 됨 → 스스로 물러남&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;D의 쓰기는 접수까지는 될지 몰라도 커밋(복제 완료)이 불가능해진다. 이 용도의 세대 번호를&lt;br&gt;&lt;strong&gt;펜싱 토큰(fencing token)&lt;/strong&gt; 이라 부른다. Chubby의 sequencer, Kafka의 leader&lt;br&gt;epoch/controller epoch, HDFS NameNode 페일오버의 fencing이 전부 이것이다.&lt;/p&gt;
&lt;p&gt;방어는 두 겹이다. &lt;strong&gt;1겹: D의 자진 사퇴&lt;/strong&gt;(리스 TTL을 스스로 감시하다 갱신 실패 시 역할 중단&lt;br&gt;— 홀더와 코어의 시계 차이 때문에 코어는 ε 여유를 두고 재배정한다). &lt;strong&gt;2겹: 펜싱 토큰&lt;/strong&gt;(D가&lt;br&gt;어떤 이유로든 안 물러나도 세상이 거부한다). 1겹은 최적화이고 2겹이 안전망이다. &amp;quot;노드가&lt;br&gt;성실하게 행동한다&amp;quot;에 기대는 장치와 &amp;quot;노드가 무슨 짓을 해도 안전하다&amp;quot;인 장치를 구분하는 것이&lt;br&gt;중요하다.&lt;/p&gt;
&lt;h2&gt;3. 리스 (Lease)&lt;/h2&gt;
&lt;h3&gt;문제: 분산 환경에서 &amp;quot;소유&amp;quot;는 어떻게 만료되는가&lt;/h3&gt;
&lt;p&gt;분산 환경에서 순수한 락은 치명적 결함이 있다. &lt;strong&gt;락을 쥔 노드가 죽으면 락이 영원히&lt;br&gt;잠긴다.&lt;/strong&gt; 해제해 줄 주체가 사라졌기 때문이다. 그룹 멤버십(&amp;quot;나 살아있어&amp;quot;), 파티션 리더십도&lt;br&gt;본질이 같다. 소유자가 사라졌을 때 그 사실이 자동으로 무효화되는 메커니즘이 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;락에 유효기간(TTL)을 붙인 것이 리스다.&lt;/strong&gt; &amp;quot;이 자원은 30초 동안 네 것이다. 계속 쓰려면&lt;br&gt;만료 전에 갱신해라. 갱신이 끊기면 자동 회수한다.&amp;quot; DHCP가 IP 주소를 나눠주는 방식 그대로다.&lt;/p&gt;
&lt;h3&gt;구현: 리스는 일관성 코어의 복제된 상태다&lt;/h3&gt;
&lt;p&gt;클라이언트가 리스를 요청하면 코어의 리더는 이것을 &lt;strong&gt;복제 로그의 엔트리로 커밋&lt;/strong&gt;한다. 코어&lt;br&gt;노드 전부가 &amp;quot;이 리스가 존재한다&amp;quot;에 합의한다. 갱신도, 만료에 의한 삭제도 로그를 탄다. 코어&lt;br&gt;리더가 죽어도 새 리더가 같은 로그에서 같은 리스 목록을 복원한다.&lt;/p&gt;
&lt;p&gt;여기서 미묘한 설계 결정이 하나 있다. &lt;strong&gt;시간을 재는 것은 오직 코어의 리더뿐이다.&lt;/strong&gt;&lt;br&gt;팔로워들도 리스 데이터는 갖고 있지만, 각자 시계를 보며 만료를 독자 판단하지 않는다. 코어&lt;br&gt;노드들의 시계도 서로 어긋나 있기 때문이다. 각자 판정하면 노드마다 리스 목록이 달라지는 —&lt;br&gt;합의로 지켜온 일관성이 시계 때문에 깨지는 — 사태가 난다. 그래서 &lt;strong&gt;만료 판정은 리더가&lt;br&gt;내리고, 그 결정(삭제)을 로그로 복제한다.&lt;/strong&gt; 팔로워에게 만료란 시계 이벤트가 아니라 로그&lt;br&gt;엔트리다. &amp;quot;시간에 대한 판단조차 단일 결정자를 거쳐 로그로 흐르게 한다&amp;quot; — 단일 갱신 큐의&lt;br&gt;철학이 시간 영역까지 확장된 모습이다.&lt;/p&gt;
&lt;p&gt;또 하나, 리더가 경과 시간을 잴 때는 벽시계(wall clock)가 아니라 &lt;strong&gt;단조 시계(monotonic&lt;br&gt;clock)&lt;/strong&gt; 를 써야 한다. 벽시계는 NTP 보정으로 뒤로 점프할 수 있고, 그 순간 모든 리스의 남은&lt;br&gt;시간이 왜곡된다. JVM이라면 &lt;code&gt;System.currentTimeMillis()&lt;/code&gt;가 아니라 &lt;code&gt;System.nanoTime()&lt;/code&gt;&lt;br&gt;기반으로 재야 한다는, 코드 리뷰에서 실제로 잡아내야 하는 디테일이다.&lt;/p&gt;
&lt;h3&gt;TTL의 딜레마&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;짧게(1~2초):&lt;/strong&gt; 장애 감지가 빨라 페일오버가 빠르다. 대신 잠깐의 네트워크 딸꾹질, JVM의&lt;br&gt;stop-the-world GC 한 번에도 리스가 날아간다 — 멀쩡한 노드를 죽었다고 오판하고(false&lt;br&gt;positive) 불필요한 재배정 폭풍이 인다. &lt;strong&gt;길게(30초~수 분):&lt;/strong&gt; 오판은 줄지만 진짜 죽은&lt;br&gt;노드를 그만큼 늦게 알아챈다. 실제 시스템들은 수 초 단위(ZooKeeper 세션 타임아웃, etcd&lt;br&gt;리스)에서 시작해 워크로드에 맞춰 조정한다. 정답이 없는 감시 민감도의 튜닝 문제다.&lt;/p&gt;
&lt;h3&gt;GC 멈춤과 펜싱 — 자가 감시로도 못 잡는 경우&lt;/h3&gt;
&lt;p&gt;홀더가 stop-the-world GC로 30초 멈췄다 깨어나면, 자기 리스가 만료된 줄 모르고 &amp;quot;아직&lt;br&gt;리더&amp;quot;라며 행동을 재개한다. 시계가 아니라 실행 자체가 멈췄으니 자가 감시로도 못 잡는다.&lt;br&gt;그래서 최후의 방어선은 언제나 펜싱 토큰이다. 이 조합(리스 + 펜싱)이 &amp;quot;Redis 분산&lt;br&gt;락(Redlock)은 펜싱 없이는 안전하지 않다&amp;quot;는 Martin Kleppmann의 유명한 비판의 요지이기도&lt;br&gt;하다 — 리스만 있고 펜싱이 없는 분산 락은 GC 멈춤 하나에 뚫린다.&lt;/p&gt;
&lt;h3&gt;코어 리더 페일오버 — 연장은 안전하고 단축은 위험하다&lt;/h3&gt;
&lt;p&gt;코어의 리더가 죽고 새 리더가 선출됐다. 새 리더는 복제 로그에서 리스 목록을 복원했지만,&lt;br&gt;각 리스의 &amp;quot;남은 시간&amp;quot;은 죽은 리더의 메모리(단조 시계 기준)에만 있었고 로그에는 없다. 새&lt;br&gt;리더는 어떻게 해야 할까. (a) 로그의 리스 생성 시각 + TTL로 만료 시점을 계산한다? (b) 모든&lt;br&gt;리스의 TTL을 지금부터 처음부터 다시 센다?&lt;/p&gt;
&lt;p&gt;답은 (b)다. 이유는 두 겹이다.&lt;/p&gt;
&lt;p&gt;첫째, &lt;strong&gt;(a)는 위험한 게 아니라 사실상 불가능에 가깝다.&lt;/strong&gt; 단조 시계는 기점(origin)이&lt;br&gt;프로세스마다 제멋대로라 &lt;strong&gt;노드 간 비교가 아예 무의미하다.&lt;/strong&gt; 죽은 리더의 단조 시계 값은 새&lt;br&gt;리더에게 의미 없는 숫자다. 벽시계 타임스탬프를 쓰자니 두 리더 간 시계 오차와 역행 문제가&lt;br&gt;그대로 들어온다.&lt;/p&gt;
&lt;p&gt;둘째 — 더 본질적으로 — &lt;strong&gt;안전성이 비대칭이다.&lt;/strong&gt; (b)는 리스를 실질적으로 &lt;strong&gt;연장&lt;/strong&gt;하는&lt;br&gt;행위다. 연장의 최악 시나리오는 이미 죽은 홀더를 TTL 한 사이클 늦게 알아채는 것 — 성능&lt;br&gt;손해일 뿐 정합성은 안 깨진다. 살아있는 홀더들은 어차피 하트비트로 계속 갱신하니 영향이&lt;br&gt;없다. 반면 (a)가 잘못되어 남은 시간을 실제보다 짧게 계산하면, 멀쩡히 일하던 홀더의 리스를&lt;br&gt;조기 회수하고 재배정한다 — 스플릿 브레인을 코어가 직접 만들어내는 꼴이다.&lt;/p&gt;
&lt;p&gt;원칙 한 줄: &lt;strong&gt;리스는 연장은 언제나 안전하고, 단축은 언제나 위험하다. 모르겠으면&lt;br&gt;연장하라.&lt;/strong&gt; 이 사고방식 — 불확실할 때 어느 방향의 오류가 안전한지 먼저 정하고 그쪽으로&lt;br&gt;넘어진다 — 는 commit-wait(모르면 기다린다), 리스 만료 판정의 ε 여유(모르면 늦게 회수한다)와&lt;br&gt;같은 계보다. 분산 시스템 설계 전반을 관통하는 태도다.&lt;/p&gt;
&lt;h3&gt;실물들&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ZooKeeper&lt;/strong&gt; — 세션이 곧 리스다. 세션이 살아있는 동안만 존재하는 임시 노드(ephemeral&lt;br&gt;node)로 멤버십과 락을 표현한다. &lt;strong&gt;etcd&lt;/strong&gt; — 리스가 일급 API다(&lt;code&gt;grant&lt;/code&gt;로 TTL 받고,&lt;br&gt;&lt;code&gt;keepalive&lt;/code&gt;로 갱신하고, 키에 리스를 붙이면 만료 시 키 자동 삭제). &lt;strong&gt;Kubernetes&lt;/strong&gt; — 이 위에&lt;br&gt;지어졌다. 컨트롤러들의 리더 선출이 &lt;code&gt;coordination.k8s.io/Lease&lt;/code&gt; 오브젝트로 돌아가고,&lt;br&gt;kubelet의 노드 하트비트도 리스 갱신이다.&lt;/p&gt;
&lt;h2&gt;4. 정리&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;일관성 코어&lt;/strong&gt;는 &amp;quot;강한 일관성이 필요한 것은 메타데이터뿐&amp;quot;이라는 관찰에서 출발해, 합의는&lt;br&gt;작은 클러스터(3~5노드)에 맡기고 큰 데이터 클러스터는 그 결과를 따르게 하는 분업 구조다.&lt;br&gt;코어는 제어면에만 두고 데이터 경로에서 치우는 것이 계율이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;리스&lt;/strong&gt;는 그 코어 위에서 &amp;quot;소유&amp;quot;와 &amp;quot;생존&amp;quot;을 시간 제한부 계약으로 만든 것이다. 만료 판정은&lt;br&gt;코어 리더가 단조 시계로 단독 수행해 로그로 복제하고, 페일오버 시에는 TTL을 다시 세며(연장은&lt;br&gt;안전, 단축은 위험), 홀더의 자진 사퇴(1겹)와 펜싱 토큰(2겹)으로 스플릿 브레인을 막는다.&lt;/p&gt;
&lt;p&gt;앞서 배운 패턴들이 전부 부품으로 재등장했다. 코어의 내부는 복제 로그 + 단일 갱신 큐 + 요청&lt;br&gt;대기 목록이고, 펜싱 토큰은 세대 시계이며, 만료 판정의 여유는 시계 제한 대기다. 패턴들은&lt;br&gt;낱개가 아니라 조립품이다.&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;『30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법』 — 일관성 코어, 리스&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/177</guid>
      <comments>https://icarus8050.tistory.com/177#entry177comment</comments>
      <pubDate>Mon, 17 Aug 2026 20:58:48 +0900</pubDate>
    </item>
    <item>
      <title>시계 제한 대기 (Clock-Bound Wait)</title>
      <link>https://icarus8050.tistory.com/176</link>
      <description>&lt;h1&gt;시계 제한 대기 (Clock-Bound Wait) — 시계 오차를 시간으로 지불하기&lt;/h1&gt;
&lt;h2&gt;1. 문제: HLC로도 안 잡히는 순서가 있다&lt;/h2&gt;
&lt;p&gt;HLC의 인과율 보장에는 숨은 전제가 있다. &lt;strong&gt;인과가 시스템이 볼 수 있는 메시지를 타고 흐를&lt;br&gt;때만&lt;/strong&gt; 작동한다는 것이다. max 규칙이 시계를 당겨주는 것은 타임스탬프가 실려 다닐 때뿐이다.&lt;br&gt;그런데 현실의 인과는 시스템 밖으로도 흐른다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;10:00.000  클라이언트1 → 서버 A에 쓰기: X = 1
           A의 시계는 빠름 → 실제보다 미래의 타임스탬프 부여
10:00.001  클라이언트1이 클라이언트2에게 슬랙으로 알림: &amp;quot;X 썼어, 확인해봐&amp;quot;
10:00.002  클라이언트2 → 서버 B에 쓰기: Y = 1
           B의 시계는 느림 → X보다 작은 타임스탬프 부여&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 세계에서는 X가 Y보다 명백히 먼저다(슬랙 메시지가 인과 사슬이다). 그러나 시스템 안에는&lt;br&gt;그 사슬의 흔적이 없다 — 클라이언트2는 A의 타임스탬프를 들고 오지 않았으므로 HLC가 당겨줄&lt;br&gt;것이 없다. 결과적으로 &lt;strong&gt;나중 사건 Y가 더 작은 타임스탬프&lt;/strong&gt;를 받을 수 있다. 이제 누군가 그&lt;br&gt;사이 시점의 스냅샷을 읽으면 Y는 보이는데 X는 안 보인다. 인과적으로 뒤인 것이 보이면서 앞인&lt;br&gt;것이 안 보이는 이상한 스냅샷이다.&lt;/p&gt;
&lt;p&gt;이것을 막는 성질을 &lt;strong&gt;외부 일관성(external consistency)&lt;/strong&gt; 이라 한다. 실시간으로 먼저 커밋된&lt;br&gt;트랜잭션은 반드시 더 작은 타임스탬프를 가져야 한다는 요구다.&lt;/p&gt;
&lt;h2&gt;2. 열쇠: 시계를 &amp;quot;점&amp;quot;이 아니라 &amp;quot;구간&amp;quot;으로 읽는다&lt;/h2&gt;
&lt;p&gt;시계에 오차가 있다는 것을 인정한다면, 정직한 시계 API는 이렇게 생겨야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;now() → [earliest, latest]    &amp;quot;진짜 현재 시각은 이 구간 안 어딘가에 있다&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;동기화 오차의 상한이 ε이면, 서버가 시계에서 pt를 읽었을 때 구간은 [pt−ε, pt+ε]이고 폭은&lt;br&gt;2ε이다. Google Spanner의 &lt;strong&gt;TrueTime&lt;/strong&gt;이 정확히 이 API이며, GPS 수신기와 원자시계를&lt;br&gt;데이터센터에 설치해 ε을 수 ms 이내로 눌러놓았다. &lt;strong&gt;오차를 없앨 수는 없지만 오차의 크기를&lt;br&gt;보장할 수는 있다&lt;/strong&gt; — 이것이 이 패턴 전체의 토대다.&lt;/p&gt;
&lt;h3&gt;세 개의 시간축&lt;/h3&gt;
&lt;p&gt;이 패턴을 추론할 때는 시간축을 구분해야 길을 잃지 않는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;진짜 시각(true time)&lt;/strong&gt;: 우주의 실제 시각. &lt;strong&gt;아무도 직접 읽을 수 없다.&lt;/strong&gt; 분석할 때 쓰는&lt;br&gt;기준축이며, &amp;quot;실시간 순서(real-time order)&amp;quot;를 판정하는 심판용 축이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;각 서버의 시계&lt;/strong&gt;: 서버가 실제로 읽는 값. 진짜 시각에서 ±ε 이내로 어긋나 있지만, 서버&lt;br&gt;자신은 얼마나·어느 방향으로 어긋났는지 모른다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;시계 동기화가 보장하는 것은 단 하나, |서버 시계 − 진짜 시각| ≤ ε 이다. 구간 시계는 이&lt;br&gt;보장을 이용해 &amp;quot;내 시계 읽기&amp;quot;로부터 &amp;quot;진짜 시각이 있을 수 있는 범위&amp;quot;를 역산한 것이다.&lt;/p&gt;
&lt;h2&gt;3. 해법: 불확실성 구간이 지나갈 때까지 기다린다 (commit-wait)&lt;/h2&gt;
&lt;p&gt;Spanner의 쓰기(커밋) 절차는 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 커밋 시각 결정: ts = now().latest        (구간의 위쪽 끝)
2. 대기: now().earliest &amp;gt; ts 가 될 때까지    (≈ 2ε 만큼)
3. 그 후에야 커밋을 노출하고 클라이언트에 응답&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;2번의 대기가 끝난 시점에는 &lt;strong&gt;진짜 시각이 확실히 ts를 지났다&lt;/strong&gt; — earliest조차 ts보다 크므로,&lt;br&gt;구간 어디에 진실이 있든 ts는 과거다. 그 말은 곧 &lt;strong&gt;어느 노드의 시계로도 &amp;quot;지금&amp;quot;은 ts 이후&lt;/strong&gt;&lt;br&gt;라는 뜻이다(모든 시계의 오차가 ε 이내라는 보장 덕분에). 따라서 이 응답을 받은 클라이언트가&lt;br&gt;어떤 경로로든(슬랙이든 전화든) 인과를 이어가 어느 서버에 쓰더라도, 그 쓰기는 반드시 ts보다&lt;br&gt;큰 타임스탬프를 받는다.&lt;/p&gt;
&lt;h3&gt;숫자로 돌려보기 (ε = 5ms)&lt;/h3&gt;
&lt;p&gt;서버 A(시계 5ms 빠름)와 서버 B(시계 4ms 느림)가 있다. A가 대기 없이 즉시 응답하는 경우:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;진짜 시각   A의 시계(+5)   B의 시계(−4)   사건
─────────────────────────────────────────────────────────────
10:00.100   10:00.105     10:00.096     A: 시계에서 105를 읽음
                                        → 구간 [100, 110] 계산
                                        → ts = 110 부여, 대기 없이 즉시 응답
10:00.101   10:00.106     10:00.097     클라이언트1 → 클라이언트2 슬랙
10:00.103   10:00.108     10:00.099     B: 쓰기 요청 받음, 시계에서 099를 읽음
                                        → 구간 [094, 104] 계산
                                        → ts = 104 부여&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;X의 타임스탬프는 110, Y의 타임스탬프는 104. 진짜 시각 기준으로 X(100)가 Y(103)보다 명백히&lt;br&gt;먼저인데 타임스탬프는 역전됐다. 원인은 하나다. A는 자기 시계가 빠른 줄 모르고 구간의 위쪽&lt;br&gt;끝(110)을 찍었는데 그것은 진짜 시각보다 10ms &lt;strong&gt;미래&lt;/strong&gt;의 숫자였고, &lt;strong&gt;그 미래가 오기 전에&lt;br&gt;응답해 버렸다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;commit-wait를 지키면: A의 대기 종료 조건은 &amp;quot;내 시계가 115를 넘을 때&amp;quot;(pt−5 &amp;gt; 110)이고, 그&lt;br&gt;순간 진짜 시각은 110이다. 즉 A는 자기 타임스탬프가 진짜로 과거가 된 뒤에야 응답한다. 이후 B가&lt;br&gt;쓰기를 받는 시점엔 진짜 시각이 110을 넘었으므로, B의 시계가 최대로 느려도(−5ms) 105 이상을&lt;br&gt;읽고 latest는 110 이상이 된다. 역전의 여지가 닫힌다.&lt;/p&gt;
&lt;p&gt;몇 가지 관찰:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;대기 시간은 정확히 2ε이다.&lt;/strong&gt; &amp;quot;latest로 찍고, 그 latest가 earliest 밑으로 내려올 때까지&amp;quot;&lt;br&gt;— 구간 폭만큼 기다린다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;위험 구간은 ts부터가 아니라 조기 응답의 순간부터 열린다.&lt;/strong&gt; 위험의 본질은 &amp;quot;ts가 미래인&lt;br&gt;채로 세상에 공개된 상태&amp;quot;이고, 그 상태는 응답 즉시 시작된다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;역전은 비대칭 문제다.&lt;/strong&gt; 위 예에서 B의 시계가 느린 게 아니라 4ms 빨랐다면, 진짜 103에&lt;br&gt;시계는 107을 읽고 latest = 112 &amp;gt; 110이라 역전이 나지 않는다. 역전은 &amp;quot;인과적으로 뒤에 쓰는&lt;br&gt;쪽의 시계가 느릴 때&amp;quot;만 발생한다. 그러나 누구의 시계가 어느 방향으로 어긋났는지 아무도&lt;br&gt;모르므로, 최악의 경우를 가정하고 기다리는 것이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A는 진짜 시각을 한 번도 보지 못한 채&lt;/strong&gt;, 자기 시계와 ε 보장만으로 &amp;quot;진짜 시각에 대한&lt;br&gt;성질&amp;quot;을 확보했다. 이 패턴의 우아한 부분이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;비용도 명확하다. &lt;strong&gt;모든 쓰기가 2ε만큼 느려진다.&lt;/strong&gt; Google이 원자시계에 투자한 이유다 — ε이&lt;br&gt;250ms면 모든 커밋이 0.5초씩 걸려 상용화가 불가능하지만, 7ms면 감당할 만하다. 하드웨어&lt;br&gt;투자가 소프트웨어 지연시간으로 직접 환산되는, 보기 드물게 정직한 트레이드오프다.&lt;/p&gt;
&lt;h2&gt;4. 변주: 쓰기 대신 읽기가 기다린다 — CockroachDB&lt;/h2&gt;
&lt;p&gt;원자시계가 없는 CockroachDB는 같은 문제를 반대쪽에서 푼다. 쓰기는 기다리지 않고 커밋한다.&lt;br&gt;대신 &lt;strong&gt;읽기가 불확실성을 떠안는다.&lt;/strong&gt; 타임스탬프 ts로 읽는 트랜잭션은 &lt;code&gt;(ts, ts + max_offset]&lt;/code&gt;&lt;br&gt;구간을 불확실 구간으로 취급하고, 이 구간에 있는 값을 만나면 — 시계 오차 때문에 실제로는 내&lt;br&gt;읽기보다 과거일 수 있는 값이므로 — &lt;strong&gt;읽기를 더 높은 타임스탬프로 재시작&lt;/strong&gt;한다(uncertainty&lt;br&gt;restart).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Spanner:     쓰기마다 2ε 대기 (항상, 예측 가능)  — 작은 ε을 하드웨어로 확보
CockroachDB: 걸린 읽기만 재시작 (가끔, 불규칙)   — 하드웨어 없이, max_offset은 크게&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;최근에는 AWS가 Time Sync Service로 마이크로초급 시계 동기화를 EC2에 제공하고&lt;br&gt;ClockBound라는 구간 시계 라이브러리를 공개하면서, &amp;quot;원자시계급 ε&amp;quot;이 클라우드 범용 인프라가&lt;br&gt;되어가고 있다. 10년 전엔 Google만 할 수 있던 설계가 보편화되는 중이다.&lt;/p&gt;
&lt;h2&gt;5. 정리 — 시간 3부작의 완성&lt;/h2&gt;
&lt;p&gt;이 패턴이 하는 일은 한 문장이다. &lt;strong&gt;&amp;quot;시계 오차가 유계(ε)라면, 그 오차만큼 기다리는 것으로&lt;br&gt;시계 오차를 없앤 것과 같은 효과를 산다.&amp;quot;&lt;/strong&gt; 불확실성을 제거할 수 없으니 시간으로 지불한다.&lt;br&gt;어디서 지불할지 — 쓰기에서 항상(commit-wait) vs 읽기에서 가끔(read restart) — 이 구현&lt;br&gt;선택의 갈림길이다.&lt;/p&gt;
&lt;p&gt;시간 도구들의 지도를 완성하면:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;도구&lt;/th&gt;
&lt;th&gt;잡는 것&lt;/th&gt;
&lt;th&gt;못 잡는 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;램포트 시계 / HLC&lt;/td&gt;
&lt;td&gt;시스템 안 인과 (메시지를 탄 인과)&lt;/td&gt;
&lt;td&gt;동시성 감지, 시스템 밖 인과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;버전 벡터&lt;/td&gt;
&lt;td&gt;동시성 감지&lt;/td&gt;
&lt;td&gt;실시각, 시스템 밖 인과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;시계 제한 대기&lt;/td&gt;
&lt;td&gt;시스템 밖으로 흐른 인과 (외부 일관성)&lt;/td&gt;
&lt;td&gt;— (비용: 쓰기 지연 or 읽기 재시작)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;한 줄 요약: &lt;strong&gt;미래의 타임스탬프를 달았으면, 그 미래가 올 때까지 입을 다물어라.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;『30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법』 — 시계 제한 대기&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/176</guid>
      <comments>https://icarus8050.tistory.com/176#entry176comment</comments>
      <pubDate>Mon, 17 Aug 2026 20:58:05 +0900</pubDate>
    </item>
    <item>
      <title>램포트 시계 &amp;amp; 하이브리드 시계</title>
      <link>https://icarus8050.tistory.com/175</link>
      <description>&lt;h1&gt;램포트 시계 &amp;amp; 하이브리드 시계 — 분산 시스템에서 시간을 다루는 법&lt;/h1&gt;
&lt;h2&gt;1. 문제: 분산 시스템에 &amp;quot;지금&amp;quot;은 없다&lt;/h2&gt;
&lt;p&gt;서버들의 물리 시계는 어긋난다. NTP로 동기화해도 수 ms의 오차(clock skew)가 남고,&lt;br&gt;가상머신에서는 시계가 멈췄다 튀기도 하며, NTP 보정 과정에서 &lt;strong&gt;한 노드의 시계가 뒤로&lt;br&gt;점프&lt;/strong&gt;하기도 한다. 시계가 뒤로 가면 &amp;quot;나중 사건이 더 작은 타임스탬프&amp;quot;를 받아 순서가 꼬인다.&lt;/p&gt;
&lt;p&gt;그런데 곰곰이 보면, 우리가 정말 필요한 것은 &amp;quot;몇 시 몇 분&amp;quot;이라는 절대 시각이 아니라 대부분&lt;br&gt;&lt;strong&gt;&amp;quot;어떤 사건이 어떤 사건보다 먼저였는가&amp;quot;라는 순서&lt;/strong&gt;다. Lamport의 1978년 논문(&amp;quot;Time, Clocks,&lt;br&gt;and the Ordering of Events&amp;quot;)의 통찰이 바로 이것이다. &lt;strong&gt;시계를 버리고 순서만 남기자.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;happened-before 관계&lt;/h3&gt;
&lt;p&gt;사건 a가 사건 b보다 &lt;strong&gt;먼저 일어났다(happened-before, a → b)&lt;/strong&gt; 고 말할 수 있는 경우는 세&lt;br&gt;가지뿐이다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;같은 프로세스에서 a가 b보다 먼저 실행됐다.&lt;/li&gt;
&lt;li&gt;a가 메시지 송신이고 b가 그 메시지의 수신이다.&lt;/li&gt;
&lt;li&gt;a → c이고 c → b인 c가 있다(추이성).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 셋으로 연결되지 않는 두 사건은 &lt;strong&gt;동시(concurrent)&lt;/strong&gt; 다.&lt;/p&gt;
&lt;h2&gt;2. 램포트 시계 (Lamport Clock)&lt;/h2&gt;
&lt;h3&gt;규칙 세 개짜리 알고리즘&lt;/h3&gt;
&lt;p&gt;각 노드는 정수 카운터 하나를 들고, 규칙 세 개를 따른다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;1. 로컬에서 사건이 일어나면: counter += 1
2. 메시지를 보낼 때: counter를 메시지에 실어 보냄
3. 메시지를 받을 때: counter = max(내 counter, 받은 counter) + 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3번이 핵심이다. 메시지를 받는 순간 상대의 시간까지 반영해 &lt;strong&gt;내 시계를 앞으로 당긴다.&lt;/strong&gt;&lt;br&gt;이 규칙 덕분에 다음이 보장된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;a → b 이면 L(a) &amp;lt; L(b)&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;인과적으로 앞선 사건은 반드시 더 작은 타임스탬프를 갖는다. 물리 시계 없이, 정수 하나로.&lt;/p&gt;
&lt;h3&gt;예제로 직접 돌려보기&lt;/h3&gt;
&lt;p&gt;노드 A, B, C의 카운터가 모두 0에서 시작한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;A: a1 발생        → counter = 1          (a1 = 1)
   B에게 전송      → 메시지에 1을 실음
B: 수신 = b1      → max(0, 1) + 1 = 2   (b1 = 2)
   b2 발생        → counter = 3          (b2 = 3)
C: c1 발생        → counter = 1          (c1 = 1)   ← C는 아무와도 통신 없음
   c2 발생        → counter = 2          (c2 = 2)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;인과 사슬 a1 → b1 → b2를 따라 타임스탬프가 1 &amp;lt; 2 &amp;lt; 3으로 증가하는 것을 확인할 수 있다.&lt;br&gt;a1과 c1이 같은 타임스탬프 1을 갖는 것도 눈여겨보자. 동시 사건에서는 자연스러운 일이다.&lt;/p&gt;
&lt;h3&gt;역은 성립하지 않는다 — 이 패턴의 가장 중요한 문장&lt;/h3&gt;
&lt;p&gt;위 예제에서 c2 = 2 &amp;lt; b2 = 3이다. 그렇다면 &amp;quot;c2가 b2보다 먼저 일어났다&amp;quot;고 결론 내릴 수&lt;br&gt;있을까? &lt;strong&gt;없다.&lt;/strong&gt; B와 C는 통신한 적이 없으므로 두 사건은 동시이고, 숫자의 대소는 인과에&lt;br&gt;대해 아무것도 말해주지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;a → b 이면 L(a) &amp;lt; L(b)        ← 보장됨
L(a) &amp;lt; L(b) 라고 a → b는 아님  ← 동시 사건도 대소는 생긴다&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 정밀한 구분이 필요하다. 타임스탬프의 &lt;strong&gt;값 비교는 언제나 가능&lt;/strong&gt;하다(램포트 시계는&lt;br&gt;결정적 알고리즘이다). 알 수 없는 것은 값이 아니라 &lt;strong&gt;그 대소가 의미하는 바&lt;/strong&gt;다. &amp;quot;비교할 수&lt;br&gt;없다&amp;quot;와 &amp;quot;비교할 수 있지만 인과로 해석하면 안 된다&amp;quot;는 다른 진술이고, 이 구분이 흐려지면 설계&lt;br&gt;실수로 이어진다. 예컨대 LWW에 램포트 타임스탬프를 쓰면 항상 승자를 고를 수는 있지만, 그&lt;br&gt;승자가 &amp;quot;진짜 나중 쓰기&amp;quot;라는 보장은 없다. 버전 벡터라면 이 경우를 충돌로 보고했을 것이다.&lt;/p&gt;
&lt;p&gt;즉 &lt;strong&gt;램포트 시계는 동시성을 감지할 수 없다.&lt;/strong&gt; 버전 벡터와의 관계는 압축의 트레이드오프다.&lt;br&gt;버전 벡터는 노드 수만큼의 공간을 쓰는 대신 인과 관계를 온전히 보존하고, 램포트 시계는 정수&lt;br&gt;하나로 압축하는 대신 동시성 정보를 버린다.&lt;/p&gt;
&lt;h3&gt;전순서 만들기&lt;/h3&gt;
&lt;p&gt;타임스탬프가 같은 두 사건은 노드 ID로 타이브레이크하면 모든 사건의 &lt;strong&gt;전순서(total order)&lt;/strong&gt;&lt;br&gt;를 만들 수 있다: &lt;code&gt;(counter, nodeId)&lt;/code&gt; 쌍으로 비교. &amp;quot;정답인 순서&amp;quot;가 아니라 &amp;quot;모두가 동의하는&lt;br&gt;임의의 순서&amp;quot;지만, 인과율을 위반하지 않는다는 것이 중요하다.&lt;/p&gt;
&lt;p&gt;실전에서는 순수한 램포트 시계 그대로보다 그 아이디어가 스며든 형태로 만난다. MVCC 저장소의&lt;br&gt;버전 번호(Versioned Value 패턴), etcd의 revision 번호가 그 예다.&lt;/p&gt;
&lt;h2&gt;3. 하이브리드 시계 (Hybrid Logical Clock, HLC)&lt;/h2&gt;
&lt;h3&gt;문제: 램포트 시계는 사람의 시간과 무관하다&lt;/h3&gt;
&lt;p&gt;램포트 시계의 값은 인과율은 지키지만 &lt;strong&gt;실제 시각과 아무 관계가 없는&lt;/strong&gt; 정수다. 두 가지가&lt;br&gt;아쉽다. 첫째, &amp;quot;어제 오후 3시 시점의 데이터 상태를 보여줘&amp;quot; 같은 &lt;strong&gt;시각 기반 쿼리&lt;/strong&gt;가&lt;br&gt;불가능하다 — 3시가 램포트 값 몇에 해당하는지 알 길이 없다. 둘째, 통신이 잦은 노드의&lt;br&gt;카운터만 치솟는 식으로 값이 물리적 시간과 제멋대로 어긋난다.&lt;/p&gt;
&lt;p&gt;그렇다고 물리 시계로 돌아가면 원점이다. 시계는 어긋나 있고, NTP 보정으로 뒤로 점프하면&lt;br&gt;인과율이 깨진다. 원하는 것을 정리하면:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;값이 실제 시각과 가깝고&lt;/li&gt;
&lt;li&gt;인과율을 보존하며 (a → b이면 반드시 증가)&lt;/li&gt;
&lt;li&gt;물리 시계가 뒤로 가도 절대 역행하지 않는 타임스탬프&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이 셋을 동시에 잡는 것이 HLC다.&lt;/p&gt;
&lt;h3&gt;구조: 물리 시각 + 논리 카운터&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;HLC = (l, c)
  l: 지금까지 관측한 물리 시각의 최댓값   ← &amp;quot;대략 몇 시인지&amp;quot;
  c: 같은 l 안에서 순서를 세는 논리 카운터 ← &amp;quot;l이 못 전진할 때의 램포트 카운터&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;동작 원리는 램포트 시계의 max 규칙을 물리 시계와 합친 것이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;로컬 사건/송신 시:&lt;/strong&gt; 내 물리 시계 pt를 읽어서, pt가 현재 l보다 크면 &lt;code&gt;l = pt, c = 0&lt;/code&gt;으로&lt;br&gt;전진한다(정상 상황). pt ≤ l이면 — 시계가 뒤로 갔거나 같은 밀리초 안에 여러 사건이 난 경우 —&lt;br&gt;&lt;strong&gt;l은 그대로 두고 c만 올린다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;수신 시:&lt;/strong&gt; 램포트의 max 규칙 그대로 &lt;code&gt;l = max(내 l, 메시지의 l, 내 pt)&lt;/code&gt;를 취하고, 최댓값이&lt;br&gt;어디서 왔느냐에 따라 c를 정한다. 내 pt가 제일 크면 &lt;code&gt;c = 0&lt;/code&gt;, 메시지의 l이 제일 크면&lt;br&gt;&lt;code&gt;c = 메시지의 c + 1&lt;/code&gt;, 내 l과 같으면 &lt;code&gt;c = max(내 c, 메시지의 c) + 1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;요컨대 &lt;strong&gt;&amp;quot;l은 최대한 물리 시각을 따라가되, 따라갈 수 없을 때만 c로 순서를 만든다&amp;quot;&lt;/strong&gt; 이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;class HybridClock(private val wallClock: () -&amp;gt; Long) {
    private var l = 0L  // 관측된 최대 물리 시각
    private var c = 0L  // 논리 카운터

    @Synchronized
    fun now(): HlcTimestamp {                    // 로컬 사건/송신
        val pt = wallClock()
        if (pt &amp;gt; l) { l = pt; c = 0 } else c++   // 시계 역행/정체 시 c로 전진
        return HlcTimestamp(l, c)
    }

    @Synchronized
    fun onReceive(remote: HlcTimestamp): HlcTimestamp {
        val pt = wallClock()
        val newL = maxOf(l, remote.l, pt)
        c = when (newL) {
            l, remote.l -&amp;gt; maxOf(if (newL == l) c else 0,
                                 if (newL == remote.l) remote.c else 0) + 1
            else -&amp;gt; 0                             // 내 물리 시계가 최신
        }
        l = newL
        return HlcTimestamp(l, c)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;비교는 &lt;code&gt;(l, c)&lt;/code&gt; 사전순(l 먼저, 같으면 c)이다. 구현 디테일 하나 — l에 밀리초 48비트, c에&lt;br&gt;16비트를 쓰면 &lt;strong&gt;전체가 64비트 정수 하나&lt;/strong&gt;에 들어간다. 기존에 타임스탬프를 long으로 저장하던&lt;br&gt;시스템에 그대로 끼워 넣을 수 있다는, 채택을 결정지은 실용적 장점이다.&lt;/p&gt;
&lt;h3&gt;시계 역행 시나리오 — l은 절대 뒤로 가지 않는다&lt;/h3&gt;
&lt;p&gt;노드 A의 HLC가 &lt;code&gt;(10:00.005, 0)&lt;/code&gt;인 상태에서 NTP 보정으로 물리 시계가 &lt;code&gt;10:00.002&lt;/code&gt;로 뒤로&lt;br&gt;점프했고, 직후 로컬 쓰기가 두 번 들어왔다고 하자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;현재 상태: l = 10:00.005, c = 0
물리 시계: 10:00.002로 역행

첫 번째 쓰기: pt(10:00.002) ≤ l(10:00.005) → l 유지, c++ → (10:00.005, 1)
두 번째 쓰기: pt 여전히 ≤ l               → l 유지, c++ → (10:00.005, 2)
...
물리 시계가 10:00.006 도달: pt &amp;gt; l → l = 10:00.006, c = 0&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 흔한 착각이 &amp;quot;물리 시계를 따라 &lt;code&gt;(10:00.002, 0)&lt;/code&gt;을 발급한다&amp;quot;는 것인데, 그러면 이미&lt;br&gt;발급된 &lt;code&gt;(10:00.005, 0)&lt;/code&gt;보다 &lt;strong&gt;작은&lt;/strong&gt; 타임스탬프가 나중 쓰기에 붙는다. 이 타임스탬프로 MVCC&lt;br&gt;버전을 정렬하는 저장소라면 나중 쓰기가 과거에 삽입되는 것이고, 같은 노드 안에서조차&lt;br&gt;인과율이 깨진다. &lt;strong&gt;l은 &amp;quot;내 물리 시계&amp;quot;가 아니라 &amp;quot;관측된 물리 시각의 최댓값&amp;quot;이며, max로만&lt;br&gt;갱신되므로 구조적으로 역행이 불가능하다.&lt;/strong&gt; c는 물리 시계의 역행·정체로부터 타임스탬프의&lt;br&gt;단조성을 지키는 완충재다.&lt;/p&gt;
&lt;h3&gt;무엇이 보장되는가&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;인과율:&lt;/strong&gt; a → b이면 HLC(a) &amp;lt; HLC(b). 램포트 시계의 성질이 그대로 유지된다. 역은 여전히&lt;br&gt;성립하지 않는다 — HLC도 동시성 감지는 못 한다. 그것은 버전 벡터의 일이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;물리 시각 근접성:&lt;/strong&gt; 노드들의 시계가 NTP로 ε 이내로 동기화되어 있다면, l과 실제 시각의&lt;br&gt;차이도 ε 수준으로 유계다. &amp;quot;오후 3시 시점&amp;quot;을 HLC 값으로 번역하는 것이 (ε의 오차 안에서)&lt;br&gt;가능해지고, 시각 기반 스냅샷 읽기가 열린다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;단조성:&lt;/strong&gt; 물리 시계가 뒤로 점프해도 HLC는 절대 뒤로 가지 않는다.&lt;/p&gt;
&lt;h3&gt;실전 — 누가 왜 쓰는가&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;MongoDB&lt;/strong&gt;는 클러스터 시간(cluster time)으로 HLC를 쓰고, 세션의 인과적 일관성(causal&lt;br&gt;consistency)을 이것으로 구현한다. 클라이언트가 마지막으로 본 HLC를 들고 다니면서 &amp;quot;이 시각&lt;br&gt;이후의 상태를 보여달라&amp;quot;고 요구하는 방식이다. &lt;strong&gt;CockroachDB, YugabyteDB&lt;/strong&gt;는 분산 트랜잭션의&lt;br&gt;타임스탬프 정렬과 MVCC 스냅샷에 쓴다.&lt;/p&gt;
&lt;p&gt;비교 대상으로 &lt;strong&gt;Google Spanner의 TrueTime&lt;/strong&gt;이 있다. Spanner는 데이터센터에 GPS·원자시계를&lt;br&gt;설치해 &amp;quot;지금 시각은 [t-7ms, t+7ms] 사이&amp;quot;라는 &lt;strong&gt;유계 불확실성 구간&lt;/strong&gt;을 하드웨어로 보장받고,&lt;br&gt;커밋 시 그 구간만큼 기다려서(commit-wait) 전역 순서를 만든다. HLC는 그런 하드웨어 없이&lt;br&gt;소프트웨어만으로 비슷한 효과를 노리는 접근이다. 불확실성 구간을 기다리는 아이디어는 책의&lt;br&gt;다음 패턴인 시계 제한 대기(Clock-Bound Wait)로 이어진다.&lt;/p&gt;
&lt;h2&gt;4. 정리 — 세 시계의 지도&lt;/h2&gt;
&lt;p&gt;시간 관련 도구 세 개를 한 줄씩으로 정리하면:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;도구&lt;/th&gt;
&lt;th&gt;형태&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;인과율 보존&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;동시성 감지&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;실시각 근접&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;램포트 시계&lt;/td&gt;
&lt;td&gt;정수 1개&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;버전 벡터&lt;/td&gt;
&lt;td&gt;노드별 카운터 맵&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;하이브리드 시계&lt;/td&gt;
&lt;td&gt;(물리 시각, 카운터)&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;X&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;HLC는 버전 벡터의 대체재가 아니라 &lt;strong&gt;램포트 시계의 상위 호환&lt;/strong&gt;이다. 동시성 감지가 필요하면&lt;br&gt;여전히 버전 벡터(또는 그 정보를 보존하는 다른 수단)가 필요하고, &amp;quot;모두가 동의하는, 실시각에&lt;br&gt;가까운, 절대 역행하지 않는 순서&amp;quot;가 필요하면 HLC가 답이다.&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;『30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법』 — 램포트 시계, 하이브리드 시계&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/175</guid>
      <comments>https://icarus8050.tistory.com/175#entry175comment</comments>
      <pubDate>Mon, 17 Aug 2026 20:55:49 +0900</pubDate>
    </item>
    <item>
      <title>버전 벡터 (Version Vector)</title>
      <link>https://icarus8050.tistory.com/174</link>
      <description>&lt;h2&gt;1. 배경: 리더가 없는 복제&lt;/h2&gt;
&lt;p&gt;리더 기반 복제는 순서가 하나이므로 충돌이라는 개념 자체가 없다. 대신 대가가 있다. 리더가&lt;br&gt;죽으면 선출까지 쓰기가 멈추고, 지리적으로 분산된 환경에서는 모든 쓰기가 리더까지 왕복해야&lt;br&gt;한다.&lt;/p&gt;
&lt;p&gt; 그래서 반대편 설계가 있다. &lt;strong&gt;아무 레플리카나 쓰기를 받는다&lt;/strong&gt;(leaderless / multi-master).&lt;br&gt;Amazon Dynamo 논문이 대표이고 Riak, Cassandra, DynamoDB가 이 계열이다. 가용성은&lt;br&gt;극대화되지만 새로운 문제가 생긴다. &lt;strong&gt;같은 데이터가 서로 다른 노드에서 동시에 갱신되면,&lt;br&gt;누가 최신인지 어떻게 아는가?&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;타임스탬프(LWW)로는 왜 안 되는가&lt;/h3&gt;
&lt;p&gt;&amp;quot;타임스탬프 붙여서 늦은 쪽이 이긴다&amp;quot;(Last-Write-Wins)는 두 가지 문제가 있다. 첫째, 서버들의&lt;br&gt;물리 시계는 완벽히 동기화되지 않는다(clock skew). 시계가 빠른 노드의 쓰기가 항상 이기는&lt;br&gt;왜곡이 생긴다. 둘째, 더 근본적으로 — &lt;strong&gt;LWW는 충돌을 감지하는 게 아니라 은폐한다.&lt;/strong&gt; 두&lt;br&gt;클라이언트가 동시에 다른 값을 썼다면 병합이 필요한 사건인데, LWW는 한쪽을 조용히 버린다.&lt;br&gt;데이터 유실이 정책으로 내장된 셈이다.&lt;/p&gt;
&lt;p&gt;필요한 것은 &amp;quot;더 최신이다&amp;quot;와 &amp;quot;&lt;strong&gt;동시였다(concurrent)&lt;/strong&gt;&amp;quot;를 &lt;strong&gt;구분하는 능력&lt;/strong&gt;이다. 단일 버전&lt;br&gt;번호로는 안 된다. 노드 A에서 v2가 되고 노드 B에서도 v2가 되면, 같은 v2라도 전혀 다른&lt;br&gt;값인데 구분할 방법이 없다.&lt;/p&gt;
&lt;h2&gt;2. 버전 벡터: 노드별로 카운터를 분리한다&lt;/h2&gt;
&lt;p&gt;해법은 버전을 숫자 하나가 아니라 &lt;strong&gt;노드별 카운터의 맵&lt;/strong&gt;으로 관리하는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;{A: 2, B: 1}   ← &amp;quot;노드 A가 2번, 노드 B가 1번 갱신에 관여한 버전&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;규칙은 단순하다. &lt;strong&gt;노드가 쓰기를 처리할 때 자기 카운터만 1 올린다.&lt;/strong&gt; 값이 레플리카 간에&lt;br&gt;전파될 때 버전 벡터도 함께 다닌다.&lt;/p&gt;
&lt;h3&gt;비교 규칙&lt;/h3&gt;
&lt;p&gt;두 버전 벡터 V1, V2에 대해:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;모든 성분에서 V1 ≤ V2이면&lt;/strong&gt; → V2가 V1의 후손이다. V2는 V1을 &amp;quot;본 뒤에&amp;quot; 만들어진&lt;br&gt;갱신이므로 V1을 V2로 덮어써도 안전하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;서로 어느 쪽도 전부 크지 않으면&lt;/strong&gt; — 예컨대 &lt;code&gt;{A:2, B:1}&lt;/code&gt;과 &lt;code&gt;{A:1, B:2}&lt;/code&gt; —&lt;br&gt;&lt;strong&gt;동시(concurrent)다.&lt;/strong&gt; 서로가 서로를 모른 채 만들어진 갱신이며, 이것이 &lt;strong&gt;감지된 충돌&lt;/strong&gt;이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;data class VersionVector(val versions: Map&amp;lt;String, Long&amp;gt;) {
    fun increment(nodeId: String) =
        VersionVector(versions + (nodeId to (versions[nodeId] ?: 0) + 1))

    fun descendsFrom(other: VersionVector): Boolean =
        other.versions.all { (node, v) -&amp;gt; (versions[node] ?: 0) &amp;gt;= v }

    fun isConcurrentWith(other: VersionVector): Boolean =
        !descendsFrom(other) &amp;amp;&amp;amp; !other.descendsFrom(this)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;시나리오&lt;/h3&gt;
&lt;p&gt;장바구니 데이터가 &lt;code&gt;{A:1}&lt;/code&gt; 버전으로 두 레플리카에 있는 상태에서 네트워크가 분단된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;초기: cart = [빵], VV = {A:1}          (A, B 양쪽에 복제된 상태)

분단 중:
  클라이언트1 → 노드 A: 우유 추가 → cart = [빵, 우유], VV = {A:2}
  클라이언트2 → 노드 B: 계란 추가 → cart = [빵, 계란], VV = {A:1, B:1}

분단 복구, 동기화:
  {A:2} vs {A:1, B:1} → 어느 쪽도 후손이 아님 → 동시! 충돌 감지&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;분단이 없었고 클라이언트2가 &lt;code&gt;{A:2}&lt;/code&gt;를 읽은 뒤 썼다면 결과는 &lt;code&gt;{A:2, B:1}&lt;/code&gt;이 되고, 이것은&lt;br&gt;&lt;code&gt;{A:2}&lt;/code&gt;의 후손이므로 조용히 덮어쓴다. &lt;strong&gt;인과 관계가 있으면 자동 병합, 없으면 충돌 보고&lt;/strong&gt; —&lt;br&gt;이것이 버전 벡터가 하는 일의 전부다.&lt;/p&gt;
&lt;h3&gt;연습: 세 벡터의 관계&lt;/h3&gt;
&lt;p&gt;V1 = &lt;code&gt;{A:2, B:1}&lt;/code&gt;, V2 = &lt;code&gt;{A:2, B:2}&lt;/code&gt;, V3 = &lt;code&gt;{A:3, B:0}&lt;/code&gt;일 때:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;V1 vs V2: 모든 성분에서 V1 ≤ V2 → &lt;strong&gt;V2가 V1의 후손&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;V2 vs V3: A는 V3가 크고 B는 V2가 크다 → &lt;strong&gt;동시(충돌)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;V1 vs V3: 마찬가지로 &lt;strong&gt;동시(충돌)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;V3는 A 성분이 가장 크지만 B의 갱신을 본 적이 없다. &amp;quot;총합이 크다&amp;quot;거나 &amp;quot;한 성분이 앞선다&amp;quot;로는&lt;br&gt;아무것도 결정되지 않는다. 비교는 언제나 &lt;strong&gt;성분 전체&lt;/strong&gt;로 한다.&lt;/p&gt;
&lt;h2&gt;3. &amp;quot;자기 카운터만 올린다&amp;quot;는 규칙이 왜 중요한가&lt;/h2&gt;
&lt;p&gt;이 규칙을 어기면 무엇이 깨지는지 시나리오로 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;초기: VV = {A:1}, 양쪽에 복제됨

분단 중:
  노드 A가 쓰기 처리 → 자기 카운터 올림 → {A:2}, 값 = X
  노드 B가 쓰기 처리 → 규칙 위반, A의 카운터를 올림 → {A:2}, 값 = Y&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;분단이 복구되면 &lt;code&gt;{A:2}&lt;/code&gt; vs &lt;code&gt;{A:2}&lt;/code&gt; — &lt;strong&gt;서로 다른 두 값이 동일한 버전 벡터&lt;/strong&gt;를 갖는다. 비교&lt;br&gt;결과는 &amp;quot;같음&amp;quot;이므로 충돌이 감지되지 않고, 동기화 과정에서 한쪽이 조용히 덮어써진다.&lt;br&gt;충돌이어야 할 것이 인과 관계로 &lt;strong&gt;위장&lt;/strong&gt;되는 것(false merge / lost update)이다.&lt;/p&gt;
&lt;p&gt;규칙의 본질은 이것이다. &lt;strong&gt;각 슬롯의 주인이 유일한 증가자여야, 벡터가 &amp;quot;이 버전이 각 출처의&lt;br&gt;갱신을 몇 개까지 반영했는가&amp;quot;의 정직한 기록이 된다.&lt;/strong&gt; 버전 벡터의 부분 순서(partial order)가&lt;br&gt;실제 사건의 happened-before 관계와 일치한다는 보장은 이 &amp;quot;단독 소유권&amp;quot; 전제 위에서만&lt;br&gt;성립한다.&lt;/p&gt;
&lt;h2&gt;4. 충돌을 감지한 다음 — 해소의 실제 흐름&lt;/h2&gt;
&lt;p&gt;버전 벡터는 충돌을 &lt;strong&gt;감지&lt;/strong&gt;할 뿐 &lt;strong&gt;해결&lt;/strong&gt;하지는 않는다. 해소는 별도의 사이클이 담당한다.&lt;br&gt;Dynamo/Riak 스타일의 흐름을 처음부터 끝까지 따라가 보자.&lt;/p&gt;
&lt;p&gt;장바구니 &lt;code&gt;cart = [빵]&lt;/code&gt;, 버전 &lt;code&gt;{C0:1}&lt;/code&gt;이 저장돼 있고, 클라이언트 C1과 C2가 둘 다 이 상태를&lt;br&gt;읽은 뒤 동시에 갱신한다(행위자를 클라이언트 ID로 두는 방식).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;C1: [빵] 읽음(VV {C0:1}) → 우유 추가해서 쓰기 → {C0:1, C1:1}, 값 [빵, 우유]
C2: [빵] 읽음(VV {C0:1}) → 계란 추가해서 쓰기 → {C0:1, C2:1}, 값 [빵, 계란]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;어느 쪽도 후손이 아니므로 동시다. 서버는 이 시점에 어느 쪽도 버리지 않고 &lt;strong&gt;둘 다&lt;br&gt;형제(siblings)로 보관&lt;/strong&gt;한다. 해소는 다음 읽기 때 일어난다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;어떤 클라이언트가 읽기 요청
  ← 서버 응답: 값 [빵,우유] 그리고 [빵,계란] (형제 둘 다)
              + 인과 컨텍스트: {C0:1, C1:1, C2:1}   ← 두 벡터의 상한(supremum)

클라이언트: 애플리케이션 로직으로 병합 → [빵, 우유, 계란]
클라이언트: 병합 결과를 받은 컨텍스트와 함께 다시 쓰기
  → 새 버전: {C0:1, C1:1, C2:1, C3:1}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;마지막 쓰기의 벡터는 두 형제 &lt;strong&gt;모두의 후손&lt;/strong&gt;이므로, 서버는 형제 둘을 지우고 하나로&lt;br&gt;수렴시킨다. 충돌은 &lt;strong&gt;&amp;quot;읽기 → 클라이언트 병합 → 컨텍스트를 실은 재쓰기&amp;quot;&lt;/strong&gt; 사이클로 해소된다.&lt;/p&gt;
&lt;p&gt;여기서 중요한 프로토콜 규칙이 보인다. &lt;strong&gt;쓰기는 반드시 &amp;quot;내가 읽었던 버전의 인과 컨텍스트&amp;quot;를&lt;br&gt;실어 보내야 한다.&lt;/strong&gt; 서버는 이 컨텍스트로 &amp;quot;이 쓰기가 무엇을 본 상태에서 만들어졌는가&amp;quot;를&lt;br&gt;판별한다. 낙관적 락의 버전 토큰과 정확히 같은 역할 — JPA의 &lt;code&gt;@Version&lt;/code&gt;을 분산 환경으로&lt;br&gt;일반화한 것이라 봐도 좋다.&lt;/p&gt;
&lt;h3&gt;병합 정책의 세 층&lt;/h3&gt;
&lt;p&gt;병합 자체는 시스템이 못 해준다. &lt;code&gt;[빵, 우유]&lt;/code&gt;와 &lt;code&gt;[빵, 계란]&lt;/code&gt;을 어떻게 합칠지는 도메인&lt;br&gt;지식이기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;애플리케이션 병합&lt;/strong&gt; — 클라이언트 코드가 도메인 규칙으로 합친다. 유연하지만 모든&lt;br&gt;클라이언트가 병합 코드를 가져야 하고, 단순 합집합 병합은 &amp;quot;삭제한 상품이 부활하는&amp;quot;&lt;br&gt;문제(합집합은 삭제를 표현할 수 없다)를 조심해야 한다. Amazon 장바구니의 유명한 일화가 바로&lt;br&gt;이 사례다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CRDT&lt;/strong&gt; — 병합이 수학적으로 항상 안전하도록 자료구조 쪽을 설계한다(아래 6장).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;LWW 폴백&lt;/strong&gt; — 형제 관리가 복잡하니 타임스탬프 큰 쪽을 채택한다. 간단하지만 애써 감지한&lt;br&gt;충돌을 도로 버리는 것이므로 유실이 허용되는 데이터에만 쓴다. Cassandra가 기본값으로 이&lt;br&gt;방식을 택했다 — 단순함을 얻고 조용한 유실을 대가로 치른 것이다.&lt;/p&gt;
&lt;h2&gt;5. 행위자(actor)를 누구로 할 것인가 — 서버 ID vs 클라이언트 ID&lt;/h2&gt;
&lt;p&gt;카운터의 주인을 누구로 하느냐가 감지 정밀도를 좌우한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;서버 노드 ID&lt;/strong&gt;를 행위자로 하면 벡터가 노드 수만큼만 자라서 컴팩트하다. 하지만 같은 노드가&lt;br&gt;서로 다른 두 클라이언트의 쓰기를 연달아 처리하면 {A:1} → {A:2} → {A:3}처럼 일렬로 쌓여서,&lt;br&gt;실제로는 동시였던 갱신이 인과 관계처럼 보일 수 있다(충돌 미감지).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;클라이언트 ID&lt;/strong&gt;를 행위자로 하면 행위자가 다르면 벡터가 반드시 갈라지므로 감지가&lt;br&gt;정확해진다. 대신 &lt;strong&gt;키 하나의 벡터가 그 키를 건드린 모든 클라이언트 수만큼 자란다.&lt;/strong&gt; 모바일&lt;br&gt;사용자 수백만이 행위자라면 벡터가 값보다 커진다. 오래된 항목을 잘라내면(pruning) 인과&lt;br&gt;정보가 유실되어 거짓 충돌이 늘어난다.&lt;/p&gt;
&lt;p&gt;Riak은 결국 &amp;quot;서버 ID를 쓰되, 같은 노드가 처리한 서로 다른 쓰기를 점(dot)으로 구분한다&amp;quot;는&lt;br&gt;&lt;strong&gt;dotted version vector&lt;/strong&gt;로 정착했다. 서버 ID의 컴팩트함과 클라이언트 ID의 정밀함을 동시에&lt;br&gt;잡는 절충이다.&lt;/p&gt;
&lt;h3&gt;용어 정리: 벡터 시계와의 구분&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;벡터 시계(vector clock)&lt;/strong&gt; 와 자주 혼용되지만 엄밀히는 다르다. 벡터 시계는 프로세스 간&lt;br&gt;&lt;strong&gt;이벤트의 인과 관계&lt;/strong&gt;를 추적하는 범용 도구이고, 버전 벡터는 &lt;strong&gt;데이터 객체의 버전 계보&lt;/strong&gt;를&lt;br&gt;추적하는 특수화된 응용이다. 구조는 같지만 목적과 갱신 규칙이 다르다.&lt;/p&gt;
&lt;h2&gt;6. CRDT (Conflict-free Replicated Data Type)&lt;/h2&gt;
&lt;p&gt;이름 그대로 &lt;strong&gt;&amp;quot;충돌이 아예 발생하지 않도록 설계된 복제 자료구조&amp;quot;&lt;/strong&gt; 다. 버전 벡터 세계에서&lt;br&gt;병합은 애플리케이션이 짜야 하는 골칫거리였다. CRDT는 발상을 뒤집는다. &lt;strong&gt;병합 함수가&lt;br&gt;수학적으로 항상 안전하도록 자료구조 쪽을 설계하면, 충돌 해소라는 문제 자체가 소멸한다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;병합 함수 merge(a, b)가 세 가지 성질을 만족하면 된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;교환법칙&lt;/strong&gt;: merge(a, b) = merge(b, a) — 합치는 순서 무관&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;결합법칙&lt;/strong&gt;: 셋 이상을 어떤 짝으로 묶어 합쳐도 동일&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;멱등성&lt;/strong&gt;: merge(a, a) = a — 같은 것을 두 번 합쳐도 무해&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이 셋이 성립하면 레플리카들이 &lt;strong&gt;어떤 순서로, 몇 번씩 중복해서&lt;/strong&gt; 상태를 교환해도 결국 전부&lt;br&gt;같은 값으로 수렴한다. 네트워크가 지연시키든 중복 전달하든 상관없어진다. 프로토콜을 정교하게&lt;br&gt;만드는 대신, 데이터에 좋은 성질을 부여해서 프로토콜이 대충해도 되게 만드는 접근이다.&lt;/p&gt;
&lt;h3&gt;가장 간단한 예: G-Counter&lt;/h3&gt;
&lt;p&gt;전역 카운터 하나를 두면 동시 증가가 충돌하지만, &lt;strong&gt;노드별 카운터의 맵&lt;/strong&gt;으로 바꾸고 각 노드는&lt;br&gt;자기 슬롯만 올리게 한다. 값 = 전체 슬롯의 합, 병합 = &lt;strong&gt;슬롯별 max&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;data class GCounter(val counts: Map&amp;lt;String, Long&amp;gt;) {
    fun increment(nodeId: String) =
        GCounter(counts + (nodeId to (counts[nodeId] ?: 0) + 1))

    fun value() = counts.values.sum()

    fun merge(other: GCounter) = GCounter(
        (counts.keys + other.counts.keys).associateWith {
            maxOf(counts[it] ?: 0, other.counts[it] ?: 0)  // 슬롯별 max
        }
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;어디서 본 구조인가 — &lt;strong&gt;버전 벡터와 완전히 같은 뼈대다.&lt;/strong&gt; &amp;quot;노드별 슬롯 + 자기 슬롯만 증가 +&lt;br&gt;성분별 비교/병합&amp;quot;. 사실 버전 벡터 자체가 CRDT의 성질을 갖는 구조이고, 두 주제는 같은 수학&lt;br&gt;위에 서 있다.&lt;/p&gt;
&lt;h3&gt;표준 레퍼토리&lt;/h3&gt;
&lt;p&gt;여기서 층층이 쌓아 올린다. 감소도 필요하면 증가용/감소용 G-Counter 두 개를 붙인&lt;br&gt;&lt;strong&gt;PN-Counter&lt;/strong&gt;, 추가만 되는 집합 &lt;strong&gt;G-Set&lt;/strong&gt;, 삭제까지 필요하면 각 원소에 고유 태그를 붙여&lt;br&gt;&amp;quot;내가 본 태그만 삭제&amp;quot;하게 한 &lt;strong&gt;OR-Set&lt;/strong&gt;(장바구니의 &amp;quot;삭제 부활&amp;quot; 문제를 이것으로 푼다), 단일&lt;br&gt;값이면 타임스탬프 큰 쪽을 취하는 &lt;strong&gt;LWW-Register&lt;/strong&gt; 등이 표준이다.&lt;/p&gt;
&lt;p&gt;실전 사례로는 Riak 내장 데이터 타입, Redis Enterprise의 액티브-액티브 복제, 그리고 협업&lt;br&gt;편집기(Yjs, Automerge — Figma/Notion류 도구들의 기반)가 있다. 협업 편집은 &amp;quot;오프라인에서&lt;br&gt;편집하고 나중에 동기화&amp;quot;가 본질이라 CRDT의 주 무대가 됐다.&lt;/p&gt;
&lt;h3&gt;한계&lt;/h3&gt;
&lt;p&gt;모든 도메인이 교환·결합·멱등 병합으로 표현되는 것은 아니다. &amp;quot;재고가 0 미만이 되면 안 된다&amp;quot;&lt;br&gt;같은 &lt;strong&gt;전역 불변식&lt;/strong&gt;은 CRDT로 지킬 수 없다. 두 레플리카가 각자 마지막 재고를 팔면 병합&lt;br&gt;결과는 -1이다. 그런 요구사항은 결국 합의(복제 로그) 세계로 돌아가야 한다. 도구 선택의&lt;br&gt;기준이 여기서 갈린다.&lt;/p&gt;
&lt;h2&gt;7. 정리 — 두 세계의 지도&lt;/h2&gt;
&lt;p&gt;복제 로그와 버전 벡터는 같은 문제(&amp;quot;여러 노드의 갱신을 정합성 있게&amp;quot;)에 대한 두 극단의 답이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;복제 로그&lt;/strong&gt;는 순서를 하나로 강제해서 충돌을 원천 봉쇄한다(일관성 우선, CP 성향). &lt;strong&gt;버전&lt;br&gt;벡터&lt;/strong&gt;는 충돌을 허용하되 정확히 감지해서 나중에 병합한다(가용성 우선, AP 성향). 그리고&lt;br&gt;&lt;strong&gt;CRDT&lt;/strong&gt;는 병합 자체를 수학으로 자동화해서, 충돌 허용 세계의 병합 비용을 없앤다.&lt;/p&gt;
&lt;p&gt;어느 쪽이 옳은 것이 아니라 트레이드오프의 양 끝이고, 실제 시스템은 요구사항 — 유실을 얼마나&lt;br&gt;허용하는가, 전역 불변식이 있는가, 가용성이 얼마나 중요한가 — 에 따라 이 사이 어딘가에 선다.&lt;/p&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;『30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법』 — 버전 벡터&lt;/li&gt;
&lt;li&gt;Martin Kleppmann, 『데이터 중심 애플리케이션 설계』 5장 (리더리스 복제, 동시 쓰기 감지)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/174</guid>
      <comments>https://icarus8050.tistory.com/174#entry174comment</comments>
      <pubDate>Sun, 9 Aug 2026 21:28:04 +0900</pubDate>
    </item>
    <item>
      <title>단일 갱신 큐 &amp;amp; 요청 대기 목록</title>
      <link>https://icarus8050.tistory.com/173</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Pattern 11 &amp;mdash; 단일 갱신 큐 (Singular Update Queue)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제: 여러 스레드가 동시에 WAL에 쓰려고 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAL은 append-only 로그라서 어차피 한 번에 하나씩, 순서대로 써야 한다. 가장 먼저 떠오르는&lt;br /&gt;방법은 락이다. 그런데 여기서 정확히 짚어야 할 것이 있다. &lt;b&gt;락도 &quot;한 번에 하나&quot;는 보장한다.&lt;/b&gt;&lt;br /&gt;락의 문제는 상호 배제가 아니라 &lt;b&gt;기다리는 방식&lt;/b&gt;에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락 방식에서는 요청을 처리하는 스레드 자신이 락을 잡으려고 &lt;b&gt;블로킹&lt;/b&gt;된다. 스레드 100개가&lt;br /&gt;append를 시도하면 99개가 잠들어 있고, 락이 풀릴 때마다 깨어나기 경쟁(컨텍스트 스위칭)을&lt;br /&gt;한다. 스레드는 잠들어 있는 동안에도 스택 메모리를 점유하고, 경합 비용은 동시성이 올라갈수록&lt;br /&gt;커진다. 처리량은 정체되는데 지연시간만 늘어나는 최악의 조합이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해법: 큐 하나 + 전용 스레드 하나&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;요청 스레드들 ──▶ [ 작업 큐 ] ──▶ 전용 스레드 1개 ──▶ WAL / 상태 갱신
   (넣고 바로 리턴)                (하나씩 순서대로 꺼내 처리)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 갱신 요청을 큐에 넣고, &lt;b&gt;전용 스레드 하나&lt;/b&gt;가 순서대로 꺼내 처리한다. 요청 스레드는&lt;br /&gt;큐에 넣고 즉시 리턴한다. 블로킹이 아니라 비동기다. 결과는 나중에 받을 수 있도록 작업에&lt;br /&gt;&lt;code&gt;CompletableFuture&lt;/code&gt; 같은 응답 핸들을 붙여둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin 코루틴과 Channel로 표현하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class SingularUpdateQueue&amp;lt;T, R&amp;gt;(
    private val handler: suspend (T) -&amp;gt; R,
    scope: CoroutineScope,
) {
    private data class Work&amp;lt;T, R&amp;gt;(val item: T, val result: CompletableDeferred&amp;lt;R&amp;gt;)
    private val channel = Channel&amp;lt;Work&amp;lt;T, R&amp;gt;&amp;gt;(capacity = 1000)  // bounded!

    init {
        scope.launch {                    // 소비자는 단 하나의 코루틴
            for (work in channel) {
                work.result.complete(handler(work.item))
            }
        }
    }

    suspend fun submit(item: T): Deferred&amp;lt;R&amp;gt; =
        CompletableDeferred&amp;lt;R&amp;gt;().also { channel.send(Work(item, it)) }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이 구조가 주는 네 가지 이점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 상태에 락이 아예 필요 없어진다.&lt;/b&gt; WAL 파일이든 인메모리 상태든 오직 한 스레드만&lt;br /&gt;만지므로, 동기화 코드 없는 순수한 단일 스레드 로직으로 작성할 수 있다. 경합 버그(race&lt;br /&gt;condition)의 가능성이 구조적으로 제거되고, 코드를 추론하기 쉬워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 순서가 공짜로 보장된다.&lt;/b&gt; 큐는 FIFO이므로 들어온 순서가 곧 처리 순서다. 복제&lt;br /&gt;로그처럼 &quot;순서가 곧 정합성&quot;인 시스템에서 이것은 부가 기능이 아니라 핵심 요구사항이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;셋째, 배칭이 자연스러워진다.&lt;/b&gt; 지난 글에서 다룬 그룹 커밋을 떠올려 보자. 전용 스레드가&lt;br /&gt;큐를 비울 때 &quot;지금 쌓여 있는 것 전부&quot;를 꺼내 한 번의 fsync로 묶는 것이 아주 쉽다. 락&lt;br /&gt;방식에서는 이런 배칭을 구현하기가 몹시 어색하다. 부하가 높을수록 배치가 커져 처리량이&lt;br /&gt;오히려 좋아지는, 부하에 우아하게 대응하는 특성이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;넷째, 호출자가 블로킹되지 않는다.&lt;/b&gt; 요청 스레드는 큐에 넣고 다른 일을 하러 간다.&lt;br /&gt;스레드가 잠들어서 낭비되는 일이 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;반드시 챙겨야 할 것 &amp;mdash; 유한 큐와 배압(backpressure)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큐를 무한(unbounded)으로 두면 소비 속도보다 유입이 빠를 때 큐가 무한히 자라 OOM으로&lt;br /&gt;죽는다. 큐는 반드시 &lt;b&gt;크기를 제한&lt;/b&gt;하고, 가득 찼을 때의 정책을 정해야 한다. 넣는 쪽을 잠시&lt;br /&gt;블로킹하거나(위 코드의 &lt;code&gt;channel.send&lt;/code&gt;가 이 방식), 즉시 에러를 반환해 클라이언트가&lt;br /&gt;재시도하게 하거나. 어느 쪽이든 &quot;밀려드는 부하를 상류로 알리는&quot; 배압이 있어야 시스템이&lt;br /&gt;과부하에서 붕괴하지 않고 버틴다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주의: 전용 스레드는 짧고 예측 가능한 일만&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전용 스레드가 느린 I/O나 무거운 계산으로 막히면 시스템 전체의 처리량 상한이 거기서&lt;br /&gt;결정된다. 무거운 작업(직렬화, 검증 등)은 이 스레드 밖에서 미리 준비해 오고, 큐의 소비자는&lt;br /&gt;&quot;순서가 중요한 최소한의 갱신&quot;만 하는 것이 원칙이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 사례&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Raft 구현체들의 로그 append, Kafka 브로커의 요청 처리가 이 구조다. 극단적으로 최적화한&lt;br /&gt;예로 LMAX Disruptor(큐를 링 버퍼로 바꿔 GC와 캐시 미스까지 제거)가 있다. 사실 Node.js의&lt;br /&gt;이벤트 루프나 액터 모델(Akka)의 메일박스도 같은 아이디어의 변주다. &lt;b&gt;&quot;상태는 한 스레드에&lt;br /&gt;가두고, 접근은 메시지로&quot;&lt;/b&gt;.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Pattern 12 &amp;mdash; 요청 대기 목록 (Request Waiting List)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제: 응답은 &quot;나중에, 다른 스레드에서&quot; 완성된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 갱신 큐에서 요청 스레드는 큐에 넣고 바로 리턴한다. 그러면 클라이언트에게 보낼 응답은&lt;br /&gt;언제, 누가 완성하는가? 복제 로그와 결합하면 문제가 선명해진다. &lt;code&gt;SET x = 10&lt;/code&gt; 요청이 리더에&lt;br /&gt;도착한 뒤의 타임라인이다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;t1: 요청 도착 &amp;rarr; 단일 갱신 큐에 투입, 요청 스레드는 리턴
t2: 전용 스레드가 WAL에 append (인덱스 42 부여)
t3: 팔로워들에게 복제 전파          &amp;larr; 여기서부터 &quot;기다림&quot;
t4: 팔로워들의 ack가 하나둘 도착
t5: 과반수 달성 &amp;rarr; HWM이 42를 통과 &amp;rarr; 커밋!
t6: 이제야 클라이언트에게 성공 응답 가능&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 t3~t5 구간이다. 팔로워의 ack는 &lt;b&gt;언제 올지 모르는 비동기 이벤트&lt;/b&gt;다. 요청 스레드가&lt;br /&gt;폴링하며 블로킹 대기하면, 단일 갱신 큐로 애써 없앤 블로킹을 도로 들여오는 꼴이다. 게다가&lt;br /&gt;t6 시점에 실행되고 있는 것은 원래 요청을 받았던 스레드가 아니라 &lt;b&gt;팔로워의 ack를 처리하던&lt;br /&gt;전혀 다른 스레드&lt;/b&gt;다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해법: 요청을 키와 함께 걸어두고, 이벤트가 오면 깨운다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리더는 &lt;b&gt;로그 인덱스를 키로 하는 대기 목록(맵)&lt;/b&gt; 을 유지한다.&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;WaitingList: { 로그 인덱스 &amp;rarr; 미완성 응답(Future/콜백) }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;t2에서 append하고 인덱스 42를 받는 순간, 응답 핸들을 &lt;b&gt;키 42로 대기 목록에 등록&lt;/b&gt;해 두고&lt;br /&gt;잊어버린다. 이후 ack를 처리하던 스레드가 t5에서 HWM을 42 이상으로 전진시키면, &lt;b&gt;대기&lt;br /&gt;목록에서 인덱스 &amp;le; 42인 항목들을 전부 꺼내 future를 완성&lt;/b&gt;시킨다. 그 완성이 곧 클라이언트&lt;br /&gt;응답 전송으로 이어진다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class RequestWaitingList&amp;lt;R&amp;gt; {
    private val waiting = ConcurrentHashMap&amp;lt;Long, CompletableDeferred&amp;lt;R&amp;gt;&amp;gt;()

    fun waitFor(logIndex: Long): CompletableDeferred&amp;lt;R&amp;gt; =
        CompletableDeferred&amp;lt;R&amp;gt;().also { waiting[logIndex] = it }

    // HWM이 전진할 때마다 호출됨 (ack 처리 스레드에서)
    fun onHighWaterMarkAdvanced(hwm: Long, resultFor: (Long) -&amp;gt; R) {
        waiting.keys.filter { it &amp;lt;= hwm }.forEach { index -&amp;gt;
            waiting.remove(index)?.complete(resultFor(index))
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 구조를 한 문장으로 말하면 이렇다. &lt;b&gt;&quot;요청 접수&quot;와 &quot;응답 완성&quot;을 서로 다른 스레드가&lt;br /&gt;담당하도록 분리하고, 그 둘을 키(로그 인덱스)로 연결한다.&lt;/b&gt; 어떤 스레드도 기다리느라 잠들지&lt;br /&gt;않는다. 모두가 이벤트에 반응만 할 뿐이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;반드시 챙겨야 할 두 가지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;만료(expiration).&lt;/b&gt; 과반수 ack가 영영 안 오면? (네트워크 분단으로 리더가 소수 쪽에&lt;br /&gt;고립된 경우 &amp;mdash; 지난 글의 그 시나리오다.) 대기 목록의 항목이 무한히 기다리면 클라이언트도&lt;br /&gt;무한히 기다린다. 각 항목에 &lt;b&gt;타임아웃&lt;/b&gt;을 걸고, 주기적으로 목록을 순회하며 만료된 요청은&lt;br /&gt;에러로 완성시킨다. 클라이언트는 에러를 받고 재시도하고, 그 재시도는 멱등 수신자가 안전하게&lt;br /&gt;처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;리더 교체 시 정리.&lt;/b&gt; 리더가 팔로워로 강등되면 대기 목록에 남은 요청들은 완성될 가망이&lt;br /&gt;없다. 목록 전체를 &quot;리더 아님&quot; 에러로 비워서 클라이언트가 새 리더로 재시도하게 만든다.&lt;br /&gt;커밋되지 못한 로그 엔트리가 잘려나가는 것과 짝을 이루는 정리 작업이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 사례&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 유명한 구현이 &lt;b&gt;Kafka의 Purgatory&lt;/b&gt;(연옥 &amp;mdash; 이름부터 &quot;응답이 기다리는 곳&quot;이다)다.&lt;br /&gt;&lt;code&gt;acks=all&lt;/code&gt; 프로듀서 요청은 ISR 전체의 복제가 끝날 때까지, 컨슈머의 long-poll fetch 요청은&lt;br /&gt;새 데이터가 도착할 때까지 purgatory에서 대기한다. 수십만 건의 대기 요청에 타임아웃을&lt;br /&gt;효율적으로 걸기 위해 계층형 타이밍 휠(hierarchical timing wheel)이라는 자료구조까지&lt;br /&gt;동원한다. etcd도 같은 구조로 커밋 대기를 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 레벨에서도 이 구조는 낯설지 않다. Spring의 &lt;code&gt;DeferredResult&lt;/code&gt;, 코루틴의&lt;br /&gt;&lt;code&gt;suspendCancellableCoroutine&lt;/code&gt;으로 만드는 비동기 API가 정확히 같은 모양이다. 요청을 받은&lt;br /&gt;스레드는 핸들만 등록하고 리턴하며, 완성은 나중에 다른 스레드(이벤트)가 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 세 패턴이 만드는 하나의 파이프라인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pattern 10, 11, 12를 겹쳐 놓으면 리더 노드의 쓰기 경로 전체가 블로킹 없는 이벤트&lt;br /&gt;파이프라인이 된다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;클라이언트 요청
    │
    ▼
[단일 갱신 큐] ── 순서 보장, 락 제거, 배칭(그룹 커밋)
    │
    ▼
WAL append &amp;rarr; 복제 전파          [요청 대기 목록에 (인덱스 &amp;rarr; 응답 핸들) 등록]
    │
    ▼
과반수 ack &amp;rarr; HWM 전진 ──────────▶ 대기 목록에서 해당 인덱스 완성 &amp;rarr; 응답 전송&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단일 갱신 큐가 순서를, 복제 로그가 내구성을, 요청 대기 목록이 비동기 응답을 담당한다.&lt;/b&gt;&lt;br /&gt;어느 스레드도 다른 스레드를 기다리며 잠들지 않고, 각자 자기 이벤트에만 반응한다. 이것이&lt;br /&gt;합의 기반 시스템들이 강한 일관성을 보장하면서도 높은 처리량을 내는 구조적 비결이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;『30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법』 &amp;mdash; Pattern 11 단일 갱신 큐, Pattern 12 요청 대기 목록&lt;/li&gt;
&lt;li&gt;Martin Thompson et al., &quot;LMAX Disruptor: High performance alternative to bounded queues&quot;&lt;/li&gt;
&lt;li&gt;Apache Kafka 문서 &amp;mdash; Request Purgatory, Hierarchical Timing Wheels&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/173</guid>
      <comments>https://icarus8050.tistory.com/173#entry173comment</comments>
      <pubDate>Sun, 19 Jul 2026 20:54:31 +0900</pubDate>
    </item>
    <item>
      <title>복제 로그 패턴</title>
      <link>https://icarus8050.tistory.com/172</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 시작하기 전에 &amp;mdash; 장애 모델과 비잔틴 결함&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산 시스템의 모든 설계는 &quot;어떤 장애까지 견딜 것인가&quot;라는 가정 위에 서 있다. 장애 모델은 크게 두 급으로 나뉜다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;크래시 결함(Crash Fault)&lt;/b&gt; 은 노드가 그냥 멈추는 것이다. 프로세스가 죽거나, 느려지거나, 네트워크가 끊긴다. 중요한 것은 &lt;b&gt;거짓말은 하지 않는다&lt;/b&gt;는 점이다. 응답을 한다면 그 내용은 올바르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비잔틴 결함(Byzantine Fault)&lt;/b&gt; 은 노드가 임의의(arbitrary) 동작을 하는 경우다. 잘못된 데이터를 보내거나, 노드 A에게는 &quot;값이 1&quot;이라 하고 노드 B에게는 &quot;값이 2&quot;라고 모순된 응답을 하거나, 프로토콜을 의도적으로 위반한다. 원인은 악의적 공격일 수도 있고, 메모리 비트 플립이나 심각한 버그일 수도 있다. 이름은 Lamport의 1982년 논문 &quot;비잔틴 장군 문제&quot;에서 왔다. 배신자 장군이 섞여 있어도 충직한 장군들이 같은 결론(공격/후퇴)에 도달할 수 있는가 하는 문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무적으로 중요한 포인트는 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, &lt;b&gt;이 책의 패턴들(Raft 포함)은 비잔틴 결함을 다루지 않는다.&lt;/b&gt; 크래시 결함 모델을 가정한다. 크래시 결함은 f개의 장애를 허용하는 데 &lt;b&gt;2f+1&lt;/b&gt; 노드면 충분하지만(과반수 정족수), 비잔틴 결함까지 견디려면 &lt;b&gt;3f+1&lt;/b&gt; 노드와 PBFT 같은 훨씬 무거운 프로토콜이 필요하다. 사내 데이터센터처럼 모든 노드를 우리가 통제하는 환경에서 비잔틴 내성은 과한 비용이다. 서로 신뢰할 수 없는 참여자가 섞이는 블록체인 같은 환경에서 비로소 BFT가 필수가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 그렇다고 아무 방어도 없는 것은 아니다. 악의는 아니지만 &quot;데이터가 잘못 전달되는&quot; 문제는 저렴한 수단으로 막는다. 네트워크/디스크 손상은 &lt;b&gt;체크섬(CRC)&lt;/b&gt; 으로, 외부 침입자는 &lt;b&gt;TLS와 인증&lt;/b&gt;으로. &quot;비잔틴 합의는 안 하지만, 흔한 비잔틴스러운 사고는 값싸게 걸러낸다&quot;가 실무의 균형점이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. WAL (Write-Ahead Log, 선행 기입 로그)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결하려는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 데이터를 메모리에만 들고 있으면 프로세스가 죽는 순간 전부 사라진다. 그렇다고 상태가 바뀔 때마다 전체 자료구조(해시맵, B-Tree 등)를 디스크에 통째로 쓰는 것은 너무 비싸고, 쓰는 도중 크래시가 나면 파일이 반쯤 깨진 상태가 될 수도 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 아이디어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;상태를 바꾸기 전에, 그 변경 내용(명령)을 append-only 로그 파일에 먼저 기록한다.&lt;/b&gt; 그래서 이름이 Write-&lt;i&gt;Ahead&lt;/i&gt; Log다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;클라이언트 요청: SET name = &quot;chulyun&quot;
  1. 로그에 append: {seq: 42, cmd: SET, key: name, value: chulyun} + fsync
  2. 그 다음에 메모리 상태(KV 스토어)에 적용
  3. 클라이언트에 응답&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 재시작되면 로그를 처음부터 &lt;b&gt;replay&lt;/b&gt;해서 메모리 상태를 복원한다. 즉 로그가 진실의 원천(source of truth)이고, 메모리 상태는 로그의 파생물이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이게 빠른가? 로그는 항상 파일 끝에 순차적으로만 쓴다(sequential append). 디스크는 랜덤 I/O보다 순차 I/O가 압도적으로 빠르기 때문에, &quot;매 변경마다 디스크에 쓴다&quot;는 부담이 현실적으로 감당 가능해진다. Kafka가 디스크 기반으로도 엄청난 처리량을 내는 이유가 정확히 이것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현 시 챙겨야 하는 디테일&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 로그 엔트리에는 단조 증가하는 &lt;b&gt;로그 순서 번호(log sequence number)&lt;/b&gt; 를 붙인다. 이 번호가 복제 로그, 로우/하이 워터 마크 같은 패턴들의 기반이 된다. 쓰다가 크래시가 나면 마지막 엔트리가 반쯤 잘린 채 남을 수 있으므로 엔트리마다 &lt;b&gt;CRC(체크섬)&lt;/b&gt; 를 기록해 두고, 재시작 시 손상된 엔트리를 감지해 버린다. 로그는 무한히 자랄 수 없으니 &lt;b&gt;세그먼트 분할(Segmented Log) + 스냅샷 + 로우 워터 마크 이전 세그먼트 삭제&lt;/b&gt;로 관리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin으로 뼈대만 그리면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class WriteAheadLog(private val file: RandomAccessFile) {
    private var lastLogSequence = 0L

    fun append(command: ByteArray): Long {
        val entry = WalEntry(++lastLogSequence, command, crc32(command))
        file.seek(file.length())          // 항상 끝에만 append
        file.write(entry.serialize())
        file.fd.sync()                    // fsync &amp;mdash; 내구성 보장 지점
        return entry.sequence
    }

    fun readAll(): List&amp;lt;WalEntry&amp;gt; = TODO(&quot;재시작 시 replay용, CRC 검증 포함&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;fsync 배치 처리 &amp;mdash; 무엇을 얻고 무엇을 잃는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fsync를 매 append마다 하면 안전하지만 느리고, OS 페이지 캐시에만 쓰고 응답하면 빠르지만 전원 장애 시 유실된다. 배치로 모아서 fsync하면 디스크 I/O 횟수가 줄어 처리량이 올라가는데, 여기서 &lt;b&gt;유실 위험이 생기느냐 마느냐는 배치 자체가 아니라 &quot;클라이언트에게 언제 응답하느냐&quot;가 결정한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방식 A &amp;mdash; ack 먼저, fsync 나중.&lt;/b&gt; append 후 바로 성공 응답을 주고 fsync는 주기적으로 모아서 한다. 처리량과 지연시간 모두 좋지만, 크래시 시 &quot;성공했다고 답해놓고 사라진 데이터&quot;가 생긴다. Redis AOF의 &lt;code&gt;appendfsync everysec&lt;/code&gt;가 이 모드다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방식 B &amp;mdash; 그룹 커밋(group commit).&lt;/b&gt; 여러 요청의 append를 모아 fsync 한 번을 하되, &lt;b&gt;fsync가 끝난 뒤에야&lt;/b&gt; 해당 배치의 클라이언트들에게 응답한다. 처리량은 똑같이 올라가고 유실은 없다. 잃는 것은 개별 요청의 &lt;b&gt;지연시간&lt;/b&gt;이다. Postgres의 그룹 커밋, Kafka 브로커가 이쪽이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 배치는 처리량을 사고, 그 대가로 &lt;b&gt;지연시간을 지불할지(방식 B) 내구성을 지불할지(방식 A)&lt;/b&gt; 선택하는 문제다. &quot;성공 응답 = fsync 완료&quot;라는 계약만 지키면 배치 자체는 안전하다. 이 트레이드오프가 Kafka의 &lt;code&gt;acks&lt;/code&gt;, Postgres의 &lt;code&gt;synchronous_commit&lt;/code&gt; 같은 설정으로 그대로 노출된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 복제 로그 &amp;mdash; WAL을 여러 노드에&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복제 로그(Replicated Log)는 WAL을 한 대가 아니라 여러 노드에 &lt;b&gt;같은 순서로&lt;/b&gt; 복제하는 패턴이다. 핵심 전제는 상태 머신 복제(State Machine Replication)다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 노드가 동일한 로그를 가지면, 동일한 순서로 replay했을 때 동일한 상태가 된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;같은 순서 보장&quot;을 하는 것이 Raft/Paxos 같은 합의 알고리즘이고, 이 글은 Raft를 골격으로 설명한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 리더가 필요한가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무 노드나 로그에 쓸 수 있게 하면 순서를 합의하는 문제가 매 엔트리마다 발생한다. 그래서 문제를 둘로 쪼갠다. &lt;b&gt;(1) 리더를 하나 뽑는다. (2) 그다음엔 리더 혼자 로그 순서를 결정하고, 나머지(팔로워)는 그대로 복제한다.&lt;/b&gt; 합의라는 비싼 작업을 &quot;엔트리마다&quot;가 아니라 &quot;리더가 바뀔 때만&quot; 하도록 만드는 구조다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 리더 선출 (Leader Election)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;선출은 언제, 어떻게 일어나는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평상시 리더는 팔로워들에게 주기적으로 &lt;b&gt;하트비트&lt;/b&gt;를 보낸다. 팔로워는 각자 &lt;b&gt;선출 타임아웃&lt;/b&gt;(예: 150~300ms 사이 랜덤값)을 두고, 그동안 하트비트가 안 오면 리더가 죽었다고 판단해 스스로 &lt;b&gt;후보(candidate)&lt;/b&gt; 가 되어 선거를 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 등장하는 것이 &lt;b&gt;세대 시계(Generation Clock)&lt;/b&gt;, Raft 용어로는 &lt;b&gt;term&lt;/b&gt;이다. 후보는 자신의 term을 1 올리고, 자신에게 투표한 뒤, 다른 노드들에게 투표 요청을 보낸다. &lt;b&gt;과반수(quorum)&lt;/b&gt; 의 표를 얻으면 그 term의 리더가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과반수가 중요한 이유는 &lt;b&gt;어떤 term에도 리더가 둘일 수 없음&lt;/b&gt;을 보장하기 때문이다. 각 노드는 한 term에 딱 한 번만 투표하고(이 투표 기록은 WAL에 영속화된다), 과반수 집합 두 개는 반드시 겹치므로 두 후보가 동시에 과반을 얻는 것은 불가능하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;아무나 리더가 되면 안 된다 &amp;mdash; 투표 조건&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &quot;빨리 손든 노드가 리더&quot;가 되면, 로그가 뒤처진 노드가 리더가 되어 이미 커밋된 엔트리를 덮어쓸 수 있다. 그래서 투표 요청에는 후보의 &lt;b&gt;마지막 로그 엔트리의 term과 인덱스&lt;/b&gt;가 담기고, 투표자는 이렇게 판단한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;후보의 로그가 내 로그보다 뒤처져 있으면 거부한다.&lt;br /&gt;(마지막 엔트리의 term이 높은 쪽이 최신, term이 같으면 로그가 긴 쪽이 최신)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋된 엔트리는 정의상 과반수 노드에 존재한다. 리더가 되려면 과반수의 표가 필요하다. 두 과반수는 반드시 겹치므로, &lt;b&gt;커밋된 엔트리를 갖지 않은 후보는 그 엔트리를 가진 누군가에게 반드시 거부당해 리더가 될 수 없다.&lt;/b&gt; 과반수 두 개가 겹친다는 사실 하나로 &quot;커밋된 데이터는 리더가 바뀌어도 유실되지 않는다&quot;가 증명된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스플릿 보트와 유령 리더&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임아웃이 비슷하게 만료되면 여러 노드가 동시에 후보가 되어 표가 갈릴 수 있다(split vote). 아무도 과반을 못 얻으면 선거는 무산되고 각자 &lt;b&gt;랜덤한&lt;/b&gt; 타임아웃 후 재시도한다. 이 랜덤화 덕분에 다음 라운드에서는 누군가 먼저 깨어나 표를 쓸어갈 확률이 높아져 몇 라운드 안에 수렴한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 분단 후 구버전 리더가 돌아오는 경우는 term이 해결한다. 모든 메시지에 term이 실려 다니므로, 구 리더는 자기보다 높은 term을 보는 순간 즉시 팔로워로 강등되고, 노드들은 낮은 term의 요청을 거부한다. 세대 시계가 &quot;과거에서 온 유령 리더&quot;를 걸러내는 것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실무에서의 두 갈래&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 선출을 구현하는 시스템(etcd, Consul, Kafka KRaft)이 있는가 하면, ZooKeeper의 임시 노드(ephemeral node)처럼 &lt;b&gt;외부 코디네이터에 선출을 위임&lt;/b&gt;하는 시스템도 많다(구세대 Kafka, HBase). 데이터 노드가 수백 대라면 그들끼리 과반 투표는 비현실적이므로, &quot;합의는 작은 클러스터(3~5대)에 맡기고 나머지는 그 결과를 따른다&quot;는 분업이 일반적이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 로그 복제와 하이 워터 마크 (High-Water Mark)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정상 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 쓰기 요청이 오면 리더는 (1) 자기 WAL에 append하고, (2) 팔로워들에게 엔트리를 전파하고, (3) &lt;b&gt;과반수가 자기 로그에 썼다고 응답한 순간&lt;/b&gt; 그 엔트리를 &lt;b&gt;커밋&lt;/b&gt;으로 선언한다. 이 &quot;여기까지는 커밋됐다&quot;는 인덱스가 &lt;b&gt;하이 워터 마크&lt;/b&gt;(Raft의 commitIndex)다. 리더는 HWM을 넘긴 뒤에야 상태 머신에 엔트리를 적용하고 클라이언트에 성공을 응답한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팔로워는 HWM을 스스로 계산할 수 없다. 과반수가 어디까지 복제했는지는 리더만 알기 때문이다. 그래서 리더는 다음 복제 요청이나 하트비트에 &lt;b&gt;HWM을 실어서(piggyback)&lt;/b&gt; 전파하고, 팔로워는 그것을 받고서야 해당 지점까지 상태 머신에 적용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그의 각 엔트리는 세 단계를 거친다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;appended&lt;/b&gt;(리더 로그에 기록됨, 아직 사라질 수 있음)&lt;br /&gt;&amp;rarr; &lt;b&gt;committed / HWM 통과&lt;/b&gt;(과반수 복제 완료, 유실 불가)&lt;br /&gt;&amp;rarr; &lt;b&gt;applied&lt;/b&gt;(상태 머신에 반영됨)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구분이 복제 로그 이해의 절반이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네트워크 분단 시나리오 &amp;mdash; 왜 HWM 아래에서만 읽어야 하는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5노드 클러스터가 2대/3대로 분단되고 기존 리더가 2대 쪽에 있다고 하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3대 쪽에서는 하트비트가 끊기므로 새 리더 선출이 일어난다(과반 3/5 달성 가능). 2대 쪽 구 리더는 자기가 강등된 것을 모르므로 쓰기 요청을 받으면 일단 로그에 append하고 복제를 시도한다. 하지만 과반수 응답을 영영 못 받으므로 그 엔트리는 &lt;b&gt;커밋되지 못하고&lt;/b&gt;, 클라이언트는 타임아웃을 받는다. 즉 정확히는 &quot;쓰기가 거부된다&quot;기보다 &quot;&lt;b&gt;커밋이 불가능하다&lt;/b&gt;&quot;이다. 로그에 적히는 것과 커밋되는 것은 다른 사건이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분단이 복구되면 구 리더는 새 리더의 높은 term을 보고 팔로워로 강등되고, 커밋되지 못한 엔트리들은 잘려나가고 새 리더의 로그로 덮어써진다. 만약 커밋 전 데이터를 클라이언트에게 보여줬다면 &quot;분명히 읽었던 데이터가 사라지는&quot; 일이 생긴다. &lt;b&gt;HWM은 &quot;여기까지는 어떤 장애가 나도 사라지지 않는다&quot;는 경계선이고, 그래서 읽기도 HWM 아래에서만 서빙한다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;팔로워 로그가 어긋나 있을 때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리더는 복제 요청에 &quot;이 엔트리 직전 엔트리의 (인덱스, term)&quot;을 함께 보낸다. 팔로워는 자기 로그의 그 위치에 같은 (인덱스, term)이 없으면 요청을 거부하고, 리더는 한 칸씩 뒤로 물러나며 일치 지점을 찾아 그 이후를 자기 로그로 덮어쓴다. 이 일관성 검사 덕분에 &quot;팔로워의 로그는 리더 로그의 접두사(prefix)와 항상 일치한다&quot;는 불변식이 유지되고, 새로 합류하거나 오래 죽어 있던 노드도 자동으로 따라잡는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;심화: 이전 term의 엔트리는 세어서 커밋하면 안 된다 (Raft Figure 8)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 리더는 이전 term의 엔트리를 &quot;과반수에 복제돼 있네?&quot;라고 세어서 커밋 처리하면 &lt;b&gt;안 된다.&lt;/b&gt; 연쇄적인 리더 교체 상황에서 그렇게 커밋한 엔트리가 나중에 덮어써지는 반례가 존재한다(Raft 논문 Figure 8). 대신 새 리더는 &lt;b&gt;자기 term의 no-op 엔트리를 즉시 append해서 복제&lt;/b&gt;한다. 이 no-op이 커밋되는 순간, 로그 일치 특성(Log Matching)에 의해 그 앞의 모든 엔트리도 함께 커밋 확정된다. 커밋 여부를 &quot;판정&quot;하는 게 아니라, 새 커밋을 만들어 이전 것들을 &lt;b&gt;딸려서 확정시키는&lt;/b&gt; 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 규칙은 다음 심화 질문의 답이기도 하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. 리더가 정족수를 달성해 커밋했지만, 커밋 인덱스를 팔로워에게 전파하지 못한 채 다운됐다. 새 리더는 이 엔트리를 어떻게 처리하나?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, 새 리더는 그 엔트리를 반드시 가지고 있다. 엔트리는 과반수에 존재하고, 당선에는 과반수의 표가 필요하며, 투표 조건 때문에 그 엔트리를 가진 노드들은 엔트리가 없는 후보를 거부한다. 두 과반수는 겹치므로 데이터 자체는 유실되지 않는다. 다만 새 리더는 그것이 커밋됐는지 모르므로, 자기 term의 no-op을 커밋시켜 이전 엔트리들을 딸려서 확정한다. 구 리더가 클라이언트에 성공 응답을 이미 보냈더라도 약속은 지켜지고, 응답을 못 보냈다면 클라이언트의 재시도를 멱등 수신자가 처리한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 재시도와 중복 &amp;mdash; 멱등 수신자 (Idempotent Receiver)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 쓰기 요청을 보냈는데 응답을 못 받고 타임아웃되면 재시도한다. 이때 같은 명령이 로그에 두 번 적히는 것을 막아야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구체적인 처리 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계 &amp;mdash; 클라이언트 등록.&lt;/b&gt; 클라이언트가 처음 접속하면 리더에 등록하고 고유한 &lt;b&gt;클라이언트 ID&lt;/b&gt;를 받는다. 이 등록 자체도 로그 엔트리로 복제된다. 그래야 리더가 바뀌어도 새 리더가 클라이언트 정보를 안다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계 &amp;mdash; 요청 번호 부여.&lt;/b&gt; 클라이언트는 모든 요청에 &lt;code&gt;(클라이언트 ID, 요청 시퀀스 번호)&lt;/code&gt;를 붙인다. 재시도할 때는 &lt;b&gt;같은 시퀀스 번호를 그대로&lt;/b&gt; 다시 보낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계 &amp;mdash; 서버 측 중복 감지와 응답 캐시.&lt;/b&gt; 서버는 클라이언트별로 &quot;마지막으로 처리한 시퀀스 번호와 그때의 응답&quot;을 저장한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun handle(request: ClientRequest): Response {
    val session = sessions[request.clientId]
        ?: return Response.sessionExpired()

    return when {
        // 이미 처리한 요청 &amp;rarr; 재실행하지 않고 저장해 둔 응답을 그대로 반환
        request.sequenceNumber &amp;lt;= session.lastProcessedSeq -&amp;gt;
            session.cachedResponse(request.sequenceNumber)

        // 새 요청 &amp;rarr; 로그에 복제하고, 적용 후 응답을 캐시에 저장
        else -&amp;gt; replicateAndApply(request).also {
            session.record(request.sequenceNumber, it)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;응답까지 저장한다&lt;/b&gt;는 점이다. 중복을 감지만 하고 버리면 클라이언트는 결과를 영영 못 받는다. 그리고 이 세션 상태도 상태 머신의 일부로 취급되어 로그 적용과 함께 갱신된다. 리더가 바뀌어도 새 리더가 같은 로그를 replay하면 같은 세션 상태를 갖게 되므로, 재시도가 어느 리더에게 가든 중복 실행되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4단계 &amp;mdash; 상태 정리.&lt;/b&gt; 클라이언트가 &quot;시퀀스 N까지 응답을 잘 받았다&quot;고 알려주면(다음 요청에 piggyback) 그 이전 캐시는 버린다. 세션에는 리스(lease)/타임아웃을 걸어 하트비트가 끊긴 클라이언트의 상태를 만료시킨다. 만료 후 뒤늦게 도착한 재시도는 거부할 수밖에 없다. &lt;b&gt;완벽한 exactly-once는 무한한 상태 없이는 불가능하다&lt;/b&gt;는 한계가 여기서 드러난다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 UUID가 아니라 시퀀스 번호인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임의의 UUID 키로도 중복 감지는 되지만, 본 적 있는 키를 전부 집합으로 들고 있어야 한다. &lt;b&gt;단조 증가하는 시퀀스 번호&lt;/b&gt;를 쓰면 &quot;클라이언트별 마지막 번호 하나&quot;만 기억해도 그 이하는 전부 중복으로 판정할 수 있어 저장 공간이 극적으로 줄어든다. Kafka의 멱등 프로듀서가 &lt;code&gt;(Producer ID, 파티션별 시퀀스 번호)&lt;/code&gt;로 중복 배치를 걸러내는 것이 정확히 이 방식이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전달 보장 의미론으로 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시도를 안 하면 &lt;b&gt;at-most-once&lt;/b&gt;(유실 가능), 재시도만 하면 &lt;b&gt;at-least-once&lt;/b&gt;(중복 가능), &lt;b&gt;재시도 + 멱등 수신자 = 사실상 exactly-once&lt;/b&gt;다. 즉 exactly-once는 마법 같은 전송 프로토콜이 아니라 &quot;&lt;b&gt;at-least-once로 배달하고 수신 측에서 중복을 제거한다&lt;/b&gt;&quot;는 조합으로 만들어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 HTTP API에 쓰는 &lt;b&gt;Idempotency-Key 헤더&lt;/b&gt;(Stripe, 토스페이먼츠 결제 API 등)가 이 패턴의 응용이다. 키 저장소가 복제 로그가 아니라 DB나 Redis일 뿐, &quot;키로 중복 감지 + 저장된 응답 재반환&quot;이라는 구조는 동일하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 심화: 클러스터 멤버십 변경 (Membership Change)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드 구성은 고정이 아니다. 그리고 구성 변경은 보기보다 위험하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 위험한가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3대 클러스터 {A, B, C}에서 {A, B, C, D, E}로 한 번에 바꾼다고 하자. 구성 변경 정보가 노드마다 도착하는 시점이 다르므로, 어느 순간 A, B는 구 구성(정족수 2/3)으로, C, D, E는 신 구성(정족수 3/5)으로 동작할 수 있다. 그러면 {A, B}가 구 구성 기준 과반으로 리더를 뽑고, 동시에 {C, D, E}가 신 구성 기준 과반으로 또 다른 리더를 뽑는 &lt;b&gt;스플릿 브레인&lt;/b&gt;이 가능해진다. 정족수의 안전성은 &quot;모두가 같은 구성을 본다&quot;는 전제 위에 있는데, 과도기에 그 전제가 깨진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해법: 구성 자체를 로그로 합의한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멤버십 구성을 설정 파일이 아니라 &lt;b&gt;복제 로그를 통해 합의되는 데이터&lt;/b&gt;로 취급한다. 그 위에서 두 가지 방식이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단일 서버 변경(single-server change)&lt;/b&gt; &amp;mdash; 실무의 주류. 한 번에 노드를 딱 하나만 추가/제거한다. 한 대씩만 바꾸면 구 구성의 과반과 신 구성의 과반이 수학적으로 반드시 겹치므로 리더 둘이 나올 수 없다. 5대로 만들려면 두 번 반복하면 된다. etcd가 이 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;공동 합의(joint consensus)&lt;/b&gt; &amp;mdash; Raft 논문의 원래 방식. 한 번에 여러 대를 바꿀 때 과도기에 C(old,new)라는 중간 구성을 거치며, 이 기간에는 &lt;b&gt;구 구성과 신 구성 양쪽 모두에서 과반&lt;/b&gt;을 얻어야 커밋과 선출이 가능하다. 안전하지만 구현이 까다로워 실제로는 단일 서버 변경이 선호된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 노드는 로그가 빈 채로 합류하므로 바로 투표권을 주면 정족수 분모만 늘려 가용성을 해친다. 그래서 etcd 같은 시스템은 &lt;b&gt;학습자(learner) 노드&lt;/b&gt;로 먼저 붙여 투표권 없이 로그를 따라잡게 한 뒤 정식 멤버로 승격한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pattern 10의 골격은 세 겹이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;WAL&lt;/b&gt;이 &quot;변경을 먼저 순차 로그에 기록&quot;함으로써 단일 노드의 내구성과 성능을 잡고, &lt;b&gt;리더 선출&lt;/b&gt;이 로그 순서 결정권의 유일성을 보장하며(term + 과반수 + 투표 조건), &lt;b&gt;복제와 하이 워터 마크&lt;/b&gt;가 유실 없는 커밋 경계를 만든다. 그 위에 &lt;b&gt;멱등 수신자&lt;/b&gt;가 재시도로 인한 중복을 제거해 exactly-once 의미론을 완성하고, &lt;b&gt;멤버십 변경&lt;/b&gt;조차 로그를 통한 합의로 처리된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 모든 것은 &quot;노드가 거짓말은 하지 않는다&quot;는 &lt;b&gt;크래시 결함 가정&lt;/b&gt; 위에 서 있다. Kafka의 ISR과 high watermark, etcd/Raft의 commitIndex, ZooKeeper의 Zab이 전부 이 구조의 변주다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;『30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법』 &amp;mdash; Pattern 10 복제 로그&lt;/li&gt;
&lt;li&gt;Diego Ongaro, John Ousterhout, &quot;In Search of an Understandable Consensus Algorithm&quot; (Raft 논문)&lt;/li&gt;
&lt;li&gt;Leslie Lamport et al., &quot;The Byzantine Generals Problem&quot; (1982)&lt;/li&gt;
&lt;li&gt;Martin Kleppmann, 『데이터 중심 애플리케이션 설계』 8~9장&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/172</guid>
      <comments>https://icarus8050.tistory.com/172#entry172comment</comments>
      <pubDate>Sun, 19 Jul 2026 20:53:38 +0900</pubDate>
    </item>
    <item>
      <title>Kotlin inline 함수의 noinline과 crossinline</title>
      <link>https://icarus8050.tistory.com/171</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Kotlin 코드를 읽다 보면 noinline, crossinline이라는 키워드를 종종 마주친다. 자주 보긴 하는데, 막상 직접 inline 함수를 작성하다 컴파일 에러가 나면 &quot;어, 이거 어떤 상황에 뭘 붙여야 하더라?&quot; 하고 매번 다시 찾아보게 된다. 헷갈릴 때마다 또 검색하지 않으려고 정리해둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. inline 함수와 람다 인라이닝의 기본&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;inline 함수는 호출부에 함수 본문이 그대로 펼쳐진다. 람다 파라미터도 마찬가지로 호출부에 인라이닝되기 때문에, 람다는 &lt;b&gt;함수 객체로 존재하지 않는다&lt;/b&gt;.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun doSomething(block: () -&amp;gt; Unit) {
    block()
}

fun caller() {
    doSomething { println(&quot;hello&quot;) }
    // 컴파일 후엔 사실상 아래와 같다
    // println(&quot;hello&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 인라이닝 덕분에 두 가지 특성이 생긴다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;람다 객체 생성 비용 제거&lt;/b&gt; (성능 이점) &amp;mdash; 일반 람다는 매 호출마다 함수 객체를 생성하지만, inline 람다는 그렇지 않다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;non-local return 허용&lt;/b&gt; &amp;mdash; 람다 안에서 return을 쓰면 람다를 호출한 함수가 아니라 &lt;b&gt;람다를 감싼 외부 함수에서 리턴&lt;/b&gt;된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun doSomething(block: () -&amp;gt; Unit) {
    block()
}

fun caller() {
    doSomething {
        return  // caller() 자체에서 return됨 (non-local return)
    }
    println(&quot;이 줄은 실행 안 됨&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;non-local return은 forEach 같은 inline 함수를 for 루프처럼 자연스럽게 쓸 수 있게 해준다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fun findUser(users: List&amp;lt;User&amp;gt;, targetId: Long): User? {
    users.forEach {
        if (it.id == targetId) return it  // findUser에서 바로 리턴
    }
    return null
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;다만 이 특성은 동시에 &lt;b&gt;뒤에서 볼 crossinline이 필요해지는 원인&lt;/b&gt;이기도 하다. 람다 안의 return이 외부 함수를 종료시킨다는 약속 때문에, 람다가 다른 컨텍스트로 캡처되는 상황에서 문제가 생기기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 두 가지 특성이 noinline과 crossinline이 존재하는 근본 이유다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. crossinline &amp;mdash; non-local return을 금지한다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 필요한가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;inline 함수 안에서 람다 파라미터는 &lt;b&gt;직접 호출(block())만 인라이닝 가능&lt;/b&gt;하다. 그 외의 사용 &amp;mdash; 다른 람다 안에서 호출하거나, 다른 함수에 인자로 넘기거나, 변수에 저장하는 것 &amp;mdash; 은 모두 금지된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun runInThread(block: () -&amp;gt; Unit) {
    Thread {
        block()  // ❌ 컴파일 에러
    }.start()
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여기서 Thread { ... }에 전달된 { block() }은 &lt;b&gt;별개의 람다&lt;/b&gt;다. 즉 block을 그 안에서 호출하는 건 &quot;직접 호출&quot;이 아니라 &lt;b&gt;다른 실행 컨텍스트로 캡처되는 호출&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;컴파일러가 이를 막는 이유는 non-local return의 위험 때문이다. block이 일반 inline 람다라면 안에서 return을 쓸 수 있어야 하고, 그 return은 &quot;&lt;b&gt;runInThread를 호출한 함수에서 return&lt;/b&gt;&quot;을 의미한다. 그런데 block()이 Thread의 Runnable 안으로 들어가 &lt;b&gt;별도 스레드에서 나중에 실행&lt;/b&gt;된다면, 그 시점에 호출자 함수는 이미 끝났을 수 있다. 거기서 호출자 함수를 종료시키는 return은 물리적으로 불가능하다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;fun caller() {
    runInThread {
        return  // 어떤 스레드에서, 언제, 무엇을 종료시킬 것인가?
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;컴파일러는 이 모순을 막기 위해 &lt;b&gt;&quot;다른 람다 안으로 캡처되는 inline 람다&quot;를 아예 금지&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결: crossinline&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;crossinline을 붙이면 &quot;이 람다는 non-local return을 포기한다&quot;고 컴파일러에게 약속하는 것이고, 그 대가로 다른 람다 안으로 캡처될 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun runInThread(crossinline block: () -&amp;gt; Unit) {
    Thread {
        block()  // ✅ OK
    }.start()
}

fun caller() {
    runInThread {
        // return  // ❌ 여전히 컴파일 에러: crossinline 람다 안에서는 non-local return 불가
        println(&quot;OK&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. noinline &amp;mdash; 인라이닝 자체를 제외한다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 필요한가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;inline 람다는 함수 객체로 존재하지 않기 때문에, &lt;b&gt;람다를 변수에 저장하거나 다른 함수에 전달하거나 반환할 수 없다&lt;/b&gt;. 람다를 &quot;값&quot;으로 다뤄야 한다면 인라이닝을 포기해야 하고, 그게 noinline이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// ❌ 컴파일 에러
inline fun wrong(block: () -&amp;gt; Unit): () -&amp;gt; Unit {
    return block  // 인라이닝된 람다는 반환할 수 없음
}

// ✅ noinline으로 해결
inline fun right(noinline block: () -&amp;gt; Unit): () -&amp;gt; Unit {
    return block
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용 예&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;inline 함수에 람다 파라미터가 여러 개 있고, 그 중 일부만 다른 함수로 넘겨야 할 때 사용한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun doSomething(
    block1: () -&amp;gt; Unit,
    noinline block2: () -&amp;gt; Unit
) {
    block1()              // 인라이닝됨
    saveForLater(block2)  // block2는 함수 객체로 존재하므로 전달 가능
}

fun saveForLater(action: () -&amp;gt; Unit) { /* ... */ }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 세 키워드 비교&lt;/h2&gt;
&lt;div&gt;키워드인라이닝non-local return람다를 값으로 다루기
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 91px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;키워드&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;인라이닝&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;non-local return&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;람다를 값으로 다루기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;inline (기본)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;noinline&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;crossinline&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 두 가지 축의 조합이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;인라이닝 여부&lt;/b&gt; &amp;rarr; 람다 객체 생성 비용 / 람다를 값으로 다룰 수 있는지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;non-local return 허용 여부&lt;/b&gt; &amp;rarr; 람다가 다른 컨텍스트로 캡처될 수 있는지&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 실무에서 마주치는 패턴&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Executor에 작업 제출&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun submitAsync(executor: Executor, crossinline task: () -&amp;gt; Unit) {
    executor.execute { task() }  // 람다 안에서 task 호출 &amp;rarr; crossinline 필수
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;재시도 + 폴백&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;inline fun &amp;lt;T&amp;gt; retryWithFallback(
    maxAttempts: Int,
    crossinline action: () -&amp;gt; T,           // 재시도 루프 안에서 실행 &amp;rarr; crossinline
    noinline fallback: (Throwable) -&amp;gt; T    // 다른 함수로 전달 &amp;rarr; noinline
): T {
    repeat(maxAttempts - 1) {
        try {
            return action()
        } catch (e: Exception) { /* 재시도 */ }
    }
    return runFallback(fallback)
}

fun &amp;lt;T&amp;gt; runFallback(fb: (Throwable) -&amp;gt; T): T = fb(RuntimeException(&quot;failed&quot;))&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하나의 함수에서 crossinline과 noinline이 함께 쓰이는 예다.&lt;/p&gt;</description>
      <category>Development/Kotlin</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/171</guid>
      <comments>https://icarus8050.tistory.com/171#entry171comment</comments>
      <pubDate>Sat, 9 May 2026 18:26:31 +0900</pubDate>
    </item>
    <item>
      <title>Spring Data JPA Custom Query Method는 왜 SimpleJpaRepository의 @Transactional을 상속받지 못할까</title>
      <link>https://icarus8050.tistory.com/170</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SimpleJpaRepository에는 클래스 레벨 @Transactional(readOnly = true)와 쓰기 메서드(save, delete*)에 메서드 레벨 @Transactional이 붙어 있다.&lt;/li&gt;
&lt;li&gt;이 어노테이션들은 &lt;b&gt;SimpleJpaRepository에 실제로 정의된 메서드를 호출할 때만&lt;/b&gt; 적용된다.&lt;/li&gt;
&lt;li&gt;findByName(...), @Query(&quot;...&quot;) 같은 &lt;b&gt;사용자 정의(custom) query method&lt;/b&gt;는 SimpleJpaRepository에 시그니처 자체가 없기 때문에, Spring Data가 사용하는 RepositoryAnnotationTransactionAttributeSource의 fallback 경로에서 조기 종료되어 트랜잭션 어트리뷰트가 매칭되지 않는다.&lt;/li&gt;
&lt;li&gt;Repository proxy에 들러붙는 또 다른 interceptor인 TransactionInterceptor 역시 같은 이유로 매칭에 실패한다 (자세한 메커니즘은 본문 &amp;sect;3에서 설명).&lt;/li&gt;
&lt;li&gt;결과: custom query method는 별도 선언이 없는 한 @Transactional이 적용되지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 배경: Repository Proxy에는 두 개의 TransactionInterceptor가 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA Repository는 JDK Proxy로 만들어진 advice chain을 가진다. 이 chain에 들어가는 트랜잭션 관련 advice는 두 가지 경로로 구성된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Path A. TransactionInterceptor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@EnableTransactionManagement이 import하는 ProxyTransactionManagementConfiguration에서 컨테이너 기동 시 한 번 만들어진다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// AbstractTransactionManagementConfiguration 에서 설정
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionAttributeSource transactionAttributeSource() {
	// Accept protected @Transactional methods on CGLIB proxies, as of 6.0
	AnnotationTransactionAttributeSource tas = new AnnotationTransactionAttributeSource(false);
	// Apply default rollback rule, as of 6.2
	if (this.enableTx != null &amp;amp;&amp;amp; this.enableTx.getEnum(&quot;rollbackOn&quot;) == RollbackOn.ALL_EXCEPTIONS) {
		tas.addDefaultRollbackRule(RollbackRuleAttribute.ROLLBACK_ON_ALL_EXCEPTIONS);
	}
	return tas;
}

// ProxyTransactionManagementConfiguration 에서 설정
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public TransactionInterceptor transactionInterceptor(TransactionAttributeSource transactionAttributeSource) {
	TransactionInterceptor interceptor = new TransactionInterceptor();
	interceptor.setTransactionAttributeSource(transactionAttributeSource);
	if (this.txManager != null) {
		interceptor.setTransactionManager(this.txManager);
	}
	return interceptor;
}

// ProxyTransactionManagementConfiguration 에서 설정
@Bean(name = TransactionManagementConfigUtils.TRANSACTION_ADVISOR_BEAN_NAME)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public BeanFactoryTransactionAttributeSourceAdvisor transactionAdvisor(
		TransactionAttributeSource transactionAttributeSource, TransactionInterceptor transactionInterceptor) {

	BeanFactoryTransactionAttributeSourceAdvisor advisor = new BeanFactoryTransactionAttributeSourceAdvisor();
	advisor.setTransactionAttributeSource(transactionAttributeSource);
	advisor.setAdvice(transactionInterceptor);
	if (this.enableTx != null) {
		advisor.setOrder(this.enableTx.&amp;lt;Integer&amp;gt;getNumber(&quot;order&quot;));
	}
	return advisor;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 빈에 적용 가능. TransactionAttributeSourcePointcut 이 매칭되는 Bean/Method 에만 advice 가 끼어든다.&lt;/li&gt;
&lt;li&gt;매칭 조건: 메서드 또는 (선언/타깃) 클래스에 @Transactional 이 발견되는가.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Path B. Per-Repository TransactionInterceptor&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각 Repository 빈이 만들어질 때 TransactionalRepositoryProxyPostProcessor 가 추가한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Override
public void postProcess(ProxyFactory factory, RepositoryInformation repositoryInformation) {

	TransactionInterceptor transactionInterceptor = new TransactionInterceptor();
	transactionInterceptor.setTransactionAttributeSource(
			new RepositoryAnnotationTransactionAttributeSource(repositoryInformation, enableDefaultTransactions));
	transactionInterceptor.setTransactionManagerBeanName(transactionManagerName);
	transactionInterceptor.setBeanFactory(beanFactory);
	transactionInterceptor.afterPropertiesSet();
	factory.addAdvice(transactionInterceptor);
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Repository proxy마다 새 인스턴스가 만들어진다.&lt;/li&gt;
&lt;li&gt;핵심: 표준 AnnotationTransactionAttributeSource가 아니라 **RepositoryAnnotationTransactionAttributeSource**가 들어간다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 TransactionInterceptor 클래스지만 인스턴스가 다르고, 들고 있는 TransactionAttributeSource도 다르다. 이게 &quot;@Transactional 마킹 유무에 따라 동작이 달라 보이는&quot; 현상의 정체다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. computeTransactionAttribute()는 언제 호출되나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;TransactionInterceptor가 메서드 호출을 가로챌 때, 어트리뷰트 lookup 이 트리거된다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;JDK Proxy.invoke()
 └─ TransactionInterceptor.invoke(MethodInvocation)
   └─ TransactionAspectSupport#invokeWithinTransaction()
     └─ getTransactionAttributeSource().getTransactionAttribute(method, targetClass)
       └─ AbstractFallbackTransactionAttributeSource#getTransactionAttribute()
         ├─ attributeCache(MethodClassKey) HIT &amp;rarr; 그대로 반환
         └─ MISS &amp;rarr; computeTransactionAttribute(method, targetClass) &amp;larr; 여기
              └─ 결과를 attributeCache에 put
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Proxy 생성 시점에는 호출되지 않는다. postProcess()는 wiring 만 한다.&lt;/li&gt;
&lt;li&gt;런타임에 각 메서드 첫 호출 시 단 1회 실행되고, 이후엔 MethodClassKey 기준으로 캐시된다.&lt;/li&gt;
&lt;li&gt;주의: null 반환도 캐시된다. 트랜잭션 어트리뷰트를 찾지 못한 경우, NULL_TRANSACTION_ATTRIBUTE가 캐시에 저장되어 같은 메서드의 두 번째 호출부터는 lookup 자체가 일어나지 않는다. 디버깅 시 &quot;왜 브레이크포인트가 한 번만 걸리지?&quot;의 답이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 두 interceptor 모두 왜 custom query method 를 잡지 못하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Path A와 Path B를 각각 따져보자.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1. Path B (RepositoryAnnotationTransactionAttributeSource)의 lookup 로직&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;표준 AnnotationTransactionAttributeSource에 한 단계가 더 붙은 형태다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Override
protected TransactionAttribute computeTransactionAttribute(Method method, Class&amp;lt;?&amp;gt; targetClass) {
	// Don't allow no-public methods as required.
	if (allowPublicMethodsOnly() &amp;amp;&amp;amp; !Modifier.isPublic(method.getModifiers())) {
		return null;
	}

	// The method may be on an interface, but we need attributes from the target class.
	// If the target class is null, the method will be unchanged.
	Method specificMethod = ClassUtils.getMostSpecificMethod(method, userClass);

	// If we are dealing with method with generic parameters, find the original method.
	specificMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
    
	TransactionAttribute txAtt = null;

	// (1) user interface 메서드의 @Transactional
	txAtt = findTransactionAttribute(specificMethod);

	if (txAtt != null) {
		return txAtt;
	}

	// (2) declaring class 레벨 @Transactional
	txAtt = findTransactionAttribute(specificMethod.getDeclaringClass());

	if (txAtt != null) {
		return txAtt;
	}

	if (!enableDefaultTransactions) {
		return null;
	}

	// (3) 구현 클래스(SimpleJpaRepository)에서 동일 시그니처 lookup
	Method targetClassMethod = repositoryInformation.getTargetClassMethod(method);

	if (targetClassMethod.equals(method)) {
		return null;  // &amp;larr; 핵심 분기
	}

	// (4) 구현체 메서드의 @Transactional
	txAtt = findTransactionAttribute(targetClassMethod);

	if (txAtt != null) {
		return txAtt;
	}

	// (5) 구현체 클래스의 @Transactional
	txAtt = findTransactionAttribute(targetClassMethod.getDeclaringClass());

	if (txAtt != null) {
		return txAtt;
	}

	return null;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;getTargetClassMethod(method)는 &quot;user interface에 선언된 메서드와 같은 시그니처를 갖는 구현 클래스(SimpleJpaRepository)의 메서드&quot;를 찾는다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구현체에 동일 메서드가 있으면 다른 Method 객체를 반환 &amp;rarr; (4), (5)로 진행 &amp;rarr; SimpleJpaRepository의 어노테이션이 적용된다.&lt;/li&gt;
&lt;li&gt;없으면 입력 메서드를 그대로 반환 &amp;rarr; targetClassMethod.equals(method) == true &amp;rarr; null 반환, 종료.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Custom query method는 후자다. 여기서 Path B는 끝난다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2. 그럼 Path A는 왜 못 잡나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여기서 자연스러운 질문이 생긴다. &quot;Path B가 fallback에서 끊긴다 치자. Path A의 표준 AnnotationTransactionAttributeSource도 결국 같은 AbstractFallbackTransactionAttributeSource를 상속하는데, 이건 왜 SimpleJpaRepository 의 클래스 레벨 @Transactional(readOnly = true)를 매칭시키지 못하는가?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답은 fallback이 검사하는 타입 계층에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표준 fallback 순서는 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;specificMethod (가장 구체적인 메서드)의 어노테이션&lt;/li&gt;
&lt;li&gt;specificMethod.getDeclaringClass()의 어노테이션&lt;/li&gt;
&lt;li&gt;method (원본 인터페이스 메서드)의 어노테이션&lt;/li&gt;
&lt;li&gt;method.getDeclaringClass()의 어노테이션&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 getMostSpecificMethod(method, targetClass)가 무엇을 반환하느냐다. Repository proxy의 targetClass는 SimpleJpaRepository이지만, custom query method는 SimpleJpaRepository에 시그니처가 없다. 따라서 가장 구체적인 메서드는 user interface의 메서드 그대로다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 fallback이 순회하는 대상은:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;user interface 메서드 &amp;rarr; 어노테이션 없음&lt;/li&gt;
&lt;li&gt;user interface (= UserRepository) &amp;rarr; 어노테이션 없음&lt;/li&gt;
&lt;li&gt;(1과 동일)&lt;/li&gt;
&lt;li&gt;(2와 동일)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;UserRepository는 JpaRepository 인터페이스 계층을 상속할 뿐, SimpleJpaRepository 구현 클래스를 상속하지 않는다. 따라서 SimpleJpaRepository의 클래스 레벨 @Transactional(readOnly = true)는 fallback이 닿는 타입 계층 어디에도 들어오지 않는다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리: Path B는 &quot;구현체에 같은 시그니처가 없으면 조기 종료&quot;라는 명시적 분기 때문에, Path A는 &quot;fallback이 인터페이스 계층만 훑고 구현 클래스에 도달하지 못하기 때문에&quot; 매칭에 실패한다. 두 interceptor의 실패 이유는 미묘하게 다르지만, 결과적으로 custom query method는 어느 쪽으로도 트랜잭션 어트리뷰트를 받지 못한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 메서드 종류별 @Transactional 상속 여부&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드 종류 출처 SimpleJpaRepository의 어트리뷰트 상속&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;save, delete*&lt;/td&gt;
&lt;td&gt;CrudRepository 선언 + SimpleJpaRepository에서 override (메서드 레벨 @Transactional 직접 부여)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;findById, findAll, count, existsById 등 read-only CRUD&lt;/td&gt;
&lt;td&gt;CrudRepository 선언 + SimpleJpaRepository override (메서드 어노 없음, 클래스 레벨만 적용)&lt;/td&gt;
&lt;td&gt;✅ &amp;mdash; 클래스 레벨 @Transactional(readOnly = true) 상속&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;findByName, @Query(&quot;...&quot;), derived query&lt;/td&gt;
&lt;td&gt;user interface에만 존재&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@Modifying 붙은 custom query&lt;/td&gt;
&lt;td&gt;user interface에만 존재&lt;/td&gt;
&lt;td&gt;❌ &amp;mdash; 별도 @Transactional 없으면 실행 시 InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove'/'persist' call 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 왜 이렇게 설계되었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional은 메서드 단위 메타데이터다. 클래스 레벨 어노테이션이 자식 메서드 시그니처에 자동으로 &quot;상속&quot;되지 않는 이유는 명확하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SimpleJpaRepository의 @Transactional(readOnly = true)는 이 클래스에 실제로 정의된 메서드 호출에 한해 의미를 가진다.&lt;/li&gt;
&lt;li&gt;Custom query method는 SimpleJpaRepository에 정의된 메서드가 아니다. 이름이 같다는 보장도 없고, 같다 한들 의미가 같다는 보장도 없다.&lt;/li&gt;
&lt;li&gt;Spring Data가 fallback 경로를 만든 건 CRUD 메서드 override 한정 편의 기능이지, 사용자가 새로 만든 메서드까지 자동으로 묶어주려는 의도가 아니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 디버깅 진입 포인트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 직접 따라가며 검증하고 싶다면 다음 위치에 브레이크포인트를 두면 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컨테이너 기동 시 global interceptor 생성: ProxyTransactionManagementConfiguration#transactionInterceptor&lt;/li&gt;
&lt;li&gt;Repository proxy 빌드 시 per-repo interceptor 추가: TransactionalRepositoryProxyPostProcessor#postProcess&lt;/li&gt;
&lt;li&gt;런타임 어트리뷰트 lookup: AbstractFallbackTransactionAttributeSource#getTransactionAttribute&lt;/li&gt;
&lt;li&gt;Spring Data 전용 lookup 로직: RepositoryAnnotationTransactionAttributeSource#computeTransactionAttribute&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TransactionInterceptor 인스턴스가 두 개라는 사실은, 위 두 생성 지점에서 System.identityHashCode(this.transactionAttributeSource)를 비교해보면 즉시 확인된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 동작 때문에 lookup 메서드의 브레이크포인트는 메서드당 단 한 번만 걸린다는 점도 기억해두자. 같은 메서드를 재호출하며 디버깅하려면 attributeCache를 직접 비우거나, 다른 메서드 호출로 전환해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Development/Spring</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/170</guid>
      <comments>https://icarus8050.tistory.com/170#entry170comment</comments>
      <pubDate>Mon, 4 May 2026 00:45:05 +0900</pubDate>
    </item>
    <item>
      <title>Consistency Core 정리</title>
      <link>https://icarus8050.tistory.com/169</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;강한 일관성을 보장하려면 노드 간 합의(consensus)가 필요하고, 합의는 노드 수가 늘어날수록 비용이 폭증한다. 그렇다고 일관성을 포기하면 데이터 정합성이 깨지고, 합의 비용을 감수하면 확장성이 천장에 부딪힌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 딜레마에 대한 정형화된 해법이 Consistency Core(일관성 코어) 패턴이다. 이미 우리가 쓰고 있는 Kafka, Kubernetes, HDFS 같은 시스템들이 모두 이 패턴의 구체적인 구현체다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 글에서는 Consistency Core 가 무엇이고, 왜 필요하며, Kafka KRaft 를 중심으로 실제로 어떻게 구현되는지 정리해본다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 정의: 합의 비용은 노드 수에 비례하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;분산 합의 알고리즘(Raft, Paxos)은 quorum, 즉 과반수 노드의 동의가 있어야 진행된다. 노드 수가 늘어나면 quorum 확보 비용이 어떻게 변하는지 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드 수 Quorum 합의 특성&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;빠르고 안정적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;운영하기 좋은 균형점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;합의 속도 저하 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;사실상 불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;100대 노드가 모든 결정을 합의로 처리한다고 상상해보자. 매 결정마다 51대 이상의 응답을 기다려야 하고, 한 노드만 느려도 전체가 대기한다. 네트워크 round trip이 노드 수에 비례해 늘어나고, 장애 복구는 더 오래 걸린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;그렇다고 강한 일관성을 포기할 수도 없다. 클러스터 멤버십, 리더 정보, 설정 같은 정보는 모든 노드가 같은 view를 가져야 시스템이 안전하게 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 아이디어: Consistency Core&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;강한 일관성이 정말로 필요한 부분만 작은 클러스터로 분리하고, 나머지는 그 코어를 참조하면서 약한 일관성으로 운영한다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;                  ┌──────────────────────┐
                  │  Consistency Core    │
                  │  (3~5 노드, Raft)    │
                  │  - 강한 일관성       │
                  │  - 적은 데이터       │
                  │  - 모든 결정의 진실  │
                  └──────────┬───────────┘
                             │ (메타데이터 조회/구독)
              ┌──────────────┼──────────────┐
              │              │              │
         ┌────▼────┐    ┌────▼────┐    ┌────▼────┐
         │ Worker  │    │ Worker  │    │ Worker  │
         │ Node 1  │    │ Node 2  │    │ ... N   │
         └─────────┘    └─────────┘    └─────────┘
            (대량 데이터, 약한 일관성, 수평 확장)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;코어는 작게 유지해서 합의 비용을 낮추고, 워커는 코어의 결정을 따르면서 자유롭게 수평 확장한다. 책임이 명확하게 분리되는 게 핵심이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;무엇이 코어에 들어가고 무엇이 워커로 빠지는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코어가 책임지는 것 (강한 일관성 필요, 양은 적음)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클러스터 멤버십: 어떤 노드가 살아있는가&lt;/li&gt;
&lt;li&gt;Leader/Owner 정보: 각 파티션의 리더는 누구인가&lt;/li&gt;
&lt;li&gt;설정/스키마: 토픽 설정, 권한&lt;/li&gt;
&lt;li&gt;글로벌 카운터: producer ID, transaction ID&lt;/li&gt;
&lt;li&gt;Lock/Lease: 분산 락, 임시 소유권&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 데이터들의 공통점은 양은 적지만 모두가 동일한 view를 가져야 안전하다는 점이다. 두 노드가 동시에 자기를 leader 라고 믿으면 split-brain 이 발생하고, 같은 producer ID 가 두 곳에서 발급되면 데이터 정합성이 깨진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;워커가 책임지는 것 (양은 많고, 약간의 stale read 허용 가능)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 메시지/데이터&lt;/li&gt;
&lt;li&gt;사용자 트래픽 처리&lt;/li&gt;
&lt;li&gt;대용량 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;반대로 이런 데이터들은 양이 압도적으로 많지만 노드별로 잠시 다른 view 를 가져도 큰 문제가 안 된다. 정확히 말하면 다른 메커니즘(예: leader-follower replication)으로 일관성을 따로 보장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 사례&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka (KRaft)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 의 KRaft 가 Consistency Core 패턴의 교과서적 구현이다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;KRaft Controllers (3~5대) &amp;larr; Consistency Core
  - 토픽/파티션 메타데이터
  - Leader election
  - Broker 등록 정보
  - Configuration

Brokers (수십~수백대) &amp;larr; Workers
  - 실제 메시지 데이터
  - Producer/Consumer 처리
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;컨트롤러 quorum은 Raft로 강한 일관성을 보장하지만, broker는 그렇지 않다. Broker는 컨트롤러로부터 metadata를 fetch해서 자기 메모리에 캐시하고, 그 정보를 바탕으로 자기 partition의 데이터를 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;KRaft 이전에는 ZooKeeper가 외부 Consistency Core 역할을 했다. KRaft는 그걸 Kafka 내부로 흡수한 것이다. 외부 의존성을 줄이고, 메타데이터 처리 경로를 단순화한 결과물이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kubernetes&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;etcd (3~5대) &amp;larr; Consistency Core
  - Pod, Service, ConfigMap 정의
  - 클러스터 상태
  - Lease (leader election용)

kubelet (수천 노드) &amp;larr; Workers
  - 실제 컨테이너 실행
  - 사용자 워크로드
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;API server는 etcd를 거쳐 모든 결정을 내리고, kubelet은 그 결정을 받아서 자기 노드에서 실행한다. 수천 대의 워커 노드가 있어도 etcd는 3~5대로 유지된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HDFS&lt;/h3&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;NameNode (1~2대 HA) &amp;larr; Consistency Core
  - 파일/디렉터리 메타데이터
  - 블록 위치 정보

DataNode (수백~수천대) &amp;larr; Workers
  - 실제 파일 블록 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;NameNode가 모든 메타데이터의 진실의 근원이고, DataNode는 실제 데이터를 저장한다. NameNode가 SPOF가 되는 문제 때문에 HA 구성이 필수가 되었고, 이것도 결국 작은 코어를 더 안전하게 만드는 방향으로 진화한 사례다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비교: Cassandra의 다른 선택&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Cassandra는 Consistency Core를 의도적으로 두지 않는다. Gossip 프로토콜로 모든 노드가 P2P로 상태를 교환한다. 이건 다른 trade-off다. Single point of bottleneck은 없지만, 강한 일관성을 보장하기 어렵고 운영 복잡도가 올라간다. Metadata-heavy하지 않고 write-heavy한 워크로드엔 더 적합한 모델이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이게 시사하는 바는, Consistency Core가 만능이 아니라 특정 trade-off를 선택한 결과라는 점이다. 워크로드 특성에 따라 다른 선택지가 더 나을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;KRaft에서 본 두 layer의 leader election&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consistency Core 패턴을 깊이 이해하려면 Kafka KRaft가 leader election을 어떻게 처리하는지 보는 게 좋다. 여기에는 &lt;b&gt;두 층위의 leader election&lt;/b&gt;이 있는데, 이 둘을 구분하지 못하면 동작 모델이 흐려진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Layer 1: Controller Quorum 내부의 Raft Leader Election&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;누가&lt;/b&gt;: 컨트롤러 노드들끼리 (3~5대)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;무엇을&lt;/b&gt;: 컨트롤러 quorum 의 active controller 결정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;방법&lt;/b&gt;: Raft 알고리즘 (term 증가 &amp;rarr; vote 요청 &amp;rarr; quorum 동의)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;언제&lt;/b&gt;: 컨트롤러 기동, active controller 장애&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Term을 올리고 표를 받고 과반을 확보하는 Raft의 정통 election 절차를 따른다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Layer 2: 각 Topic Partition 의 Leader 결정&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;누가&lt;/b&gt;: Active controller가 결정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;무엇을&lt;/b&gt;: 각 partition의 leader broker와 ISR&lt;/li&gt;
&lt;li&gt;&lt;b&gt;방법&lt;/b&gt;: &lt;b&gt;선거가 아니라 active controller의 단독 결정 (assignment)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;언제&lt;/b&gt;: Topic 생성, broker 장애, partition reassignment&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Broker는 컨트롤러에게 &quot;내가 leader 할게요&quot;라고 요청하지 않는다. 컨트롤러가 메타데이터와 정책을 보고 일방적으로 결정하고, broker들은 metadata log를 fetch하다가 자기 역할 변경을 인지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Broker 장애 시의 흐름을 보면 이 모델이 명확해진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. Broker 1이 죽음 (이 broker가 partition-0의 leader였음)

2. Active Controller가 broker 1의 죽음을 감지
   - Heartbeat 타임아웃으로 fenced 상태 전환

3. Active Controller가 영향받는 partition 스캔
   - ISR이었던 [1,2,3] 중 broker 1 제외한 [2,3]에서 새 leader 선택
   - preferred replica 순서로 &amp;rarr; broker 2

4. 새 결정을 metadata log에 record로 append
   PartitionChangeRecord(
     topic=UUID-A, partition=0,
     leader=2,           &amp;larr; 새 leader
     isr=[2,3],
     leaderEpoch=1       &amp;larr; epoch 증가
   )

5. Controller quorum 내부 Raft 합의 &amp;rarr; commit

6. 모든 broker가 변경을 fetch하고 자기 역할 인지
   - Broker 2: &quot;이제 내가 partition-0의 leader구나&quot;
   - Broker 3: &quot;leader가 broker 2로 바뀌었네, 거기로 fetch해야지&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 임명 모델인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;왜 Layer 2는 선거가 아니라 임명일까. 두 가지 이유가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;첫째, Consistency Core가 이미 권위 있는 진실의 근원이다. Controller 는 모든 broker 의 상태, ISR, leader 정보를 알고 있다. 이미 가진 정보로 결정할 수 있는 일을 굳이 broker 들끼리 분산 합의로 풀 이유가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;둘째, ISR 정책이 결정론적이다. Kafka 는 leader 후보 선택에 명확한 규칙이 있다. ISR에 속한 broker만 후보가 되고, preferred replica order 에 따라 선택된다. 같은 metadata 상태에서 controller가 계산하면 항상 같은 답이 나온다. 결정론적인 일에 굳이 vote 메커니즘을 쓸 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;일관성 코어가 권위를 갖고 결정하면, 워커 입장의 복잡한 분산 합의를 피할 수 있다. 덕분에 수만 개의 partition에 대한 leader 결정을 모두 broker 끼리 선거를 할 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;패턴의 trade-off&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;얻는 것&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;워커 노드는 거의 무한 수평 확장 가능&lt;/li&gt;
&lt;li&gt;코어는 작아서 합의가 빠름&lt;/li&gt;
&lt;li&gt;책임 분리가 명확 (메타데이터 vs 데이터)&lt;/li&gt;
&lt;li&gt;운영 모델이 단순 (한 곳만 보면 시스템 상태를 알 수 있음)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;잃는 것&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코어가 critical path에 있음 &amp;rarr; 장애 시 전체 영향
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HA 구성 필수 (3~5대 quorum)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;코어 용량이 시스템 전체의 천장이 됨
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메타데이터를 무한정 코어에 넣을 수 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;워커는 코어 정보를 캐시해서 쓰므로 약간의 stale read 발생
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;eventual consistency를 받아들이는 설계 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;모든 메타데이터 변경이 코어를 거쳐야 하므로 throughput 한계 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;관련 패턴들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consistency Core는 단독으로 쓰이지 않고, 다음 패턴들과 조합되어 등장한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Replicated Log: 코어 내부에서 합의를 이루는 메커니즘 (Raft)&lt;/li&gt;
&lt;li&gt;Lease: 코어가 워커에게 임시 권한을 부여하는 방식&lt;/li&gt;
&lt;li&gt;Generation Clock / Epoch: leader 변경을 안전하게 처리&lt;/li&gt;
&lt;li&gt;Heartbeat: 코어가 워커의 생사 확인&lt;/li&gt;
&lt;li&gt;Gossip Dissemination: 코어 결정을 워커들에게 전파&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Raft, etcd, KRaft가 모두 이 패턴들의 조합으로 만들어진 것이고, 그래서 Patterns of Distributed Systems 카탈로그를 통독하면 분산 시스템의 큰 그림이 잡힌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://martinfowler.com/articles/patterns-of-distributed-systems/&quot;&gt;Martin Fowler - Patterns of Distributed Systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://martinfowler.com/articles/patterns-of-distributed-systems/consistent-core.html&quot;&gt;Consistent Core&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Architecture</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/169</guid>
      <comments>https://icarus8050.tistory.com/169#entry169comment</comments>
      <pubDate>Sun, 3 May 2026 15:54:09 +0900</pubDate>
    </item>
    <item>
      <title>WAL Compaction 설계기</title>
      <link>https://icarus8050.tistory.com/168</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&quot;30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법&quot; 책으로 스터디를 하면서 머릿속으로만 이해했던 개념들을 직접 코드로 옮겨보고 싶었다. 그래서 작은 인메모리 KV 스토어 &lt;a href=&quot;https://github.com/icarus8050/peacock&quot;&gt;peacock&lt;/a&gt;을 만들기 시작했다. WAL을 붙이고, 시간이 지나니 자연스럽게 &quot;로그가 무한히 자라는 문제&quot;에 부딪혔다. 이 글은 그 문제를 풀어가며 마주친 설계 결정들과, 그것을 어떻게 정리했는지에 대한 기록이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;WAL이 뭔가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;Write-Ahead Log&lt;/b&gt;는 이름 그대로 &quot;변경을 메모리에 적용하기 전에 디스크에 먼저 기록한다&quot;는 패턴이다. 데이터베이스 엔진의 거의 표준적인 영속성 메커니즘이다. PostgreSQL, MySQL의 InnoDB, RocksDB, LevelDB, etcd 등 안정성을 따지는 거의 모든 storage system이 어떤 형태로든 WAL을 갖고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 왜 필요한지는 단순한 시나리오로 명확해진다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;Put(&quot;a&quot;, 1)  &amp;rarr; 메모리 맵에 적용
Put(&quot;b&quot;, 2)  &amp;rarr; 메모리 맵에 적용
☠️ 크래시
재시작 &amp;rarr; 메모리는 휘발됐다. 데이터 손실.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;메모리만 가진 시스템의 운명이다. 영속성을 더하려면 디스크에 뭔가 남겨야 하는데, 매 쓰기마다 메모리 맵 전체를 디스크에 dump하는 건 비용이 크다. 그래서 차선의 답은 &lt;b&gt;&quot;변경 자체&quot;를 디스크에 append&lt;/b&gt;하는 것이다. 메모리 맵을 통째로 저장하지 않고, &quot;이런 일이 일어났다&quot;는 사실만 기록한다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;Put(&quot;a&quot;, 1)  &amp;rarr; ① 디스크 로그에 &quot;Put a=1&quot; entry append
              ② 메모리 맵에 적용
Put(&quot;b&quot;, 2)  &amp;rarr; ① 디스크 로그에 &quot;Put b=2&quot; entry append
              ② 메모리 맵에 적용
☠️ 크래시
재시작 &amp;rarr; 디스크 로그를 처음부터 끝까지 읽어 entry 순서대로 다시 적용
       &amp;rarr; 메모리 맵이 크래시 직전 상태로 복원됨&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이게 WAL의 핵심 아이디어다. &lt;b&gt;메모리 상태는 휘발성이지만, 그 상태를 만든 변경 이력은 디스크에 영속화돼 있다.&lt;/b&gt; 재시작 시 이력을 처음부터 다시 적용하면 마지막 상태가 재구성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;메모리에 적용하기 전에 로그가 먼저 디스크에 도달해야 한다. 만약 메모리에 먼저 쓰고 로그를 나중에 쓴다면, 두 단계 사이에 크래시 시 메모리에는 적용됐지만 로그엔 없는 변경이 생긴다 &amp;mdash; 재시작 시 이 변경이 사라져 inconsistency가 발생한다. 그래서 디스크 로그가 항상 &quot;선행&quot;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;peacock의 KV는 이 패턴 그대로다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;PUT(k, v) &amp;rarr;  ① WAL에 entry append (디스크에 fsync까지)
            ② 메모리 맵에 적용
GET(k)    &amp;rarr;  메모리 맵에서 직접 lookup&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 entry는 다음 바이너리 레이아웃으로 직렬화된다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;TotalLen(4) | CRC32(4) | Op(1) | Index(8) | TimeStamp(8) | DataLen(4) | Data(var)
                        &amp;uarr;─────────────── CRC32 대상 ──────────────────&amp;uarr;&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;TotalLen&lt;/code&gt;: entry body의 바이트 수. 다음 entry로 점프할 때 사용.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CRC32&lt;/code&gt;: 본문 무결성 체크. 디스크가 일부만 fsync된 상태에서 크래시한 경우(tail truncation)를 감지한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Op&lt;/code&gt;: &lt;code&gt;OpPut&lt;/code&gt; 또는 &lt;code&gt;OpDelete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Data&lt;/code&gt;: KV 레이어가 정의한 페이로드 (&lt;code&gt;KeyLen | Key | Value&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여러 entry가 하나의 segment 파일(&lt;code&gt;wal-NNNNNNNNNN.log&lt;/code&gt;)에 append되고, segment가 일정 크기를 넘기면 다음 시퀀스 파일로 &lt;b&gt;roll&lt;/b&gt;된다. 파일 하나가 무한히 커지지 않게 자르는 단위다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;인메모리 맵은 그저 빠른 lookup만 신경 쓰면 되고, 영속성은 WAL이 다 알아서 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;그런데 이 단순함이 곧 한계가 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어쩌다 compaction까지 왔나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 키를 100번 덮어쓰면 WAL에는 100개 entry가 쌓인다. 99개는 replay 시 읽히기만 하고 버려지는 죽은 데이터다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;Put(&quot;counter&quot;, 1)   &amp;rarr; entry 0
Put(&quot;counter&quot;, 2)   &amp;rarr; entry 1
...
Put(&quot;counter&quot;, 1000) &amp;rarr; entry 999&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;재시작하면 1000개 entry를 다 읽어 999번의 무의미한 덮어쓰기를 거친 뒤에야 최종 상태(&lt;code&gt;counter=1000&lt;/code&gt;)에 도달한다. 디스크 점유와 startup 시간이 운영 시간에 비례해 선형으로 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;해결의 방향은 둘 중 하나다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;Snapshot&lt;/b&gt;은 주기적으로 메모리 맵 전체를 디스크에 덤프하는 방식이다. snap만 있으면 그 시점 상태를 즉시 복원할 수 있어 단순하다. 그런데 snapshot을 만드는 동안 메모리 맵의 상태를 일관되도록 잠궈야 하는 게 문제점이 있다. 쓰기 잠금을 걸면 stall이 발생하고, deepCopy로 우회하면 큰 맵에서 메모리 spike와 복제 stall이 따라온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;Compaction&lt;/b&gt;은 로그가 롤링되어 더 이상 Write 되지 않는 WAL segment 파일을 읽어서 최신 상태의 엔트리 로그만 남긴 새 파일을 Write한다. 과거의 segment는 더 이상 쓰이지 않으니 메모리 맵과 어떤 자원도 공유하지 않기 때문에 제거할 수 있다. 이 과정은 백그라운드 goroutine이 디스크만 I/O하고 끝낸다. 덕분에 writer를 잠그는 시간을 최소화 할 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;사이드 프로젝트 성격의 간단한 서버라 Snapshot 방식으로도 충분하지만 이왕 해보는거 Compaction 방식으로 구현해 보고 싶어서 이 방식을 택했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 1: 매니페스트 도입&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;compaction을 하려면 &quot;지금 살아있는 segment 파일은 정확히 어떤 것들인가?&quot;를 파악해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;첫 시도는 디렉터리에 있는 &lt;code&gt;wal-*.log&lt;/code&gt;를 모두 시퀀스 번호로 읽는 것이었다. 평상시엔 동작한다. 그런데 compaction 도중 죽으면 끔찍해진다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;T0: [1.log, 2.log, 3.log, 4.log, 5.log] (활성=5)
T1: 압축 시작 &amp;rarr; 새 체크포인트 작성 중
T2: ☠️ 크래시
디스크 상태:
  - 1.log ~ 4.log: 옛 데이터
  - 5.log: 활성
  - tmp 또는 부분 체크포인트 파일: 알 수 없는 상태&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이걸 디스크 스캔으로 다시 열면, &quot;어디까지가 권위 있는 데이터인가&quot;를 알 수 없다. 부분 파일을 합법적인 segment로 오인하면 데이터가 망가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;그래서 manifest파일에 segment 목록을 관리하도록 했다. 이 파일이 &quot;지금 살아있는 segment는 정확히 이것뿐이다&quot;를 체크할 수 있게 해준다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;manifest 파일 (v1) 레이아웃:
  Magic         (4)  &quot;PCKM&quot;
  Version       (2)  uint16, 1
  Reserved      (2)  uint16, 0 고정
  Generation    (8)  uint64, 갱신마다 단조 증가
  SegmentCount  (4)  uint32
  Records       SegmentCount &amp;times; Seq(int64, 8)
  CRC32         (4)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 파일이 가리키지 않는 모든 디스크 상의 &lt;code&gt;wal-*.log&lt;/code&gt;는 고아로 간주해 무시한다. compaction 도중 죽어 부분 파일이 떠 있어도 매니페스트가 그것을 가리키지 않으면 무시된다. 덕분에 &quot;어떤 파일이 진짜인가&quot;라는 모호함이 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Phase 2: Compaction&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매니페스트가 자리잡으면 compaction의 큰 그림은 단순하다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;1. 과거의 segment를 읽어 키별 최신 값을 빌드
2. 결과를 새 체크포인트 파일에 직렬화
3. 매니페스트 갱신 &amp;rarr; {checkpointSeq, [active]}
4. 과거의 segment 파일 제거&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;체크포인트의 결정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;처음엔 가볍게 가정했다. segment 파일이 [1, 2, 3, 4, 5(활성화 상태)] 있다고 생각했을 때, &quot;압축 결과도 그냥 또 하나의 segment 파일이지.&quot; 즉 봉인 [1..4]를 압축하면 새 segment seq를 부여해서(예: max+1=6) &lt;code&gt;wal-6.log&lt;/code&gt;로 저장.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만 활성이 5인데 압축 결과가 6이라면 파일명이 의미상 1..5의 데이터를 담고 있는데 현재 Write 중인 5보다 높은 seq를 가지므로 &quot;더 새로운 데이터&quot;라는 직관에 어긋난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지 옵션을 두고 고민했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;옵션 (a) 옛 source의 seq 상속 + 덮어쓰기.&lt;/b&gt; 활성 세그먼트 이전의 max seq를 그대로 받아 컴팩션하고 그 자리를 덮어쓴다. 즉, 4.log가 압축본으로 바뀐다. 하지만 운영자가 디스크를 봤을 때 &lt;code&gt;wal-4.log&lt;/code&gt;가 원본인지 압축본인지 식별 불가능하다. 중간에 예상치 못한 문제로 종료가 되면 WAL 로그 자체가 깨질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;옵션 (b) max+1 새 seq.&lt;/b&gt; 압축 결과가 새 seq를 받는다. 모든 파일이 다른 seq를 가져 운영 식별이 명확하지만, WAL의 &lt;code&gt;seq&lt;/code&gt; 필드가 두 의미를 짊어지게 된다 &amp;mdash; &quot;활성 식별&quot;과 &quot;다음 할당 카운터&quot;. 분리하려면 두 필드 필요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;옵션 (c) 별도 명명: &lt;code&gt;*.checkpoint&lt;/code&gt;.&lt;/b&gt; 압축 결과는 &lt;code&gt;wal-N.checkpoint&lt;/code&gt;라는 다른 이름. seq 공간 자체가 분리.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Before: [1.log, 2.log, 3.log, 4.log, 5.log]  활성=5
After:  [4.checkpoint, 5.log]                활성=5, seq counter=5
다음 roll: [4.checkpoint, 5.log, 6.log]      활성=6&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크포인트 seq는 흡수한 마지막 봉인 seq를 상속하지만, 파일명 suffix가 달라 활성과 절대 충돌하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(c)를 택한 결정적 이유는 &quot;체크포인트는 segment와 의미가 다르다&quot;는 인식이었다. 체크포인트는 스냅샷이고 segment는 이력이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;비용은 매니페스트 포맷 변경 한 번. 매니페스트가 체크포인트의 존재를 알아야 하니 v1에 &lt;code&gt;CheckpointSeq&lt;/code&gt; 필드를 추가한 v2를 만들었다.&lt;/p&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;manifest v2:
  Magic | Version=2 | Reserved | Generation | CheckpointSeq(8) | SegmentCount | Seq... | CRC
                                              &amp;uarr; v2 신규&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;0이면 &quot;체크포인트 없음&quot;, &amp;gt; 0이면 &lt;code&gt;wal-NNN.checkpoint&lt;/code&gt;가 매니페스트 segments보다 먼저 replay된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Compaction commit point&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;매니페스트 갱신은 단일 commit point가 되어야 한다. 압축 commit은 단일 syscall이 아니다. 여러 단계가 순차적으로 일어난다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;1. tmp 파일에 체크포인트 entry들 쓰기 (bufio + Sync)
2. tmp.Close
3. os.Rename(tmp &amp;rarr; wal-N.checkpoint)
4. writeManifest(새 매니페스트)   &amp;larr; tmp + rename + dir-fsync
5. 옛 segment / 옛 체크포인트 unlink&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POSIX는 단일 syscall만 atomic하므로 이 묶음을 통째로 atomic하게 만들 수 없다. 그런데 매니페스트 갱신(4)을 commit point로 정의하면 어디서 죽어도 안전하다.&lt;/p&gt;
&lt;table style=&quot;height: 160px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style4&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;죽는 시점&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;디스크 상태&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;매니페스트&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;부분 tmp&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;tmp는 다음 시도에서 &lt;code&gt;O_TRUNC&lt;/code&gt;로 덮임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;1 후, 3 전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;완전한 tmp&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;tmp는 다음에 덮임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;3 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;rename atomic &amp;mdash; 과거 또는 새 버전 체크포인트&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;어느 쪽이든 매니페스트 밖 &amp;rarr; 무시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;3 후, 4 전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;완전한 새 버전 체크포인트&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;매니페스트 밖 &amp;rarr; 고아 무시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;4 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;atomic&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거 또는 새 버전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;둘 다 정확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;4 후, 5 전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;과거 segment 잔존&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;새 버전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;매니페스트 밖&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;5 도중&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;일부 unlink&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;새 버전&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;매니페스트&amp;nbsp;밖&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;매니페스트 갱신 이전에 죽으면 압축은 무효, 이후에 죽으면 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동시 호출 보호&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;(*WAL).Compact&lt;/code&gt;는 공개 API다. 두 goroutine이 동시에 호출하면 같은 sealed 파일을 두 번 처리하고 매니페스트 갱신이 충돌한다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;goroutine A: BeginCompaction() &amp;rarr; sealed=[1,2,3,4] 스냅샷
goroutine B: BeginCompaction() &amp;rarr; sealed=[1,2,3,4] 같은 스냅샷
A: 파일 read + checkpoint write
B: 파일 read + checkpoint write
A: CommitCompaction([1,2,3,4]) &amp;rarr; 성공
B: CommitCompaction([1,2,3,4]) &amp;rarr; 검증 실패 (sealed에 더 이상 없음)
   &amp;rarr; B의 체크포인트는 매니페스트 밖이라 고아로 남음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;정확성은 무너지지 않지만 디스크 leak와 운영 잡음이 발생한다. 이를 해결하기 위해 WAL에 &lt;code&gt;compacting bool&lt;/code&gt; 필드를 두고 락 안에서 체크하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;go&quot;&gt;&lt;code&gt;func (w *WAL) beginCompaction(trigger int) (compactionPlan, bool) {
    w.mu.Lock()
    defer w.mu.Unlock()

    if w.closed || w.compacting {
        return compactionPlan{}, false
    }
    w.compacting = true
    return plan, true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;압축 본체는 &lt;code&gt;compacting&lt;/code&gt; 플래그가 true인 상태에서 락 밖으로 나가서 진행된다. 그래서 Append/roll은 압축의 read 단계와 동시 진행 가능하고(자원이 다르니까), 충돌 구간은 commit 시점뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Development/Distributed System 만들어보기 [Peacock]</category>
      <author>Icarus8050</author>
      <guid isPermaLink="true">https://icarus8050.tistory.com/168</guid>
      <comments>https://icarus8050.tistory.com/168#entry168comment</comments>
      <pubDate>Sun, 3 May 2026 04:39:39 +0900</pubDate>
    </item>
  </channel>
</rss>