호라이즌앱스 로고
← 기술 노트

매출 리포트 날짜가 하루씩 어긋날 때, 시간대 9시간 점검법

임균영조회 —

급식 납품업체 매출 시스템의 '어제 업로드' 숫자는 90일 중 19일 틀려 있었습니다.

다른 날로 잘못 들어간 업로드는 40건이었습니다.

틀린 모양도 고르지 않았습니다. 9월 7일은 업로드 12회로 보고됐는데 실제는 6회였습니다.

바로 다음 날인 9월 8일은 7회로 보고됐고, 실제는 13회였습니다.

원인은 시간대였습니다. 시각을 저장할 때는 UTC로 적고, 날짜를 끊어 읽을 때는 한국 달력을 댔습니다.

둘 사이는 정확히 9시간입니다. 그리고 이 함정을 막는 규칙은 한 달 전에 이미 문서로 적혀 있었습니다.

하루가 9시간 밀리는 구조

DB의 시각 컬럼 중에는 시간대 정보 없이 숫자만 담는 형식이 있습니다. PostgreSQL의 timestamp without time zone이 그렇습니다.

이 칸에 03:05가 들어 있으면 그게 런던 시각인지 서울 시각인지 칸 스스로는 모릅니다. 어느 존인지는 그 값을 쓴 코드가 정합니다.

이 시스템에서 ORM(Prisma)이 쓰는 값은 UTC였습니다. 리포트 쿼리는 그 값에 '9월 7일 0시부터 9월 8일 0시까지'라는 한국 날짜 경계를 그대로 댔습니다.

UTC 9월 7일 0시는 한국 시각으로 9월 7일 오전 9시입니다. 그래서 리포트의 '9월 7일'은 실제로 9월 7일 9시부터 9월 8일 9시까지였습니다.

다음 날 아침 9시 전에 올라온 업로드는 전날로 넘어갑니다. 12회와 6회, 7회와 13회는 차이가 똑같이 6건입니다.

집계 창이 9시간 오른쪽으로 밀리면 9월 8일 아침 업로드 6건이 9월 7일 막대에 붙어 12회와 7회가 됩니다. 창을 한국 날짜로 되돌리면 6회와 13회로 제자리를 찾습니다.

막대 아래쪽 6회와 7회는 두 상태에 공통입니다. 움직이는 건 9월 8일 아침의 6건 덩어리 하나뿐입니다.

증거가 된 짝 대조

고치기 전에 컬럼마다 어느 존으로 적혀 있는지부터 가려야 했습니다. 한쪽은 UTC이고 한쪽은 한국 시각이면 보정 방향이 반대가 되기 때문입니다.

처음 시도는 시각 분포였습니다. 사람이 하는 일은 한국 업무시간 9~18시에 몰린다는 가정으로 컬럼별 히스토그램을 돌렸습니다.

여기서 속을 뻔했습니다. 집계 시각을 적는 컬럼은 값의 99.7%가 0~8시에 몰려 있었습니다. UTC 업무시간처럼 보이는 분포입니다.

들여다보니 대부분이 00:00:28이었습니다. 한국 자정에 도는 배치가 찍은 값이었고, 자정 배치는 UTC 업무시간과 생김새가 같습니다.

배치가 찍는 컬럼은 업무시간 가정으로 존을 가를 수 없었고, 결국 쓸 수 있는 증거는 짝 대조 하나였습니다.

같은 업로드가 두 컬럼에 동시에 찍혀 있었습니다. 업로드 기록 쪽은 03:05:38.309, 집계 쪽은 12:05:38.367이었습니다.

초까지 같고 정확히 9시간 차이입니다. 밀리초 단위까지 붙어 있는 짝은 우연으로 나오기 어려워서, 앞은 UTC이고 뒤는 한국 시각으로 판정했습니다.

-- 같은 사건이 찍힌 두 컬럼을 나란히 놓고 차이를 본다 (테이블 이름은 예시)
SELECT u.created_at, s.computed_at, s.computed_at - u.created_at AS gap
FROM uploads u JOIN summaries s ON s.upload_id = u.id
ORDER BY u.created_at DESC LIMIT 20;

차이가 대부분 9시간 근처에 몰리면 두 컬럼의 존이 갈린 겁니다. 0초 근처면 같은 존입니다.

컬럼마다 쓰는 주체

