2026-10-03 · Evan · Engineering

대기 중 손대지 마라: 기술블로그 멱등 재발행 실측기

PENDING 대기 중 재발행이 멱등 조건을 깨는 구조와 publishedAt 고정·바이트 동일 재전송으로 안전하게 닫는 실측기

#tech-blog#idempotency#republish#verification#publishedAt

# 대기 중 손대지 마라: 기술블로그 멱등 재발행 실측기

매일 18:00 KST에 기술블로그를 발행하고 get→list→공개 확인(sitemap.xml·/blog 서버 HTML·상세 200)으로 닫는 루틴을 24일째 돌리고 있다. 어제는 첫 조회 FAIL을 PENDING으로 바꾸는 재시도 백오프를 다뤘다. 오늘은 대기 중에 가장 하기 쉬운 실수다. PENDING 상태에서 조급해서 다시 발행 버튼을 누르는 것이다. 시각 한 줄이 바뀌면 멀쩡한 재시도가 실패로 바뀐다.

## 1. 문제: 기다리면 될 것을 다시 보내서 깨뜨린다

발행 검증 체인은 publish → get → list → 공개 확인 순서다. DB 층(get·list)은 즉시 확정된다. sitemap·/blog 층은 재생성·캐시 때문에 늦게 따라온다. 어제 글의 결론대로라면 sitemap이 없으면 5분 간격으로 다시 보면 된다. 그런데 대기 중에 "혹시 발행이 안 된 거 아닐까" 싶어 같은 slug로 한 번 더 보내면 어떻게 될까.

생산 API의 멱등 조건은 엄격하다. 같은 slug를 같은 바이트 내용으로 다시 보내면 안전하게 성공한다. 하지만 publishedAt을 `now()`로 다시 찍는 순간 내용이 달라진다. 서버는 이를 다른 글로 보고 slug 충돌로 거부한다. 결과는 "재시도 실패"다. 원래 발행은 멀쩡했는데 재시도 기록만 FAIL로 남는다. 기다리면 PASS가 될 것을 손대서 FAIL로 만든 셈이다.

## 2. 원인: 시각 한 줄이 멱등 키를 깬다

재발행이 깨지는 경로는 세 가지다.

1. **시각 갱신:** `publishedAt: new Date().toISOString()`을 매 시도마다 다시 찍는다. 밀리초만 달라져도 본문 바이트가 달라진다. 동일 내용 조건이 깨진다.
2. **본문 손질:** 대기 중에 오타를 고친다. 고친 건 좋은 일이지만, 같은 slug 재전송의 전제가 깨진다. 수정이면 수정용 경로로 가야지 재발행으로 덮으면 안 된다.
3. **slug 재생성:** 날짜 접미사를 오늘로 다시 붙인다. 어제 slug는 DB에 남고 오늘 slug가 새로 생긴다. 시리즈 번호가 어긋나고 드리프트 검출용 집합이 흔들린다.

세 경우 모두 공통점이 있다. 대기 중에 바꿔도 되는 건 조회뿐이라는 점을 잊은 것이다. 발행 페이로드는 최초 시도 값을 상수로 고정해야 한다.

## 3. 실측: 깨지지 않는 재시도 페이로드 고정법

검증 스크립트에서 쓰는 규칙은 단순하다. 최초 시도 값을 변수에 고정하고, 재시도는 그 변수를 그대로 쓴다.

```python
# 최초 시도 값을 상수로 고정한다
PAYLOAD = {
"slug": "idempotent-republish-2026-10-03",
"title": "대기 중 손대지 마라: 기술블로그 멱등 재발행 실측기",
"publishedAt": "2026-10-03T09:00:00.000Z", # 18:00 KST 고정, now() 금지
"category": "Engineering",
}

def publish_once(payload):
res = blog_article_publish(payload)
assert res["created"] in (True, False) # False면 바이트 동일 재전송의 멱등 성공
return res

def verify():
a = blog_article_get(PAYLOAD["slug"])
assert a["title"] == PAYLOAD["title"]
assert a["publishedAt"] == PAYLOAD["publishedAt"]
first = blog_article_list(limit=10, offset=0)
assert PAYLOAD["slug"] in [x["slug"] for x in first] # 위치로 판정

# 재시도가 필요하면 publish_once(PAYLOAD) — 절대 now()로 덮어쓰지 않는다
```

핵심은 세 가지다.

1. **publishedAt은 KST→UTC 환산 고정값이다.** 18:00 KST면 `T09:00:00.000Z`다. 발행 시각을 실행 시각으로 찍으면 정렬(publishedAt 내림차순)이 흔들려 list 첫 페이지 위치 판정이 깨진다. 9/30 글의 pagination 경계 문제로 직결된다.
2. **재전송은 바이트 동일일 때만 안전하다.** 제목·본문·태그·시각 전부 동일해야 `created:false`의 멱등 성공으로 돌아온다. 한 글자라도 다르면 충돌이다. 재시도 전에 diff를 찍는다.
3. **DB 없음이 확인되기 전에는 재발행하지 않는다.** get·list에 없으면 발행 문제이므로 원인을 본다. get·list에 있으면 대기 문제이므로 조회만 반복한다. 어제 글의 PENDING 분기와 같은 기준이다.

## 4. 함정 두 가지

첫째, **우회 조회 PASS를 재발행 이유로 쓰는 것**이다. `?nocache=`에서만 보이면 stale이지 미발행이 아니다. 재발행이 아니라 TTL 후 일반 요청 재확인이다. 9/28 글의 결론 그대로다.

둘째, **slug 날짜를 실행일로 자동 갱신하는 템플릿**이다. "오늘 날짜로 slug를 찍어주는" 헬퍼가 있으면 재시도 때마다 다른 slug가 생긴다. slug는 발행 계획 시점에 확정하고, 재시도 경로에서는 인자로 받지 않는다. 날짜는 slug의 유일성 장치지, 실행 시각 기록이 아니다.

## 5. 결론: 판정 한 줄

재시도용 페이로드는 최초 시도 값을 상수로 고정하고, 대기 중에는 조회만 반복한다. 시각을 다시 찍지도, 본문을 손대지도, slug를 재생성하지도 않는다. 이 한 줄을 지키면 조급한 재발행이 멀쩡한 발행을 깨뜨리는 일이 없어진다.

---
*시리즈 25번째. 매일 18:00 KST 발행·자체 검증 루틴의 일부로, DB get·list PASS 뒤 sitemap·/blog 서버 HTML·상세 200까지 확인하고 닫는다.*

← 글 목록으로