March 17, 2022

경과 시간을 측정하는 방법

위키에 쓸까 며칠 고민했는데, 이 정도는 간단하게 블로그에 적어도 되지 않을까 싶어서 여기에 쓴다.

얼마 전 회사에서 어떤 분이 질문하기를, Go에서 fmt.Println()으로 time.Now()를 찍으면 나오는 m=+0.xxxxx와 같은 수치가 뭐냐고 물어 보았다. 그냥 프로그램 실행 이후 경과된 시간을 알려주는 것이라고 간단하게 대답했는데, 대답을 들었던 분이 이게 왜 필요한지를 정확히 이해하지 못한 것 같았다. 그렇지, 일반적으로는 실행했을 때 시간을 알면 현재 시간에서 빼기만 해도 바로 경과 시간을 알 수 있으니까 말이다.

package main

import (
    "fmt"
    "time"
)

func main() {
    now := time.Now()
    fmt.Println("now?: ", now)
    time.Sleep(time.Millisecond * 454)
    fmt.Println("after?: ", time.Now())
}

위 코드의 출력은 아래와 같다. m 필드를 보면 454 밀리세컨드를 sleep했더니 m도 454만큼 증가되었다.

$ go run main.go
now?:  2009-11-10 23:00:00 +0000 UTC m=+0.000000001
after?:  2009-11-10 23:00:00.454 +0000 UTC m=+0.454000001

결론부터 이야기하면 2009-11-10 23:00:00 +0000 이후 등장하는 mMonotonic Clock이라고 불린다. 단순히 증가만 하는 시계를 말한다. 오늘 할 이야기는 이 Monotonic clock에 대한 이야기이다.


어떤 진동이 굉장히 일정한 경우를 생각해 보자. 얼마나 되는지는 모르겠지만 어떤 진자를 보니 이게 굉장히 일정하게 움직인다고 했을 때 우리는 이 진자가 60번 흔들린 기간을 1분으로 잡고, 진자 한 번이 흔들린 것을 1초로 정의할 수 있다. 그러나 분과 초를 알게 되었다 하더라도 이게 현재 시간을 알려주는 것은 아니다. 이 진자의 흔들림을 통해 얻을 수 있는 것은 얼마나 흔들렸는지, 즉 경과 시간이다. 경과 시간을 얻은 후에 여기에 우리가 알고 있는 시간을 더하여야만 현재 시간을 알게 된다. 앞을 Monotonic Clock, 뒤를 Wall Clock이라고 부른다.

진자와 비슷하게 컴퓨터의 시계는 부품에서 발생하는 틱을 통해서 계산한다. 컴퓨터 안에는 대략적으로 시간을 계산하는 내부 카운터가 있는데, 틱을 사용해 1초를 얻고 이를 내부 카운터에 적당히 더하도록 구현이 되어 있을 것이다. 틱은 오실레이터와 같은 부품을 통해 얻을 수 있다. 이 오실레이터에 전압을 걸면 일정한 진동을 가지도록 할 수 있다. 일반적인 전자 시계에 Quartz라는 글자가 있는 경우 이 시계의 진동자는 석영(Quartz)이고, 석영도 마찬가지로 전압을 걸면 진동이 일정하다. 그래서 톱니 등의 어떤… 기계식으로 구현한 무브먼트에 비해 오차가 적은 것이고. 아무튼 진동 수가 위 진자처럼 일정하니 여기서 발생한 진동에, 내부 카운터를 적당히 증가시키도록 하는 어떤 로직을 잘 만들어 두면 그것이 바로 시계가 된다. 진동 몇 번에 100ns? 뭐 이런 식으로 말이다.

A miniature 16 MHz quartz crystal enclosed in a hermetically sealed HC-49/S package, used as the resonator in a crystal oscillator.

그러면 시간 계산 뭐 이거는 이미 잘 되고 있는 것 아냐? 라고 생각할 수 있는데 그렇지도 않다. 어떤 틱이라 하더라도 반드시 오차가 발생한다는 것이다. 애초에 완전히 정확한 진동이란 것은 없다. 원자 시계는 완전 정확한 시계(Wall Clock)가 아니고, 오차가 극도로 적은 진동자(Monotonic Clock)다. 세슘 원자시계의 경우 그 오차가 3000만년에 1초 수준이라고 하니, 정확한 것은 아니지만 어마어마하게 높은 정확도를 가지고 있는 것이다. 원자 시계쯤 가더라도 작지만 이런 오차가 있으니 일반적인 크리스탈 오실레이터에도 당연히 오차가 있다. 오차가 적은 오실레이터는 가격도 비싸고 아마 크기도 더 클지도? 물론 직접적으로 이런걸 다뤄본 적은 없어서 잘 모른다.

시간 그 자체도 문제가 있다. 윤년의 경우 적당히 주기적으로 오긴 하겠는데, 제일 큰 문제는 윤초다. 윤년과는 다르게 이거는 완전히 랜덤이다. 왜냐 하면 애초에 지구 자전이 불규칙한 것 때문에 발생하는 것이기 때문이다. 윤초가 자주 발생했다면 아무리 정확도가 높은 틱이라 하더라도 윤초만큼 시간이 틀어질 수 밖에 없다… 윤초가 얼마나 추가가 된거지? 라고 생각할 수 있기 때문에 대략 찾아봤더니, 최근에는 2008, 2012, 2015, 2016년에 추가되었던 것 같다.