기준을 잡은 뒤에는 시각 컬럼 전부를 '누가 쓰는가'로 나눴습니다. 저장 계층과 질의 경계, 화면 표시를 따로 훑고 DB를 직접 조회해 맞췄습니다.

쓰는 주체적히는 존해당 컬럼
ORM 기본값(now, updatedAt)UTC업로드·거래처·감사 기록 대부분
raw SQL의 CURRENT_TIMESTAMP한국 시각집계 시각 등 2곳
DB 기본값 CURRENT_TIMESTAMP한국 시각12개 컬럼의 폴백(평소엔 미사용)
now() AT TIME ZONE 'utc'UTC1곳

raw SQL이 한국 시각이 된 건 DB 세션의 시간대가 한국으로 잡혀 있어서입니다. 그래서 CURRENT_TIMESTAMP가 한국 시각을 돌려주고, ORM은 같은 칸을 UTC로 읽습니다.

한 컬럼 안에 두 존이 섞인 곳은 없었습니다. UTC 기준으로 미래인 행이 두 컬럼에 3,618행과 7행 있었고, 한국 시각 기준 미래 행은 0이었습니다.

숫자가 실제로 틀린 곳은 리포트의 '어제 업로드' 하나였습니다. 나머지는 입력 폼 기본 날짜 4곳과 내보내기 파일명 4곳 같은 표시 오차였습니다.

고객 메시지의 '새벽 4시 30분'

판정을 끝낸 직후에 제가 같은 함정에 걸렸습니다. 고객사에 보낼 메시지 초안에 한 지점의 업로드 시각을 '새벽 4시 30분'이라고 적었습니다.

그 값은 UTC 04:30이었고, 한국 시각으로는 오후 1시 30분입니다. 해당 업로드를 다시 환산하니 전부 09:39~17:52 사이 업무시간이었습니다.

바로 앞에서 그 컬럼이 UTC라고 판정해 놓고 다음 문장에서 숫자를 그냥 읽은 겁니다. 보내기 전 검토에서 "새벽이 맞느냐"는 질문을 받고 다시 환산했습니다.

열 지점이 모두 새벽에 파일을 올린다는 그림부터 이상하게 봤어야 했습니다. 저는 그걸 스스로 알아채지 못했습니다.

머릿속 환산은 언젠가 빠집니다. 그 뒤로는 조회 쿼리부터 원값과 한국 시각을 나란히 찍게 바꿨습니다.

SELECT created_at AS raw_utc,
       created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Seoul' AS kst
FROM uploads ORDER BY created_at DESC LIMIT 20;

한 달 전의 규칙 문서

이 시스템의 작업 규칙 문서에는 8월 17일자로 한 줄이 있었습니다. 시간대 정보가 없는 컬럼에 CURRENT_TIMESTAMP를 쓰지 말 것, ORM이 UTC로 읽어 9시간 밀린다는 내용입니다.

그날 문제가 된 한 곳은 고쳤고 수정 기록도 남겼습니다. 같은 날 한국 0~9시에 날짜 계산이 전날로 잡히던 곳들도 손봤습니다.

그때는 매월 1일의 신규 거래처가 빠지고 있었습니다. 고친 뒤 대시보드의 신규 거래처 증감이 ▼18%에서 ▼28.9%로 바뀌었고, 빠졌던 월초 데이터가 들어온 결과였습니다.

문제는 같은 형태를 끝까지 찾지 않은 데 있었습니다. 한 달 뒤에도 집계 쪽 3곳과 첫 주문 기록 쪽 4곳, 모두 7곳이 한국 시각으로 쓰고 있었습니다.

함정 하나를 고치고 규칙을 적는 일과 같은 형태를 전부 찾아 없애는 일은 따로 해야 합니다. 규칙을 적는 그 자리에서 금지 패턴을 전체 검색해 남은 개수를 세야 했습니다.

문서 대신 미래 시각 감시

이번에는 문서를 더 쓰지 않고 감시를 붙였습니다. 정합성 리포트에 'UTC 컬럼에 지금보다 미래인 행이 있나' 검사를 넣었습니다.

UTC로 제대로 적힌 값은 현재 UTC 시각보다 미래일 수 없습니다. 그래서 이 검사에는 오탐이 끼어들 자리가 없습니다.

SELECT count(*) FROM uploads
WHERE created_at > (now() AT TIME ZONE 'UTC');

