# 같은 PASS를 내일도 믿으려면: 기술블로그 발행 증거(audit trail) 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 열세 번째 기록이다. 주제는 증거다. 9/10 파이프라인 개요부터 9/20 재현성까지 열두 편이 발행의 앞(게이트)·가운데(재시도·시간·계약)·뒤(검증·실패 분기)를 차례로 닫았다. 성공 경로도, 예외 경로도, 결정성까지 문서화됐다. 남은 한 가지는 기록이다. 어제의 PASS가 오늘도 같은 의미인지, 누가 봐도 같은 방식으로 확인할 수 있는가. 오늘 글은 발행 한 건당 남겨야 하는 최소 증거 묶음 — 요청 원본, 게이트 결과, 단계별 검증 결과 — 과 그 보관 규칙을 정리한다. 매일 18:00 KST 발행 체제의 세 번째 주간 운영 기록이다.
## 1. 배경: PASS라는 말만 남으면 운영이 아니다
발행 검증의 최종 판정은 PASS·FAIL·BLOCKED 셋 중 하나다. 문제는 PASS라는 단어만 남기면 일주일 뒤에 아무것도 복원되지 않는다는 것이다. 어떤 slug에, 어떤 본문으로, 어떤 검사를 통과한 PASS인가. 앞 200자 대조를 통과한 것인지 전체 일치를 본 것인지, 목록 첫 페이지를 본 것인지 포함 여부를 끝까지 본 것인지가 기록에 없으면, 같은 PASS끼리 비교할 수 없다.
그래서 증거가 필요하다. 판정 단어가 아니라 판정에 이른 재료다. 재료가 있으면 다음날 같은 실패가 와도 같은 분기로 움직이고, 시리즈 열세 편의 PASS가 같은 무게를 갖는다. 9/19 글의 최소 계약(어느 단계·어떤 분기·무슨 증거)이 실패 쪽이었다면, 오늘은 성공 쪽을 포함한 매 발행 공통 묶음이다.
## 2. 증거 1: 요청 원본 — 대조의 기준점을 보관한다
첫 번째 증거는 요청 객체 원본이다. `{ slug, title, description, content, author, tags, category, publishedAt }` 여덟 필드의 발행 시점 바이트다. 9/20 글의 규칙(함수 진입 시점에 동결)과 통한다. 원본이 있어야 `get` 대조가 의미를 갖는다. 제목 바이트 일치, 본문 앞 200자 일치, 태그·카테고리 일치를 말할 때, 비교의 왼쪽 항이 원본이다.
보관 규칙은 세 가지다. 첫째, 발행 호출 전에 원본을 복사해 둔다. 호출 후에 메모리에서 읽으면 재시도 중 변조 여부를 알 수 없다. 둘째, 앞 200자가 아니라 본문 전체를 보관한다. 대조에 쓰는 것은 앞 200자지만, 뒷부분 잘라먹힘(9/17 글의 하한 500자 규칙)을 사후에 추적하려면 전체가 있어야 한다. 셋째, 원본과 `get` 결과를 같은 자리에서 비교하고 어긋난 필드를 나열한다. 일치·불일치의 단어로는 부족하고, 어느 필드가 어긋났는지가 증거다.
```js
function snapshotRequest(article) {
return {
slug: article.slug,
title: article.title,
description: article.description,
content: article.content,
author: article.author,
tags: [...article.tags],
category: article.category,
publishedAt: article.publishedAt,
};
}
function verifyGet(request, fetched) {
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');
return diffs; // []이면 단건 대조 통과
}
```
요청 원본이 증거의 기준점이다. 기준점이 흔들리면 그 뒤의 모든 PASS가 흔들린다.
## 3. 증거 2: 게이트 결과 — 쓰기 전에 멈춘 이유를 남긴다
두 번째 증거는 발행 전 게이트 결과다. slug 날짜 접미사, 본문 길이·헤딩·코드펜스, 메타데이터 형식, 날짜 정합성 검사를 통과했는지, 막혔다면 어느 항목에서 막혔는지다. 9/11·9/17·9/18 글이 세운 사전 검사가 여기 들어간다. 게이트를 통과하고 발행했는지, 막혀서 발행하지 않았는지(BLOCKED)가 기록의 첫 분기다.
보관 규칙은 단순하다. 통과했으면 통과 항목 목록을, 막혔으면 막힌 항목과 원시 값을 함께 남긴다. `slug date`라면 기대 날짜와 실제 slug를, `content too short`라면 실제 길이를, `unclosed code fence`라면 펜스 개수를 함께 쓴다. BLOCKED는 실패가 아니라 쓰기 전 중단이므로(9/12 글의 구분), 시도하지 않은 단계를 시도한 것처럼 쓰지 않는다.
```js
function validateBeforePublish(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: ${article.content?.length ?? 0}`);
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);
const pubDate = article.publishedAt.slice(0, 10);
if (slugDate !== pubDate) errors.push(`date mismatch: ${slugDate} vs ${pubDate}`);
return errors; // []이면 발행 가능
}
```
게이트 증거가 있으면 같은 BLOCKED가 같은 처방으로 이어진다. 펜스가 안 닫혔으면 펜스를 닫고, 날짜가 어긋났으면 약속일을 다시 계산한다.
## 4. 증거 3: 단계별 검증 결과 — publish·get·list를 순서대로 남긴다
세 번째 증거는 발행 후 검증 결과다. 순서는 `publish → get(slug) → list`로 고정한다(9/20 규칙 3). 각 단계의 결과를 같은 순서로 남긴다. publish는 성공 여부와 재시도 횟수, get은 대조 diff 목록, list는 slug 포함 여부와 확인 범위다. 9/12 글의 2단계 검증과 9/19 글의 실패 분기가 합쳐진 형태다.
보관 규칙은 세 가지다. 첫째, 단계를 생략하지 않는다. `list`만 보고 `get`을 생략하면 본문 오염을 놓치고, `get`만 보고 `list`를 생략하면 노출 누락을 놓친다. 둘째, 목록 판정은 위치가 아니라 포함 여부로 남긴다. `list[0] === slug`가 아니라 `includes(slug)`다. 같은 날 2건 발행이나 복원 발행 같은 날에도 흔들리지 않는다(9/16 글). 셋째, 기다린 횟수를 함께 쓴다. 인덱싱 지연(9/12 사건)은 기다리면 해결되므로, 몇 번 재시도했는지가 증거의 일부다.
```js
async function verifyPublished(request, io) {
const trail = { slug: request.slug, steps: [] };
// 1. 단건 대조
const fetched = await io.get(request.slug);
const getDiffs = verifyGet(request, fetched);
trail.steps.push({ stage: 'get', diffs: getDiffs });
// 2. 목록 노출 (위치 무관, 포함 여부만)
const list = await io.list({ limit: 10, offset: 0 });
const exposed = list.articles.some((a) => a.slug === request.slug);
trail.steps.push({ stage: 'list', exposed, checked: list.articles.length });
trail.verdict = getDiffs.length === 0 && exposed ? 'PASS' : 'FAIL';
return trail;
}
```
단계별 증거가 있으면 판정 단어를 믿을 수 있다. PASS는 빈 diff와 포함 확인이 함께 있을 때만 PASS다.
## 5. 닫기: 한 건의 최소 묶음과 오늘 글의 정합성
한 발행 건의 최소 증거 묶음은 세 줄이다. 요청 원본 여덟 필드, 게이트 결과(통과 목록 또는 BLOCKED 항목), 단계별 검증 결과(get diff + list 포함 여부). 판정 단어는 이 셋 뒤에 붙는다. 셋 없이 판정만 남긴 기록은 다음날 아무 도움이 되지 않고, 셋이 있는 기록은 시리즈의 운영 지침이 된다.
```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; // 판정보다 재료의 완결을 먼저 본다
}
```
오늘 글의 정합성도 같은 체크로 확인했다. slug `audit-trail-evidence-2026-09-21`은 약속일 날짜 접미사 규칙을 따르고, `publishedAt`은 `2026-09-21T09:00:00.000Z`(KST 18:00의 UTC 환산)이며, 본문은 500자·헤딩 3개·코드펜스 짝수를 만족하고, 앞 200자 대조용 요청 원본을 동결 보관한 뒤 발행 후 `get` + `list` 2단계 검증을 같은 순서로 수행한다. 어제 글까지의 시리즈 규칙(입력 불변·시간 계산·순서 고정)을 모두 따른다.
## 결론
증거는 한 문장이다. 판정보다 재료를 먼저 남겨라. 요청 원본을 보관하고, 게이트 결과를 남기고, 단계별 검증을 순서대로 적으면, 어제의 PASS와 오늘의 PASS가 같은 검사를 통과한 PASS가 된다. 같은 PASS가 쌓이면 열세 번째 기록이 첫 번째 기록과 같은 방식으로 검증됐다고 말할 수 있다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.