Skip to Content

11장. 벽시계를 믿지 마라 — 단조시계와 경과 시간 측정

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

보정되어 뒤로 갈 수 있는 벽시계 → 한 방향으로만 흐르는 단조시계 → 시각과 경과 시간에 다른 도구를 쓰는 원칙.

면접 실전 질문: ① System.currentTimeMillis()와 System.nanoTime()은 각각 무엇을 재는 데 써야 하는가? ② 두 Instant의 차이로 경과 시간을 재면 왜 음수가 될 수 있는가? ③ 타임아웃과 재시도 백오프는 왜 nanoTime 기반이어야 하는가?


“언제”를 묻는 시계와 “얼마나”를 묻는 시계는 다른 도구다 — 벽시계는 보정되어 뒤로도 가지만, 단조시계는 오직 앞으로만, 일정한 속도로만 흐른다.

면접 실전 질문: ① System.currentTimeMillis()System.nanoTime()은 각각 무엇을 재는 데 써야 하는가? ② 두 Instant 값의 차이로 경과 시간을 재면 왜 음수가 나올 수 있는가? ③ 재시도 백오프·타임아웃·캐시 TTL 로직은 왜 Instant.now()가 아니라 System.nanoTime() 기반이어야 하는가?


배경 — 벽시계는 “얼마나”에 대답하지 못한다

10장은 질문 하나를 남기고 끝났습니다. plusDays(1)plusHours(24)도 시스템 시계 자체는 의심하지 않았다고요. 두 연산 다 “지금 이 순간부터”를 전제로 계산했을 뿐, 그 “지금”을 읽는 시계가 믿을 만한지는 묻지 않았습니다. 이번 장이 그 전제를 깹니다.

지금까지 9장과 10장은 전부 하나의 질문에 답했습니다. “지금은 언제인가.” Instant, LocalDateTime, ZonedDateTime, OffsetDateTime — 다 이 질문의 답을 표현하는 방법들이었습니다. 그런데 백엔드 코드가 실제로 훨씬 더 자주 던지는 질문은 따로 있습니다. “이 요청은 얼마나 걸렸는가”, “타임아웃까지 얼마나 남았는가”, “이 캐시는 만든 지 얼마나 됐는가.” 이건 “언제”가 아니라 **“얼마나”**를 묻는 질문입니다. 언뜻 같은 문제처럼 보입니다 — 시작 시각과 끝 시각을 읽어서 빼면 되는 거 아닌가? 실제로 많은 코드가 그렇게 짜여 있습니다.

Instant start = Instant.now(); doSomething(); Duration elapsed = Duration.between(start, Instant.now());

이 코드는 대부분의 순간 정상적으로 동작합니다. 문제는 “대부분”입니다. Instant.now()가 읽는 시계는 벽시계입니다. 그리고 벽시계는 조용히 있지 않습니다. NTP가 주기적으로 값을 보정하고, 관리자가 수동으로 시각을 바꾸고, 6장의 DST 전환이 벽시계 눈금을 앞뒤로 튀게 만들고, 3장에서 본 윤초 스미어가 하루의 길이 자체를 미세하게 늘였다 줄입니다. 벽시계는 “정확한 지금”을 유지하기 위해 끊임없이 고쳐지는 시계입니다. 그런데 경과 시간을 재는 코드는 그 시계가 두 번 읽는 사이에 고쳐지지 않았을 거라고 암묵적으로 믿습니다. 이 장은 그 믿음이 왜 틀렸는지, 그리고 자바가 이 문제를 위해 이미 준비해둔 다른 시계가 무엇인지를 다룹니다.

스토리 — 평소엔 맞다가, 어느 순간 어긋난다

먼저 벽시계로 잰 경과 시간과, 자바가 제공하는 또 다른 시계로 잰 경과 시간을 나란히 비교해봅니다. System.nanoTime()입니다.

