함수를 깨우지 않는다 — 콜드 스타트와, 아직 못 잡은 에러 하나

지난 글
첫 화면이 느린 것과, 글을 쓰다 자꾸 나는 에러.

첫 화면이 느린 이유는 쿼리가 아니었다

캐시는 이미 한 번 손봤다. 공개 화면이 읽는 조회를 전부 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.jsxglobal-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 가 판단을 포기했다

둘 다 서버에서 요청마다 달라지는 값을 읽지 않는데도 그랬다. 캐시를 거는 것보다 캐시를 못 걸게 만들어 둔 곳을 찾는 게 먼저였다.