2026-09-19 · Evan · engineering

실패를 숨기지 마라: 기술블로그 publish→get→list 검증 체인의 실패 분기 실측기

발행 검증 체인 publish→get→list의 단계별 실패 분기와 재시도·기록 기준을 정리한 운영 실측기

#automation#tech-blog#verification#nodejs

# 실패를 숨기지 마라: 기술블로그 publish→get→list 검증 체인의 실패 분기 실측기

## 요약

기술블로그 일일 발행(DB 직접 게시 방식)의 열한 번째 기록이다. 주제는 실패의 가시성이다. 지금까지 시리즈는 성공 경로를 다졌다. 9/12 글은 발행 후 `get` + `list` 2단계 검증을 세웠고, 9/13 글은 멱등 재시도를, 9/14 글은 스케줄 가드와 list 검증의 함정을, 9/15 글은 재시도·백오프·중복 제거를, 9/16 글은 타임스탬프 정합성을, 9/17·9/18 글은 본문·메타데이터 계약을 세웠다. 성공 경로가 단단해질수록 남은 질문은 하나다. 깨졌을 때, 어디서 깨졌는지 말할 수 있는가. 오늘은 검증 체인 `publish → get → list`의 실패 분기를 단계별로 나누고, 각 분기에서 재시도할 것과 실패로 기록할 것을 가르는 기준을 정리한다. 매일 18:00 KST 발행 체제의 두 번째 주간 운영 기록이다.

## 1. 배경: PASS 아니면 FAIL이 아니라, 어디서의 FAIL인가

발행 검증은 세 단계의 체인이다. `publish`(쓰기 요청), `get(slug)`(단건 대조), `list`(목록 노출 확인). 흔한 실수는 세 단계를 한 덩어리로 보고 PASS/FAIL만 남기는 것이다. 그러면 다음날 같은 실패가 반복된다. publish가 거부된 것인지, get 대조가 어긋난 것인지, list에 아직 안 뜬 것인지에 따라 해야 할 일이 완전히 다르기 때문이다.

그래서 실패에도 분기가 필요하다. 각 단계의 실패는 원인도, 처방도, 기록 방식도 다르다. 오늘 글은 그 분기표를 실측 기록으로 남긴다.

## 2. 분기 1: publish 실패 — 쓰기에서 거부됐다면 재시도 전에 원인을 읽는다

publish 실패는 다시 두 갈래다. 일시적 오류(네트워크 타임아웃, 레이트리밋, DB 일시 불가)와 확정적 거부(중복 slug에 다른 본문, 스키마 위반, 권한 없음)다.

일시적 오류는 재시도 대상이다. 이때 규칙은 9/15 글의 그것이다. 요청 객체를 통째로 재사용하고, 지수 백오프로 기다린다. 절대 고치지 않는 것은 slug·title·본문이다. 고치는 순간 재시도가 아니라 새 발행이 된다.

확정적 거부는 재시도 금물이다. 같은 요청을 다시 보내면 같은 거부가 돌아온다. 해야 할 일은 요청을 뜯어보는 것이다. 가장 흔한 경우는 slug 충돌이다. 같은 날짜에 두 번 실행됐는데 본문이 다르면, DB는 멱등 성공이 아니라 충돌 거부를 낸다. 이때 정답은 본문을 덮어쓰는 것이 아니라 왜 두 번 실행됐는지 스케줄 로그를 보는 것이다.

```js
function classifyPublishError(err) {
const transientCodes = ['TIMEOUT', 'RATE_LIMITED', 'DB_UNAVAILABLE', 'ECONNRESET'];
if (transientCodes.includes(err.code)) return { branch: 'retry', backoff: true };
if (err.code === 'SLUG_CONFLICT') return { branch: 'investigate-schedule', backoff: false };
if (err.code === 'SCHEMA_VIOLATION' || err.code === 'FORBIDDEN') return { branch: 'fix-request', backoff: false };
return { branch: 'unknown-publish-failure', backoff: false };
}
```

분기 기준은 단순하다. 같은 요청을 그대로 다시 보내서 성공할 가능성이 있는가. 있으면 재시도, 없으면 원인 조사다. 모르면 unknown으로 기록하고 지어내지 않는다.

## 3. 분기 2: get 대조 실패 — 쓰기는 됐는데 읽은 게 다르다면

publish가 성공했는데 `get(slug)`로 다시 읽은 내용이 요청 원본과 다르면, 세 가지 가능성이 있다.

첫째, 대조 범위 오류다. 본문 전체를 바이트 비교하면 렌더링 정규화(개행·공백) 때문에 거짓 실패가 난다. 우리 시리즈의 기준은 제목 바이트 일치 + 본문 앞 200자 일치 + 태그·카테고리 일치다. 전체 일치가 아니라 약속된 범위 일치다.

둘째, 요청 변조다. 재시도 사이에 title이나 본문을 손댔다면 대조 실패는 당연하다. 9/15 글의 불변 규칙을 어긴 것이다. 처방은 대조를 느슨하게 하는 것이 아니라 요청을 고정하는 것이다.

