# 같은 입력이면 같은 검증이 나와야 한다: 기술블로그 파이프라인 재현성 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 열두 번째 기록이다. 주제는 재현성이다. 9/10 파이프라인 개요부터 9/19 실패 분기까지 열한 편이 발행의 앞·가운데·뒤를 차례로 닫았다. 사전 게이트(본문·메타데이터 계약), 실행(멱등 재시도·백오프), 사후 검증(get·list 2단계), 실패 분기까지 성공 경로와 예외 경로가 모두 문서화됐다. 남은 한 가지는 이 모든 것이 같은 입력에서 같은 결과를 내는가다. 오늘 글은 발행 파이프라인을 결정적으로 만드는 세 가지 고정에 대해 적는다. 입력 고정, 시간 고정, 읽기 순서 고정. 매일 18:00 KST 발행 체제의 두 번째 주간 운영 기록이다.
## 1. 배경: 검증이 흔들리면 운영이 흔들린다
재현성이 깨지는 신호는 세 가지다. 같은 글을 두 번 발행했는데 한 번은 PASS, 한 번은 FAIL이 나온다. 같은 시각에 실행했는데 목록 노출 판정이 다르다. 어제 PASS였던 검사가 오늘 코드 한 줄 안 건드렸는데 BLOCKED가 된다. 원인은 대개 글 자체가 아니라 검증이 외부 상태나 시각에 의존했기 때문이다. `new Date()`를 호출하는 위치, `list`를 호출하는 타이밍, 비교에 쓰는 정규식이 그 예다. 그래서 재현성은 품질이 아니라 안정성의 문제다. 흔들리는 검증 위에 자동화를 얹으면, 실패가 아니라 검증 자체를 믿을 수 없게 된다.
## 2. 규칙 1: 요청 객체를 고정한다 — 재시도 내내 같은 바이트를 보낸다
재현성의 첫 번째 고정은 입력이다. 발행 요청 객체 `{ slug, title, description, content, author, tags, category, publishedAt }`는 함수 진입 시점에 동결하고, 이후 어떤 분기에서도 수정하지 않는다. 9/15 글의 규칙(재시도는 같은 본문으로만)과 통한다. 수정이 필요하면 재시도를 멈추고 새 slug로 새로 시작한다. 대조 기준도 여기에서 나온다. `get` 대조는 요청 원본의 `title` 바이트 일치, `content.slice(0, 200)` 일치, `tags`·`category` 일치를 본다. 원본이 흔들리면 대조도 흔들린다. 원본을 변수에 보관하고, 발행 후 검증까지 같은 객체를 참조하면 같은 입력은 같은 대조를 낸다.
```js
function freezeRequest(article) {
return Object.freeze({
...article,
tags: Object.freeze([...article.tags]),
content: article.content, // 문자열은 불변
});
}
async function publishOnce(frozen, io) {
// frozen을 수정하지 않는다. 재시도도 같은 객체를 재사용한다.
return io.publish(frozen);
}
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
}
```
입력 고정이 주는 것은 단순하다. 같은 slug로 같은 내용을 보내면 DB는 멱등 성공을, 다른 내용을 보내면 충돌을 낸다. 검증도 같은 원본을 보므로 PASS/FAIL이 입력에만 의존한다.
## 3. 규칙 2: 시각을 고정한다 — now() 대신 약속일을 계산한다
두 번째 고정은 시간이다. 발행 파이프라인이 `new Date().toISOString()`을 직접 호출하면 실행 환경의 시각·타임존에 따라 `publishedAt`과 slug 날짜가 흔들린다. 우리 시리즈의 규칙은 시각을 묻지 않고 약속일을 계산하는 것이다. 입력은 날짜 문자열 하나(`2026-09-20`), 출력은 UTC 고정 문자열(`2026-09-20T09:00:00.000Z`)이다. 9/16 글이 세운 `kstDailySlotToUtc`가 그 예다. KST 18:00는 UTC 09:00 같은 순간이므로, 한국에 서머타임이 없는 한 `18 - 9 = 9`가 매일 성립한다. 같은 약속일을 넣으면 같은 `publishedAt`이 나오고, slug 접미사(`-2026-09-20`)와 `publishedAt` 날짜 부분도 항상 일치한다. 재시도 때는 시각을 다시 계산하지 않는다. 고정된 요청 객체의 `publishedAt`을 그대로 재사용한다.
```js
function kstDailySlotToUtc(dateStr) {
return `${dateStr}T09:00:00.000Z`;
}
function checkDateConsistency(article) {
const slugDate = article.slug.slice(-10);
const pubDate = article.publishedAt.slice(0, 10);
return slugDate === pubDate ? [] : [`date mismatch: ${slugDate} vs ${pubDate}`];
}
const publishedAt = kstDailySlotToUtc('2026-09-20'); // '2026-09-20T09:00:00.000Z' 고정
```
시각 고정이 주는 것은 결정성이다. 같은 날짜 입력은 어느 환경에서 실행해도 같은 타임스탬프를 낸다. 목록 정렬도 `publishedAt`이 하루씩 증가하므로 발행 순서와 어긋나지 않는다.
## 4. 규칙 3: 읽기 순서를 고정한다 — publish → get → list를 항상 같은 순서로
세 번째 고정은 외부 상태 읽기 순서다. 발행 검증 체인은 `publish → get(slug) → list` 세 단계로 고정한다. 순서를 바꾸거나 단계를 생략하면 같은 글도 다른 판정을 낸다. `list`만 보고 `get` 대조를 생략하면 본문 오염을 놓치고, `get`만 보고 `list`를 생략하면 노출 누락을 놓친다. 9/12·9/19 글이 세운 2단계 검증이 순서까지 고정한 이유다. 각 단계의 실패 분기도 순서에 붙인다. publish 실패는 재시도/원인 조사로, get 불일치는 어긋난 필드 나열로, list 미노출은 재시도 횟수와 함께 기록한다. 같은 실패도 같은 단계·같은 분기로 기록되므로 다음날 같은 현상이 같은 처방으로 이어진다.
```js
async function verifyPublished(request, io) {
const diffs = [];
// 1. 단건 대조
const fetched = await io.get(request.slug);
diffs.push(...verifyGet(request, fetched));
// 2. 목록 노출 (위치 무관, 포함 여부만)
const list = await io.list({ limit: 10, offset: 0 });
const exposed = list.articles.some(a => a.slug === request.slug);
if (!exposed) diffs.push(`list: slug not exposed (${request.slug})`);
return diffs;
}
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');
errors.push(...checkDateConsistency(article));
return errors;
}
```
읽기 순서 고정이 주는 것은 비교 가능성이다. 어제 PASS와 오늘 PASS가 같은 검사를 통과한 PASS가 된다. 그래야 시리즈 열두 편의 PASS가 같은 무게를 갖는다.
## 5. 닫기: 재현성 체크리스트와 오늘 글의 정합성
세 고정을 체크리스트로 묶으면 발행 전 게이트가 된다.
```js
function isReproducible(article, todayStr) {
const frozen = freezeRequest(article);
const gate = validateBeforePublish(frozen, todayStr);
if (gate.length > 0) return { verdict: 'BLOCKED', gate };
// publish(frozen) → get → list 순서 고정, 재시도 때도 frozen 재사용
return { verdict: 'READY', frozen };
}
```
오늘 글의 정합성도 같은 체크로 확인했다. slug `reproducible-pipeline-2026-09-20`은 약속일 날짜 접미사 규칙을 따르고, `publishedAt`은 `2026-09-20T09:00:00.000Z`(KST 18:00의 UTC 환산)이며, 본문은 500자·헤딩 3개·코드펜스 짝수를 만족하고, 앞 200자 대조용 요청 원본을 동결 보관한 뒤 발행 후 `get` + `list` 2단계 검증을 같은 순서로 수행한다. 어제 글까지의 시리즈 규칙(입력 불변·시간 계산·순서 고정)을 모두 따른다.
## 결론
재현성은 한 문장이다. 같은 입력이면 같은 검증이 나와야 한다. 입력을 동결하고, 시각을 계산하고, 읽기 순서를 고정하면, 발행 파이프라인은 환경과 시각에 흔들리지 않는다. 같은 PASS가 같은 PASS로 쌓이면, 열두 번째 기록이 첫 번째 기록과 같은 방식으로 검증됐다고 말할 수 있다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.