Skip to Content

3장. 원자가 시간을 정하다 — GMT에서 UTC로, 그리고 윤초

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

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

흔들리는 지구 자전과 정확한 원자시계 → 윤초라는 이음매 → 서버는 계산 가능한 균일한 시간을 택함.

면접 실전 질문: ① UTC와 TAI는 왜 같은 속도로 흐르지만 값이 다른가? ② 윤초가 서버 장애로 이어질 수 있는 이유는? ③ java.time이 윤초를 모델링하지 않는 이유는?


세슘 원자가 초를 정의하는 순간, 시계는 지구보다 정확해졌다 — 그런데 지구는 여전히 자전하고, 그 둘 사이의 어긋남을 메우는 윤초가 오늘날 서버가 멈추는 이유다.

면접 실전 질문: ① UTC와 TAI는 왜 같은 속도로 흐르는데 값이 다른가? ② java.time은 왜 윤초를 흉내 내지 않는가? ③ 윤초 삽입 시 서버 시각이 시스템에 따라 다르게 동작할 수 있는 이유는?


배경 — 하루는 86,400초가 아니다

2장에서 표준시는 정치가 그은 선이었습니다. 그런데 그 선이 가리키는 시계 자체는 여전히 하늘을 보고 있었어요. 그리니치 표준시(GMT)는 지구 자전을 관측해서 정의됩니다. 지구가 한 바퀴 자전하는 데 걸리는 시간을 24시간으로 나누고, 다시 3600으로 나누면 1초가 나오는 식이죠. 1초는 하루의 86,400분의 1이라는 정의였습니다.

문제는 지구가 일정한 속도로 돌지 않는다는 겁니다. 달의 조석력이 지구 자전을 서서히 붙잡아 늦추고 있고(조석 제동, tidal braking), 지각과 맨틀의 움직임, 극의 흔들림, 심지어 대기와 해류의 계절적 변화까지 자전 속도에 미세하게 끼어듭니다. 그래서 하루의 실제 길이는 86,400초보다 아주 조금 길기도, 짧기도 합니다. 1장에서 본 “하늘은 정수로 떨어지지 않는다”는 문제가, 이번엔 날짜가 아니라 초 단위에서 똑같이 반복되는 거예요.

여기서 시계의 정의 자체가 흔들립니다. 자전으로 초를 정의하면, 초의 길이가 매일 조금씩 달라지는 시계를 기준 시계로 쓰는 셈이 됩니다. 길이가 자꾸 바뀌는 자로 물건을 재는 꼴이죠. 20세기 중반, 물리학자들은 이 문제에 답을 하나 내놓습니다. 하늘이 아니라 원자에게 초를 맡기자.

스토리 — 원자가 초를 새로 정의하다

1955년 5월 24일, 영국 국립물리연구소(NPL)의 **루이스 에센(Louis Essen)**과 **잭 패리 (Jack Parry)**가 세계 최초의 실용 세슘 원자시계를 가동시킵니다. 원리는 이렇습니다. 세슘 133 원자는 특정 마이크로파 주파수를 쬐면 두 에너지 준위 사이를 정확히 일정한 진동수로 오갑니다. 이 진동은 지구 자전과 달리 환경에 흔들리지 않는, 물리 상수에 가까운 주기를 갖습니다. 에센과 패리의 시계는 300년에 1초 오차 수준의 정밀도를 냈고, 이는 당시 가장 정밀한 진자시계보다 300배, 수정시계보다 30배 정확한 수치였습니다.

1958년부터 이 세슘시계는 실제로 하늘의 시간과 나란히 비교되기 시작했고, 그 결과를 바탕으로 1967년 제13차 국제도량형총회(CGPM)가 초의 정의 자체를 바꿔버립니다.

1초는 세슘-133 원자의 바닥상태 두 초미세 준위 사이 전이에서 나오는 복사의 9,192,631,770번 진동에 걸리는 시간이다.

숫자 하나가 이전에 있던 천문학적 정의(하루의 86,400분의 1)를 완전히 대체했습니다. 더는 지구를 보고 초를 재지 않습니다. 초는 이제 세슘 원자가 정의합니다. 이 값도 사실은 임의의 숫자가 아니라, 당시 천문학적으로 정의된 초와 최대한 값이 맞아떨어지도록 역산해서 고른 결과였습니다. 원자시계가 하늘의 뒤를 이어받되, 하늘이 그때까지 정해놓은 초의 길이는 존중한 셈이죠.

