주간 다큐 봇이 수요일 회차에서 죽었습니다. 영상은 이미 올라간 뒤였고, 마지막 단계인 플레이리스트 편성에서 traceback이 났습니다.
googleapiclient.errors.HttpError: <HttpError 409 when requesting
https://youtube.googleapis.com/youtube/v3/playlistItems ...
returned "The operation was aborted.">바로 직전 줄에는 이렇게 찍혀 있었습니다.
[playlists] 생성: ... PLAF•••••••••플레이리스트를 만들고, 그 플레이리스트에 영상을 넣으려다 409를 맞은 겁니다.
이 그림이 익숙했습니다. 이 원인으로 글을 쓴 게 바로 지난주였기 때문입니다. 방금 만든 플레이리스트를 조회하면 404가 온다는 이야기였고, 그건 8월에 고쳐서 두 봇 모두에 들어가 있었습니다.
질문 하나 드리겠습니다. 당신이 지난번에 고친 그 버그, 가드는 원인에 걸려 있습니까 아니면 그때 본 증상에 걸려 있습니까?
고친 자리와 안 고친 자리
코드를 열어보니 이렇게 돼 있었습니다.
# 방금 만든 플레이리스트는 비어 있다. 조회하면 전파 전이라 404가 뜬다.
have = set() if fresh else _existing_items(yt, pid)
for vid in vids:
if vid in have:
continue
yt.playlistItems().insert(...).execute() # 맨몸주석까지 정확합니다. "방금 만든 것은 전파 전이라 404가 뜬다." 그걸 알고 조회를 건너뛰게 해뒀습니다.
그리고 바로 다음 줄에서 그 방금 만든 플레이리스트에 쓰기를 칩니다. 아무 보호 없이요.
전파가 안 끝난 리소스는 읽을 때만 없는 게 아닙니다. 쓸 때도 없습니다. 읽기는 404로, 쓰기는 409로 말이 다를 뿐입니다. 8월에 제가 배운 건 "전파가 끝나기 전에는 그 리소스를 건드릴 수 없다"였어야 하는데, 코드에 남은 건 **"404가 나는 조회를 피한다"**였습니다. 사고에서 상태 코드만 가져오고 구간은 안 가져온 겁니다.
포크 두 개가 같은 반쪽을 들고 있었습니다
이 봇은 포크가 둘입니다. 니치만 다르고 엔진은 같은 조상에서 갈라졌습니다. 그래서 한쪽을 고치면 다른 쪽에도 옮기는 습관이 있고, 실제로 8월의 404 수정도 양쪽에 다 들어가 있었습니다.
그 말은 이렇게 됩니다.
fork A 조회 가드 있음 쓰기 가드 없음
fork B 조회 가드 있음 쓰기 가드 없음두 포크를 서로 diff 떠도 이 구멍은 안 보입니다. 둘 다 똑같이 비어 있으니까요. 포크 사이의 동일성은 "고침이 양쪽에 다 갔는가"는 잡지만 "고침이 애초에 맞았는가"는 못 잡습니다.
저는 포크 드리프트 검사를 따로 돌리고 있습니다. 사고 하나당 불변식 한 줄을 적는 방식입니다.
("engine/claude_cli.py", "일시 실패는 1회 재시도", r"for attempt in \(1, 2\)", None),
("engine/discover.py", "429는 실패가 아니라 백오프 신호", r"Retry-After", None),이 표에 플레이리스트 항목이 없었습니다. 8월에 404를 고칠 때 불변식을 안 적었거든요. 양쪽에 손으로 옮겼으니 됐다고 생각했습니다.
여기서 선택지가 둘이었습니다
409를 맞은 호출 하나만 감싸면 끝나는 일입니다. 당신이라면 어디까지 하시겠습니까?
playlistItems.insert에 409 재시도를 넣는다. 오늘 난 사고가 오늘 막힌다.- 그 호출만이 아니라 "전파가 끝나기 전"이라는 구간 전체에 재시도를 건다. 그리고 그 사실을 두 포크에 강제한다.
2번으로 갔습니다. 코드 자체는 짧습니다.
TRANSIENT = (404, 409, 500, 503)
RETRIES = (2, 5, 15) # 초
def _retry(request, what, sleep=time.sleep):
for i, wait in enumerate(RETRIES + (None,)):
try:
return request().execute()
except HttpError as e:
if wait is None or e.resp.status not in TRANSIENT:
raise
sleep(wait)중요한 건 마지막 두 줄입니다. 전파 지연이 아닌 실패(403 같은 것)는 첫 시도에 그대로 올리고, 재시도를 다 써도 안 되면 마지막 예외를 올립니다. 여기서 예외를 삼키면 편입 안 된 영상이 "편성 완료"로 보입니다. 실패를 빈 결과로 바꾸는 건 제가 다른 데서 이미 크게 데인 적이 있어서, 이 줄은 손이 먼저 씁니다.
그리고 포크 불변식에 한 줄 넣었습니다.
("engine/playlists.py", "생성 직후 편입은 전파 지연(409/404)을 재시도한다 (2026-09-09)",
r"def _retry", None),이제 한쪽에서 _retry를 지우면 검사가 막습니다.
검사는 네트워크 없이 돕니다
재시도 코드는 실제 409를 다시 맞기 전에는 검증이 어렵습니다. 그래서 sleep을 주입해서 세 가지만 봤습니다.
- 전파 지연(409·404)은 넘긴다 — 3번째 시도에 성공, 대기 2초·5초
- 403은 첫 시도에 그대로 올린다 — 권한 문제를 재시도로 덮지 않는다
- 재시도를 다 쓰면 실패로 끝난다 — 조용한 성공 금지
[playlists] 테스트 409 - 2초 뒤 재시도(1/3)
[playlists] 테스트 404 - 5초 뒤 재시도(2/3)
selfcheck OK - 재시도 3회 · 전파지연 (404, 409, 500, 503)솔직한 부분: 재시도 경로 자체는 아직 진짜 409를 맞아본 적이 없습니다. 라이브로 두 채널을 돌려봤지만 새로 만들 플레이리스트가 없어서 재시도 분기는 안 탔습니다. 진짜 증거는 다음에 새 카테고리가 두 편 찰 때 나옵니다. 그때까지 이건 "검사는 통과한 수정"이지 "현장에서 확인된 수정"이 아닙니다.
자가진단 3개
- 지난 분기에 고친 버그 하나를 고르세요. 그 가드는 원인에 걸려 있습니까, 그때 본 상태 코드에 걸려 있습니까?
- 같은 원인이 다른 호출로 나타날 자리가 그 파일에 남아 있습니까? (읽기를 막았으면 쓰기, 목록을 막았으면 단건)
- 포크·복사본이 있다면, 둘을 diff 뜨는 것 말고 둘 다 지켜야 할 불변식을 어디에 적어두셨습니까?
결론
이번 사고는 새 버그가 아닙니다. 8월에 고친 버그의 나머지 절반입니다. 그리고 그 절반이 넉 달을 버틴 이유는, 제가 사고에서 원인이 아니라 증상을 가져왔기 때문입니다.
봇이 죽은 건 회차 하나였습니다. 손실은 영상 한 편의 플레이리스트 편입이고, 발행 자체는 끝난 뒤였습니다. 작은 사고입니다. 다만 이런 작은 사고가 "고쳤다"고 적힌 자리에서 나올 때는, 고친 방식 쪽을 한 번 더 봐야 합니다.
지금 딱 하나만 해보세요. 최근에 "이건 고쳤다"고 적어둔 파일을 열어서, 그 주석이 설명하는 원인이 그 아래 몇 줄에서 또 나타나는지 읽어보시는 겁니다.