로그가 미래에서 왔다 — 시계가 만든 다섯 사건 · 멀쩡한 인증서가 거절당했다 · 이론
멀쩡한 인증서가 거절당했다
한 줄 요약
인증서·토큰·캐시는 모두 "지금 몇 시인가" 를 남과 맞춰 본다. 내 시계가 틀리면
멀쩡한 것이 거절되고, 만료된 것이 통과한다.
왜 이게 필요했나
시계 오차의 대부분은 아무 일도 일으키지 않는다. 로그가 몇 초 어긋나는 것은
읽기 불편할 뿐이다. 그런데 기한을 판정하는 자리 에서는 같은 오차가 곧바로
거절이 된다. 그리고 그 거절은 원인을 가리키지 않는다. 메시지는 "인증서가 아직
유효하지 않습니다" 이고, 인증서는 방금 받은 정상 인증서다.
특히 사람을 헷갈리게 하는 것은 스스로 낫는다 는 점이다. 시계가 12초 뒤처진
기계는 새 인증서를 받은 뒤 12초 동안만 실패하고 그 뒤로는 멀쩡하다. 배포 직후에만
오류가 몇 건 나고 재현이 안 된다. 이런 것은 보통 "일시적인 네트워크 문제" 로
정리되고 다음 배포에 그대로 반복된다.
어떻게 동작하나
X.509 인증서에는 유효 구간이 두 개의 시각으로 들어 있다. notBefore 와 notAfter 다.
[RFC 5280 4.1.2.5절](https://www.rfc-editor.org/rfc/rfc5280)은 이 구간을
"notBefore 부터 notAfter 까지, 양 끝을 포함" 이라고 정의한다. 검증하는 쪽은 **자기
시계** 로 지금이 그 구간 안인지 본다. 그래서 같은 인증서가 어떤 기계에서는 통과하고
어떤 기계에서는 거절된다.
openssl x509 -in edge-1.pem -noout -startdate -enddate# notBefore=Nov 1 09:38:00 2025 GMT# notAfter=Feb 1 09:38:00 2026 GMT시계가 뒤처진 기계는 앞쪽 끝 에 걸린다. 새로 발급된 인증서의 notBefore 가 그
기계의 "지금" 보다 미래라서 아직 유효하지 않다고 판단한다. 걸리는 시간은 정확히
뒤처진 만큼이다. 반대로 시계가 앞선 기계는 뒤쪽 끝 에 걸린다. 남들보다 먼저
만료로 본다. 두 증상은 반대 방향이고, 어느 쪽인지 알면 시계가 어느 쪽으로 틀렸는지도
바로 알 수 있다.
토큰도 같은 구조다. [RFC 7519](https://www.rfc-editor.org/rfc/rfc7519)의 exp
클레임은 "그 시각 이후로는(on or after) 받아들이면 안 된다" 이고, nbf 는
"그 시각보다 전에는 받아들이면 안 된다" 이다. 그리고 두 절 모두 같은 문장을
덧붙인다 — 구현자는 시계 오차를 감안해 약간의 여유(leeway) 를 둘 수 있고, 보통
몇 분을 넘지 않는다.
여기서 수명과 오차의 관계가 드러난다. 수명 300초짜리 토큰을 시계가 277초 앞선
기계가 검증하면, 발급 23초 뒤부터 만료로 보인다. 오차가 수명에 가까워질수록 쓸 수
있는 시간이 0 으로 수렴한다. 그래서 수명이 짧을수록 시계 정확도 요구가 가혹해진다.
1분짜리 토큰을 쓰기로 했다면 시계 오차 예산을 먼저 정해야 한다.
쓸 수 있는 시간 = 수명 - (검증하는 쪽 시계가 앞선 만큼) 수명 300초 · 오차 +277초 → 23초 수명 300초 · 오차 + 5초 → 295초 수명 60초 · 오차 +277초 → 아예 못 쓴다캐시도 같은 축에 있다. 응답에 붙는 만료 시각을 절대 시각으로 주면 받는 쪽 시계에
좌우되고, 남은 초로 주면 받는 쪽이 자기 단조 시계로 세므로 오차의 영향을 받지
않는다. HTTP 가 Expires 헤더와 별개로 Cache-Control: max-age 를 두고 후자를
우선하게 만든 이유가 여기에 있다.
현장에서 만나는 모습
자동 갱신이 도는 환경에서는 이 문제가 배포 주기와 맞물린다. 인증서를 만료 직전에
갈아 끼우는 대신 여유를 크게 두고 미리 갈아 끼우면, 시계 오차가 몇 분 있어도
양쪽 끝 어디에도 걸리지 않는다. 갱신 시점을 만료의 3분의 2 지점쯤으로 잡는 관행이
여기서 나온다.
인증 실패를 조사할 때 순서도 정해져 있다. 메시지가 "not yet valid" 인지 "expired"
인지부터 본다. 전자면 검증하는 쪽 시계가 뒤처졌거나 발급이 방금 끝난 것이고, 후자면
검증하는 쪽 시계가 앞섰거나 정말로 만료된 것이다. 그 다음에 양쪽 기계의 시각을
직접 비교한다. 이 두 걸음이면 대부분의 사건이 정리되는데, 실제로는 인증서를 다시
발급하는 쪽으로 먼저 손이 간다. 그리고 같은 일이 다음 갱신 때 또 난다.
여유(leeway)를 크게 잡는 것으로 덮는 방법도 있지만, 이것은 대가가 있다. 만료된
토큰을 그 여유만큼 더 받아 주겠다는 뜻이기 때문이다. 재발급을 취소로 쓰는 설계라면
그 시간만큼 취소가 늦게 듣는다. 여유는 시계를 고칠 때까지의 임시 조치 로 두고,
근본 대책은 오차를 재서 감시하는 쪽이다.
한 가지 더. 기한 때문에 생긴 실패는 양쪽 끝에서 증상이 대칭이 아니다. 앞쪽 끝
문제(아직 유효하지 않음)는 발급 직후 짧게 났다가 저절로 낫고, 뒤쪽 끝 문제(조기
만료)는 한 번 시작되면 시계를 고치거나 인증서를 갱신할 때까지 계속된다. 그래서
전자는 "재현이 안 되는 산발적 오류" 로, 후자는 "갑자기 전부 죽었다" 로 보고된다.
같은 원인인데 보고서의 제목이 정반대다. 사건을 받았을 때 이 비대칭을 먼저 떠올리면,
어느 쪽 끝을 볼지 곧바로 정해진다.
그리고 기한을 판정하는 자리는 코드에서 찾아내기 쉽다. 어딘가에서 「지금」을 읽어
남이 준 시각과 비교하는 곳이 전부 그 자리다. 인증서 검증, 토큰 검증, 캐시 신선도,
서명된 URL 의 만료, 재생 공격을 막는 타임스탬프 창이 모두 같은 모양이다. 이 자리
목록을 한 번 만들어 두면, 시계 오차 예산을 얼마로 잡아야 하는지가 추측이 아니라
계산이 된다 — 그 목록에서 가장 짧은 수명 이 예산의 상한을 정한다.
다음 퀴즈에서 확인할 것
시계가 뒤처진 기계와 앞선 기계가 각각 유효 구간의 어느 끝에 걸리는지, 토큰 수명과
시계 오차가 어떻게 곱해져 쓸 수 있는 시간을 줄이는지, 여유를 늘리는 대책의 대가가
무엇인지 확인한다.