그런데 여기서 갈라지는 문제가 생깁니다. 원자시계가 정의한 초는 완벽하게 일정한데, 지구 자전은 여전히 불규칙합니다. 그래서 시간을 재는 방법이 두 갈래로 나뉩니다.

  • TAI(국제원자시, International Atomic Time) — 전 세계 원자시계 수백 대의 평균으로 만든, 오직 세슘 초만 세는 시계. 절대 멈추지 않고, 절대 보정하지 않습니다.
  • UT1 — 지구 자전을 실제로 관측해서 재는 시간. 하루의 실제 길이를 그대로 반영합니다.

TAI는 한 방향으로만 똑딱거리고, UT1은 지구의 변덕을 그대로 따라갑니다. 둘은 태생부터 다른 것을 재는 시계라서, 시간이 갈수록 서서히 벌어집니다.

여기서 실용적인 타협안이 등장합니다. **UTC(협정 세계시)**입니다. UTC는 1972년부터 시행됐고, 두 가지를 동시에 만족시키도록 설계됐습니다. 초의 길이는 TAI와 똑같이 원자시계 기준으로 가되, UT1과의 차이가 0.9초를 넘지 않도록 가끔 1초를 더하거나 뺍니다. 이게 **윤초(leap second)**입니다. 하늘과 원자, 둘 다 놓치지 않으려는 절충이 UTC의 정체입니다.

1972년 이후 지금까지 윤초는 총 27번 삽입됐고, 전부 양(+)의 윤초였습니다(음의 윤초, 즉 1초를 빼는 경우는 아직 한 번도 없었습니다). 가장 최근 윤초는 2016년 12월 31일 23시 59분 60초에 삽입됐고, 그 뒤로 지금까지 TAI는 UTC보다 정확히 37초 앞서 있습니다. 이 37이라는 숫자는 1972년 시작 시점의 기본 차이 10초에, 그 후 삽입된 윤초 27개를 더한 값입니다.

그런데 최근 들어 지구 자전이 예상과 다르게 빨라지는 조짐이 나타나면서, 역사상 처음으로 음의 윤초가 필요할 수도 있다는 이야기가 나왔습니다. 1초를 더하는 코드만 준비해둔 시스템 입장에서, 1초를 빼는 상황은 한 번도 검증되지 않은 경로입니다. 이 우려도 한몫해서, 2022년 제27차 CGPM2035년까지(러시아는 더 늦추자고 반대했지만) 윤초 삽입을 중단하기로 결의했습니다. 짚어둘 건 이게 “이미 끝난 일”이 아니라 시한을 못 박은 계획이라는 점입니다. 2035년 이후에도 지구 자전과 원자시계는 계속 어긋날 텐데, 그 어긋남을 언제·어떻게 다시 정리할지(1초가 아니라 1분, 혹은 1시간 단위로 묶어서 처리하자는 “윤시” 논의도 있습니다)는 아직 정해지지 않았습니다.

핵심 — 이음매는 실제로 서버를 멈췄다

이론만 보면 “1초 더하기”는 사소해 보입니다. 그런데 실제로 이 이음매는 여러 번 실제 장애를 냈습니다. 왜 하필 윤초가 이렇게 위험할까요? 컴퓨터 시계는 시간이 단조롭게, 그리고 정확히 초 단위로 증가한다는 가정 위에서 만들어졌기 때문입니다. 윤초는 이 가정을 정면으로 깹니다 — 어느 순간 같은 초(23:59:60)가 두 번 있거나, 시계가 잠깐 거꾸로 갑니다.

2012년 6월 30일 윤초가 대표적입니다. 리눅스 커널의 시간 관리 서브시스템(timekeeping)이 윤초 삽입을 hrtimer 서브시스템에 제대로 통보하지 않는 버그가 있었습니다. 그 결과 초 단위 미만의 타이머들이 즉시, 그것도 반복적으로 발화하면서 CPU가 아무 일도 하지 않으면서 계속 스핀(livelock)하는 상태에 빠졌습니다. Reddit, LinkedIn, Mozilla, Yelp, StumbleUpon 등 여러 서비스의 서버가 동시에 이 부하 폭증을 겪었고, 당장은 문제가 된 프로세스를 강제 재시작하는 식으로 넘겼습니다. 근본 수정은 그 뒤 커널 패치로 들어갔고요.

