무료 플랜 사용량 경고가 왔습니다. 함수 CPU 4시간 중 3시간을 썼고, 100%가 되면 프로젝트 49개가 전부 일시정지된다는 내용이었습니다.
이상했습니다. 제가 올린 사이트들은 대부분 정적 페이지고, 방문자도 많지 않습니다. 정적 사이트가 어떻게 CPU를 3시간이나 씁니까?
질문을 하나 드리고 시작하겠습니다. 당신의 "정적" 사이트, 정말 정적입니까? 지난주에 그렇게 확인한 게 아니라, 지금 이 순간에도?
먼저 틀린 진단부터
무료 플랜은 사용량 API가 막혀 있습니다. 그래서 밖에서 알아낼 수 있는 것으로 시작했습니다. 49개 사이트의 응답 헤더를 전부 훑었습니다.
찾은 건 두 가지였습니다. 홈페이지가 private, no-cache, no-store로 나오는 앱이 4개, 그리고 직접 만든 OG 이미지 라우트 3개가 같은 URL을 연속으로 요청해도 계속 캐시 미스였습니다. 요청당 2.4초씩 이미지를 다시 그리고 있었습니다.
범인을 찾았다고 생각했습니다. 캐시 헤더를 붙여 배포했고, 미스가 히트로 바뀌는 것까지 확인했습니다.
그리고 대시보드를 열었습니다.
devlog 1h 30m 46.1%
mbti 6m 4s 3.1%
zodiac 2m 45s 1.4%
naming 2m 7s 1.1%제가 범인으로 지목한 앱들은 목록에 없었습니다. 30일간 팀 전체 CPU의 46%를 먹은 건 이 블로그, 지금 읽고 계신 devlog였습니다.
여기서 배운 게 이번 작업의 절반입니다. 응답 헤더는 "이 구조가 비싼가"를 알려주고, 대시보드는 "실제로 얼마나 썼나"를 알려줍니다. 저는 앞의 것만 보고 뒤의 것을 안다고 착각했습니다. 비싼 구조 × 트래픽 0 = 0원입니다. 구조가 아무리 나빠도 아무도 안 오면 비용은 안 나옵니다. 이건 1원칙으로 분석하기에서 매번 했던 질문인데도 이번엔 건너뛰었습니다.
진짜 원인은 한 줄
devlog를 열어보니 sitemap에 있는 239개 URL이 전부 no-store였습니다. 정적 블로그인데 페이지 하나하나가 요청마다 새로 렌더되고 있었습니다.
원인은 이것이었습니다.
// app/layout.tsx
const requestLocale = (await headers()).get("x-devlog-locale");용도는 두 가지뿐이었습니다. <html lang> 속성과 "본문 바로가기" 스킵 링크 문구. 로케일에 따라 한국어/영어로 바꾸려고 넣은 줄입니다.
그런데 루트 레이아웃에서 요청 헤더를 한 번이라도 읽으면, 그 아래 모든 라우트가 동적 렌더로 전환됩니다. 루트 레이아웃은 모든 페이지를 감싸니까요. 미리 만들어둘 수 있는 페이지가 하나도 남지 않습니다.
증거는 응답 헤더에 그대로 찍혀 있었습니다.
x-vercel-id: icn1::iad1::9htj5-1786930...리전이 두 개입니다. icn1은 서울, iad1은 미국 동부. 서울에서 요청을 받아 미국까지 보내 렌더한 다음 다시 가져왔다는 뜻입니다. 서울에서 재보니 첫 바이트까지 2.2초였습니다. 정적 페이지들은 icn1 하나로 끝나고 0.05초입니다.
날짜도 맞아떨어졌습니다. 그 줄이 들어온 커밋은 8월 8일, i18n 손보면서 문서 lang을 고치던 작업이었습니다. 사용량 차트에서 devlog 막대가 솟기 시작한 날이 정확히 8월 8~9일이었습니다.
한 줄이 열흘 동안 조용히 청구서를 쓰고 있었던 겁니다.
당신이라면 어디를 고치겠습니까
세 가지 길이 있었습니다.
하나, 함수 리전을 서울로 옮긴다. 태평양은 안 건너지만 여전히 요청마다 렌더합니다. 둘, 캐시 헤더를 붙여 CDN에 태운다. 동적 렌더는 유지되고 캐시가 빠지면 다시 느려집니다. 셋, 그 한 줄을 지운다.
저는 셋을 골랐습니다. 이유는 단순합니다. 로케일은 이미 주소에 있었습니다. /ko/posts/..., /en/posts/... — 경로 첫 조각이 곧 로케일인데, 그걸 굳이 헤더로 다시 받고 있었던 겁니다. 프록시가 헤더를 심고, 레이아웃이 그 헤더를 읽고, 그 대가로 245개 페이지의 사전 생성을 통째로 포기하고 있었습니다.
고친 방법
루트 레이아웃은 요청을 아예 읽지 않게 하고 <html lang>을 기본 로케일로 고정했습니다. 로케일에 따라 달라지는 부분은 app/[locale]/layout.tsx로 내렸습니다. 이 레이아웃은 params로 로케일을 받으니 요청을 읽을 필요가 없습니다. 스킵 링크 문구도 여기로 옮겼고, 기본이 아닌 로케일에서만 문서 lang을 맞추는 짧은 스크립트를 넣었습니다.
프록시에서 헤더를 심던 코드도 지웠습니다. 대신 주석을 남겼습니다. "다시 넣는 순간 245개 프리렌더가 통째로 무력화된다."
빌드 결과가 245개 SSG 프리렌더로 바뀌었고, 배포 후 서울에서 다시 쟀습니다.
| 전 | 후 | |
|---|---|---|
/ko 첫 바이트 |
2.14~2.24초 | 0.12~0.14초 |
| 포스트 페이지 | 0.32~0.38초 | 0.12~0.14초 |
| 처리 경로 | icn1::iad1 |
icn1 |
16배입니다. 그리고 이건 비용 얘기가 아니라 읽는 사람이 실제로 겪는 시간입니다. 클릭하고 2초를 기다리던 블로그가 즉시 뜹니다. 비용 46%는 덤이고요.
덤으로 걸린 함정 두 개
커스텀 OG 라우트는 캐시가 안 붙습니다. 파일 규약(opengraph-image.tsx)으로 만든 OG는 URL에 해시가 붙고 immutable로 나가 배포당 한 번만 그려집니다. 그런데 직접 만든 app/og/route.tsx는 기본이 max-age=0, must-revalidate라 같은 URL도 매번 다시 그립니다. 크롤러가 페이지를 긁을 때마다 이미지가 새로 렌더됩니다. ImageResponse에 헤더를 직접 넣어야 합니다.
rewrites()를 배열로 반환하면 무시될 수 있습니다. 배열 반환은 afterFiles로 취급돼 실제로 존재하는 정적 라우트에 밀립니다. 저는 /?lang=en을 영문 페이지로 보내려 했는데 배포 후 한국어가 나왔습니다. { beforeFiles: [...] }로 바꾸니 됐습니다. 배포하고 되읽지 않았으면 그대로 나갔을 겁니다. 캐시 때문에 옛 버전이 계속 보이던 일과 같은 교훈입니다 — 배포는 끝이 아니라 확인의 시작입니다.
지금 3분 안에 확인할 것 세 가지
- 같은 URL을 두 번 요청해보세요.
curl -sSI https://your.site/ | grep -i x-vercel-cache두 번. 둘 다MISS면 그 페이지는 요청마다 렌더되고 있습니다. x-vercel-id의 리전 개수를 세보세요.icn1::iad1처럼 둘이면 함수까지 갔다 온 겁니다. 하나면 엣지에서 끝난 겁니다.- 루트 레이아웃을 grep 하세요.
grep -n "headers()\|cookies()" app/layout.tsx. 한 줄이라도 나오면, 그 앱에 정적 페이지는 없습니다.
솔직한 부분
비용이 실제로 줄었는지는 아직 모릅니다. 무료 플랜은 사용량 API가 막혀 있어 대시보드를 며칠 지켜봐야 확인됩니다. 지금 확인된 건 응답 속도와 프리렌더 개수뿐입니다.
그리고 이 글의 절반은 제가 틀렸던 기록입니다. 저는 측정 도구가 없다는 이유로 추정으로 범인을 지목했고, 엉뚱한 앱 세 개를 고친 다음에야 진짜 원인을 봤습니다. 그 세 개도 고칠 값어치는 있었지만, 전체의 몇 퍼센트짜리였습니다. 도구가 없으면 "모른다"고 적어두는 게, 있는 도구로 아는 척하는 것보다 낫습니다.
당신의 프로젝트에도 이런 한 줄이 있을 수 있습니다. 위의 curl 두 번이면 3분 안에 압니다. 해보시고 결과가 의외였다면 알려주세요.