쓰레드 캐러셀 발행 버그, 같은 URL인데 응답이 200과 404를 오간 이유 (실측)
duckpipe 블로그의 새 글을 카드뉴스로 만들어 Threads에 캐러셀로 자동 발행하는 스크립트가 있다. 배포 직후에 발행을 시도하면 실패할 수 있다는 건 알고 있었고 대기 로직도 넣어뒀는데, 하루 간격으로 두 번 실패했다. 원인은 매번 달랐다.
배경 — 배포 직후 이미지가 아직 없을 수 있다
카드뉴스 슬라이드는 public/cardnews/<slug>/slide-N.png로 만들어져 npm run build + wrangler deploy로 배포된다. Threads API는 이미지 URL을 직접 가져가서 처리하므로, 그 URL이 실제로 열려야 발행이 된다. 문제는 Cloudflare 배포가 끝났다고 그 순간 모든 지역에서 파일이 바로 열리는 게 아니라는 점이다 — 엣지로 전파되는 데 시차가 있다.
1차 버그 — 슬라이드 1장만 확인하고 넘어갔다
처음 짠 코드는 이랬다.
async function waitForUrl(url, { timeoutMs = 180_000, intervalMs = 5_000 } = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const res = await fetch(url, { method: "HEAD" });
if (res.ok) return;
} catch {}
if (Date.now() > deadline) throw new Error(`${url} 가 열리지 않음`);
await new Promise((r) => setTimeout(r, intervalMs));
}
}
// 호출부
await waitForUrl(`${slideBase}/slide-1.png`);
slide-1.png 하나가 200을 받으면 배포가 끝났다고 보고 다음 단계로 넘어갔다. 그런데 카드뉴스는 보통 57장이다. slide-1이 열렸다고 slide-27도 같은 순간에 열려 있다는 보장은 없다. 실제로 slide-1은 200인데 slide-4가 아직 404인 상태에서 Threads 쪽 자식 컨테이너 생성을 시작했고, Meta가 그 슬라이드를 못 받아와 error_subcode 2207052(“미디어 다운로드에 실패했습니다”)로 실패했다.
수정은 확인 대상을 “발견된 슬라이드 URL 전부”로 넓히는 것이었다.
async function waitForAllUrls(urls, { timeoutMs = 180_000, intervalMs = 5_000 } = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const results = await Promise.all(
urls.map((url) =>
fetch(url, { method: "HEAD" }).then((res) => res.ok).catch(() => false),
),
);
if (results.every(Boolean)) return;
if (Date.now() > deadline) {
const failed = urls.filter((_, i) => !results[i]);
throw new Error(`${failed.length}/${urls.length}장이 안 열림: ${failed.join(", ")}`);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
하나라도 실패하면 전체를 다시 폴링한다. 부분적으로만 살아있는 상태를 “됐다”고 보지 않는다. 이걸로 끝났다고 생각했다.
2차 버그 — 다음 날, “슬라이드가 0장뿐임”
바로 다음 날 발행에서 새 에러가 났다. waitForAllUrls를 넣었는데도 “슬라이드가 0장뿐임(최소 2장 필요)“이라는, 이전엔 본 적 없는 실패였다. 로그를 보니 waitForUrl로 slide-1이 200인 걸 방금 확인했는데도, 바로 이어서 실행된 슬라이드 개수 탐지 단계에서 그 슬라이드 개수가 0으로 나왔다.
탐지 코드는 이랬다.
async function findSlideUrls(slideBase) {
const urls = [];
for (let n = 1; n <= 20; n++) {
const url = `${slideBase}/slide-${n}.png`;
const res = await fetch(url, { method: "HEAD" }).catch(() => null);
if (!res?.ok) break; // 첫 실패에서 바로 중단
urls.push(url);
}
return urls;
}
디렉터리 리스팅 API가 없어서, slide-1부터 순서대로 HEAD 요청을 보내다가 없는 번호가 나오면 “거기서 끝났다”고 판단하는 방식이다. 여기서 slide-1에 대한 이 함수 자신의 HEAD 요청이 404를 받으면, 방금 waitForUrl이 확인한 200과 무관하게 즉시 “슬라이드 0장”으로 끝나버린다.
waitForAllUrls는 이미 알고 있는 목록이 전부 열릴 때까지 기다리는 함수다. 그런데 그 목록 자체를 만드는 이 findSlideUrls는 별개의 코드 경로이고, 단발 요청 하나에 전부를 걸고 있었다. 1차 수정이 고친 건 “대기” 문제였고, 이건 “탐지” 문제였다 — 증상은 같은 배포 직후 실패였지만 겹이 달랐다.
왜 같은 URL인데 응답이 오갔나
정말 같은 순간, 같은 URL에 연속으로 HEAD 요청을 보내도 200과 404가 섞여 나올 수 있다. Cloudflare 같은 CDN은 요청을 여러 엣지 PoP 중 하나로 라우팅하는데, 배포 직후에는 이 PoP들에 새 파일이 전파되는 속도가 제각각이다. 어떤 PoP는 이미 파일을 받았고 어떤 PoP는 아직이다. 요청이 어느 PoP로 갈지는 이쪽에서 통제할 수 없으므로, “방금 200이었다”는 사실이 “다음 요청도 200이다”를 보장하지 않는다.
수정 — 탐지 단계에도 재시도를 넣는다
findSlideUrls가 부르는 개별 확인 함수에 재시도를 추가했다.
async function probeSlide(url, { retries = 3, delayMs = 2_000 } = {}) {
for (let i = 0; i <= retries; i++) {
const res = await fetch(url, { method: "HEAD" }).catch(() => null);
if (res?.ok) return true;
if (i < retries) await new Promise((r) => setTimeout(r, delayMs));
}
return false;
}
async function findSlideUrls(slideBase) {
const urls = [];
for (let n = 1; n <= 20; n++) {
const url = `${slideBase}/slide-${n}.png`;
if (!(await probeSlide(url))) break;
urls.push(url);
}
return urls;
}
한 번의 404로 “다음 번호부터는 없다”고 단정하지 않는다. 재시도 후에도 계속 실패해야 그 번호에서 끝났다고 본다. slide-1이 우연히 안 좋은 PoP로 라우팅돼도, 2초 뒤 재시도에서는 다른 PoP로 갈 가능성이 있으니 그 사이 통과할 여지를 준 것이다.
정리
같은 “배포 직후 URL이 불안정하다”는 원인에서 나온 두 버그였지만, 걸린 코드 경로가 달랐다. 하나는 “이미 아는 목록을 기다리는” 로직이었고, 다른 하나는 “목록 자체를 알아내는” 로직이었다. 첫 번째를 고쳤을 때는 두 번째가 그대로 남아 있다는 걸 몰랐다 — 같은 종류의 실패가 다시 나고 나서야 코드 경로가 다르다는 걸 확인했다. 엣지 캐시 레이스를 다룰 때는 “한 번 확인했다”는 사실이 그 다음 요청에도 참이라고 가정하면 안 된다는 게 이번에 실측으로 확인한 것이다.
자주 묻는 질문
1차 수정(waitForAllUrls)만으로는 왜 부족했나요?
waitForAllUrls는 '이미 알고 있는 슬라이드 URL 목록'이 전부 열릴 때까지 기다리는 함수입니다. 그런데 그 목록 자체를 만드는 findSlideUrls 함수는 slide-1.png부터 순서대로 HEAD 요청을 보내다가 404를 받으면 '거기서 끝났다'고 판단하고 멈춥니다. waitForAllUrls를 아무리 강화해도, 애초에 목록에 슬라이드가 몇 장 들어갈지를 정하는 이 탐지 단계가 단발성 요청 하나에 의존하고 있었던 게 별개의 문제였습니다.
같은 URL인데 요청마다 응답이 다른 이유가 뭔가요?
Cloudflare 같은 CDN은 여러 엣지 PoP(접속 지점)에 캐시를 분산시켜두고, 요청이 올 때마다 그중 하나로 라우팅합니다. 배포 직후에는 이 PoP들에 새 파일이 전파되는 속도가 제각각이라, 어떤 PoP는 이미 파일을 받았고 어떤 PoP는 아직입니다. 같은 URL이라도 어느 PoP로 라우팅되느냐에 따라 응답이 200이거나 404일 수 있고, 이건 우리 쪽에서 URL이나 헤더로 통제할 수 없는 영역입니다.
재시도 횟수와 간격은 어떻게 정했나요?
슬라이드 존재 확인(probeSlide)은 3회 재시도에 2초 간격, 전량 배포 확인(waitForAllUrls)은 최대 3분에 5초 간격으로 뒀습니다. 정확한 전파 완료 시간을 알 방법이 없어서 정해진 공식은 아니고, 실제 실패 사례에서 재시도 없이 걸렸던 지점(첫 번째 실패 직후 재시도하면 성공)을 근거로 여유 있게 잡은 값입니다.
이 버그는 어떻게 발견했나요?
둘 다 실제 발행 중 발생한 에러 로그로 발견했습니다. 1차는 Meta API가 준 error_subcode 2207052(미디어 다운로드 실패)였고, 2차는 스크립트 자체가 낸 '슬라이드가 0장뿐임' 에러였습니다. 두 에러 모두 같은 curl 확인으로는 재현이 안 돼서, 코드에 있는 그대로의 순차 요청 흐름을 다시 읽고 나서야 어느 단계가 단발 요청에 의존하는지 찾을 수 있었습니다.