2017년 1월 1일, 이번엔 Cloudflare 차례였습니다. 자체 DNS 소프트웨어(RRDNS)가 Go로 짜여 있었는데, Go의 time.Now()는 시간이 절대 거꾸로 가지 않는다는 걸 보장하지 않습니다. 문제의 코드는 “이 값은 최소 0이거나 양수”라고 암묵적으로 믿고, 두 시각의 차이를 rand.Int63n()(음수를 받으면 패닉하는 함수)에 그대로 넘기고 있었습니다. 윤초가 삽입되는 순간 값이 음수로 떨어졌고, rand.Int63n()이 패닉했습니다. Go의 recover로 패닉은 잡혔지만, 전체 DNS 쿼리의 약 0.2%가 영향을 받았습니다. 원인은 결국 “시간은 항상 앞으로만 간다”는, 아주 자연스러워 보이는 가정이 깨진 것 하나였습니다.

업계가 찾은 실용적인 답이 **윤초 스미어(leap smear)**입니다. 정확히 23:59:60이라는 순간을 만들지 않고, 그 대신 윤초 전후 몇 시간에 걸쳐 시계를 아주 조금씩 늦게 가게 만들어 누적된 1초를 눈에 안 띄게 흡수하는 방식입니다. Google은 2016년 윤초까지는 윤초 전후 10시간씩, 총 20시간에 걸쳐 나눠 갚았고, 지금은 윤초 당일 정오부터 다음 날 정오까지 24시간 동안 선형으로 흡수하는 방식을 표준으로 삼고 있습니다. Amazon·Microsoft·Akamai도 비슷한 방식을 씁니다.

스미어는 개별 시스템 안에서는 23:59:60이라는 비정상적인 시각을 없애주지만, 대가가 있습니다. 스미어를 쓰는 시계와 안 쓰는 시계는 스미어 구간 동안 서로 다른 시각을 보고합니다. 스미어 서버는 진짜 원자시계보다 몇백 밀리초 느리게 가고 있는 중이니까요. 그래서 스미어를 쓰는 시스템과 안 쓰는 시스템을 같은 NTP 계층에서 섞어 쓰면 안 됩니다 — 그 자체가 새로운 어긋남의 원인이 됩니다.

이제 java.time이 이 문제를 어떻게 다루는지 코드로 확인해봅시다.

import java.time.Duration; import java.time.Instant; public class LeapDuration { public static void main(String[] args) { Instant before = Instant.parse("2016-12-31T23:59:59Z"); Instant after = Instant.parse("2017-01-01T00:00:00Z"); Duration d = Duration.between(before, after); System.out.println("2016-12-31T23:59:59Z -> 2017-01-01T00:00:00Z"); System.out.println("Duration.between = " + d.getSeconds() + "s"); System.out.println(); System.out.println("epochSecond(23:59:59) = " + before.getEpochSecond()); System.out.println("epochSecond(00:00:00) = " + after.getEpochSecond()); System.out.println("차이 = " + (after.getEpochSecond() - before.getEpochSecond())); System.out.println(); try { Instant leap = Instant.parse("2016-12-31T23:59:60Z"); System.out.println("Instant.parse(23:59:60Z) 성공 = " + leap + " / epochSecond=" + leap.getEpochSecond()); } catch (Exception e) { System.out.println("Instant.parse(23:59:60Z) 실패: " + e.getClass().getSimpleName() + " - " + e.getMessage()); } } }
2016-12-31T23:59:59Z -> 2017-01-01T00:00:00Z Duration.between = 1s epochSecond(23:59:59) = 1483228799 epochSecond(00:00:00) = 1483228800 차이 = 1 Instant.parse(23:59:60Z) 성공 = 2016-12-31T23:59:59Z / epochSecond=1483228799

실제로 2016년 12월 31일 23시 59분 59초와 2017년 1월 1일 0시 0분 0초 사이에는, 실제 UTC로는 23:59:59 → 23:59:60(윤초) → 00:00:00, 초가 두 번 지나갑니다. 그런데 Duration.between1초라고 답합니다. getEpochSecond()도 두 시각의 차이를 정확히 1로 봅니다. java.time의 시간 축에는 애초에 윤초가 존재하지 않으니까요. java.time모든 하루를 정확히 86,400초로 취급하는 균일한 시간 척도 위에서 동작합니다.

