당신의 시간 버그는 사실 타입 에러입니다
시간을 다루는 코드를 몇 년째 유지보수해 본 백엔드 엔지니어라면 이 문장에 뜨끔했을 것입니다. 정산 금액이 하루치 어긋나고, 배치가 새벽에 두 번 돌고, 멀쩡하던 타임아웃이 갑자기 무더기로 발동하는 사고들 말입니다. 이 글이 주장하는 것은 하나입니다. 이 사고들은 서로 다른 버그가 아니라 같은 버그가 다른 옷을 입고 나타난 것입니다. 그리고 그 버그의 정체는 논리 실수가 아니라 타입 설계 실수입니다.
우리는 시간을 하나의 개념으로 다룹니다. 데이터베이스 컬럼 하나, 코드의 날짜 객체 하나, API 응답의 문자열 필드 하나로 말입니다. 그런데 현실의 시간은 최소 세 가지 서로 다른 대상이고, 이 셋은 더하고 빼는 규칙부터 다릅니다. 다른 것을 같은 그릇에 담아 두면 언젠가 반드시 잘못된 연산이 코드 리뷰를 통과합니다. 이 글은 그 사실 하나를 여러 각도에서 증명해 보려고 합니다.
글의 핵심 개념을 형상화했습니다.
조용히 터지는 버그
시간 버그가 특히 다루기 어려운 이유는 겉으로 티가 나지 않는다는 데 있습니다. 널 참조는 그 자리에서 예외를 던지고 스택 트레이스를 남깁니다. 시간 버그는 다릅니다. 코드는 시킨 계산을 정확히 수행하고, 로그에는 예외 하나 남기지 않으며, 테스트도 대개 초록불입니다. 문제는 몇 달 뒤 정산 담당자가 숫자를 손으로 대조하다가 발견합니다.
이런 사고를 되짚어 보면 공통점이 드러납니다. 코드는 틀리지 않았습니다. 다만 우리가 코드에게 시킨 연산이 애초에 물리적으로 성립하지 않는 연산이었을 뿐입니다. 예를 들어 벽시계로 오전 두 시 반에 삼십 분을 더하는 계산은, 그날이 일광절약이 시작되는 날이라면 정답이 아예 존재하지 않을 수 있습니다. 그런데 대부분의 언어는 이 연산을 아무 불평 없이 받아 주고 숫자 하나를 돌려줍니다.
컴파일러나 타입 체커가 이런 실수를 잡아 주지 못하는 이유도 명확합니다. 대부분의 언어에서 시간은 하나의 타입입니다. 정수나 문자열처럼 범용적으로 취급되고, 그 안에 순간인지 벽시계 시각인지 기간인지를 구분하는 정보는 담겨 있지 않습니다. 타입 시스템이 구분하지 못하는 실수는 사람이 매번 기억으로 방어해야 하고, 사람의 기억은 대규모 코드베이스에서 반드시 실패합니다.
그래서 이 문제는 문서화나 코드 리뷰 체크리스트로 풀리지 않습니다. 체크리스트는 사람이 실수하지 않기를 바라는 방식이고, 시간 버그는 정확히 사람이 실수하기 쉬운 지점에서 태어나기 때문입니다. 실제로 풀리는 방식은 하나뿐입니다. 애초에 서로 다른 것을 서로 다른 타입으로 만들어, 잘못된 연산 자체가 코드 상에서 표현되지 않게 막는 것입니다.
순간, 벽시계 시각, 기간이라는 세 가지 문법
시간이 몇 가지로 나뉘는지 정리해 보면 생각보다 단순합니다. 첫째는 순간입니다. 우주 어디에서 관측하든 같은 하나의 점이고, 흔히 기준 시각으로부터 흐른 초나 밀리초로 표현합니다. 결제가 승인된 시각, 로그가 기록된 시각이 여기 속합니다. 순간끼리는 뺄 수 있고 그 결과는 흘러간 시간의 길이입니다.
둘째는 벽시계 시각입니다. 달력과 시계판이 가리키는 표현, 이를테면 특정 연월일과 시분초입니다. 이 값은 그 자체로 순간이 아닙니다. 어느 지역의 시계판인지 모르면 지구상의 어느 점인지 알 수 없고, 심지어 어떤 지역에서는 그 시각이 아예 존재하지 않는 경우도 있습니다. 알람, 회의 예약, 근무 교대표가 여기 속합니다.
셋째는 기간입니다. 여기서도 다시 갈립니다. 물리적 길이가 고정된 기간이 있고, 한 달이나 하루처럼 달력에 의존해 길이가 달라지는 기간이 있습니다. 앞의 것은 순간에 그대로 더할 수 있습니다. 뒤의 것은 벽시계 시각에만 더할 수 있고, 그 결과를 다시 순간으로 바꾸려면 지역 규칙을 다시 거쳐야 합니다.
| 구분 | 순간 | 벽시계 시각 | 물리적 기간 |
|---|---|---|---|
| 기준점 | 없음, 전역적으로 하나 | 특정 지역의 달력 | 없음 |
| 존재하지 않는 값이 있는가 | 없음 | 있음, 일광절약 시작 시각 등 | 없음 |
| 지역 규칙이 필요한가 | 필요 없음 | 필요함 | 필요 없음 |
| 서로 뺄 수 있는가 | 가능, 결과는 기간 | 지역이 같아야 가능 | 가능 |
이 표가 말하려는 것은 하나입니다. 네 개의 칸이 전부 다르게 채워져 있다는 것 자체가, 이 셋을 같은 타입으로 묶으면 안 되는 이유입니다. 순간에 하는 연산을 벽시계 시각에 그대로 적용하면 존재하지 않는 값을 만들어 낼 수 있고, 벽시계 시각에 하는 연산을 순간에 적용하면 애초에 필요 없는 지역 규칙을 억지로 끌어들이게 됩니다.
오프셋을 저장하면 정치적 결정을 미래에 떠넘기게 됩니다
타임존 버그는 이 구분을 어기는 가장 흔한 형태입니다. 많은 엔지니어가 시간대를 플러스 아홉 시간 같은 오프셋으로 저장합니다. 편리해 보이지만 이 오프셋은 사실 어느 한 순간에만 유효한 계산 결과이지, 저장해도 되는 값이 아닙니다. 타임존은 지역의 이름이고, 그 이름 뒤에는 오프셋이 시간에 따라 어떻게 바뀌어 왔고 앞으로 어떻게 바뀔지를 정하는 규칙 묶음이 붙어 있습니다.
이 차이는 미래를 다룰 때 특히 위험합니다. 각국 정부는 정치적 판단으로 표준시나 일광절약 시행 여부를 바꿉니다. 발표에서 시행까지 몇 주밖에 남지 않는 경우도 드물지 않습니다. 지역 이름으로 저장된 예약은 규칙이 갱신되면 자동으로 올바른 순간을 다시 가리킵니다. 반면 오프셋으로 저장된 예약은 그 갱신을 알 방법이 없고, 옛 규칙에 그대로 붙들려 있습니다. 정부가 결정을 바꾸는 순간, 오프셋으로 저장된 모든 예약이 조용히 한 시간씩 어긋나게 됩니다.
과거를 다룰 때도 마찬가지 함정이 있습니다. 한 국가가 지금 쓰는 오프셋이 늘 그대로였던 것은 아닙니다. 표준시가 바뀐 적도 있고 일광절약을 시행했다가 그만둔 시기도 있습니다. 오래된 기록을 다루면서 오프셋을 상수로 박아 두면, 그 상수가 적용되지 않던 시기의 데이터가 조용히 틀어집니다. 통계 그래프에서 특정 연도 구간만 이상하게 튀는데 원인을 못 찾는 경우, 상당수가 이 유형입니다.
그래서 규칙은 짧게 정리됩니다. 저장하는 값은 지역 식별자여야 하고, 오프셋은 그 식별자와 특정 순간으로부터 계산해 내는 파생값이어야 합니다. 사용자 화면에 오프셋을 함께 보여 주는 것은 괜찮습니다. 다만 그 표시값을 다시 읽어 들여서 다음 계산의 입력으로 삼는 순간, 미래의 정치적 결정을 오늘의 코드가 예측하려 드는 셈이 됩니다. 아무도 그 예측에 성공한 적이 없습니다.
시계는 두 개인데 이름은 하나로 불립니다
시간대가 사람이 만든 문제라면, 이제부터는 기계가 만드는 문제입니다. 운영체제는 보통 두 종류의 시계를 제공합니다. 하나는 지금이 몇 시인지 알려 주는 시스템 시계입니다. 사람이 읽을 수 있고 기록에 적합하지만, 외부 시각 동기화 결과에 따라 앞뒤로 조정됩니다. 다른 하나는 어떤 기준점부터 얼마나 흘렀는지만 알려 주는 단조 시계입니다. 이 값은 절대 뒤로 가지 않지만 지금이 몇 시인지는 알려 주지 않습니다.
용도는 명확히 갈립니다. 두 시점 사이의 경과 시간을 재려면 단조 시계를 써야 합니다. 타임아웃, 지연 측정, 재시도 간격, 속도 제한 창처럼 흐른 양이 중요한 계산은 전부 여기 해당합니다. 반대로 언제 일어난 일인지를 기록할 때는 시스템 시계를 써야 합니다. 로그, 감사 기록, 생성 시각이 그렇습니다. 그런데 언어나 프레임워크가 제공하는 기본 함수는 이름만으로 어느 쪽인지 구분되지 않는 경우가 흔합니다.
이 구분을 지키지 않으면 증상이 요란하게 나타납니다. 시각 동기화가 시스템 시계를 일 초 뒤로 돌리는 순간, 시스템 시계로 재던 타임아웃은 일 초를 더 기다리거나 즉시 만료됩니다. 서버 시각이 크게 틀어져 있다가 한 번에 교정되는 경우에는 훨씬 심각합니다. 모든 연결이 동시에 만료되거나, 반대로 만료됐어야 할 것들이 몇 분간 죽지 않고 살아남습니다. 장애 보고서에는 갑자기 커넥션이 몰렸다고만 적히고, 진짜 원인은 몇 주 뒤에야 밝혀집니다.
분산 시스템에서는 여기에 착각이 하나 더 붙습니다. 서로 다른 서버가 찍은 타임스탬프를 비교해서 어느 이벤트가 먼저 일어났는지 판단하는 코드입니다. 두 서버의 시계가 완벽히 일치한다는 전제가 있어야 이 판단이 성립하는데, 그런 전제는 어느 분산 시스템에도 존재하지 않습니다. 시계는 서버마다 조금씩 다른 속도로 흐르고, 동기화 프로토콜은 오차를 줄일 뿐 없애지는 못합니다. 순서가 정말 중요한 자리에서는 타임스탬프 대신 순서 자체를 보장하는 별도의 장치가 필요하고, 이것 역시 시각과 순서라는 서로 다른 개념을 같은 값 하나로 표현하려다 생기는 문제입니다.
스케줄러는 언제가 아니라 무엇을 묻습니다
정기 배치도 같은 착각의 또 다른 얼굴입니다. 매시 정각에 도는 작업이 있다고 해 봅니다. 이 작업이 실제로 하는 일은 무엇일까요. 대개는 지난 한 시간 동안 쌓인 데이터를 처리하는 것입니다. 그렇다면 이 작업의 본질은 정각에 깨어나는 것이 아니라 특정 시간 구간을 소비하는 것입니다. 정각이라는 순간은 그 구간을 가리키는 방아쇠일 뿐 작업 그 자체가 아닙니다.
이 관점 전환이 설계를 바꿉니다. 작업에 넘기는 인자를 실행 시각이 아니라 처리 구간의 시작과 끝으로 잡으면, 어제 오후 세 시 구간을 다시 처리하고 싶을 때 같은 코드를 인자만 바꿔 호출할 수 있습니다. 실행 시각에 의존하는 코드는 이런 재실행이 애초에 불가능하고, 결국 별도의 백필 스크립트가 하나 더 생깁니다. 그 스크립트는 본체와 조금씩 달라지다가 언젠가 다른 결과를 냅니다. 하나의 정답이 둘로 갈라지는 순간입니다.
구간을 명시적으로 다루면 누락도 드러납니다. 처리한 구간을 기록해 두면 어느 구간이 비었는지 질의 한 번으로 알 수 있습니다. 스케줄러가 몇 시간 멈춰 있었다는 사실을 고객 문의보다 먼저 발견하게 됩니다. 경계 규칙도 함께 정해 둬야 합니다. 구간의 시작은 포함하고 끝은 제외하는 관례를 지키지 않으면, 인접한 두 구간이 한 시점을 두 번 세거나 어느 쪽에도 넣지 않게 됩니다. 사소해 보이지만 정산에서는 이 한 건이 대조 실패로 이어집니다.
중복 실행 문제도 같은 틀로 풀립니다. 분산 스케줄러는 리더 선출이 흔들리거나 배포 중에 새 인스턴스와 옛 인스턴스가 겹치면 같은 작업을 두 번 띄울 수 있습니다. 완벽하게 한 번만 실행되게 만드는 일은 대체로 비용이 크고, 그 비용을 치러도 완벽하지 않습니다. 그런데 작업을 구간 처리로 설계해 두면 두 번 실행돼도 문제가 되지 않습니다. 같은 구간을 두 번 처리해서 같은 결과를 내는 연산은 몇 번을 돌려도 안전하기 때문입니다. 완벽한 실행 보장을 좇는 대신, 연산 자체를 몇 번 돌려도 무해한 형태로 설계하는 편이 훨씬 값싸게 같은 안정성을 줍니다.
사실 시각과 인지 시각, 서로 다른 두 개의 시간축
시간을 다루는 시스템은 언젠가 같은 요청을 받습니다. 지난달 정산을 다시 뽑아 달라, 이 고객의 요금을 그때 기준으로 계산해 달라, 지표 정의가 바뀌었으니 반년치를 다시 채워 달라는 요청입니다. 이 요청을 처리할 수 있는지 없는지가 시스템의 성숙도를 가릅니다. 그리고 여기서도 답은 시간을 하나가 아니라 둘로 나누는 데 있습니다.
하나는 사실이 성립한 시각입니다. 계약이 발효된 날, 요금제가 적용되기 시작한 날, 직원이 부서를 옮긴 날이 여기 속합니다. 다른 하나는 시스템이 그 사실을 알게 된 시각입니다. 담당자가 화면에서 입력한 시각, 외부 시스템에서 데이터가 들어온 시각입니다. 이 둘이 다를 수 있다는 사실이 핵심입니다. 삼월 일일자 부서 이동이 삼월 십오일에 입력되는 일은 흔합니다.
만약 시스템 입력 시각만 기록해 두었다면, 삼월 십일 시점의 조직도를 다시 그릴 방법이 없습니다. 반대로 사실 시각만 기록했다면, 삼월 십일에 우리가 무엇을 알고 있었는지를 재현할 수 없습니다. 감사 대응이나 분쟁 상황에서 필요한 것은 대개 후자입니다. 그때 우리가 가진 정보로 그 판단이 타당했는지를 묻는 것이기 때문입니다. 두 축을 함께 기록하지 않으면 이 질문에 답할 방법 자체가 존재하지 않습니다.
구현은 생각보다 단순합니다. 이력이 중요한 테이블에서 갱신을 덮어쓰기 대신 새 행 추가로 바꾸고, 각 행에 유효 시작과 유효 종료, 그리고 기록 시각을 함께 붙입니다. 조회는 시점을 인자로 받는 함수 하나로 감쌉니다. 이 감싸는 층이 없으면 여기저기서 직접 조건을 적게 되고, 그중 하나는 반드시 두 시간축을 헷갈립니다. 값싼 설계처럼 보이지만, 이 층 하나가 나중에 몇 주짜리 데이터 복구 작업을 통째로 없애 줍니다.
타입을 나누는 일은 규율이지 라이브러리가 아닙니다
여기까지 다룬 네 가지 사고, 즉 타임존 오차, 타임아웃 폭주, 중복 배치, 감사 불가능한 이력은 겉보기에 전혀 다른 문제입니다. 담당 팀도 다르고 원인 조사 방법도 다릅니다. 하지만 코드를 한 겹만 벗겨 보면 매번 같은 실수가 나옵니다. 서로 다른 연산 규칙을 가진 두 개념을 하나의 타입, 하나의 컬럼, 하나의 변수로 뭉뚱그린 실수입니다.
좋은 라이브러리가 이 문제를 일부 완화해 주는 것은 사실입니다. 순간과 벽시계 시각을 서로 다른 타입으로 제공하는 언어를 쓰면 실수의 절반은 개발 단계에서 걸러집니다. 하지만 라이브러리가 규율까지 대신해 주지는 않습니다. 지역 식별자 대신 오프셋을 저장하기로 한 설계 결정, 스케줄러 인자를 실행 시각으로 잡은 설계 결정, 이력 테이블에 시간축을 하나만 두기로 한 설계 결정은 전부 사람이 내립니다. 타입 시스템은 그 결정이 틀렸을 때 경고해 줄 수 있을 뿐, 결정 자체를 대신 내려 주지는 않습니다.
그래서 실무에서 쓸 수 있는 점검법은 하나로 요약됩니다. 코드에 시간 값이 등장할 때마다, 이것이 순간인지 벽시계 시각인지 기간인지, 그리고 기간이라면 물리적으로 고정된 것인지 달력에 의존하는 것인지를 스스로에게 묻는 것입니다. 답이 즉시 나오지 않는다면, 그 변수는 잘못된 타입에 담겨 있을 가능성이 높습니다. 이 질문 하나를 리뷰 습관으로 만드는 것이, 최신 시간 처리 라이브러리를 새로 도입하는 것보다 사고를 더 많이 막아 줍니다.
시간 버그가 유독 오래 방치되는 이유는 겉으로 조용하기 때문입니다. 하지만 조용하다고 해서 원인이 복잡한 것은 아닙니다. 지금까지 살펴본 모든 사례에서 원인은 하나였습니다. 서로 다른 것을 같은 그릇에 담았다는 것입니다. 그릇을 나누는 순간, 사고의 절반은 애초에 코드로 표현조차 되지 않습니다.