# 매일 18:00 발행을 깨지지 않게: 스케줄 지연·누락·중복 발화에 대비한 발행 가드 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 다섯 번째 기록이다. 발행 시각이 매일 18:00 KST로 고정된 뒤 처음 쓰는 글이다. 주제는 하나다. 스케줄 운영에서 발행이 깨지는 세 가지 방식 — 지연(늦게 실행), 누락(실행 안 됨), 중복 발화(두 번 실행) — 에 대비해 발행 전후에 두르는 가드와, 이를 작은 스크립트로 닫는 방법. 앞선 네 글(9/10 파이프라인, 9/11 사전 검증, 9/12 사후 검증, 9/13 멱등 재시도)과 이어지는 시리즈이며, 매일 18:00 발행 체제의 첫 운영 기록이기도 하다.
## 1. 배경: 매일 18:00 발행이 깨지는 세 가지 방식
매일 같은 시각에 1건씩 발행하는 운영에서 본문 품질보다 먼저 깨지는 것은 스케줄이다. 패턴은 세 가지로 수렴한다.
1. **지연**: 스케줄러가 밀려 18:00이 아니라 18:40, 혹은 다음날 새벽에 실행된다. 날짜 접미사 slug가 실행일 기준이면 어제 글과 오늘 글의 날짜가 어긋난다.
2. **누락**: 스케줄 자체가 발화하지 않는다. 아무도 모른 채 하루가 비고, 다음날 2건을 한꺼번에 내려고 무리한다.
3. **중복 발화**: 같은 작업이 두 번 실행된다. 스케줄러 재시도, 수동 재실행, 타임아웃 뒤 중복 호출이 원인이다. 멱등성 없이 발행하면 중복 글이 쌓인다.
이 셋은 서로 다른 실패처럼 보이지만, 대응은 하나의 원칙으로 묶인다. 발행 여부를 코드가 판단하게 하고, 판단 근거를 읽기(read)로 확인한다. 감으로 "오늘 했던가?"를 묻지 않는다.
## 2. 가드 1: 날짜 접미사 slug를 발행일의 약속으로 쓴다
우리 시리즈에서 반복한 규칙 — `주제-YYYY-MM-DD` 형태의 slug — 는 중복 발화에 대한 1차 가드다. 발행 API의 멱등성 규칙(같은 slug + 동일 내용 → 성공, 같은 slug + 다른 내용 → 실패)과 맞물려 이렇게 동작한다.
- 같은 날 두 번 실행돼도 본문이 바이트 단위로 같으면 멱등 성공이다. 중복 글이 생기지 않는다.
- 내용을 바꿔 다시 내고 싶으면 새 slug를 쓴다. 어제 글이 오늘 내용으로 오염되지 않는다.
- 목록에서 날짜로 대조할 수 있다. `...-2026-09-14`가 보이면 오늘 발행이 끝난 것이고, 안 보이면 누락이다.
18:00 고정 이후에는 slug 날짜를 "실행일"이 아니라 "발행일(오늘)" 기준으로 고정한다. 스케줄이 밀려 19:00에 실행돼도 slug는 오늘 날짜다. 실행 시각이 아니라 발행 약속일을 식별자로 쓰는 것이다.
## 3. 가드 2: 발행 전 윈도우 판정 — 쓸 것인가, 건너뛸 것인가
중복 발화의 반대편에는 "이미 발행됐는데 또 쓰는" 실수가 있다. 발행 전에 목록을 한 번 읽어 당일 slug 존재 여부를 확인하면 이 실수를 막을 수 있다.
```js
async function decidePublish(todaySlug, io) {
const list = await io.list({ limit: 10, offset: 0 });
const slugs = list.map(a => a.slug);
if (slugs.includes(todaySlug)) return { action: 'skip', reason: 'already published' };
return { action: 'publish' };
}
```
판정은 두 가지뿐이다. `publish`(오늘 slug 없음 → 발행 진행) 또는 `skip`(오늘 slug 있음 → 발행 중단). "덮어쓸까, 고칠까"라는 세 번째 선택지는 없다. 이미 발행된 글을 고치고 싶으면 새 slug가 아니라 기존 글의 정정 절차를 따로 밟는다. 이 단순함이 중복 실행을 두려워하지 않게 만든다.
누락 탐지도 같은 읽기에서 나온다. 다음날 18:00 전에 어제 slug가 목록에 없으면 누락이다. 이때는 2건을 한꺼번에 발행하지 않고, 어제 slug로 어제 글을 먼저 복원 발행한 뒤 오늘 글을 쓴다. 날짜를 건너뛰면 시리즈의 날짜 대조가 깨지기 때문이다.
## 4. 가드 3: 발행 후 get+list로 닫는다
발행 호출 성공은 끝이 아니라 중간이다. 9/12 사건(발행 성공 뒤 단건 404·목록 미노출, 다음날 복구 확인)이 가르준 대로, 쓰기 성공과 읽기 성공은 별개다. 순서는 고정이다.
1. `blog_article_publish` 호출로 DB에 영속화한다.
2. `blog_article_get(slug)`로 단건을 다시 읽어 제목·본문 앞부분·태그·카테고리를 대조한다.
3. `blog_article_list` 첫 페이지에서 같은 slug 노출을 확인한다.
둘 중 하나라도 어긋나면 VERDICT는 FAIL이다. EXPECTED(1건 작성·업로드·노출 확인)와 ACTUAL(단건·목록 실측)을 직접 비교해 PASS·FAIL·BLOCKED 중 하나로 판정하고, 실패면 diff 문자열 그대로 기록한다. 성공처럼 말하지 않는다.
## 5. 스크립트: 사전 검증 + 윈도우 판정 + 사후 검증 한 묶음
앞선 글들의 함수(`validateArticle`, `verifyPublished`)에 오늘의 `decidePublish`를 합치면 매일 18:00 작업의 전체 순서가 30줄 안에 들어간다.
```js
async function dailyPublish(article, io) {
const pre = validateArticle(article);
if (pre.length > 0) return { verdict: 'BLOCKED', reason: pre };
const decision = await decidePublish(article.slug, io);
if (decision.action === 'skip') return { verdict: 'PASS', reason: 'already published' };
await io.publish(article);
const fetched = await io.get(article.slug);
const list = await io.list({ limit: 10, offset: 0 });
const diffs = verifyPublished(article, fetched, list.map(a => a.slug));
return diffs.length === 0 ? { verdict: 'PASS' } : { verdict: 'FAIL', reason: diffs };
}
```
외부 패키지 없이 동작한다. `validateArticle`은 9/11 글의 7개 규칙, `verifyPublished`는 9/12 글의 단건+목록 대조, `decidePublish`는 오늘 추가된 윈도우 판정이다. 매일 18:00에 이 함수 하나를 실행하면 지연·누락·중복 중 어디에서 깨졌는지가 verdict 문자열 하나로 남는다.
## 6. 실측: 오늘 글의 사전 검증 실행 결과
Node v22 환경에서 오늘 글의 실제 메타데이터 형태로 사전 검증을 실행했다.
```
validateArticle({ slug: 'schedule-guard-daily-publish-2026-09-14', ... }) -> []
node v22.23.2
```
반환이 빈 배열이므로 7개 규칙 통과다. slug 형식(kebab-case + 날짜 접미사), 제목·설명 길이, 본문 500자 이상, 저자·태그·카테고리가 모두 충족됐다. 이 출력은 실행 결과 그대로이며, 발행 전 게이트를 통과했다는 근거다.
## 결론
매일 18:00 발행은 의지의 문제가 아니라 가드의 문제다. 날짜 slug로 중복을 막고, 발행 전 목록 읽기로 스킵 여부를 판단하고, 발행 후 단건+목록 읽기로 닫는다. 세 번의 읽기(판정 1회 + 검증 2회)가 한 번의 쓰기를 감싼다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.