17장. 시계는 흐른다 — NTP와 클록 드리프트

만화로 보는 요약 — 먼저 읽어보세요
서버마다 다른 물리 시계 → 지연 대칭을 가정하는 NTP 보정 → 줄일 수는 있어도 지울 수 없는 시간 오차.
면접 실전 질문: ① 쿼츠 오실레이터의 드리프트는 왜 생기는가? ② NTP 오프셋 계산이 왕복 지연 대칭에 의존하는 이유는? ③ last-write-wins가 분산 환경에서 위험한 이유는?
서버마다 심장이 다르게 뛰고, NTP는 그 어긋남을 줄일 뿐 지우지 못한다 — 그래서 두 기계가 찍은 타임스탬프는 애초에 비교 대상이 아니다.
면접 실전 질문: ① 쿼츠 오실레이터가 드리프트하는 근본 원인은 무엇이고, 방치하면 하루에 시계가 얼마나 어긋나는가? ② NTP의 오프셋 계산식은 왜 왕복 지연이 대칭이라는 가정에 의존하며, 그 가정이 깨지면 오차가 어떻게 커지는가? ③ 분산 시스템에서 “타임스탬프가 더 늦은 쪽이 이긴다”는 last-write-wins 전략이 위험한 이유는?
배경 — 지금까지 모든 장이 딛고 있던 가정
16장 마지막 질문이 이 장의 출발점입니다. 지금까지의 모든 처방 — UTC로 통일하고, 크론에 zone을 명시하고, 멱등성 키를 트리거의 Instant로 만드는 것 — 은 전부 하나를 전제로 했습니다. 서버 한 대의 시계는 정확하다. 11장은 이미 그 전제 안에서도 함정 하나를 팠습니다. 벽시계는 “지금이 언제인가”엔 답하지만 “얼마나 지났는가”엔 어울리지 않는 도구라고요. 하지만 11장도 벽시계가 가리키는 그 순간 자체가 맞는 시각이라는 것은 의심하지 않았습니다. 이 장이 의심하는 건 바로 그 지점입니다.
서버가 한 대뿐이면 “내 시계가 맞는 시각인가”는 그럭저럭 넘어갈 수 있는 질문입니다. 그런데 서버가 두 대, 열 대, 리전이 다른 백 대가 되는 순간 질문이 바뀝니다. 이 서버의 지금과 저 서버의 지금이 같은가. 대답은 언제나 “아니오”입니다. 모든 컴퓨터는 저마다의 크리스탈 오실레이터로 초를 세고, 그 오실레이터는 서로 다른 속도로 흔들립니다. NTP(Network Time Protocol)가 주기적으로 그 어긋남을 바로잡지만, 그 교정 자체가 근사치일 뿐입니다. 그래서 두 기계는 한 번도 완전히 같은 시각을 가리킨 적이 없고, 앞으로도 그럴 일이 없습니다. 이 어긋남은 언젠가 고칠 버그가 아니라, 분산 시스템이라는 구조 자체가 갖는 영구적인 성질입니다.
스토리 — 오실레이터는 왜 흔들리고, NTP는 그 오차를 어떻게 재는가
크리스탈은 정확한 초를 만들지 않는다
서버 안의 시계는 대개 수정(쿼츠) 오실레이터입니다. 전압을 걸면 특정 주파수로 진동하는 성질을 이용해 초를 세는데, 이 진동수는 온도, 제조 공차, 시간에 따른 노화(aging) 세 가지에 흔들립니다. 그중 가장 큰 영향을 주는 게 온도입니다 — 서버실 온도가 조금만 바뀌어도 크리스탈의 물리적 치수가 미세하게 변하고, 그만큼 진동수가 어긋납니다.
📄 문서 기반(미검증) — 온도 보정이 없는 일반 PC·서버급 쿼츠 오실레이터는 대략 10~100ppm 범위에서 드리프트한다고 알려져 있습니다. ppm(백만분율)을 하루 단위로 옮기면, 1ppm은 하루에 약 0.0864초입니다. 즉 50ppm짜리 오실레이터는 하루에 약 4.3초씩 어긋납니다. 10100ppm 범위 전체로 보면 하루 1~9초 어긋난다는 뜻입니다. (온도 보정 오실레이터(TCXO)는 연간 0.52ppm, 오븐 제어 오실레이터(OCXO)는 연간 0.05~0.1ppm까지 좋아지지만, 이건 노화율이지 순간 오차가 아니라 단순 비교는 어렵습니다.) 3장에서 세슘 원자가 “초”의 정의 자체를 하늘 대신 물리 상수로 옮겨놓은 이유가 바로 이겁니다 — 크리스탈은 그 정의를 근사할 뿐, 정의 자체가 아닙니다.
교정 없이 며칠만 놔둬도 서버 시계는 초 단위로 어긋날 수 있다는 뜻입니다. NTP가 존재하는 이유가 이겁니다.
실측 — 이 컨테이너의 시계는 공용 NTP 서버와 얼마나 다른가
이론으로만 말하지 않고, 실제로 재봤습니다. Apache Commons Net의 NTPUDPClient(commons-net 3.13.0, Maven Central)로 세 개의 공용 NTP 서버에 UDP 요청을 보내 오프셋과 왕복 지연을 직접 측정했습니다. Temurin JDK 21 컨테이너에서 실행했고, close() 호출이 내부적으로 commons-io를 참조하길래 commons-io 2.18.0도 클래스패스에 함께 올렸습니다.
import org.apache.commons.net.ntp.NTPUDPClient;
import org.apache.commons.net.ntp.TimeInfo;
import java.net.InetAddress;
public class NtpOffsetProbe {
public static void main(String[] args) throws Exception {
String[] servers = {"time.google.com", "pool.ntp.org", "time.cloudflare.com"};
NTPUDPClient client = new NTPUDPClient();
client.setDefaultTimeout(5000);
client.open();
for (String server : servers) {
InetAddress address = InetAddress.getByName(server);
TimeInfo info = client.getTime(address);
info.computeDetails();
System.out.println("server=" + server
+ " offset(ms)=" + info.getOffset()
+ " delay(ms)=" + info.getDelay());
}
client.close();
}
}server=time.google.com offset(ms)=-382 delay(ms)=70
server=pool.ntp.org offset(ms)=-383 delay(ms)=6
server=time.cloudflare.com offset(ms)=-393 delay(ms)=6(✅ 실측 — 2026년 7월, Temurin JDK 21 컨테이너, commons-net 3.13.0. UDP 123 아웃바운드가 이 환경에서 막혀 있지 않아 세 서버 모두 응답을 받았습니다. 네트워크 경로·서버 부하에 따라 값은 매번 달라질 수 있으니, 정확한 숫자보다 아래 두 가지 패턴에 주목하세요.)
세 서버가 서로 무관한 곳(구글, 공용 풀, 클라우드플레어)인데도 오프셋이 전부 -380ms대로 모여 있습니다. 세 개의 독립적인 서버가 우연히 똑같이 틀렸을 가능성보다, 이 컨테이너 자신의 시계가 실제 UTC보다 약 380~390ms 느리게 흐르고 있다는 설명이 훨씬 그럴듯합니다 — 애초에 이 실행 환경은 지속적으로 NTP 데몬이 돌며 시계를 맞춰주는 서버가 아니었던 겁니다. 그리고 지연(delay) 값도 서버마다 다릅니다. 지리적으로 가까운 pool.ntp.org·time.cloudflare.com은 6ms인데, time.google.com은 70ms입니다. 이 지연 값이 왜 중요한지는 바로 다음 절에서 이어집니다 — 왕복 지연이 클수록 오프셋 계산의 오차 폭도 커집니다.
핵심 — 스트라텀, 네 개의 타임스탬프, 그리고 지울 수 없는 오차
스트라텀 계층 — 원자시계에서 몇 단계 떨어져 있는가
NTP는 시간의 출처를 계층으로 관리합니다. 아래로 내려갈수록 원자시계(3장의 그 세슘시계)로부터 멀어지고, 오차가 누적됩니다.
| 스트라텀 | 정의 |
|---|---|
| 0 | 시각을 직접 만들어내는 기준 시계 — 세슘 원자시계, GPS 수신기. 네트워크에 직접 연결되지 않는다 |
| 1 | 스트라텀 0에 직접 연결된 1차 타임서버 |
| 2 | 스트라텀 1에 동기화하는 서버 — 대부분의 공용 NTP 풀이 여기 속한다 |
| 3 이상 | 상위 스트라텀에 동기화. 숫자가 커질수록 원자시계로부터 멀어진다 |
오프셋과 지연 — 네 개의 타임스탬프로 재는 두 값
NTP 클라이언트가 서버에 요청 하나를 보내고 응답 하나를 받는 동안, 시각 네 개가 찍힙니다.
T1— 클라이언트가 요청을 보낸 시각 (클라이언트 시계 기준)T2— 서버가 요청을 받은 시각 (서버 시계 기준)T3— 서버가 응답을 보낸 시각 (서버 시계 기준)T4— 클라이언트가 응답을 받은 시각 (클라이언트 시계 기준)
이 네 값으로 클라이언트가 계산하는 두 값은 이렇습니다.
왕복 지연 delay = (T4 - T1) - (T3 - T2)
클록 오프셋 offset = ((T2 - T1) + (T3 - T4)) / 2delay는 순수하게 네트워크를 왕복하는 데 걸린 시간(서버가 처리하는 동안은 뺀 값)이고, offset은 클라이언트 시계가 서버 시계보다 얼마나 앞서거나 뒤처져 있는지의 추정치입니다.
결정적 한계 — 대칭을 가정할 뿐, 확인할 방법이 없다
offset 공식을 자세히 보면 가는 길(T2-T1)과 오는 길(T3-T4)의 지연이 똑같다고 가정하고 있습니다. 클라이언트는 왕복 시간의 총합(delay)만 잴 수 있을 뿐, 그중 몇 밀리초가 가는 길이고 몇 밀리초가 오는 길인지는 원리적으로 알 방법이 없습니다. 실제 네트워크의 가는 경로와 오는 경로는 라우팅·혼잡·비대칭 회선 때문에 다른 경우가 흔하고, 그 차이가 클수록 offset 오차도 커집니다 — 편도 지연 차이의 절반이 그대로 오프셋 오차로 남습니다. 이게 NTP가 “얼추 맞지만 완벽히 맞을 수는 없는” 근본적인 이유입니다. 앞서 측정한 세 서버 중 time.google.com의 지연(70ms)이 나머지 둘(6ms)보다 훨씬 큰데, 편도 비대칭이 존재한다면 그 오차 폭도 그만큼 넓게 잡아야 한다는 뜻입니다.
이런 이유로 현실적인 정확도 기대치도 딱 그 정도 선에서 그칩니다. 📄 문서 기반(미검증) — 같은 LAN 안에서는 대개 1ms 안팎, 인터넷을 건너는 WAN에서는 수십 ms 오차가 흔하고 경로가 나쁘면 100ms를 넘기도 합니다. 앞서 실측한 오프셋이 -380ms대였던 것도 이 범위가 결코 좁지 않다는 방증입니다. 훨씬 더 높은 정밀도가 필요하면 하드웨어 타임스탬프를 쓰는 **PTP(Precision Time Protocol)**가 대안이며, 서브마이크로초에서 나노초 단위 정밀도를 낼 수 있습니다 — 다만 전용 스위치·NIC가 필요해 일반 서버 환경에 그냥 깔리는 기술은 아닙니다.
슬루냐 스텝이냐는 11장이 이미 답했다
NTP가 이 오프셋을 실제로 어떻게 반영하는지는 — 서서히 늦추거나 당기는 슬루(slew), 아니면 한 번에 값을 바꿔버리는 스텝(step) — 11장에서 이미 다뤘습니다. 리눅스 슬루의 상한이 초당 500ppm이라 1초 오차를 메우는 데도 약 2,000초가 걸린다는 것도, 오차가 크면 커널이 그냥 스텝을 택해 시계를 과거 방향으로도 옮길 수 있다는 것도 11장의 결론입니다. 이 장에서 다시 유도하지 않습니다 — 여기서 중요한 건 그 교정의 입력값 자체, 즉 offset이 애초에 근사치라는 사실입니다. 근사치를 아무리 정교하게 반영해도 결과는 근사치입니다.
분산 시스템의 대가 — 타임스탬프는 순서를 말해주지 않는다
두 서버가 각자 NTP로 잘 교정된 시계를 갖고 있어도, 그 사이에는 여전히 LAN 기준 수 ms, WAN 기준 수십 ms의 오차 구간이 남습니다. 이 구간 안에서 실제로 벌어진 두 사건은, 벽시계 타임스탬프만 보면 순서가 뒤바뀔 수 있습니다. “타임스탬프가 더 늦은 쪽이 이긴다”는 last-write-wins(LWW) 전략이 여기서 무너집니다.
구성한 예로 확인해봅니다. (아래 스큐 값은 실측이 아니라, 11장의 NegativeDuration 예제와 같은 방식으로 논의를 위해 손으로 만든 값입니다.) 노드 A의 시계는 정확하고(오프셋 0), 노드 B의 시계는 50ms 느리게 흐르고 있다고 가정합니다. 같은 레코드에 대해 노드 A가 실제로 먼저, 노드 B가 실제로 40ms 뒤에 값을 씁니다.
Instant realEventA = Instant.parse("2026-07-31T09:00:00.000Z"); // A가 실제로 먼저 씀
Instant realEventB = realEventA.plus(40, ChronoUnit.MILLIS); // B가 실제로 40ms 뒤에 씀
long offsetA_ms = 0; // A의 시계는 정확
long offsetB_ms = -50; // B의 시계는 50ms 느림 (구성한 값)
Instant tsA = realEventA.plusMillis(offsetA_ms);
Instant tsB = realEventB.plusMillis(offsetB_ms);
// LWW: timestamp가 더 늦은 쪽이 승리
Write winner = tsA.isAfter(tsB) ? writeA : writeB;A: value=email=alice@old.com timestamp=2026-07-31T09:00:00Z (offset=0ms)
B: value=email=alice@new.com timestamp=2026-07-31T08:59:59.990Z (offset=-50ms, 구성된 스큐)
Last-write-wins 판정(timestamp가 더 늦은 쪽이 승리): A -> email=alice@old.com
실제로 나중에 일어난 쓰기는 B인데, LWW가 고른 승자는 A.
결과: 오답 — 실제로 나중에 일어난 B의 쓰기가 조용히 유실됨실제로 나중에 일어난 건 노드 B의 쓰기입니다. 그런데 노드 B의 시계가 50ms 느린 탓에, B가 찍은 타임스탬프가 A보다 더 이른 시각으로 기록됩니다. LWW는 이 타임스탬프만 보고 A를 승자로 고르고, B가 실제로 나중에 쓴 값(alice@new.com)은 조용히 사라집니다. 로그 어디에도 에러는 남지 않습니다 — 그냥 유실될 뿐입니다. “last-write-wins by wall-clock timestamp”는 동시성 문제의 해법이 아니라, 편향을 제어할 수 없는 동전 던지기입니다. 두 노드의 시계가 아무리 NTP로 잘 관리되어도 이 편향은 사라지지 않습니다. 근본 원인이 바로 이 장 전체의 결론 — 어떤 두 기계도 완전히 같은 시각을 가리킨 적이 없다는 것 — 이기 때문입니다.
📄 문서 기반(미검증) — 이 문제를 근본적으로 줄이고 싶은 클라우드 사업자는 이제 하드웨어 지원 시각 서비스를 내놓고 있습니다. 예컨대 AWS는 Nitro 시스템에 PTP 하드웨어 클록을 얹어 일부 EC2 인스턴스에서 마이크로초 단위 오차를 보장하는 타임 싱크 서비스를 제공합니다. 이런 서비스도 오차를 0으로 만들지는 못하지만, 오차의 상한을 명시적으로 알려준다는 점이 다릅니다 — 이 발상이 20장에서 다룰 구글 TrueTime과 Spanner로 이어집니다.
정리
- 지금까지의 모든 처방은 “서버 한 대의 시계는 정확하다”를 전제했다. 이 장은 그 전제를 걷어낸다 — 모든 서버는 저마다 다른 속도로 흔들리는 크리스탈 오실레이터로 초를 센다.
- 📄 문서 기반(미검증) — 온도 보정이 없는 오실레이터는 대략 10
100ppm 드리프트하며, 이는 하루 약 19초의 어긋남에 해당한다(50ppm 기준 약 4.3초/일). 가장 큰 원인은 온도, 그다음이 노화·제조 공차다. - NTP는 요청·응답 네 시각(T1~T4)으로 오프셋과 왕복 지연을 계산한다.
offset = ((T2-T1)+(T3-T4))/2,delay = (T4-T1)-(T3-T2). 이 공식은 가는 길과 오는 길의 지연이 같다고 가정할 뿐, 확인할 방법이 없다 — 이것이 NTP 정확도의 근본 한계다. - 실측(2026-07,
commons-net3.13.0, JDK 21 컨테이너): 세 공용 NTP 서버(time.google.com, pool.ntp.org, time.cloudflare.com) 모두 이 컨테이너 시계를 약 -380~-393ms 오프셋으로 보고했고, 왕복 지연은 6~70ms로 서버마다 갈렸다. 세 서버가 일치한다는 건 로컬 시계 쪽이 밀려 있다는 뜻이고, 지연이 클수록 오프셋의 오차 여지도 커진다. - 📄 문서 기반(미검증) — 현실적인 기대치는 LAN에서 1ms 안팎, WAN에서 수십 ms이고 경로가 나쁘면 100ms를 넘는다. 더 높은 정밀도가 필요하면 하드웨어 타임스탬프 기반 PTP(서브마이크로초~나노초)를 쓴다.
- 슬루 대 스텝, 벽시계가 어떻게 교정되는지는 11장이 이미 다뤘다 — 여기서 다시 설명하지 않는다. 중요한 건 그 교정의 입력값(offset)이 근사치라는 것.
- 가장 중요한 문장: 두 기계의 타임스탬프는 비교 대상이 아니다. 구성한 예제에서, 실제로 나중에 일어난 쓰기가 시계가 느린 노드에서 이루어졌다는 이유만으로 더 이른 타임스탬프를 받았고, last-write-wins가 그 쓰기를 조용히 지워버렸다. 로그엔 아무 흔적도 남지 않는다.
- 📄 문서 기반(미검증) — AWS 같은 클라우드 사업자는 하드웨어 지원 시각 서비스로 오차의 상한을 보장하는 방향으로 가고 있다. 이 발상은 20장의 TrueTime·Spanner로 이어진다.
생각해볼 질문: 이 장은 “타임스탬프로 순서를 매길 수 없다”는 사실만 확인했습니다. 그런데 분산 시스템은 여전히 순서를 알아야 합니다. 어떤 쓰기가 다른 쓰기를 봤는지, 어떤 이벤트가 다른 이벤트의 원인인지 — 이건 벽시계 없이도 답할 수 있는 질문일까요? 시각을 아예 묻지 않고, 오직 순서만 기록하는 시계가 있다면 어떨까요?