import java.time.Duration; import java.time.Instant; public class SleepAgreement { public static void main(String[] args) throws InterruptedException { Instant wallStart = Instant.now(); long nanoStart = System.nanoTime(); Thread.sleep(200); Instant wallEnd = Instant.now(); long nanoEnd = System.nanoTime(); Duration wallElapsed = Duration.between(wallStart, wallEnd); long nanoElapsed = nanoEnd - nanoStart; System.out.println("Instant 델타 = " + wallElapsed.toMillis() + "ms"); System.out.println("nanoTime 델타 = " + (nanoElapsed / 1_000_000) + "ms"); } }
Instant 델타 = 200ms nanoTime 델타 = 200ms

(실측 — 밀리초 값은 스케줄링·컨테이너 부하에 따라 실행마다 몇 ms씩 오차가 날 수 있습니다. 여기서 볼 건 정확한 숫자가 아니라 두 방식이 같은 답을 낸다는 사실입니다.) 평소엔 두 방식이 완전히 같은 결과를 냅니다. 이 정도만 보면 “그럼 둘 중 아무거나 써도 되는 거 아닌가” 싶습니다. 여기서 함정이 하나 숨어 있습니다 — 이 실험은 200밀리초 동안 벽시계가 고쳐지지 않았다는 운 좋은 전제 위에서만 성립합니다.

System.nanoTime()이 도대체 뭘 재고 있길래 벽시계 보정의 영향을 받지 않는지, 값 자체를 한번 찍어봅니다.

public class NanoTimeNotATimestamp { public static void main(String[] args) { long millisEpoch = System.currentTimeMillis(); long nano = System.nanoTime(); System.out.println("currentTimeMillis() = " + millisEpoch + " (1970-01-01 기준 epoch millis)"); System.out.println("nanoTime() = " + nano + " (임의 원점 기준, 날짜로 해석 불가)"); } }
currentTimeMillis() = 1785460827805 (1970-01-01 기준 epoch millis) nanoTime() = 1356904946587 (임의 원점 기준, 날짜로 해석 불가)

두 숫자 모두 실행할 때마다 달라집니다. 특히 nanoTime() 쪽은 원점 자체가 임의라 같은 머신에서 다시 돌려도 전혀 다른 값이 나옵니다 — 그게 바로 이 절의 요지입니다.

currentTimeMillis()는 4장에서 다룬 epoch, 즉 1970년 1월 1일부터 센 밀리초입니다. 저 숫자를 날짜로 되돌리면 실행 시점의 실제 날짜가 나옵니다. 그런데 nanoTime()의 숫자는 그런 의미가 전혀 없습니다. epoch도 아니고, 부팅 시각도 아니고, JVM이 시작한 시각도 정확히 아닙니다. 자바독은 이 값을 “고정돼 있지만 임의인 어떤 원점(fixed but arbitrary origin)부터 잰 나노초”라고만 설명합니다. 심지어 그 원점이 미래일 수도 있어서 값 자체가 음수로 나올 수도 있다고 명시합니다. 이 숫자 하나만 떼어놓고 “이게 몇 시야?”라고 묻는 건 애초에 질문이 성립하지 않습니다.

자바독이 nanoTime()에 대해 실제로 보장하는 건 오직 이것뿐입니다. 같은 JVM 인스턴스 안에서, 같은 원점을 기준으로 잰 두 값의 차이만 의미가 있다. 다른 JVM, 다른 머신에서 읽은 nanoTime() 값과 비교하는 건 무의미합니다 — 원점 자체가 서로 다르니까요.