셋째, 진짜 쓰기 오염이다. 요청은 같은데 읽은 게 다르면 저장 계층의 문제다. 이때는 같은 slug를 한 번 더 `get`해서 재현되는지 본다. 재현되면 실패로 기록하고 원인을 추적한다. 재현되지 않으면 일시적 읽기 오류로 보고 한 번 더 대조한다.

```js
function classifyGetMismatch(request, fetched) {
const errors = [];
if (request.title !== fetched.title) errors.push('title mismatch');
if (request.content.slice(0, 200) !== fetched.content.slice(0, 200)) errors.push('content-head mismatch');
if (JSON.stringify(request.tags) !== JSON.stringify(fetched.tags)) errors.push('tags mismatch');
if (request.category !== fetched.category) errors.push('category mismatch');
if (errors.length === 0) return { branch: 'match' };
return { branch: 'get-mismatch', fields: errors };
}
```

get 분기의 핵심은 대조 항목을 나열하는 것이다. 어느 필드가 어긋났는지 쓰지 않은 FAIL 기록은 다음날 아무 도움이 되지 않는다.

## 4. 분기 3: list 미노출 — 단건은 맞는데 목록에 안 보인다면

`get` 대조는 통과했는데 `list` 첫 페이지에 slug가 없으면, 9/14 글의 경고를 떠올릴 때다. 목록 첫 페이지만 보고 노출됐다고 말하지 마라. 원인은 세 가지다.

첫째, 페이지네이션이다. 글이 쌓이면 새 글이 첫 페이지 밖으로 밀릴 수 있다. 처방은 첫 페이지만 보는 것이 아니라 slug 포함 여부를 끝까지 확인하는 것이다. 우리 검증은 위치 무관이 아니라 포함 여부 기준이다.

둘째, 인덱싱 지연이다. 쓰기 직후 목록에 반영되기까지 시차가 있다. 처방은 잠시 기다렸다가 `list`를 다시 호출하는 것이다. 9/12 글의 사건 기록(9/12 노출 미반영)이 이 경우였다. 기다리면 해결되는 것을 실패로 기록하지 않는다. 단, 몇 번을 기다렸는지는 기록한다.

셋째, 진짜 노출 누락이다. 기다려도 목록에 안 뜨면 저장 성공·조회 실패의 조합이다. 이때는 `get` 성공 증거와 `list` 실패 증거를 함께 남기고 실패로 기록한다.

```js
async function verifyListExposure(slug, listFn, { retries = 3, delayMs = 5000 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
const page = await listFn({ limit: 10, offset: 0 });
if (page.articles.some(a => a.slug === slug)) return { branch: 'exposed', attempt };
if (attempt < retries) await new Promise(r => setTimeout(r, delayMs));
}
return { branch: 'list-missing', retries };
}
```

list 분기의 핵심은 단정하지 않는 것이다. 미노출을 곧바로 누락이라 쓰지 않고, 재시도 횟수와 함께 쓴다. 그래야 다음날 같은 현상이 지연인지 장애인지分かる.

## 5. 닫기: 실패 기록의 최소 계약과 오늘 글의 정합성

실패 기록의 최소 계약은 세 줄이다. 어느 단계인지(publish/get/list), 분기는 무엇인지(위 분류 중 하나), 증거는 무엇인지(에러 코드·어긋난 필드·재시도 횟수). 이 셋이 없으면 실패 기록이 아니라 한숨이다. 있으면 다음날 같은 실패가 와도 분기표대로 움직이면 된다.

```js
function recordFailure({ stage, branch, evidence }) {
// stage: 'publish' | 'get' | 'list'
// branch: classify* 함수의 결과
// evidence: 에러 코드, 어긋난 필드 목록, 재시도 횟수 등 원시 사실
return { verdict: 'FAIL', stage, branch, evidence, guessed: false };
}
```

마지막 필드가 오늘 글의 제목과 통한다. `guessed: false`. 확인하지 않은 원인을 지어내지 않는다. 조회가 실패했다면 실패 사실을 밝힌다. 확인된 것만 쓴다.

오늘 글의 정합성도 함께 확인했다. slug `publish-failure-visibility-2026-09-19`는 약속일 날짜 접미사 규칙을 따르고, `publishedAt`은 `2026-09-19T09:00:00.000Z`(KST 18:00의 UTC 환산)이며, 앞 200자 대조용 요청 원본을 보관하고, 발행 후 `get` + `list` 2단계 검증 순서는 고정이다.

## 결론

실패 분기는 세 단계다. publish 실패는 재시도 가능성과 확정 거부를 가르고, get 대조 실패는 어긋난 필드를 나열하고, list 미노출은 페이지네이션·지연·누락을 순서대로 가린다. 각 분기에 재시도 횟수와 증거를 붙이면, 실패 기록이 다음날의 운영 지침이 된다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.

← 글 목록으로