rate limit에 걸려 블로그 글 절반이 404가 됐다
글 36개 중 17개가 404였다. 근데 빌드는 계속 성공했고 배포도 초록불이네.. 뭐지?
들어가며
내 블로그는 노션을 CMS로 쓰는 노션 기반의 블로그이다 . (예전 글은 여기 (새 탭에서 열림)서 볼 수 있습니다)
예전에 쓴 글 링크를 눌렀는데 404가 떴다. 노션에는 글이 멀쩡히 있고 published 체크도 되어 있었다. 로컬에서 dev 서버를 띄워보니 잘 열린다. 배포된 곳에서만 안 되는 거였다. 😭
혹시나 해서 사이트맵에 있는 주소를 전부 돌려봤다.
curl -s https://changjun.dev/sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed 's/<[^>]*>//g' | grep posts/ \
| while read u; do echo "$(curl -s -o /dev/null -w '%{http_code}' "$u") $u"; done전체 글이 36개였는데 그 중 17개가 404였다. 절반 가까이 죽어 있었는데 그동안 빌드는 계속 성공했고 배포도 초록불이었다. 뭐지..?
어떤 문제가 있었을까?
기존 구조
빌드할 때 노션에 있는 글을 전부 받아와서 정적 페이지로 만들어두는 방식이었다. 거기에 1시간짜리 ISR을 걸어서 노션에서 글을 고치면 알아서 반영되게 했다.
글이 열 개쯤일 때는 잘 돌았다. 노션에서 글 쓰고 공개로 바꿔두면 알아서 올라갔으니 딱히 신경쓰지 않고 잘 돌아가겠거니 잊고 지냈다.
글 수만큼 불어나는 호출량
문제는 이 구조의 비용이 글 수에 비례하는 게 아니라 제곱에 가깝게 늘어난다는 점이었다. 글 한 장을 그릴 때 어떤 호출이 나가는지 따라가 봤다.
다음 글 제목 하나 보여주려고 블로그 글 전체를 펼쳐보고 있었다. 코드로 보면 두 군데가 겹쳐서 생긴 일이다.
글 상세 페이지는 이전 글과 다음 글을 표시하려고 전체 목록을 부른다.
// posts/[slug]/page.tsx
const post = await getPostBySlugCached(slug);
const allPosts = await getAllPostsCached(); // ← 여기
const currentIndex = allPosts.findIndex((p) => p.slug === slug);그리고 그 목록을 만드는 코드가 이랬다.
// NotionPostAdapter
const notionPosts = await notionGetAllPosts();
return Promise.all(notionPosts.map(async (post) => ({
id: post.id,
title: post.title,
// ...
coverImage: await getPostThumbnail(post), // ← 글마다 한 번씩
})));getPostThumbnail은 커버 이미지가 없으면 본문을 받아온다.
export async function getPostThumbnail(post: NotionPost) {
if (post.coverImage?.trim()) return post.coverImage;
let blocks = readPageBlocksCache(post.id, post.updatedAt);
if (!blocks) {
blocks = await getPostBlocks(post.id); // ← Notion 호출
writePageBlocksCache(post.id, post.updatedAt, blocks);
}
return firstImage(blocks);
}목록을 한 번 부르면 전체 n개의 본문이 전부 딸려 온다. 글 상세 페이지 하나를 그릴 때마다 이게 통째로 실행된다.
썸네일을 "커버 이미지가 없으면 본문 첫 이미지"로 정해뒀는데, 커버 이미지를 한 번도 설정한 적이 없었다... 그래서 모든 썸네일이 본문을 열어봐야만 알 수 있는 값이었다. 게다가 블록을 받아오는 함수는 자식이 있는 블록마다 자기 자신을 다시 호출한다. 토글이나 컬럼 안의 내용도 받아와야 하니까.
실제로 재봤더니 목록 한 번 만드는 데 41.7초가 걸렸다. 코드에 400밀리초 간격 제한을 넣어놨으니 이 시간은 곧 호출랑 동일하다. 대충 계산해봐도 100회가 넘는다는 뜻이었다. 노션은 초당 3회까지만 응답한다. 빌드 로그를 뒤져보니 rate limit 경고가 298번 찍혀 있었다.
진짜 범인
그런데 호출이 많은 것만으로는 404가 설명되지 않는다. 느려질 뿐이지 글이 사라질 이유는 없으니까. 범인은 이 코드였다.
} catch (error) {
console.error("Error fetching post:", error);
notFound();
}언뜻 보면 방어적으로 잘 짠 코드 같다. 글을 못 가져오면 404를 보여준다. 합리적으로 보인다.
그런데 notFound()는 에러가 아니다. Next 입장에서는 "이 경로는 404가 정답"이라는 정상적인 렌더링 결과다. 그래서 Next는 그 자리에 404 HTML을 만들어서 저장하고, 빌드는 exit 0으로 성공한다.
빌드 산출물을 직접 열어보니 확실했다. .next/server/app/posts/ces-2023--….html 파일이 존재했고 내용이 "This page could not be found"였다. 파일이 없어서 404가 난 게 아니라 404라는 내용의 파일이 만들어져 있었던 거다.
지금 못 가져온 거랑 원래 없는 거는 다른 건데, 코드가 둘을 똑같이 취급하고 있었다.
더 나쁜 건 자가치유가 안 된다는 점이었다. 1시간마다 재검증이 돌면 복구될 법도 한데, 크롤러가 사이트맵을 따라 여러 글을 한꺼번에 치면 또 rate limit에 걸리고 또 404가 덮어써진다. 성공한 재검증이 한 번도 끼어들지 못하면 영원히 404다.
어떤 고민을 했는지
노션 버리기
원인을 알고 나서 제일 먼저 든 생각이었다. 애초에 외부 API에 빌드를 의존하는 게 문제 아닌가 싶었다.
근데 따져보니 노션이 문제를 일으킨 게 아니었다. 초당 3회는 문서에 적혀 있는 한도고, 내 코드가 그걸 100배로 넘기고 있었을 뿐이다. 도구를 바꿔도 같은 코드면 같은 일이 벌어진다. 외부 API를 쓰는 다른 CMS로 가면 이름만 바뀌는 셈이다.
MDX로 갈아타기
글을 MDX 파일로 저장소에 두면 이 문제는 통째로 사라진다. 빌드가 네트워크를 안 타니까 rate limit도 없고, 노션이 죽어도 배포가 되고, 글 이력이 git에 남는다. 기술적으로는 가장 확실한 답이다.
며칠 굴려보다가 접었다.
노션에서 글 쓰는 경험이 좋아서 이 구조를 택한 거였다. 이미지를 그냥 붙여넣으면 되고, 토글이랑 콜아웃으로 구조를 잡고, 쓰다 만 글을 초안으로 묵혀두고, 폰으로도 고칠 수 있다. MDX로 가면 이걸 전부 잃는다. 이미지 하나 넣으려고 파일 옮기고 경로 쓰는 순간부터 글 쓰기가 귀찮아질 게 뻔했다.
블로그가 터진 이유가 "노션을 썼기 때문"은 아니었다. 노션을 쓰는 방식이 잘못됐기 때문이었다. 그러면 방식을 고치는 게 맞다.
노션을 유지한 채 고치기
문제를 다시 정리해보니 층이 세 개였다.
- 에러 처리 — 일시적 실패를 영구적 사실로 저장한다
- 호출량 — 목록 하나 만드는 데 글 수만큼 요청이 나간다
- 호출 시점 — 방문자가 오는 순간이 곧 노션 호출 순간이다
셋은 서로 다른 문제였고 하나만 고쳐서는 안 됐다. 에러 처리만 고치면 빌드가 계속 깨지고, 호출량만 줄이면 언젠가 또 몰릴 때 다시 굳는다.
어떻게 해결했을까?
1. 모름을 없음으로 바꾸지 않기
notFound()는 노션이 "그런 페이지 없다"고 명확히 답했을 때와 주소 형식이 잘못됐을 때만 호출한다. 나머지 에러는 다시 던진다.
export function isNotionNotFoundError(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const notionError = error as { code?: string; name?: string };
return notionError.code === "object_not_found"
|| notionError.name === "InvalidPostSlugError";
}
// 글 페이지
} catch (error) {
if (isNotionNotFoundError(error)) notFound();
throw error;
}주소 형식이 틀렸을 때 던지는 에러도 따로 클래스를 만들었다. 전에는 그냥 new Error(...)를 던지고 있어서 429랑 구분할 방법이 없었다.
이제 빌드 중에 노션이 막히면 빌드가 그냥 터진다. 로그를 안 봐도 배포가 안 되니까 모를 수가 없다. 배포된 뒤에 막히면 재검증만 실패하고 끝이라 원래 페이지가 그대로 남는다.
2. 빌드가 이미 갖고 있던 답
썸네일 때문에 글 n개를 매번 펼쳐보는 문제는 좀 허무하게 풀렸다.
이미지를 내려받아 WebP로 바꾸는 빌드 스크립트가 있는데, 그 스크립트는 어차피 모든 글의 본문 이미지를 다 수집하고 있었다. 최적화하려면 다 열어봐야 하니까. 그때 첫 이미지만 따로 적어두면 되는 거였다.
// 빌드 때 한 번 만들고
const thumbnailMap = buildThumbnailMap(
postImageData.map(({ post, imageUrls }) => ({ id: post.id, imageUrls })),
);
// 런타임은 읽기만 한다
const precomputed = lookupPrecomputedThumbnail(post.id);
if (precomputed !== undefined) {
return precomputed || undefined;
}값을 세 가지로 나눈 게 포인트였다.
| 값 | 의미 | 런타임 동작 |
|---|---|---|
| URL 문자열 | 대표 이미지 | 그대로 사용 |
| 빈 문자열 | 확인했는데 이미지가 없는 글 | 조회하지 않음 |
| 키 없음 | 빌드 이후에 발행된 글 | 그 글만 한 번 조회 |
빈 문자열을 구분하지 않으면 이미지 없는 글 7개가 매번 헛조회를 유발한다.
캐시를 비운 상태에서 목록 한 번 만드는 데 41.7초 100회 넘게 걸리던 게 1.2초 3회가 됐다.
3. 노션을 누가 부를지 바꾸기
앞의 둘로 비용은 줄었지만, 여전히 방문자가 들어올 때마다 노션을 부르는 구조였다. 1시간짜리 ISR을 껐다.
export const revalidate = false;대신 재검증 엔드포인트를 만들고 30분마다 크론이 호출하게 했다.
revalidatePath는 렌더링이 아니라 "이 경로 낡았다"는 표시만 남긴다. 실제로 그리는 건 다음 방문자가 왔을 때다. 아무도 안 들어오면 노션 호출도 없다.
크론은 GitHub Actions로 걸었다.
# .github/workflows/revalidate.yaml
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch: # 바로 반영하고 싶을 때 누르는 버튼
jobs:
revalidate:
runs-on: ubuntu-latest
steps:
- run: |
curl -fsS -X POST "$SITE_URL/api/revalidate" \
-H "Authorization: Bearer $REVALIDATE_SECRET" \
-H "Content-Type: application/json" -d '{}'workflow_dispatch를 같이 넣어서 수동으로도 트리거할 수 있게 구성했다.
curl -f를 쓴 이유는 응답이 에러면 워크플로가 실패하게 하려는 거다. 재검증이 조용히 멈춰 있는 상황을 만들고 싶지 않았다.
여기서 하나 신경 쓴 게 있다. 목록 조회에 실패하면 아무것도 무효화하지 않고 502를 반환하게 했다. 무엇이 바뀌었는지 모르는 채로 경로만 비워두면, 다음 방문자가 실패하는 렌더링을 떠안게 되기 때문이다. 노션이 죽어 있을 때 멀쩡한 페이지까지 깨뜨리고 싶지는 않았다.
그 외
- 전체 목록 조회를 프로세스당 60초 공유했다. 빌드 워커가 글마다 목록을 다시 받아오던 걸 없앴다
- 재시도를 5회에서 8회로 늘리고 노션이 보내는
Retry-After헤더를 따르게 했다 - 블록 캐시를
.next/cache아래로 옮겼다. Vercel이 빌드 간에 보존해주는 자리라, 수정되지 않은 글은 다음 빌드에서 조회를 안 탄다 - RSS 피드 세 개가 요청마다 실행되고 있었다. Next 15에서 라우트 핸들러가 기본적으로 캐시되지 않기 때문인데, 구독자 요청이 그대로 노션 호출이었던 셈이라 정적으로 고정했다
결과
요롷게 바꼈다
| 전 | 후 | |
|---|---|---|
| 404인 글 | 36개 중 17개 | 0개 |
| 목록 한 번 만들기 | 41.7초 / 100회 이상 | 1.2초 / 3회 |
| 홈 썸네일 | 20개 중 2개 | 20개 중 18개 |
| 노션 호출 시점 | 방문자가 올 때마다 | 30분에 한 번 + 수동 |