그러면 이 오차를 어떻게 줄일 수 있는가? 물론 애초에 크리스탈에 오차 보정을 해서 - 저항을 달거나 하는 등의 - 내놓을 수 있겠지만 그것도 뭐 원자시계가 아닌 바에야 한계가 있다. 그래서 평범한 손목 시계 같은 경우, 보통은 평소에는 그대로 두다가 크게 오차가 발생할 때 사용자가 시계를 직접 다시 보정한다. 시계가 아닌 내 컴퓨터에 있는 시계라면? 그건 NTP와 같은 프로토콜을 통해서 해결한다. 프로토콜이 시간을 직접 수정하는 것이다.

Network Time Protocol

NTP는 네트워크를 통해 얻은 데이터로 시간을 재조정한다. 최신 RFC는 RFC 5905에 NTPv4로 정의되어 있는 것 같다. NTP는 계층 구조를 가지는데 계층의 제일 위, 최상위는 원자 시계이다. 에이, 그래도 네트워크를 통하면 내 시계를 교정하다가 오차가 더욱 크게 발생하는 것 아냐? 라고 생각할 수 있는데, 나름 이를 훌륭하게 교정하는 알고리즘이 있어서 이건 기회가 되면 나중에 정리해보면 되겠다.

내 컴퓨터가 원자시계와 비교하여 더 느린 경우를 생각해 보자. 원자시계로부터 받아온 시간 데이터는 내 컴퓨터보다 더 빠른 시간이므로 내 컴퓨터의 시간을 더 빠르게 조정해야 한다. 이럴 경우 시간을 조정하는 방법은 내 시계의 값을 원자 시계의 값으로 강제로 변경하는 방법이 있다. 다른 방법은 기존까지는 n회 틱에 100ns를 증가시켰다 가정하는 경우 이제 틱 n/2번에 100ns를 올리는 식으로 변경하면 된다. 이렇게 하면 올바른 시간을 가리킬 때까지 그 오차를 점점 줄여나가며 시간이 동기화될 것이다. 어쨌든 기준에 맞춰 내 시간을 더 빠르게 해야 한다. 그러면 반대의 경우는? 그러면 틱 2n번에 100ns를 조정하면 된다. 만약 윤초를 넣어야 하는 상황이 왔다면? 이것도 그대로 시간을 재조정하면 된다.

오차가 1초당 1.01초로 계산되는 손목 시계를 생각해 보자. 하루는 86400000 밀리세컨드이다. 만약 시계가 1초당 0.01초 오차가 발생한다면 1010 * 86400 = 87264000이 되는데, 이를 분으로 바꾸면 대략 14분 정도가 나온다. 하루가 지나면 14분의 오차가 발생하는 것이다. 많이 정확하지 않은 느낌이다. 그러나 지금 몇 시인지 아는 것에는 큰 문제가 없다. 14분이라고 해봤자 한 시간에 대략 36초정도인데, 이정도면 시간 때문에 회의에 늦거나 하는 등의 곤란한 일은 발생하지는 않는다.(일반적인 쿼츠 시계는 30일당 대략 15초 정도의 오차가 난다…)

그러나 언젠가는 시간을 반드시 교정해야 한다. 시간을 교정하지 않으면 정확한 시간에서 아예 멀어진다. AM인데 PM 시간대를 가리킨다거나 하는 상황이 발생하는 것이다. 특히 요즘같은 분산 환경에서는 서버마다 시간이 달라지게 되므로 뭔가 동작도 정확히 되지 않을 것이다. 여기서 문제가 발생한다. 언제 교정이 됐는지 모르는 시계를 가지고 경과시간을 알려고 하는 것이 바로 그것이다. aftertime - beforetime을 해서 나온 elapsed가 있는데, aftertime이 만약 교정된 시간이라면? 예를 들어… beforetime을 잰 후 5분이 지나 시간을 15분 뒤로 돌려 보정했다면, aftertime은 보정된 이후 나온 시간이므로 elapsed가 음수가 될 수도 있는 것이다. 아니 그러면, 15분씩 한 번에 보정하니까 그렇죠, 더 잘게잘게 시간을 보정하면 되는거 아닙니까? 할 수도 있는데, 마찬가지로 경과시간이 음수가 나올 수 있다. 단위가 더 작아져서 그렇지. -100ns라던가…

아무튼 이야기를 정리하면 이렇다. 사용하려는 용도에 맞추어 시계가 달라져야 하고, 일반적인 목적에서는 Wall clock, 일반 시각으로도 충분하다. 그러나 이는 필요에 따라 보정값을 가질 수 있다. 그래서 경과 시간은 정확하지 않게 된다. 경과 시간을 측정할 때는 Monotonic Clock을 사용하여야 한다.

Go에서는 1.9부터 해당 기능이 포함되었다. 그전에는 이런 것이 없었고 덕분에 Cloudflare에서 장애가 한 번 났던 모양이다. 의외로 당연한 건데 Go에서 나중에야 지원되었다는 것이 약간 놀랍다.

대략 다른 언어의 Monotonic Clock을 보면 이와 같다.

  • Java: system.nanoTime(): wall clock과는 상관없고, 경과시간을 잴 때만 써라, 다른 버추얼 머신으로부터 가져온 값과는 계산하지 마라 뭐 이런 내용이 있다.
  • php: hrtime()
  • Python: PEP-418: # Add monotonic time, performance counter, and process time functions
  • Rust: std::time::Instant