시간대 경계시간대KST결제

자정은 어디에 있는가

서버는 UTC 로 돈다. 그런데 사용자의 하루는 KST 로 시작한다. 이 어긋남이 여섯 군데에서 각각 다른 얼굴로 나타났다.

2026년 9월 8일5

일주일 사이에 고친 버그 여섯 개가 있다. 증상은 전부 달랐다.

주간 랭킹이 월요일 아침에 갱신되지 않았다. 복습 카드가 예정일에 안 떴다. 크레딧이 무한정 충전됐다. 관리자 대시보드 숫자와 목록 숫자가 안 맞았다. 공지가 등록한 날부터 안 보였다. 리퍼럴 월 상한이 달이 바뀌었는데 안 풀렸다.

원인은 하나였다. 그리고 그 원인은 "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시까지 크레딧이 무한이었다.

15명
정당한 일일 상한을 초과해 충전받은 사용자
195건
그렇게 발생한 초과 충전 횟수. 금액으로는 약 $0.65
69%
초과 사용자의 야간 활동 비중. 나머지 사용자는 17%였다

마지막 숫자가 원인을 확정해 준다. 초과 충전을 받은 사람들은 밤에 쓰는 사람들이었다. 버그가 열려 있는 시간대에 앱을 쓴 사람만 혜택을 받았다. 금액은 작지만, 이건 결제 시스템의 결함이고 규모는 사용자 수에 비례해서 자란다.

같은 어긋남, 다른 얼굴

나머지 다섯 개도 뿌리는 같은데 증상이 전혀 안 닮았다.

기능사용자가 본 것어긋난 지점
주간 랭킹월요일 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시"라는 문장을 일반화했다면 나머지 다섯 개는 하루에 끝났을 것이다. 하나를 고칠 때 그것이 어떤 부류의 첫 번째인지 묻는 일을 매번 잊는다.