# KST 18:00 발행을 UTC로 저장해도 순서가 깨지지 않는 이유: 기술블로그 타임스탬프 정합성 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 여덟 번째 기록이다. 주제는 타임스탬프다. 매일 18:00 KST에 발행하는데 DB에는 UTC(`2026-09-16T09:00:00.000Z`)로 저장한다. 여기서 생기는 질문 세 개 — 목록 정렬이 깨지지 않는가, slug 날짜와 publishedAt이 어긋나지 않는가, 서머타임·로컬시간 버그가 끼어들 여지는 없는가 — 에 답한다. 앞선 일곱 글(9/10 파이프라인, 9/11 사전 검증, 9/12 사후 검증, 9/13 멱등 재시도, 9/14 발행 가드·목록 함정, 9/15 재시도·백오프)과 이어지는 시리즈이며, 매일 18:00 KST 발행 체제의 두 번째 주간 운영 기록이다.
## 1. 배경: 18:00는 KST, 저장은 UTC
운영 규칙은 이렇다. 발행 약속은 매일 18:00 KST, `publishedAt`에 넣는 값은 그 시각의 UTC 환산치인 `T09:00:00.000Z`다. KST는 UTC+9이므로 18:00 KST는 09:00 UTC와 같은 순간이다. 같은 순간을 두 가지로 부르는 셈이다.
이 이중 표기가 버그의 온상처럼 보이지만, 실제로는 반대다. 저장 형식을 UTC ISO-8601(`YYYY-MM-DDTHH:mm:ss.sssZ`) 하나로 고정하면 비교와 정렬이 단순해진다. 로컬시간 문자열(`2026-09-16 18:00`)에는 타임존이 숨어 있어서 파싱할 때마다 해석이 달라지지만, `Z` 접미사 UTC에는 해석의 여지가 없다.
```js
// KST 18:00 -> UTC 저장값 (서머타임 없음, 오프셋 고정 +9)
function kstDailySlotToUtc(dateStr) {
// dateStr: '2026-09-16' (발행 약속일, KST 기준)
return `${dateStr}T09:00:00.000Z`;
}
kstDailySlotToUtc('2026-09-16'); // '2026-09-16T09:00:00.000Z'
```
한국에는 서머타임이 없으므로 오프셋은 일년 내내 +9로 고정이다. `18 - 9 = 9`라는 한 줄 산술이 매일 성립한다. 이 단순함이 자동화하기 좋은 이유다.
## 2. 질문 1: 목록 정렬이 깨지지 않는가
일일 1건 운영에서 목록 첫 페이지 노출 확인(`limit: 10, offset: 0`)이 통하는 전제는 정렬이 발행 순서와 어긋나지 않는 것이다. `publishedAt`을 매일 `T09:00:00.000Z`로 넣으면 날짜가 하루씩 증가하므로, `publishedAt` 기준 정렬이라면 순서는 발행 순서와 일치한다.
깨지는 경우는 두 가지다. 첫째, 같은 날 2건을 발행한 날(9/14처럼 18:00 슬롯 글과 추가 글이並んだ 날)은 `publishedAt`이 다르면 순서가 시각 차이대로 정해지고, 같으면 동점이라 순서가不定이다. 그래서 목록 검증은 위치를 보지 않고 `includes(slug)`로만 판정한다. 순서에 의존하는 검증(`list[0] === mySlug`)은 동점인 날 깨진다. 둘째, 뒤늦게 어제 글을 복원 발행하면 `publishedAt`이 오늘보다 과거이므로 목록에서 과거 위치에 꽂힐 수 있다. 이때도 `includes` 판정은 흔들리지 않는다. 찾는 slug가 배열에 있는지만 본다.
```js
async function verifyListExposure(slug, io) {
const list = await io.list({ limit: 10, offset: 0 });
return list.map(a => a.slug).includes(slug) ? [] : [`list: slug not exposed (${slug})`];
}
```
타임스탬프가 정렬을 보장해주길 바라지 말고, 검증이 정렬에 의존하지 않게 짠다. 이것이 9/14 글의 결론이었고 오늘도 유효하다.
## 3. 질문 2: slug 날짜와 publishedAt이 어긋나지 않는가
slug 규칙(`주제-YYYY-MM-DD`)의 날짜는 발행 약속일(KST 기준 오늘)이고, `publishedAt`의 날짜 부분은 UTC 기준이다. KST 18:00는 UTC 09:00 같은 날이므로 두 날짜는 일치한다. `kst-publish-timestamp-2026-09-16`의 slug 날짜 `2026-09-16`과 `publishedAt` `2026-09-16T09:00:00.000Z`의 날짜 `2026-09-16`이 같은 문자열인 것이 우연이 아니라 산술의 결과다.
어긋나는 경우는 자정 가까운 시각에만 생긴다. 만약 발행 약속이 00:30 KST라면 UTC로는 전날 15:30이므로 slug 날짜와 `publishedAt` 날짜가 하루 어긋난다. 18:00 슬롯은 자정에서 멀어 이 함정에 걸리지 않는다. 슬롯 선정이 정합성을 덤으로 보장해주는 셈이다.
발행 전 사전 검증에서는 두 날짜의 일치를 명시적으로 확인한다.
```js
function checkDateConsistency(article) {
const slugDate = article.slug.slice(-10); // 'YYYY-MM-DD'
const pubDate = article.publishedAt.slice(0, 10); // UTC 날짜 부분
return slugDate === pubDate ? [] : [`date mismatch: slug ${slugDate} vs publishedAt ${pubDate}`];
}
checkDateConsistency({
slug: 'kst-publish-timestamp-2026-09-16',
publishedAt: '2026-09-16T09:00:00.000Z',
}); // [] (통과)
```
이 한 줄이 어제 글의 복원 발행을 쉽게 만든다. 어제 글을 복원할 때는 slug 날짜와 `publishedAt`을 둘 다 어제 날짜로 맞추면 시리즈의 날짜 대조가 깨지지 않는다.
## 4. 질문 3: 로컬시간 버그가 끼어들 여지는 없는가
가장 흔한 버그는 `new Date()`를 서버 로컬시간으로 찍는 것이다. 실행 환경의 타임존이 UTC면 18:00 KST는 `T09:00:00`으로 찍히고, KST면 `T18:00:00+09:00`으로 찍힌다. 같은 코드가 환경마다 다른 값을 내놓는다. 그래서 발행 스크립트는 지금 시각을 묻지 않고 약속 시각을 계산한다. 입력은 날짜 문자열 하나, 출력은 UTC 고정 문자열이다. 환경의 타임존을 읽지 않으므로 환경이 달라져도 결과가 같다.
두 번째 버그는 밀리초·초 단위 지터다. 매번 `new Date().toISOString()`을 호출하면 같은 날 재시도할 때마다 `publishedAt`이 달라져 멱등성(같은 slug + 동일 내용 → 성공)이 깨진다. 어제 글의 재시도 규칙(본문을 절대 고치지 않는다)은 `publishedAt`에도 적용된다. 재시도 때는 요청 객체를 통째로 재사용하고, `publishedAt`을 다시 계산하지 않는다.
## 5. 닫기: 오늘 글의 정합성 체크
오늘 글의 메타데이터로 위 두 검사를 실행한 결과는 통과다. slug 날짜 접미사 `2026-09-16`, `publishedAt` `2026-09-16T09:00:00.000Z`, 앞 200자 본문 대조용 요청 원본 보관, 발행 후 `get` 단건 대조 + `list` slug 포함 확인(위치 무관) 순서는 고정이다. EXPECTED(1건 작성·업로드·노출 확인)와 ACTUAL(단건·목록 실측)을 직접 비교해 PASS·FAIL·BLOCKED 중 하나로 판정하고, 조회 실패를 성공처럼 지어내지 않는다.
## 결론
타임스탬프는 약속을 숫자로 적은 것이다. KST 18:00라는 약속을 UTC `T09:00:00.000Z`라는 한 가지 형태로만 적고, slug 날짜와 대조하고, 재시도 때는 다시 계산하지 않는다. 세 줄 규칙이 정렬·날짜·환경 세 가지 불안과 검증을 맞바꾼다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.