감시가 정말 잡는지는 대조군으로 확인했습니다. 고치기 전 데이터에서는 위반 두 컬럼을 7행과 3,618행으로 정확히 잡았고, 고친 뒤에는 0행이었습니다.

한계도 있습니다. 한국 시각으로 잘못 쓴 값은 9시간이 지나면 UTC 현재 시각이 따라잡아 더는 미래가 아니게 됩니다.

검출 창이 9시간이라는 점은 검사 코드 주석에 적어 두었습니다. 감시를 하루 한 번만 돌리면 놓치는 구간이 생긴다는 뜻이기도 합니다.

98만 행을 다시 쓰지 않은 이유

근본 해법은 컬럼을 시간대 정보를 담는 형식(timestamptz)으로 바꾸는 것입니다. 다만 매출 행 테이블만 98만 행이 넘었고, ORM 스키마와 raw SQL 전부를 다시 봐야 했습니다.

대안장단점결정
timestamptz 전환근본 해법, 대형 테이블 재작성장기 과제로 보류
저장 규약을 UTC 하나로변경이 작고 되돌리기 쉬움, ORM 해석과 맞음채택
DB 세션 시간대를 UTC로CURRENT_DATE·now()의 뜻이 바뀌어 영업일 경계에 새 버그기각

마이그레이션은 한국 시각으로 쌓인 값을 9시간 당기는 작업이었습니다. 두 번 돌면 18시간이 당겨지므로 한 번만 돌게 막는 가드를 넣었습니다.

적용 전에는 트랜잭션 안에서 돌리고 되돌리는 방식(BEGIN…ROLLBACK)으로 먼저 쟀습니다. 보정 폭이 정확히 9시간인지, 미래 행이 0이 되는지, 재실행을 가드가 막는지 확인했습니다.

배포 뒤에는 새로 쓰인 값을 봤습니다. 방금 기록된 집계 시각이 10:08:03이었고 그때 한국 시각은 19:08이었습니다.

새 코드가 UTC로 쓰고 있다는 확인입니다. 리포트의 9월 7일과 8일도 6회와 13회로 돌아왔습니다.

우리 리포트는 괜찮을까

날짜별 숫자가 가끔 하루씩 밀리는 것 같다면 아침 9시 전후에 생긴 일부터 보세요. 그 시간대 기록이 전날로 가 있으면 거의 이 문제입니다.

증상먼저 의심할 곳
매월 1일 숫자가 빠짐날짜 계산이 UTC 기준인지
아침 업로드가 전날로 감UTC 저장값에 한국 날짜 경계를 댔는지
기록 시각이 새벽에 몰려 보임화면이 UTC 원값을 그대로 보여 주는지
기본 날짜·파일명이 하루 전서버가 UTC로 '오늘'을 계산하는지

엑셀로 매일 받던 숫자를 화면 하나로 옮기면 이런 날짜 경계 문제가 같이 따라옵니다. 날짜별 매출과 지점 현황을 한 화면에 모은 구성은 급식 운영 관제 데모에서 가상 데이터로 볼 수 있습니다.

이미 쓰고 있는 시스템의 숫자가 맞는지 의심될 때도 같은 절차로 점검할 수 있습니다. 컬럼별 존 판정과 감시 쿼리가 필요한 상황이면 대시보드 상담에 지금 보이는 증상을 적어 두세요.

다음에 바꿀 것

  • 날짜·시각 컬럼마다 누가 쓰는지 표로 적습니다. ORM, raw SQL, DB 기본값, 배치를 나눠 각 컬럼의 존을 채웁니다.
  • 존 판정은 같은 사건이 찍힌 두 컬럼의 짝 대조로 합니다. 시각 히스토그램은 사람이 만드는 이벤트에만 보조로 씁니다.
  • 'UTC 컬럼에 미래 시각 행이 있나'를 정합성 리포트의 상시 검사로 겁니다. 검출 창이 9시간이라 몇 시간 간격으로 돌립니다.
  • 시각을 보고서나 메시지에 옮길 때는 원값과 한국 시각을 나란히 적습니다. 'UTC 04:30(한국 13:30)'처럼 씁니다.
  • 규칙을 새로 적는 날 금지 패턴을 전체 검색해 남은 개수를 세고, 남긴 곳에는 이유를 붙입니다.

이어서 읽기