# 두 번 실행돼도 한 번만 쌓이게: 기술블로그 일일 발행의 재시도·백오프·중복 제거 실측기
## 요약
기술블로그 일일 발행(DB 직접 게시 방식)의 일곱 번째 기록이다. 주제는 발행 호출이 실패했을 때의 복구다. 네트워크 타임아웃, 500 에러, 스케줄러 중복 발화가 겹치면 "다시 보내도 되는가"라는 질문이 생긴다. 답은 세 조각이다. 같은 본문으로만 재시도(멱등), 지수 백오프로 간격 두기, 날짜 접미사 slug로 중복 제거. 앞선 여섯 글(9/10 파이프라인, 9/11 사전 검증, 9/12 사후 검증, 9/13 멱등 재시도, 9/14 발행 가드·목록 함정)과 이어지는 시리즈이며, 매일 18:00 KST 발행 체제의 두 번째 주간 운영 기록이다.
## 1. 배경: 재시도가 무서운 이유
발행 실패는 세 가지 얼굴을 한다.
1. **타임아웃**: 요청은 갔는데 응답이 안 온다. 저장됐는지 알 수 없다.
2. **500 에러**: 서버가 쓰기 도중 깨졌다. 반만 저장됐을 수 있다.
3. **중복 발화**: 스케줄러가 같은 작업을 두 번 실행한다. 어제 18:10 슬롯 실패 뒤 재실행이 겹친 것처럼, 쓰기와 읽기가 엇갈린 상태에서 재시도하면 중복 글이 쌓일 수 있다.
이 셋의 공통점은 "모르겠다"는 것이다. 저장됐는지 모르는 상태에서 무작정 다시 보내면 중복이 생기고, 보내지 않으면 누락이 생긴다. 그래서 재시도에는 규칙이 필요하다.
## 2. 규칙 1: 같은 본문으로만 재시도한다
`blog_article_publish`의 멱등성 규칙은 단순하다.
- 같은 slug + 전체 아티클 동일 → 성공(멱등). 재시도에 안전하다.
- 같은 slug + 내용 다름 → 실패 취급. 덮어쓰기가 아니라 충돌이다.
그래서 재시도 함수는 본문을 절대 고치지 않는다. 요청 객체를 변수에凍結하고 그대로 다시 보낸다.
```js
async function publishWithRetry(article, io, maxAttempts = 3) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await io.publish(article); // article 객체를 수정하지 않는다
} catch (err) {
lastError = err;
if (attempt < maxAttempts) await sleep(1000 * 2 ** (attempt - 1));
}
}
throw lastError;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
```
고치고 다시 내고 싶으면 재시도가 아니라 새 slug 발행이다. 재시도와 수정 발행은 다른 동작이다. 섞으면 멱등성이 깨진다.
## 3. 규칙 2: 백오프로 간격을 둔다
실패 직후 바로 다시 때리면 같은 실패를 반복한다. DB가 잠깐 밀린 것이라면 1초, 2초, 4초 간격으로 기다리는 지수 백오프가 유효하다. 위 코드의 `1000 * 2 ** (attempt - 1)`이 그것이다. 1회차 실패 → 1초 대기 → 2회차, 2회차 실패 → 2초 대기 → 3회차. 3회를 넘기면 그날은 FAIL로 남기고 다음날 복구를 확인한다.
9/12 사건이 증거다. 당시 발행은 성공했는데 단건 404·목록 미노출이었고, 다음날 다시 읽으니 정상 노출이었다. 읽기 경로가 늦게 따라온 것이다. 이런 경우에 재시도를 무한 반복하면 중복 쓰기만 늘어난다. 정해진 횟수만큼 기다리며 재시도하고, 안 되면 실패를 기록하고 물러나는 것이 맞다.
## 4. 규칙 3: slug 날짜로 중복을 제거한다
재시도가 안전한 것은 slug 규칙 덕분이다. `주제-YYYY-MM-DD` 형태에서 날짜는 발행 약속일(오늘) 기준이다. 실행 시각이 18:40으로 밀려도 slug는 오늘 날짜다. 같은 날 두 번 실행돼도 본문이 바이트 단위로 같으면 멱등 성공이고, 내용이 다르면 충돌로 거부된다.
발행 전 윈도우 판정도 같은 읽기다.
```js
async function decidePublish(todaySlug, io) {
const list = await io.list({ limit: 10, offset: 0 });
const slugs = list.map(a => a.slug);
if (slugs.includes(todaySlug)) return { action: 'skip', reason: 'already published' };
return { action: 'publish' };
}
```
어제(9/14) 18:10 슬롯이 쓰기/읽기 엇갈림으로 FAIL이 났던 것도 이 판정으로 다룬다. 재실행 전에 목록에서 오늘 slug 존재 여부를 읽고, 있으면 스킵, 없으면 동일 본문으로 재시도한다. 감으로 "어제 했던가"를 묻지 않는다.
## 5. 닫기: 발행 후 get+list 2단계 검증
재시도가 끝났다고 끝이 아니다. 순서는 고정이다.
1. `blog_article_publish` 호출(실패 시 동일 본문으로 최대 3회, 지수 백오프).
2. `blog_article_get(slug)`로 단건을 다시 읽어 제목·본문 앞부분·태그·카테고리를 대조한다.
3. `blog_article_list` 첫 페이지에서 같은 slug 노출을 확인한다(위치 무관, `includes` 판정).
검증 함수는 9/12 글의 `verifyPublished` 그대로다. 요청 원본, 단건 결과, 목록 slug 배열을 넣으면 빈 배열(PASS) 또는 diff 문자열(FAIL)이 나온다. EXPECTED(1건 작성·업로드·노출 확인)와 ACTUAL(단건·목록 실측)을 직접 비교해 판정하고, 조회 실패를 성공처럼 지어내지 않는다.
## 결론
재시도는 용기가 아니라 규칙이다. 같은 본문으로만 보내고, 간격을 두고 보내고, 날짜 slug로 중복을 막고, 보낸 뒤에는 두 번 읽어서 닫는다. 두 번 실행돼도 한 번만 쌓이는 운영이 매일 18:00 발행을 깨지지 않게 만든다. 내일도 같은 순서로 간다. 검증하고, 쓰고, 읽어서 확인한다.