오픽 앱결제시간대진단

UTC 서버에서 생긴 KST 경계 버그 여섯 개

서버는 UTC 로 돌고 사용자의 하루는 KST 로 시작한다. 증상이 전혀 안 닮은 버그 여섯 개가 같은 뿌리였고, 두 기준을 한 함수 안에서 섞은 게 문제였다.

2026년 9월 8일5분

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

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

서버는 UTC 에 산다

이 앱의 서버는 서버리스 함수다. 타임존은 UTC 다. 데이터베이스도 timestamptz 로 UTC 를 저장한다. 여기까지는 옳고, 바꿀 이유도 없다.

문제는 코드가 "오늘"을 물을 때 생긴다.

new Date().setHours(0, 0, 0, 0);

이 한 줄은 로컬 자정을 뜻하는데, 서버의 로컬은 UTC 라서 한국 사용자에게 이 값은 KST 오전 9시다. 아무 예외도 나지 않고, 로그에도 안 남고, 개발자 노트북에서는 정상으로 보인다. 노트북의 로컬이 KST 니까.

한 비교식 안의 두 시간대

여섯 개를 늘어놓고 보니 공통점이 더 정확하게 잡혔다. UTC 를 썼다는 것 자체보다, 한 비교식의 양쪽이 서로 다른 시간대였다는 게 문제였다.

가장 비쌌던 것부터 보자. 일일 크레딧 충전 판정이다.

// 세션 콜백
오늘날짜 = KST 기준 날짜키        // "2026-09-08"
마지막충전 = UTC 기준 날짜키       // "2026-09-07"
if (오늘날짜 !== 마지막충전) 충전한다

한쪽은 KST 로 읽고 다른 쪽은 UTC 로 읽었다. KST 00시부터 09시 사이에는 이 두 값이 항상 다르다. 그 아홉 시간 동안은 잔액이 기준 미만으로 떨어질 때마다 매번 재충전됐고, 크레딧이 사실상 무한이었다.

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시"라는 문장을 일반화했다면 나머지 다섯 개는 하루에 끝났을 것이다.

끝