여기서 한 가지는 정확히 구분해두는 게 좋습니다. 자바독은 “이 시간 소스는 단조적(monotonic)이다”라고 보장하지 않습니다. 두 호출의 차이가 항상 0 이상이라고 명시한 적도 없어요. 다만 “경과 시간 측정에만 쓰라”는 용도 규정과 함께, 실제 JVM 구현체들이 뒤로 가지 않는 단조 카운터(리눅스의 CLOCK_MONOTONIC 등) 위에 이 메서드를 얹습니다. 그래서 실무에서는 사실상 언제나 0 이상으로 관찰되지만, 그건 명세가 준 약속이 아니라 구현이 준 관행입니다. 벽시계와 비교했을 때 신뢰할 만한 이유로는 충분하지만, “규격상 보장된다”고 외워두면 곤란합니다. 해상도(값이 실제로 얼마나 자주 바뀌는지)는 자바독도 “나노초 정밀도(precision)는 보장하지만 나노초 해상도(resolution)까지 보장하지는 않는다”고 못 박아, JVM·OS·하드웨어 조합에 따라 갈립니다 — 어떤 플랫폼에선 수십 나노초 단위로 갱신되고, 어떤 플랫폼에선 더 성기게 갱신됩니다. 값을 저장하거나, 다른 서버로 보내거나, 날짜로 변환하는 건 전부 이 계약을 벗어난 사용입니다.

이제 벽시계 쪽이 왜 불안한지, 실제로 어긋나는 상황을 손으로 재현해봅니다. 컨테이너 안에서 호스트 시계를 실제로 조작하긴 어렵지만, NTP가 시계를 뒤로 스텝시켰을 때 벌어지는 일은 Instant 두 개를 직접 만들어 정확히 같은 메커니즘으로 재현할 수 있습니다.

