함수를 깨우지 않는다 — 콜드 스타트와, 아직 못 잡은 에러 하나
지난 글
첫 화면이 느린 것과, 글을 쓰다 자꾸 나는 에러.
첫 화면이 느린 이유는 쿼리가 아니었다
캐시는 이미 한 번 손봤다. 공개 화면이 읽는 조회를 전부 unstable_cache 로 감싸고
(lib/cache.js), 무효화는 시간이 아니라 태그로 하게 했다. 글 상세는 완성된 HTML 까지
캐시한다. 그런데도 랜딩은 계속 느렸다.
측정부터 했다.
$ curl -sI https://ksh.ai.kr/
Cache-Control: private,no-cache,no-store,max-age=0,must-revalidate
Cache-Status: "Netlify Durable"; fwd=bypass
Cache-Status: "Netlify Edge"; fwd=miss; fwd-status=200
$ curl -w "%{time_starttransfer}\n" -o /dev/null -s https://ksh.ai.kr/
1.005615
0.596929
0.584044
fwd=bypass. 엣지가 캐시를 아예 건너뛰고 있다. 방문 한 번마다 서버리스 함수가
새로 뜬다는 뜻이다.
같은 명령을 글 상세에 걸면 다르다.
$ curl -sI https://ksh.ai.kr/p/2026-08-13-zbmsz
Cache-Control: public,max-age=0,must-revalidate
Cache-Status: "Next.js"; hit
Cache-Status: "Netlify Durable"; fwd=vary-miss; stored
Age: 1258
Age: 1258. 21분 전에 구운 HTML 이 그대로 나가고 있다. 함수는 호출되지 않는다.
차이는 한 줄이었다.
export const dynamic = 'force-dynamic'
force-dynamic 은 응답에 no-store 를 단다. 그러면 Netlify 는 그 응답을 캐시하지
않는다. 나는 이 줄을 남겨 두면서 "쿼리를 캐시했으니 남는 비용은 렌더뿐" 이라고
주석까지 적어 뒀었는데, 거기서 빠뜨린 값이 있었다. 함수를 깨우는 비용.
쿼리가 0회여도 콜드 스타트는 그대로 남는다. 램다가 뜨고, 번들이 로드되고, 그 다음에야 0회의 쿼리를 한다. TTFB 0.58~1.0초는 거의 전부 그 부팅이었다. 그리고 그 값을, 이 사이트에서 가장 먼저 열리는 화면이 매번 냈다.
고친 것
글 상세와 같은 방식으로 바꿨다.
export const revalidate = 3600
이러면 HTML 이 엣지에서 나가고 함수는 호출되지 않는다. "저장하면 바로 반영된다"는
약속은 그대로다 — 쓰기 경로가 부르는 invalidateContent() 가 revalidateTag('content')
를 때리고, 이 페이지가 읽는 네 조회가 전부 그 태그를 달고 있다. 3600 은 그 무효화를
놓쳤을 때의 안전망이지 정상 경로가 아니다.
대가
공짜가 아니다. 빌드가 DB 에 의존하게 된다.
요청마다 달라지는 값을 읽지 않는 페이지라, Next 는 이걸 빌드 시점에 한 번 굽는다. 그 시점에 DB 가 안 되면 배포가 통째로 선다. 로컬에서 자격증명 없이 돌려 보면 이렇게 난다.
Error occurred prerendering page "/".
AggregateError:
code: 'ECONNREFUSED'
Export encountered an error on /(site)/page: /, exiting the build.
글 상세는 이걸 generateStaticParams 가 빈 배열을 돌려주는 것으로 피했다. 빌드 때는
아무 글도 굽지 않고, slug 마다 첫 요청에서 한 번 그린 뒤 캐시가 낸다.
파라미터가 없는 페이지에는 그 손잡이가 없다. force-static 도 답이 아니다 —
그것도 빌드 프리렌더다. 실질적으로 선택지는 둘뿐이었다.
- 빌드를 DB 에 묶고, 모든 방문자가 엣지에서 받는다
- 빌드를 자유롭게 두고, 모든 방문자가 함수 부팅을 기다린다
전자를 골랐다. 실패가 조용하지 않고(배포가 서고, 이전 배포는 그대로 살아 있고), 다시 돌리면 되는 종류라서다. 매 방문자의 0.6초와 바꿀 값은 아니다.
/posts 는 그대로 뒀다
searchParams 를 읽는다. ?category=·?tag=·?page= 로 결과가 달라지니 애초에
요청마다 다른 페이지고, Next 도 이걸 정적으로 굽지 않는다. 여기를 캐시하려면 쿼리
조합별 엣지 캐시를 따로 설계해야 하는데, 아직 그럴 만큼 트래픽이 있지 않다.
두 번째 — 글 쓰기 중에 나는 에러
이건 못 잡았다. 다만 파 내려간 과정은 남길 만하다.
스크린샷이 앱 화면이 아니었다
증상 스크린샷은 이렇게 생겼다.
⚠ This page couldn't load Reload to try again, or go back.
[Reload][Back]
첫 단서는 저장소를 뒤진 것이었다.
$ rg "couldn.t load|Reload to try" src/
No matches found
앱이 그린 화면이 아니다. app/ 아래에 error.jsx 도 global-error.jsx 도 없다.
브라우저가 직접 띄운 페이지라는 뜻이고, 그러면 자바스크립트 예외가 아니라 문서
요청 자체가 실패했다는 쪽으로 방향이 바뀐다. React 안에서 터진 예외를 찾을 게 아니었다.
후보를 지웠다
- 세션 만료 —
SESSION_TTL_DAYS가 14일이고, 7일 안쪽이면 조회할 때마다 연장된다. 글 하나 쓰는 동안 끊길 값이 아니다. - 미리보기 요청 실패 — 250ms 디바운스로
/api/v1/posts/preview를 때리고 있지만, 실패해도 마지막 성공 결과를 유지하고 배지만 띄우게 돼 있다(E-08). 화면이 죽지 않는다. - 연재 필드 타입 — 공개 API 는
series를 객체로 내는데 에디터는form.series.trim()을 부른다. 여기서 터지면 트리 전체가 언마운트된다. 확인해 보니 편집용 직렬화기toEditOut()은post.series?.name ?? ''로 문자열을 낸다. 아니었다.
그러다 헤더를 봤다
콜드 스타트를 재는 김에 관리 화면도 같이 걸어 봤다.
$ curl -sI https://ksh.ai.kr/admin/new
X-Nextjs-Prerender: 1
Cache-Status: "Netlify Edge"; hit; ttl=31079783
$ curl -sI https://ksh.ai.kr/admin/edit/11
Cache-Control: private,no-cache,no-store,max-age=0,must-revalidate
Cache-Status: "Netlify Durable"; fwd=bypass
새 글 화면은 엣지에서 나가는데, 편집 화면만 매 요청 함수를 깨우고 있었다.
이유는 랜딩과 같은 계열이다. 동적 구간([id])에 generateStaticParams 가 없으면
Next 는 그 경로를 요청마다 서버에서 그리고 no-store 를 단다.
그런데 이 페이지는 서버에서 읽는 게 하나도 없다.
export default async function EditPostPage({ params }) {
const { id } = await params
return (
<RequireAdmin>
<AdminEditor id={id} />
</RequireAdmin>
)
}
id 를 클라이언트 컴포넌트에 넘길 뿐이다. 본문은 AdminEditor 가 마운트된 뒤
/api/v1/posts/{id}/edit 로 가져오고, 권한 확인도 그 API 가 매번 한다. 어떤 id 로
열어도 HTML 이 같다. 캐시된 셸에 남에게 보여 줄 내용이 없다는 뜻이다.
빈 배열 하나면 됐다.
export async function generateStaticParams() {
return []
}
빌드 라우트 표에서 ƒ 가 ● 로 바뀌었다.
- ƒ /admin/edit/[id]
+ ● /admin/edit/[id]
여기서 멈춘 이유
이게 그 에러의 원인이라고 확정하지 못했다.
말이 되는 이야기는 만들 수 있다 — 글을 쓰다 탭을 떠났다 돌아오면 브라우저가 문서를 다시 요청하고, 그때 콜드 스타트에 걸린 함수가 늦거나 실패하면 브라우저가 자기 에러 페이지를 띄운다. 편집 화면만 그 조건에 있었으니 앞뒤가 맞는다.
맞는다는 것과 그렇다는 것은 다르다. 재현을 못 했고, 함수 로그도 아직 안 봤다. 그럴듯한 이야기에 맞춰 코드를 더 고치는 건 디버깅이 아니라 추측이다. 여기서 끊고, 다음에 그 화면이 뜨면 이것부터 남기기로 했다.
- 주소창에 뭐가 있었나 —
/admin/new인가/admin/edit/{id}인가 - 새로고침·복귀 때인가, 가만히 타이핑하는 중인가
- 그 시각 Netlify Functions 로그에 500·타임아웃이 있나
겸사겸사 눈에 걸린 것
작성 내용 자동 저장이 새 글에만 걸려 있다.
useEffect(() => {
if (id) return // 편집 모드는 서버 값이 원본이다
const t = setTimeout(() => localStorage.setItem(DRAFT_KEY, JSON.stringify(form)), 1000)
return () => clearTimeout(t)
}, [form, id])
"편집 모드는 서버 값이 원본" 이라는 말은 맞지만, 화면이 죽는 상황에서는 그 원본이 마지막 저장 시점이다. 기존 글을 고치다 죽으면 그 뒤로 친 게 전부 날아간다.
원인을 못 잡는 동안의 안전망으로는 이쪽이 더 급할 수도 있다. 다만 이건 에러를 고치는 게 아니라 다른 기능이라, 원인부터 잡고 결정하기로 했다.
정리
두 문제가 같은 뿌리였다. Next 가 이 페이지를 캐시해도 되는지 판단할 근거를 내가 안 준 것.
- 랜딩은
force-dynamic으로 "캐시하지 마" 라고 명시해 뒀다 - 편집 화면은
generateStaticParams가 없어 Next 가 판단을 포기했다
둘 다 서버에서 요청마다 달라지는 값을 읽지 않는데도 그랬다. 캐시를 거는 것보다 캐시를 못 걸게 만들어 둔 곳을 찾는 게 먼저였다.
댓글 0
불러오는 중…