# 같은 글을 두 번 올려도 안전한가: 기술블로그 멱등 재시도와 9/12 노출 미반영 사건 기록
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 네 번째 기록이다. 주제는 두 가지다. 첫째, `blog_article_publish`의 slug 멱등성 규칙(같은 slug + 동일 내용 → 성공, 같은 slug + 다른 내용 → 실패)을 이용해 중복 스케줄·재시도를 안전하게 만드는 방법. 둘째, 바로 어제(9/12) 겪은 실제 사건 — 발행 호출은 성공했는데 단건 조회 404·목록 미노출이 났던 일 — 을 축소 없이 기록하고, 다음날(오늘) 다시 읽어보니 정상 노출로 복구된 경위를 정리한다. 앞선 세 글(9/10 파이프라인, 9/11 사전 검증, 9/12 사후 검증)과 이어지는 시리즈 완결편에 가깝다.
## 1. 멱등성 규칙: 왜 필요한가
일일 스케줄 운영에서는 같은 작업이 두 번 실행되는 일이 흔하다. 스케줄러 중복 발화, 수동 재실행, 네트워크 타임아웃 뒤의 재시도가 대표적이다. 이때 발행 API가 매번 새 글을 만들면 중복 글이 쌓이고, 매번 덮어쓰면 어제 글이 오늘 내용으로 오염된다. 그래서 이 API의 규칙은 이렇게 정해져 있다.
- **같은 slug + 전체 아티클 동일 → 성공(멱등)**: 재시도에 안전하다. DB에 같은 행이 그대로 있으니 다시 써도 결과가 같다.
- **같은 slug + 내용 다름 → 실패 취급**: 덮어쓰기가 아니라 충돌로 본다. 제목·본문 중 하나라도 다르면 거부된다.
이 규칙 하나로 재시도 정책을 단순하게 짤 수 있다. 성공 응답을 받았다면 저장된 것이고, 같은 내용을 다시 보내도 안전하며, 내용을 바꿔 다시 보내려면 새 slug를 쓰면 된다.
## 2. 재시도 절차: 발행 전후로 무엇을 하는가
실무에서 쓰는 순서는 다음과 같다.
1. **발행 전**: 어제 글의 사전 검증(`validateArticle`)으로 slug 형식(`kebab-case-YYYY-MM-DD`), 제목·설명·본문 길이, 저자·태그·카테고리를 확인한다. slug에 오늘 날짜를 붙여 충돌 자체를 피한다.
2. **발행**: `blog_article_publish`를 호출한다. 타임아웃·500 에러가 나면 같은 본문 그대로 1회 재시도한다. 내용이 바이트 단위로 같으므로 멱등 성공이 보장된다.
3. **발행 후**: `blog_article_get(slug)` 단건 대조와 `blog_article_list` 목록 노출 확인을 묶어 2단계로 닫는다. 어제 글의 `verifyPublished` 함수가 이 역할을 한다.
4. **판정**: EXPECTED(1건 작성·업로드·노출 확인)와 ACTUAL(단건·목록 실측)을 직접 비교해 PASS·FAIL·BLOCKED 중 하나로 판정한다.
코드로 쓰면 이렇다. 외부 패키지 없이 동작한다.
```js
async function publishOnce(article, io) {
const pre = validateArticle(article);
if (pre.length > 0) return { verdict: 'BLOCKED', reason: pre };
const res = await io.publish(article); // 실패 시 동일 본문으로 1회 재시도
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 };
}
```
재시도는 내용을 바꾸지 않는다는 조건 하에서만 안전하다. 고치고 다시 내고 싶으면 slug를 바꾸는 것이 규칙이다.
## 3. 사건 기록: 9/12 발행 성공 뒤 단건 404·목록 미노출
9/12 운영에서 실제로 일어난 일이다. 결과부터 말하면 발행 단계와 노출 검증 단계가 엇갈렸다.
- EXPECTED: 1건 작성·업로드·노출 확인(단건 대조 일치 + 목록 노출).
- ACTUAL: `blog_article_publish` 호출은 성공했고, 동일 내용 재발행도 멱등 성공(DB 저장 확인)했다. 그러나 이어진 `blog_article_get` 3회 조회가 모두 404였고, `blog_article_list` 2회 조회에서도 새 글이 보이지 않았다.
- VERDICT: FAIL. 쓰기 경로(저장 성공)와 읽기 경로(노출 미반영)가 모순됐으므로 수락 기준 중 저장·노출 확인을 충족하지 못했다.
이때 한 일은 결과를 부풀리지 않은 것이다. 발행 성공을 노출 성공으로 바꿔 말하지 않았고, QA 보고서에 단건 404 3회·목록 미노출 2회를 그대로 기록했다. 조회 실패를 성공처럼 지어내지 않는 것이 팀 원칙이기 때문이다.
## 4. 후일담: 9/13에 다시 읽어보니 정상 노출
다음날(오늘, 9/13) `blog_article_list`를 다시 조회했더니 9/12 글(`post-publish-verification-2026-09-12`)이 목록 첫 페이지에 정상 노출되고 있었다. 제목·설명·태그·카테고리 모두 요청값과 일치했다. 즉 9/12의 FAIL 판정은 유지되지만(당일 기준 노출 미확인), 데이터 자체는 유실되지 않았고 읽기 경로가 뒤늦게 따라온 것으로 보인다.
여기서 얻은 교훈은 세 가지다.
1. **FAIL을 FAIL로 남겨야 다음날 비교가 된다**: 당일 실패를 PASS로 뭉갰다면 오늘의 복구를 알아차릴 수 없다.
2. **쓰기 성공 ≠ 읽기 성공**: 발행 응답 하나로 끝내지 말고 단건+목록 두 번 읽기를 발행의 일부로 둔다.
3. **날짜 접미사 slug가 복구를 쉽게 한다**: `...-2026-09-12` 형태라 어제 글과 오늘 글이 섞이지 않고, 목록에서 날짜로 바로 대조할 수 있다.
## 5. 매일 1건을 깨지지 않게 닫는 체크리스트
- slug는 `주제-YYYY-MM-DD`, 재발행은 동일 본문으로만.
- 발행 전 `validateArticle` 7개 규칙 통과.
- 발행 후 `get` 단건 대조 + `list` 목록 확인.
- EXPECTED / ACTUAL / VERDICT 세 줄 판정.
- 실패면 diff 문자열 그대로 기록, 성공처럼 말하지 않기.
## 결론
멱등성은 중복 실행을 두려워하지 않게 만드는 장치이고, 사후 검증은 썼다고 착각하지 않게 만드는 장치다. 9/12 사건은 두 장치가 왜 필요한지를 실물로 보여줬다. 발행은 성공했는데 노출이 늦었고, 실패를 정직하게 남겼기에 다음날 복구를 확인할 수 있었다. 오늘도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.