import java.time.Duration; import java.time.Instant; public class NegativeDuration { public static void main(String[] args) { // NTP가 시계를 뒤로 스텝시켰다고 가정하고, 그 상황을 직접 손으로 구성한다. // (실제 호스트 시계를 건드리지 않는다 — 두 Instant를 명시적으로 만들어 재현할 뿐이다.) Instant readingBeforeStep = Instant.parse("2026-07-31T00:00:05.000Z"); // 스텝 전 벽시계 읽음 Instant readingAfterStep = Instant.parse("2026-07-31T00:00:03.500Z"); // NTP가 1.5초 뒤로 스텝 Duration measured = Duration.between(readingBeforeStep, readingAfterStep); System.out.println("측정 시작(스텝 전) = " + readingBeforeStep); System.out.println("측정 종료(스텝 후) = " + readingAfterStep); System.out.println("Duration.between = " + measured + " (음수!)"); System.out.println("isNegative() = " + measured.isNegative()); long negativeMillis = measured.toMillis(); System.out.println(); System.out.println("이 음수 " + negativeMillis + "ms를 그대로 대기 시간으로 넘기면?"); try { Thread.sleep(negativeMillis); } catch (IllegalArgumentException e) { System.out.println("Thread.sleep(" + negativeMillis + ") 실패: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }
측정 시작(스텝 전) = 2026-07-31T00:00:05Z 측정 종료(스텝 후) = 2026-07-31T00:00:03.500Z Duration.between = PT-1.5S (음수!) isNegative() = true 이 음수 -1500ms를 그대로 대기 시간으로 넘기면? Thread.sleep(-1500) 실패: IllegalArgumentException - timeout value is negative

Instant 값은 조작한 것이지만, 여기서 벌어지는 일 자체는 조작이 아닙니다. NTP는 오차가 클 때 시계를 서서히 늦추는 슬루(slew) 대신, 한 번에 값을 바꿔버리는 **스텝(step)**을 씁니다. 리눅스의 슬루는 초당 최대 500ppm으로만 속도를 조절할 수 있어서(1초 오차를 메우는 데도 약 2,000초가 걸립니다), 오차가 크면 커널은 그냥 시계를 즉시 정확한 값으로 옮겨버립니다 — 그 방향이 과거일 수도 있습니다. 정확히 그 순간, “시작보다 끝이 앞서 있는” 벽시계 읽기가 만들어집니다. 위 코드는 그 상황을 Instant.parse로 흉내 냈을 뿐, Duration.between()이 음수를 내놓는 메커니즘도, 그 음수가 Thread.sleep()에 들어가 IllegalArgumentException을 던지는 것도 전부 실제 동작입니다.

이 장면, 낯익지 않나요? 3장에서 이미 정확히 이 사고를 봤습니다. 2017년 Cloudflare 장애입니다. 윤초가 삽입되는 순간 Go의 time.Now()로 잰 두 시각의 차이가 음수로 떨어졌고, 그 값을 음수를 받으면 패닉하는 rand.Int63n()에 그대로 넘긴 코드가 죽었습니다. 원인이 윤초였다는 점만 다를 뿐, 메커니즘은 위 코드와 완전히 같습니다. 벽시계로 잰 경과 시간은 음수가 될 수 있고, 그 값을 “0 이상”이라고 가정하는 코드는 어디서든 이 순서로 무너집니다. 윤초든 NTP 스텝이든 관리자의 수동 조정이든, 벽시계가 보정되는 방식은 여러 가지지만 결과는 항상 같습니다 — 경과 시간 계산이 이 사실을 모르고 있으면, 부호가 뒤집힌 값 하나가 시스템 전체를 넘어뜨립니다.

핵심 — 두 시계, 두 용도, 절대 섞지 않는다

지금까지 본 걸 규칙 하나로 요약할 수 있습니다. “언제”를 묻는 값은 Instant.now() 또는 System.currentTimeMillis()로, “얼마나”를 묻는 값은 System.nanoTime()으로 잰다. 이 둘을 바꿔 쓰는 게 버그입니다.

두 시계가 이렇게 다른 이유는 설계 목표 자체가 다르기 때문입니다. 벽시계는 정확한 지금을 유지하는 게 임무입니다. 그래서 NTP가 슬루·스텝으로 값을 고치고, DST가 오프셋을 바꾸고, 리프 스미어가 초의 길이를 미세하게 조정하는 걸 전부 받아들입니다 — 그래야 “지금 몇 시야?”에 정확히 답할 수 있으니까요. 반대로 단조시계는 경과 시간만 잘 재는 게 임무입니다. 그래서 애초에 달력·타임존·윤초 같은 걸 하나도 모릅니다. 원점이 언제인지조차 의미가 없고, 오직 일정한 속도로, 한 방향으로만 흐른다는 것만 보장합니다. 정확한 지금을 아는 것과, 일정하게 흐르는 것은 같은 시계 하나가 동시에 잘할 수 없는 두 가지 목표입니다. 자바가 이 둘을 완전히 다른 두 메서드로 갈라놓은 이유가 이겁니다.

여기서 자바의 Clock 추상화를 오해하기 쉽습니다. Clock.systemUTC(), Clock.fixed(...), Clock.offset(...) — 시간을 주입 가능하게 만들어 테스트를 쉽게 해주는 걸로 잘 알려진 이 타입은, 어디까지나 Instant.now()가 무엇을 반환할지를 갈아 끼울 수 있게 만든 것입니다. 즉 Clock은 전부 벽시계 계열입니다. 어떤 Clock 구현체도 단조성을 보장하지 않습니다. Clock으로 테스트하기 쉬운 코드를 만드는 것과, 경과 시간을 안전하게 재는 것은 별개의 문제입니다.

실무에서 이 규칙이 실제로 적용되는 자리는 생각보다 많습니다.

  • 타임아웃·재시도 백오프 — “5초 안에 응답이 없으면 재시도”는 경과 시간 문제입니다. 시작 시각을 벽시계로 찍어두고 그 차이로 판단하면, 그 사이 벽시계가 스텝될 경우 타임아웃이 아예 발동하지 않거나 즉시 발동해버릴 수 있습니다.
  • 레이턴시 측정 — API 응답 시간, DB 쿼리 시간 같은 지표는 정의상 “얼마나 걸렸나”입니다. 벽시계 기반 측정값에 NTP 보정이 한 번 섞이면, 존재하지 않는 음수 레이턴시나 비정상적으로 큰 스파이크가 모니터링 대시보드에 그대로 찍힙니다.
  • 캐시 TTL·rate limiter — “이 항목을 만든 지 60초가 지났으면 만료”도 마찬가지입니다. 벽시계가 뒤로 스텝되면 만료 판정이 되돌아가고, 앞으로 스텝되면 갑자기 무더기로 만료되는 항목이 쏟아집니다.

이 세 경우 전부 정답은 같습니다. 시작 시점에 System.nanoTime()을 찍어두고, 판단이 필요한 순간 다시 System.nanoTime()을 찍어 그 차이를 비교합니다. 절대 값 자체(그게 몇 시인지)는 필요 없고, 오직 두 시점 사이에 얼마나 흘렀는지만 필요하니 단조시계가 정확히 맞는 도구입니다. 반대로 로그에 “이 요청은 몇 시에 들어왔다”를 남기거나, DB에 created_at을 찍는 건 여전히 Instant.now()의 몫입니다 — 그건 “얼마나”가 아니라 “언제”를 묻는 질문이니까요.

정리

  • 9~10장이 답한 질문은 “지금은 언제인가”였고, 이 장이 답하는 질문은 “얼마나 흘렀는가”다. 둘은 다른 질문이고, 다른 시계로 답해야 한다.
  • 벽시계(Instant.now(), System.currentTimeMillis())는 정확한 지금을 유지하기 위해 끊임없이 보정된다. NTP의 슬루·스텝, 관리자의 수동 조정, DST 전환, 윤초 스미어 — 전부 벽시계 값을 바꾼다. 그중 스텝은 시계를 한 번에, 과거 방향으로도 옮길 수 있다(실측: 리눅스 슬루 상한 500ppm, 큰 오차는 즉시 스텝).
  • System.nanoTime()은 임의의 원점부터 잰 나노초이고, 같은 JVM 안에서 두 값의 차이로만 의미가 있다. 절대값은 날짜도, 부팅 시각도 아니며 저장·비교·머신 간 전송 대상이 아니다(실측: 자바독 — 원점은 미래일 수도 있어 값이 음수일 수 있고, 나노초 정밀도는 보장해도 나노초 해상도는 보장하지 않는다).
  • 평소엔 Instant 델타와 nanoTime 델타가 같은 값을 낸다(실측: 200ms 수면에서 둘 다 200ms — 밀리초 수치는 실행마다 오차가 있다). 하지만 벽시계가 두 읽기 사이에 스텝되면 Duration.between()음수를 낼 수 있고(실측: PT-1.5S), 그 값을 양수를 기대하는 API에 넘기면 즉시 깨진다(실측: Thread.sleep(-1500)IllegalArgumentException).
  • 이게 이론이 아니라는 증거가 3장의 2017년 Cloudflare 장애다 — 윤초로 음수가 된 경과 시간이 rand.Int63n()을 패닉시켰다. 원인(윤초 vs NTP 스텝)은 달라도 메커니즘은 동일하다.
  • 규칙: “언제”는 Instant/currentTimeMillis(), “얼마나”는 nanoTime(). 타임아웃, 재시도 백오프, 레이턴시 측정, 캐시 TTL, rate limiter처럼 경과 시간이 곧 로직인 자리는 전부 단조시계로 재야 한다. java.timeClock 추상화는 전부 벽시계 계열이라 이 문제를 대신 풀어주지 않는다.

생각해볼 질문: 이 장까지, 시간을 정확하게 표현하고 안전하게 측정하는 법을 다졌습니다. 그런데 그 값은 결국 어딘가에 찍혀야 합니다 — 로그 한 줄, API 응답의 문자열 한 필드로요. Instant를 문자열로 바꾸는 순간, 지금까지와는 결이 다른 함정이 기다립니다. 같은 DateTimeFormatter가 서버가 어느 로케일로 떴는지에 따라 다른 문자열을 뱉을 수 있다면, 그리고 zone을 깜빡 잊은 포맷터가 조용히 엉뚱한 시각을 찍는다면 — 정확히 잰 시간도 결국 잘못 전달됩니다.

12장 · 포맷과 파싱의 함정 — DateTimeFormatter, 로케일, withZone

Last updated on