더 흥미로운 건 마지막 줄입니다. 23:59:60Z라는, 실제로 있었던 시각 문자열을 Instant.parse에 넣으면 — 예외가 나지 않습니다. 파싱은 성공하고, 결과는 23:59:59Z, 즉 직전 초와 똑같은 시각으로 조용히 접혀버립니다. 초 60이라는 값을 “윤초가 있었다”는 신호로 별도 처리하는 게 아니라, 그냥 59로 뭉개서 저장하는 겁니다. 여기서 예외가 던져지거나 최소한 별도 예외 타입이 있을 거라 예상하기 쉽지만, 실제 동작은 그보다 훨씬 조용합니다. DateTimeFormatter.ISO_INSTANT가 초 60을 문법적으로는 허용하되 의미상으로는 59와 동일한 순간으로 취급하도록 만들어져 있기 때문입니다.

java.time은 이렇게 설계됐을까요? 1장에서 본 것과 같은 트레이드오프입니다. 윤초를 실제로 반영하려면 Instant의 산술 하나하나가 “이 구간에 윤초가 몇 개 끼어 있었는가”라는, 미래엔 아직 결정되지도 않은 정보에 의존해야 합니다(다음 윤초가 언제 올지는 지구 자전을 관측해봐야 알 수 있고, 보통 반년 전에야 공지됩니다). 그래서 java.time은 역사적 정확성 대신 항상 계산 가능한 균일한 척도를 택했습니다. 실제 윤초 정보가 필요한 시스템(GPS, 일부 천문 소프트웨어)은 java.time 바깥에서 별도의 윤초 테이블을 참조해야 합니다.

정리

  • 하늘(1장)과 나라(2장)를 거쳐, 마지막으로 원자가 시간을 정의했다. 1955년 에센·패리의 세슘시계, 1967년 초의 재정의(세슘-133 초미세 전이의 9,192,631,770번 진동)로 초는 더 이상 지구 자전이 아니라 원자 상수로 정의됐다.
  • 그 결과 **TAI(순수 원자시)**와 **UT1(지구 자전 관측)**이라는 서로 다른 두 시간이 생겼다. UTC는 초의 길이는 TAI를 따르되 UT1과의 차이를 0.9초 이내로 묶는 절충안이며, 그 봉합 장치가 윤초다(1972년 시작, 현재까지 27회 삽입, 최근은 2016-12-31, 현재 TAI−UTC = 37초).
  • 2022년 CGPM은 윤초를 2035년까지 단계적으로 폐지하기로 결의했다 — 이미 끝난 일이 아니라 시한이 걸린 계획이다.
  • 윤초는 “시간은 단조롭게 흐른다”는 가정을 깨뜨려 실제 장애를 냈다. 2012년 리눅스 hrtimer livelock, 2017년 Cloudflare의 음수 duration 패닉이 그 증거다. 업계는 리프 스미어(수 시간에 걸쳐 시계를 미세하게 늦추는 방식)로 이 이음매를 감춘다.
  • java.time은 윤초를 아예 모델링하지 않는다. Duration.between·getEpochSecond()는 하루를 언제나 정확히 86,400초로 계산하고, Instant.parse("...23:59:60Z")는 예외 없이 직전 초와 같은 시각으로 조용히 접혀 파싱된다. 역사적 사실 대신 계산 가능성을 택한, 1장의 예변 그레고리력과 같은 결정이다.

생각해볼 질문: 여기까지가 제1부, “시간을 정하는 자”의 전체 궤적입니다. 하늘이 정하고 (1장), 나라가 다시 정하고(2장), 마침내 원자가 정했습니다(3장). 그런데 정작 컴퓨터는 이 유구한 역사 중 어느 것도 그대로 물려받지 않았습니다. 컴퓨터는 하늘도, 나라의 법령도, 윤초표도 다 무시하고 훨씬 단순한 결정을 내렸습니다 — 아무 순간이나 하나 잡아 0으로 찍고, 그 뒤로 그냥 초를 셌습니다. 그 0이 왜 하필 1970년 1월 1일이었을까요? 그리고 정수로 초를 세는 방식은, 언제 정수의 한계에 부딪힐까요?

4장 · 1970년 1월 1일 — epoch와 2038년 문제

Last updated on