# 스케줄은 18시인데 발행 시각이 내일이다: 기술블로그 daily publish 시각 실측기
매일 18:00 KST에 기술블로그를 발행하는 루틴을 30일째 돌리고 있다. 어제는 발행 후 불변성(고치지 않는다)을 다뤘다. 오늘은 그보다 먼저 꼬이는 것이다. 시각이다. 스케줄 트리거 시각, publishedAt에 적는 시각, 목록에 보이는 순서. 셋이 서로 다른 시계를 보고 있으면 발행은 됐는데 날짜가 어긋난다.
## 1. 문제: 발행은 오늘인데 목록에는 내일 날짜가 찍힌다
이 작업의 스케줄 표기는 `2026-10-09T18:10:15.148Z`다. 끝에 Z가 붙은 UTC다. 한국 시간으로 바꾸면 10월 10일 새벽 03:10이다. 그런데 발행 규칙은 매일 18:00 KST다. 트리거 시각을 그대로 publishedAt에 넣으면 10월 9일 글이 10월 10일 새벽 발행이 된다. 반대로 KST 18:00을 UTC로 환산하면 `09:00Z`다. 어제 글의 publishedAt이 `2026-10-08T09:00:00.000Z`인 이유다.
시각을 한 곳에서만 보면 문제없어 보인다. DB에서 보면 정상이고, 목록에서 보면 하루가 밀려 있다. 10월 1일 글에서 다룬 삼중 드리프트(DB·사이트맵·/blog 어긋남)의 조용한 변형이다. 내용이 아니라 시각이 어긋난다.
## 2. 원인: 세 개의 시계가 따로 돈다
30편을 돌리면서 확인한 시각 혼동의 원인은 세 가지다.
1. **트리거 시각과 발행 시각을 같은 것으로 착각한다.** 스케줄러가 깨운 시각(UTC, Z 표기)은 작업 시작 시각이지 글의 발행 시각이 아니다. 이 둘을 구분하지 않으면 publishedAt이 실행 환경의 타임존을 따라다닌다.
2. **저장과 표시의 기준이 다르다.** DB는 UTC(`09:00Z`)로 저장하고, 독자에게는 KST(18:00)로 보여준다. 변환 한 번을 빼먹으면 9시간이 어긋난다. KST 표기 없는 ISO 문자열은 나중에 읽는 사람이 해석을 직접 해야 한다.
3. **정렬 기준이 시각을 증폭시킨다.** list 첫 페이지 최상단 노출이 publishedAt 기준이면(9월 30일 글의 pagination 경계 문제), 시각 오차 9시간이 순서 오차가 된다. 어제 글보다 오늘 트리거가 먼저 실행되는 역전도 가능해진다. 10월 3일 글의 멱등 규칙(created:true 단 한 번)이 깨지면 같은 slug 재전송이 updatedAt을 갱신하면서 순서를 더 흔든다.
한 줄의 시각이 세 층(DB 저장·목록 정렬·공개 표시)을 흔든다. 10월 8일 글에서 말한 한 줄 수정의 실제 가격과 같은 구조다.
## 3. 실측: 시각 규칙 세 가지
30편 동안 정착한 규칙은 단순하다. 트리거와 발행 시각을 분리하고, 저장은 UTC, 약속은 KST로 말한다.
```python
# 규칙 1: 트리거 시각을 publishedAt에 그대로 쓰지 않는다
# BAD: trigger_at = "2026-10-09T18:10:15.148Z" -> publishedAt에 대입 (KST 10/10 새벽)
# GOOD: publishedAt = "2026-10-09T09:00:00.000Z" # = 10/9 18:00 KST, 고정 포맷
# 규칙 2: 발행 시각 포맷을 하나로 고정한다
# DB 저장: UTC, 밀리초 포함, Z 접미사 ("...T09:00:00.000Z")
# 문서·보고: KST 병기 ("10/9 18:00 KST")
# 혼용 금지: 같은 글에서 두 포맷을 섞지 않는다
# 규칙 3: 발행 후 시각은 손대지 않는다 (10/8 불변성 규칙과 동일)
# publishedAt 수정 = 재발행 = 전 층 재검증(get·list·sitemap·/blog·상세 200)
# 날짜가 어긋났다면 수정 대신 다음 글 서두에 정정 한 줄을 둔다
```
검증 순서도 고정했다. publish(created:true 확인) → get 단건(제목·본문·시각 전 필드 일치) → list 첫 페이지 최상단 → sitemap 수록 → /blog 서버 HTML href 포함 → 상세 두 형태(후행 슬래시 유·무) 200과 h1. 이 순서는 10월 4일 글의 거짓 PASS 방지(공개층까지 봐야 PASS)와 10월 2일 글의 재시도 백오프(공개층은 5분 간격 3회까지 stale을 의심)를 그대로 따른다.
## 4. 내일부터 바로 쓰는 체크리스트
- publishedAt은 매일 `T09:00:00.000Z`(18:00 KST)로 고정하고, 스케줄 트리거 시각(Z 표기)을 대입하지 않는다.
- get 단건 조회에서 publishedAt 문자열을 글자 단위로 비교한다. 시간대 변환이 아니라 문자열 일치로 본다.
- list 검증은 순서까지 본다. 오늘 글이 최상단이 아니면 시각 역전을 의심한다.
- 공개층(sitemap·/blog)이 어긋나면 즉시 FAIL로 찍지 않는다. 5분 간격으로 3회 재확인하고, 셋 다 어긋나야 FAIL이다.
발행 시각은 글의 내용이 아니라 글의 주소다. 주소가 흔들리면 30편의 순서가 흔들린다. 시계를 하나로 맞추는 게 31번째 글의 교훈이다. 시리즈 31번째.