자정은 어디에 있는가
서버는 UTC 로 돈다. 그런데 사용자의 하루는 KST 로 시작한다. 이 어긋남이 여섯 군데에서 각각 다른 얼굴로 나타났다.
일주일 사이에 고친 버그 여섯 개가 있다. 증상은 전부 달랐다.
주간 랭킹이 월요일 아침에 갱신되지 않았다. 복습 카드가 예정일에 안 떴다. 크레딧이 무한정 충전됐다. 관리자 대시보드 숫자와 목록 숫자가 안 맞았다. 공지가 등록한 날부터 안 보였다. 리퍼럴 월 상한이 달이 바뀌었는데 안 풀렸다.
원인은 하나였다. 그리고 그 원인은 "UTC 를 썼다"가 아니었다.
서버는 UTC 에 산다
이 앱의 서버는 서버리스 함수다. 타임존은 UTC 다. 데이터베이스도 timestamptz 로 UTC 를 저장한다. 여기까지는 옳고, 바꿀 이유도 없다.
문제는 코드가 "오늘"을 물을 때 생긴다.
new Date().setHours(0, 0, 0, 0)이 한 줄은 로컬 자정을 뜻한다. 그리고 서버의 로컬은 UTC 다. 한국 사용자에게 이 값은 KST 오전 9시다.
즉 이 코드로 잡은 "하루의 시작"은 자정이 아니라 아침 9시다. 아무 예외도 나지 않고, 로그에도 안 남고, 개발자 노트북에서는 정상으로 보인다. 노트북의 로컬이 KST 니까.
진짜 결함은 섞은 것이다
여섯 개를 늘어놓고 보니 공통점이 더 정확하게 잡혔다. 전부 UTC 를 쓴 게 아니라, 한 비교식의 양쪽이 서로 다른 시간대였다.
가장 비쌌던 것부터 보자. 일일 크레딧 충전 판정이다.
// 세션 콜백
오늘날짜 = KST 기준 날짜키 // "2026-09-08"
마지막충전 = UTC 기준 날짜키 // "2026-09-07"
if (오늘날짜 !== 마지막충전) 충전한다한쪽은 KST 로 읽고 다른 쪽은 UTC 로 읽었다. KST 00시부터 09시 사이에는 이 두 값이 항상 다르다. 그 아홉 시간 동안은 잔액이 기준 미만으로 떨어질 때마다 매번 재충전됐다.
한 줄로 요약하면 이렇다. 자정부터 아침 9시까지 크레딧이 무한이었다.
마지막 숫자가 원인을 확정해 준다. 초과 충전을 받은 사람들은 밤에 쓰는 사람들이었다. 버그가 열려 있는 시간대에 앱을 쓴 사람만 혜택을 받았다. 금액은 작지만, 이건 결제 시스템의 결함이고 규모는 사용자 수에 비례해서 자란다.
같은 어긋남, 다른 얼굴
나머지 다섯 개도 뿌리는 같은데 증상이 전혀 안 닮았다.
| 기능 | 사용자가 본 것 | 어긋난 지점 |
|---|---|---|
| 주간 랭킹 | 월요일 0~9시 학습이 지난주로 집계되고, 그동안 지난주 순위가 그대로 보임 | getDay() 로 주 시작을 잡아 실제로는 KST 월요일 09시 |
| 복습 카드 | 예정일 오전 9시가 되어야 카드가 뜸. 아침 학습자는 못 봄 | nextReviewAt 은 UTC 자정 저장, 판정은 절대 시각 비교 |
| 리퍼럴 월 상한 | 달이 바뀌었는데 1일 오전 9시까지 상한이 안 풀림 | 월 경계가 UTC 기준 |
| 관리자 날짜 필터 | 목록 숫자가 대시보드 숫자와 안 맞음 | 필터는 UTC 하루, 대시보드는 KST 하루를 봄 |
| 공지 노출 | 시작일로 지정한 날 오전 9시부터 노출됨 | 관리자 입력을 서버가 UTC 로 해석 |
관리자 필터와 공지가 특히 얄궂다. 이 둘은 한쪽만 KST 로 이미 고쳐져 있었다. 대시보드는 KST 를 보는데 목록 필터는 UTC 를 봤고, 그래서 같은 화면 안에서 두 숫자가 달랐다. 절반만 맞추면 안 맞추느니만 못하다.
고친 방법
시간대 유틸을 한 파일로 모으고, 경계를 잡는 함수를 이름으로 구분했다. 주 시작, 달 시작, N일 뒤 자정, 날짜키, 관리자 입력 파싱.
저장은 UTC 그대로 뒀다. 바꾼 것은 경계 계산뿐이다. 스키마 변경도 없다.
전환에는 일회성 영향이 있었다. 주 시작이 9시간 앞당겨지면서 일요일 밤 활동이 이번 주 집계에 들어온다. 사용자에게 유리한 방향이라 그대로 뒀다. 저장된 복습 예정일은 백필하지 않았다. 다음 복습 때 새 값으로 갱신되고, 그때까지 최대 9시간 늦게 뜰 뿐이다.
두 번째로 같은 실수를 했다
이 글을 쓰면서 불편한 걸 하나 발견했다. 나는 이 실수를 전에도 했다.
다른 프로젝트에서, 완전히 다른 코드베이스에서, 사용자 발화의 기준일을 UTC 로 계산하고 있었다. 그때도 증상은 "한국 시간 00시부터 09시 사이"였다. 그때 내린 결론은 파드 타임존 설정에 기대지 말고 오프셋을 코드에 못 박자는 것이었다.
그 결론은 옳았는데, 그 결론이 다음 프로젝트로 따라오지 않았다. 교훈은 기억에 저장했지 코드나 도구에 저장하지 않았다. 그래서 새 저장소를 시작할 때 아무것도 나를 막지 않았다.
남은 것
린트로 막을 수 있는지 아직 모른다. setHours(0,0,0,0) 과 getDay() 를 서버 코드에서 금지하는 규칙은 만들 수 있다. 그런데 클라이언트에서는 그게 정답이라 파일 경로로 갈라야 하고, 규칙이 복잡해지면 예외 주석이 늘어나다가 무력해진다. 아직 안 만들었다.
경계를 다루는 코드에 테스트가 없다. 여섯 개를 고치면서 단위 테스트를 한 줄도 안 썼다. KST 08:59 와 09:01 을 넣어보는 테스트 몇 개면 전부 잡혔을 것이고, 다음에 같은 자리가 깨질 때도 잡힌다. 지금 없는 건 그냥 안 만든 것이다.
증상이 원인을 안 닮으면 여섯 번 고치게 된다. 랭킹이 안 갱신되는 것과 크레딧이 무한 충전되는 것은 겉으로 아무 관계가 없다. 그래서 각각 다른 버그로 접수됐고 각각 따로 고쳤다. 첫 번째를 고칠 때 "서버 로컬 자정이 KST 09시"라는 문장을 일반화했다면 나머지 다섯 개는 하루에 끝났을 것이다. 하나를 고칠 때 그것이 어떤 부류의 첫 번째인지 묻는 일을 매번 잊는다.