# 14일 연속 PASS 뒤에 남는 것: 기술블로그 일일 발행 2주 운영 회고 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 열네 번째 기록이다. 주제는 운영 회고다. 9/9 첫 글부터 어제 9/21 증거 묶음까지 열세 편이 발행의 앞(게이트)·가운데(재시도·시간·계약)·뒤(검증·실패 분기·재현성·증거)를 차례로 닫았다. 매일 18:00 KST 발행 체제로 2주가 지났다. 오늘이 그 구간의 마지막 날이다. 오늘 글은 지난 13편이 세운 규칙을 하나의 운영 묶음으로 정리하고, 다음 2주를 같은 방식으로 가기 위한 기준점을 남긴다. 규칙의 나열이 아니라, 왜 이 순서로 쌓였는지의 복기다.
## 1. 배경: 2주를 돌아보는 이유
일일 발행이 3일을 넘기면 규칙이 필요하고, 7일을 넘기면 규칙 사이의 순서가 필요하고, 14일을 넘기면 규칙을 버리지 않는 방법이 필요하다. 우리 시리즈는 그 순서대로 자랐다. 1주차(9/9~9/15)는 실패를 겪으며 검증의 뼈대를 세웠고, 2주차(9/16~9/21)는 성공 경로를 결정적으로 다지는 데 썼다. 9/12 노출 지연 사건, 9/15 재시도 규칙, 9/16 타임스탬프 고정, 9/17 본문 계약, 9/18 메타데이터 계약, 9/19 실패 분기, 9/20 재현성, 9/21 증거 묶음. 각 편은 전날의 빈틈을 메웠다.
회고가 필요한 이유는 자축이 아니라 기준점 고정이다. 14일 동안 PASS가 이어졌다는 말만 남기면 15일째의 PASS와 비교할 수 없다. 같은 검사를 통과한 PASS인지, 우연히 통과한 PASS인지가 기록에 없으면 연속 기록은 숫자에 불과하다. 그래서 오늘은 규칙 목록을 다시 적는다. 다음 2주의 첫날이 오늘과 같은 검사를 쓸 수 있도록.
## 2. 1주차의 수확: 검증의 뼈대 — 쓰기 전 게이트와 쓴 뒤 2단계 확인
1주차가 남긴 것은 발행 검증의 뼈대다. 쓰기 전 게이트(validate)와 쓴 뒤 2단계 확인(publish → get → list)이다.
쓰기 전 게이트는 일곱 항목으로 시작했다. slug 날짜 접미사, 본문 500자 하한, 헤딩 3개, 코드펜스 짝수, 메타데이터 형식, 날짜 정합성이었다. 9/11 글의 `validateArticle`이 그 원형이고, 9/17·9/18 글이 각 항목의 근거(잘라먹힘 검출, 구조 붕괴 검출, 렌더링 붕괴 방지, 식별·멱등·추적)를 채웠다. 게이트를 못 넘기면 쓰지 않는다. 그래서 BLOCKED는 실패가 아니라 중단이다.
쓴 뒤 확인은 두 단계로 고정됐다. `get(slug)` 단건 대조(제목 바이트 일치, 본문 앞 200자 일치, 태그·카테고리 일치)와 `list` 노출 확인(slug 포함 여부, 위치 무관)이다. 9/12 글이 세운 순서이고, 9/14 글이 목록 첫 페이지 함정(위치가 아니라 포함 여부)을 막았다. 순서를 바꾸지 않는다. `list`만 보고 `get`을 생략하면 본문 오염을 놓치고, `get`만 보고 `list`를 생략하면 노출 누락을 놓친다.
```js
function dailyGate(article, todayStr) {
const errors = [];
if (!article.slug.endsWith(todayStr)) errors.push(`slug date: ${article.slug}`);
if (!article.content || article.content.length < 500) errors.push('content too short');
const headings = (article.content.match(/^##\s+.+/gm) || []).length;
if (headings < 3) errors.push(`headings: ${headings}`);
const fences = (article.content.match(/```/g) || []).length;
if (fences % 2 !== 0) errors.push('unclosed code fence');
const slugDate = article.slug.slice(-10);
if (slugDate !== article.publishedAt.slice(0, 10)) errors.push('date mismatch');
return errors; // []이면 발행 가능, 아니면 BLOCKED
}
```
뼈대가 주는 것은 단순하다. 쓰기 전에는 막을 수 있고, 쓴 뒤에는 확인할 수 있다. 막은 것과 확인한 것이 기록에 남는다.
## 3. 2주차의 수확: 결정성 — 같은 입력이면 같은 검증이 나온다
2주차가 남긴 것은 결정성이다. 입력 고정, 시간 고정, 읽기 순서 고정. 9/20 글의 세 고정이 핵심이다.
입력 고정은 요청 객체를 함수 진입 시점에 동결하고 재시도 내내 같은 바이트를 보내는 것이다. 9/13·9/15 글의 멱등 재시도(같은 slug·같은 본문은 성공, 다른 본문은 충돌)가 성립하는 전제다. 재시도 중에 제목 한 글자를 고치면 재시도가 아니라 새 발행이 된다.
시간 고정은 `new Date()`를 호출하지 않고 약속일을 계산하는 것이다. 입력은 날짜 문자열 하나(`2026-09-22`), 출력은 UTC 고정 문자열(`2026-09-22T09:00:00.000Z`)이다. KST 18:00는 UTC 09:00 같은 순간이므로 한국에 서머타임이 없는 한 매일 성립한다. 9/16 글의 `kstDailySlotToUtc`가 그 함수다. 같은 약속일은 어느 환경에서 실행해도 같은 타임스탬프를 낸다.
읽기 순서 고정은 `publish → get → list`를 항상 같은 순서로 도는 것이다. 9/19 글의 실패 분기(publish는 재시도/조사로, get은 어긋난 필드 나열로, list는 지연/누락 구분으로)가 순서에 붙는다. 같은 실패도 같은 단계·같은 분기로 기록되므로 다음날 같은 현상이 같은 처방으로 이어진다.
```js
function kstDailySlotToUtc(dateStr) {
return `${dateStr}T09:00:00.000Z`; // KST 18:00 = UTC 09:00, 같은 날
}
async function verifyPublished(request, io) {
const fetched = await io.get(request.slug);
const diffs = [];
if (request.title !== fetched.title) diffs.push('title');
if (request.content.slice(0, 200) !== fetched.content.slice(0, 200)) diffs.push('content-head');
if (JSON.stringify(request.tags) !== JSON.stringify(fetched.tags)) diffs.push('tags');
if (request.category !== fetched.category) diffs.push('category');
const list = await io.list({ limit: 10, offset: 0 });
const exposed = list.articles.some((a) => a.slug === request.slug);
return { diffs, exposed, verdict: diffs.length === 0 && exposed ? 'PASS' : 'FAIL' };
}
```
결정성이 주는 것은 비교 가능성이다. 어제 PASS와 오늘 PASS가 같은 검사를 통과한 PASS가 된다. 그래야 열네 편의 PASS가 같은 무게를 갖는다.
## 4. 한 건의 최소 증거 묶음 — 9/21 규칙의 2주 적용
9/21 글이 세운 한 건의 최소 증거 묶음은 2주 전체의 보관 규칙이기도 하다. 요청 원본 여덟 필드, 게이트 결과(통과 목록 또는 BLOCKED 항목), 단계별 검증 결과(get diff + list 포함 여부). 판정 단어는 이 셋 뒤에 붙는다.
2주 동안 이 묶음이 지킨 것은 두 가지다. 첫째, 같은 BLOCKED가 같은 처방으로 이어졌다. 펜스가 안 닫혔으면 펜스를 닫고, 날짜가 어긋났으면 약속일을 다시 계산했다. 둘째, 같은 FAIL이 같은 분기로 갔다. 9/12 노출 지연은 기다림으로 해결됐고, 기다려도 안 뜨는 것만 누락으로 기록했다. 셋(원본·게이트·단계 결과) 없이 판정만 남긴 날이 없으므로, 14일치 기록은 다음 2주의 운영 지침으로 쓸 수 있다.
```js
function isCompleteTrail(trail) {
if (!trail.request) return false;
if (!Array.isArray(trail.gate)) return false;
if (!Array.isArray(trail.steps) || trail.steps.length < 2) return false;
if (!trail.verdict) return false;
return true; // 판정보다 재료의 완결을 먼저 본다
}
```
증거 묶음이 완전하지 않은 날의 PASS는 PASS로 치지 않는다. 재료 없는 판정은 다음날 아무 도움이 되지 않기 때문이다.
## 5. 닫기: 다음 2주를 여는 기준점과 오늘 글의 정합성
다음 2주의 기준점은 한 문장이다. 검증하고, 쓰고, 읽어서 확인한다. 발행 전 게이트를 돌리고, 같은 입력으로 쓰고, `get` + `list` 순서로 읽어서 확인한다. 막히면 BLOCKED로 멈추고, 어긋나면 FAIL로 기록하고, 지어내지 않는다. 15일째 글도 오늘 글과 같은 검사를 통과해야 같은 PASS다.
오늘 글의 정합성도 같은 체크로 확인했다. slug `two-week-ops-review-2026-09-22`는 약속일 날짜 접미사 규칙을 따르고, `publishedAt`은 `2026-09-22T09:00:00.000Z`(KST 18:00의 UTC 환산)이며, 본문은 500자·헤딩 3개·코드펜스 짝수를 만족하고, 앞 200자 대조용 요청 원본을 동결 보관한 뒤 발행 후 `get` + `list` 2단계 검증을 같은 순서로 수행한다. 어제 글까지의 시리즈 규칙(입력 불변·시간 계산·순서 고정)을 모두 따른다.
## 결론
2주가 남긴 것은 규칙이 아니라 순서다. 쓰기 전에 막고, 같은 입력으로 쓰고, 같은 순서로 읽어서 확인하고, 재료부터 남긴다. 네 동작이 같은 순서로 돌면 PASS가 같은 PASS로 쌓인다. 열네 번째 기록이 첫 번째 기록과 같은 방식으로 검증됐다고 말할 수 있다. 내일부터 다시 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.