21장. 분산 시스템에서 시간을 다루는 규칙

만화로 보는 요약 — 먼저 읽어보세요
불확실한 벽시계 → 순서·인과·전역 순서·경과 시간·멱등성 중 필요한 보장을 먼저 식별 → 도구를 그 뒤에 고르는 규칙.
면접 실전 질문: ① 두 서버의 벽시계 타임스탬프로 순서를 판단하면 안 되는 이유는? ② 멱등성을 순서 보장보다 먼저 검토해야 하는 이유는? ③ 전역 단일 순서가 정말 필요한 상황은 어떻게 구분하는가?
분산 시스템에서 시간 문제를 만나면 시계부터 고치려 들지 마라 — 필요한 게 순서인지, 충돌 감지인지, 전역 단일 순서인지, 경과 시간인지, 아니면 애초에 순서 자체가 필요 없는 멱등한 연산인지부터 골라야 한다.
면접 실전 질문: ① 두 서버에서 찍은 벽시계 타임스탬프로 이벤트의 선후를 판단하면 안 되는 이유는 무엇이고, 그 대신 순서가 필요할 때는 무엇을 쓰는가? ② 멱등성이 순서 보장보다 우선 검토돼야 하는 이유는 무엇이고, 멱등성 키를 설계할 때 가장 흔히 저지르는 실수는? ③ “전역에 걸친 단일 순서”가 정말 필요한 상황과, 순서 없이도 되는 상황을 어떻게 구분하는가?
배경 — 다섯 개의 답, 아직 답하지 않은 질문
17장부터 20장까지, 이 책은 같은 불가능성 하나에 네 번 다른 답을 내놨습니다. 어떤 두 기계도 완전히 같은 시각을 가리킨 적이 없다는 사실 앞에서, 17장은 그 어긋남의 크기를 실측(-380ms대 오프셋)으로 보여주고 last-write-wins가 실제로 나중에 일어난 쓰기를 조용히 지우는 걸 확인했습니다. 18장은 시각을 포기하고 순서만 세는 램포트 카운터를 내놨지만, C(a) < C(b)가 인과관계를 증명하지 못한다는 한계를 남겼습니다. 19장은 카운터를 벡터로 바꿔 인과관계와 동시성을 정확히 구별해냈지만, 그 벡터는 노드 수만큼 자라고 churn 환경에서의 가지치기는 여전히 풀리지 않은 문제로 남았습니다. 20장은 시각을 아예 버리지 않고, 대신 불확실성을 TTinterval이라는 측정 가능한 값으로 드러낸 뒤 commit-wait으로 그 불확실성이 지나가길 기다려 외부 일관성을 삽니다 — 대가는 커밋마다 지불하는 레이턴시입니다.
네 장 모두 메커니즘은 이미 충분히 다뤘습니다. 이 장이 다시 설명하지 않는 이유입니다. 이 장의 임무는 다른 데 있습니다. 월요일 아침, 실제 코드 앞에서 이 넷 중 무엇을 집어야 하는지 — 그리고 애초에 이 넷 중 아무것도 필요 없는 다섯 번째 길이 있다는 것을 정리하는 것입니다. 그 다섯 번째 길은 16장이 이미 한 번 보여줬습니다. 배치가 몇 번 실행되든 결과가 같도록 만드는 멱등성, 그리고 그 키를 실행 중 다시 읽은 지역 시각 문자열이 아니라 트리거의 UTC Instant나 미리 확정한 사업 날짜로 만들라는 규칙이었습니다. 11장의 “언제 vs 얼마나” 구분도 여기서 다시 필요합니다. 분산 환경이라고 해서 그 구분이 사라지지 않습니다 — 오히려 더 자주 틀립니다.
한 가지는 분명히 해둘 필요가 있습니다. 9장과 15장이 이미 세운 단일 머신 규칙 — 이미 일어난 일은 Instant로, 저장·연산은 UTC로 — 은 이 장이 다루는 분산 시스템의 규칙으로 대체되지 않습니다. 분산 규칙은 단일 머신 규칙을 지운 자리에 들어서는 게 아니라, 그 위에 쌓이는 것입니다. UTC로 통일해도 두 서버의 시계가 어긋난다는 사실 자체는 그대로고, 이 장의 규칙은 그 어긋남이 실제로 문제가 되는 지점 — 순서·충돌·전역성 — 에서만 추가로 작동합니다.
스토리 — 순서를 사지 않고 멱등성을 사는 법
가장 흔한 상황부터 봅니다. 클라이언트가 “10원 적립” 요청을 한 번 보냈는데, 네트워크가 불안정해 재시도가 걸렸고, 그 재시도가 하필 다른 노드로 라우팅됐습니다. 같은 논리적 연산 하나가 노드 A와 노드 B 양쪽에 중복 도착합니다. 17장의 그 구성된 스큐를 그대로 가져옵니다 — 노드 A의 시계는 정확하고(오프셋 0), 노드 B의 시계는 50ms 느립니다. 그리고 도착 순서는 전송 순서와 다릅니다. B가 보낸 사본이 A가 보낸 사본보다 먼저 서버에 닿습니다.
이 상황을 두 가지 리졸버로 각각 처리해봤습니다. 하나는 “타임스탬프가 이전에 처리한 값보다 새로우면 적용한다”는 흔한 방식 — 사실상 last-write-wins의 변형입니다. 다른 하나는 요청에 실린 안정적인 식별자(requestId)로만 중복을 판단하는 멱등 리졸버입니다. (아래 스큐 50ms는 실측이 아니라, 11장의 NegativeDuration 예제와 같은 방식으로 논의를 위해 손으로 구성한 값입니다. 벽시계 호출도, 난수도 없는 고정 스크립트라 몇 번을 돌려도 같은 결과가 나옵니다.)
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
public class IdempotencyVsOrdering {
// 클라이언트가 만든 하나의 논리적 연산(적립 10원)이 재전송·경로 분기로
// 노드 A와 노드 B 양쪽에 중복 도착한다고 가정한 이벤트.
record CreditEvent(String requestId, long amount, Instant sentAt, String fromNode) {}
// -------- 1) 순진한 last-write-wins 리졸버 -- 타임스탬프만 보고 "새 값인가"를 판단 --------
static class LwwResolver {
Instant lastAppliedAt = Instant.EPOCH;
long balance = 0;
void apply(CreditEvent e) {
if (e.sentAt().isAfter(lastAppliedAt)) {
balance += e.amount();
lastAppliedAt = e.sentAt();
System.out.printf(" [LWW] %-5s req=%s sentAt=%s -> 새 값으로 판단, 적용. balance=%d%n",
e.fromNode(), e.requestId(), e.sentAt(), balance);
} else {
System.out.printf(" [LWW] %-5s req=%s sentAt=%s -> 과거 타임스탬프라 무시. balance=%d%n",
e.fromNode(), e.requestId(), e.sentAt(), balance);
}
}
}
// -------- 2) 멱등 리졸버 -- 안정적인 식별자(requestId)로만 중복을 판단 --------
static class IdempotentResolver {
Map<String, Boolean> applied = new HashMap<>();
long balance = 0;
void apply(CreditEvent e) {
if (applied.putIfAbsent(e.requestId(), true) == null) {
balance += e.amount();
System.out.printf(" [IDEMP] %-5s req=%s sentAt=%s -> 최초 처리, 적용. balance=%d%n",
e.fromNode(), e.requestId(), e.sentAt(), balance);
} else {
System.out.printf(" [IDEMP] %-5s req=%s sentAt=%s -> 이미 처리된 req, 무시. balance=%d%n",
e.fromNode(), e.requestId(), e.sentAt(), balance);
}
}
}
public static void main(String[] args) {
// 아래 스큐는 실측이 아니라, 11장 NegativeDuration 예제와 같은 방식으로
// 논의를 위해 손으로 구성한 값이다. 노드 A의 시계는 정확(offset=0),
// 노드 B의 시계는 50ms 느리다(17장과 같은 크기).
Instant sentByA = Instant.parse("2026-07-31T09:00:00.000Z");
long offsetB_ms = -50;
CreditEvent viaA = new CreditEvent("req-42", 10, sentByA, "nodeA");
CreditEvent viaB = new CreditEvent("req-42", 10, sentByA.plusMillis(offsetB_ms), "nodeB");
System.out.println("=== 도착 순서: nodeB 먼저 -> nodeA 나중, 같은 req-42가 중복 도착 ===");
System.out.println();
System.out.println("[1] 순진한 LWW 리졸버 -- 타임스탬프만 보고 판단");
LwwResolver lww = new LwwResolver();
lww.apply(viaB);
lww.apply(viaA);
System.out.println(" 최종 balance = " + lww.balance + " (기대값 10 -- 실제로는 두 번 적립됨, 에러 없이 조용히 틀림)");
System.out.println();
System.out.println("[2] 멱등 리졸버 -- requestId로 판단, 도착 순서·중복·클록 스큐 무관");
IdempotentResolver idemp = new IdempotentResolver();
idemp.apply(viaB);
idemp.apply(viaA);
System.out.println(" 최종 balance = " + idemp.balance + " (기대값 10 -- 정확히 일치)");
}
}=== 도착 순서: nodeB 먼저 -> nodeA 나중, 같은 req-42가 중복 도착 ===
[1] 순진한 LWW 리졸버 -- 타임스탬프만 보고 판단
[LWW] nodeB req=req-42 sentAt=2026-07-31T08:59:59.950Z -> 새 값으로 판단, 적용. balance=10
[LWW] nodeA req=req-42 sentAt=2026-07-31T09:00:00Z -> 새 값으로 판단, 적용. balance=20
최종 balance = 20 (기대값 10 -- 실제로는 두 번 적립됨, 에러 없이 조용히 틀림)
[2] 멱등 리졸버 -- requestId로 판단, 도착 순서·중복·클록 스큐 무관
[IDEMP] nodeB req=req-42 sentAt=2026-07-31T08:59:59.950Z -> 최초 처리, 적용. balance=10
[IDEMP] nodeA req=req-42 sentAt=2026-07-31T09:00:00Z -> 이미 처리된 req, 무시. balance=10
최종 balance = 10 (기대값 10 -- 정확히 일치)(✅ 실측 — 2026년 7월, Temurin JDK 21 컨테이너. 고정 스크립트, 난수·벽시계 호출 없음. 위 스큐 50ms 자체는 구성한 값이지 측정치가 아닙니다.)
LWW 리졸버는 “타임스탬프가 마지막으로 적용한 값보다 새로우면 적용한다”는, 얼핏 합리적인 규칙 하나로만 움직입니다. B의 사본이 먼저 도착해 balance=10이 되고, 그 뒤 A의 사본이 도착합니다. A의 타임스탬프(09:00:00.000)는 B의 타임스탬프(08:59:59.950)보다 늦으니 “새 값”으로 판단되어 다시 적용됩니다 — balance=20. 같은 요청이 두 번 적립된 겁니다. 에러도, 경고 로그도 없습니다. 17장에서 본 것과 정확히 같은 패턴입니다. 벽시계 타임스탬프는 “이게 정말 새 사건인가, 아니면 이미 본 사건의 재전송인가”를 구별할 근거가 애초에 없습니다. 반면 멱등 리졸버는 타임스탬프를 아예 쳐다보지 않습니다. requestId가 이미 처리된 목록에 있는지만 봅니다. 어느 노드에서 왔는지, 어느 순서로 도착했는지, 시계가 얼마나 어긋나 있는지 — 전부 무관합니다. 결과가 정확히 일치하는 이유는 순서를 더 잘 맞춰서가 아니라, 애초에 순서에 의존하는 판단 자체를 없앴기 때문입니다.
핵심 — 다섯 갈래 길과 결정표
규칙 다섯 개
- ① 벽시계 타임스탬프로 두 기계의 이벤트를 정렬하지 않는다. 17장이 -380ms대 오프셋을 실측했고, 그 위에서 last-write-wins가 실제로 나중에 일어난 쓰기를 조용히 지우는 걸 확인했습니다. 원인은 두 기계가 한 번도 완전히 같은 시각을 가리킨 적이 없다는, 고칠 수 없는 구조적 사실입니다.
- ② 순서만 필요하면 논리적 시계나 시퀀스 번호를 쓴다. 18장의 램포트 카운터, 혹은 DB의 LSN·큐의 시퀀스 번호가 이 자리입니다. 다만 한계를 함께 기억합니다 —
C(a) < C(b)는 인과관계를 증명하지 않습니다. 램포트 카운터는 순서 검사이지 인과 검사가 아닙니다. - ③ 충돌을 감지해야 하면(승자를 임의로 고르지 않으려면) 벡터/버전 벡터를 쓴다. 19장이 인과관계와 동시성을 정확히 구별해냈고, Dynamo는 이걸로 승자를 고르는 대신 형제 버전을 남겼습니다. 대가도 함께 옵니다 — 벡터는 노드 수만큼 자라고, churn 환경에서 오래된 슬롯을 안전하게 가지치기하는 문제는 여전히 풀리지 않았습니다.
- ④ 정말 전역에 걸친 단일 순서가 필요하면, 그 값을 지불한다. 20장의 답은 두 갈래였습니다 — 하드웨어로 불확실성을 좁혀 매번 commit-wait으로 기다리거나(Spanner), 소프트웨어로 넉넉한 불확실성 구간을 잡고 문제가 감지될 때만 재시도하거나(CockroachDB). 공짜인 버전은 없습니다.
- ⑤ 도메인이 허락하면, 순서 대신 멱등성을 고른다. 위 시뮬레이션에서 멱등 리졸버가 한 일은 식별자 하나를 대조한 게 전부입니다 — 논리적 시계도, 벡터도, 대기도 필요 없었습니다. 앞의 네 답이 각각 자료구조·크기·레이턴시를 요구하는 데 비해, 이건 처리한 식별자를 기억하는 비용만 냅니다. 그래서 도메인이 허락하는 한 먼저 검토할 값어치가 있습니다. 16장이 이미 못 박았듯, 멱등성 키는 실행 중 다시 읽은 지역 시각 문자열이 아니라 안정적인 식별자나 미리 확정한 값으로 만들어야 합니다 — 그렇지 않으면 멱등성이라는 방어 자체가 시계 어긋남에 도로 뚫립니다.
여기에 두 규칙이 항상 같이 붙습니다. 경과 시간은 언제나 단조시계로 잽니다. 11장의 결론 그대로 — Instant.now()는 “언제”를 묻는 시계이고, 타임아웃·재시도 백오프·TTL처럼 “얼마나”를 묻는 자리에 벽시계를 쓰면 NTP 스텝 한 번에 음수 Duration이 나옵니다. 그리고 단일 머신 규칙은 그대로 유지됩니다. 이미 일어난 일은 UTC Instant로, 아직 오지 않은 벽시계 약속은 LocalDateTime + ZoneId로(9장, 15장) — 분산 환경이라고 이 저장 규칙이 바뀌지 않습니다. 이 장이 다루는 다섯 갈래 길은 전부 그 위에 얹힙니다.
결정표 — 무엇이 필요한가 → 무엇을 쓰는가 → 무엇을 내는가
| 필요한 것 | 무엇을 쓰는가 | 무엇을 내는가 |
|---|---|---|
| 두 이벤트의 선후 판정 | 논리적 시계·시퀀스 번호(18장) | 인과관계 증명은 안 됨 — 동시적 이벤트를 오판할 수 있다 |
| 충돌 감지 | 벡터 클록(이벤트 단위) 또는 버전 벡터(레플리카 단위) — 19장 | 노드·레플리카 수만큼 자람, churn 시 가지치기 미해결 |
| 전역 단일 순서 | 불확실성 구간 + commit-wait / 재시도(20장) | 커밋마다 레이턴시(하드웨어) 또는 충돌 시 재시도 비용(소프트웨어) |
| 경과 시간 측정 | 단조시계 System.nanoTime()(11장) | 절대 시각을 못 말함, 다른 머신과 비교 불가 |
| 재실행 안전성 | 안정적 식별자 기반 멱등성(16장) | dedup 상태를 어딘가 유지해야 함 — 대신 시계·자료구조·대기는 필요 없다 |
안티패턴 — 그럴듯해 보이지만 아닌 것들
- 서비스 간
updated_at비교로 “최신값”을 판단한다. 18장이 이미 짚었습니다 — 벽시계 타임스탬프는 흔들리고, 순번·LSN은 흔들리지 않습니다. - 유실을 감수할 생각 없이 LWW를 “충돌 해소 전략”으로 채택한다. LWW는 해법이 아니라 “누군가의 쓰기를 조용히 버리겠다”는 결정입니다. 그 유실을 받아들일 수 있을 때만 씁니다.
- 멱등성 키를 로컬 벽시계 시각에서 파생한다. 16장의 결론 그대로 — 오버랩 구간에서 서로 다른 두 실행이 같은 지역 시각 문자열을 만들어냅니다.
- 램포트 카운터를 인과관계 증명으로 신뢰한다. 18장 실측이 보여준 대로
C(a) < C(b)이면서 a와 b가 동시적인 경우가 실제로 존재합니다. - NTP가 서버들의 시계를 “똑같게” 만들어준다고 가정한다. NTP는 어긋남을 줄일 뿐 지우지 않습니다 — 이 장 전체가 그 위에 서 있습니다.
정리
이 책은 시간을 정하는 자리가 하늘에서 나라로, 나라에서 원자시계로 옮겨가는 과정을 따라왔습니다(13장). 그 정의를 컴퓨터가 epoch와 tz 데이터베이스로 받아 적었고(47장), java.time이 “언제”와 “얼마나”를, “순간”과 “눈금”을 서로 다른 타입으로 갈라놓았습니다(812장). 저장 계층에서는 그 원칙이 UTC 하나로 통일하는 경계 규칙과 멱등한 스케줄러로 굳어졌습니다(1316장). 그리고 이 5부는 그 모든 규율이 서버 한 대를 벗어나는 순간 무너진다는 사실 — 아무도 시간을 정하지 못한다는 사실 — 에서 출발해, 그 불가능성에 네 가지 답을 내놨습니다. 시계를 더 믿지 않는 법(17장), 순서만 세는 법(18장), 인과관계를 증명하는 법(19장), 불확실성에 값을 매겨 기다리는 법(20장). 이 장은 그 네 가지에 다섯 번째 —애초에 시계가 필요 없게 만드는 법— 를 더해, 언제 무엇을 집어야 하는지 하나의 결정표로 묶었습니다.
생각해볼 질문: 하늘이 시간을 정하던 시절부터 지금까지, 이 책은 “누가 시간을 정하는가”라는 질문 하나를 따라왔습니다. 하늘, 나라, 원자시계, 그리고 끝내 아무도 정하지 못한 분산 시스템까지. 이 21개 장이 확인한 규칙들을 전부 지킨다 해도, 여전히 남는 질문이 하나 있습니다 — 정확한 규칙을 안다는 것과, 그 규칙대로 코드를 짜는 것 사이에는 무엇이 있을까요?