브라우저에서는 멀쩡한 페이지가 크롤러에게는 스켈레톤뿐인 빈 문서였습니다. 서버 컴포넌트로 만든 페이지는 왜 서버에서 그려지지 않았을까요?
서비스의 핵심 페이지 세 곳(상품 마진 페이지, 정산달력 페이지, 선정산 신청 페이지)은 로그인 전에는 서비스 소개를, 로그인 후에는 실제 데이터를 보여줍니다. 그중 상품 마진 페이지에는 검색엔진용 구조화 데이터(JSON-LD)가 붙어 있습니다. 어느 날 페이지 소스를 열어 봤더니 그 데이터가 선언한 내용과 실제 본문이 서로 달랐습니다. JSON-LD에는 서비스 설명이, script 안의 RSC 데이터에는 소개 화면의 FAQ 문구가 실려 있는데, 정작 HTML 본문에는 둘 중 어느 것도 없고 스켈레톤만 있었습니다.
그런데 브라우저 화면과 Elements 탭에서는 본문이 정상이었습니다. 둘 다 JavaScript가 실행된 뒤의 결과이기 때문입니다. Googlebot도 스크립트를 실행하지만 렌더링은 별도 대기열에서 나중에 이뤄지고, 스크립트를 실행하지 않는 크롤러나 링크 미리보기 봇에게는 첫 HTML이 전부입니다.
그 첫 HTML이 어떤 모습인지 보려고 JavaScript를 끄고 열어 봤습니다.
상품 마진 페이지에서 가장 크게 보이는 헤드라인 문구도 페이지 소스에서 검색하면 0/0입니다.
그렇다고 서버에 내용이 없던 건 아닙니다. 처음에 본 FAQ 문구를 검색하면 잡히긴 하는데, 전부 self.__next_f.push(...), 즉 클라이언트가 나중에 그릴 RSC 데이터 안입니다. 서버는 보여줄 내용을 갖고 있으면서 HTML로 그리지 않고 script에 실어 보내고 있었습니다.
화면 몇 장이 아니라 숫자로 확인하려고 콘솔 스크립트를 하나 만들었습니다. 현재 페이지를 쿠키 없이, 즉 크롤러와 같은 비로그인 응답으로 다시 받아 script와 style을 뺀 크기와 본문 텍스트 길이를 잽니다.
await (async () => {
const html = await (await fetch(location.href, { credentials: 'omit' })).text()
const doc = new DOMParser().parseFromString(html, 'text/html')
doc.querySelectorAll('script, style').forEach((el) => el.remove())
return {
script제외_KB: +(doc.documentElement.outerHTML.length / 1024).toFixed(1),
본문텍스트_글자_수: doc.body.textContent.replace(/\s+/g, ' ').trim().length,
h1태그_수: doc.body.querySelectorAll('h1').length
}
})()
측정은 script를 빼고Next.js는 직렬화 데이터를
<script>에 실어 보내서, 전체 바이트로 재면 본문이 늘었는지 정확히 알 수 없습니다.
| script 제외 | 본문 텍스트 | h1태그 수 |
|---|---|---|
| 13.6KB | 386자 | 0개 |
386자를 뽑아보면 GNB와 푸터 문구뿐이었습니다. 크롤러는 해당 본문을 제대로 수집하지 못할 것입니다.
세 페이지의 공통점은 처음에 적은 대로 로그인 여부로 화면이 갈린다는 것입니다. 이 분기를 맡은 인증 게이트 컴포넌트는 로그인 사용자 정보를 돌려주는 인증 API를 클라이언트 useQuery로 조회해 판정하고 있었습니다.
서버 렌더 시점: 캐시 비어 있음 → isPending → 스켈레톤이 HTML에 박힘
브라우저: 인증 API 조회 완료 → 그제야 본문 렌더
크롤러: 스켈레톤만 읽고 떠남
TanStack Query의 Advanced Server Rendering 문서도 useQuery는 서버 렌더에서 suspend하지 않아 "opts out of server rendering the content"라고 명시합니다. 페이지는 서버 컴포넌트로 되어 있었지만, 클라이언트 쿼리 하나에 걸린 분기가 본문 전체를 클라이언트 렌더로 만들고 있었습니다.
그렇다면 판정을 서버로 옮기면 됩니다. 요청 쿠키로 서버에서 인증 API를 조회해, 인증 분기가 끝난 HTML을 내보냅니다.
다만 인증 게이트는 클라이언트 쿼리로 인증 정보를 읽으니, 서버에서 조회한 결과를 그 쿼리에 넘겨야 합니다. 그 방법은 TanStack Query 공식 문서에 이미 있습니다. 서버 컴포넌트에서 쿼리를 prefetch하고 HydrationBoundary로 캐시를 넘기면, 클라이언트의 useQuery나 useSuspenseQuery가 첫 렌더부터 그 데이터를 읽습니다.
그런데 인증 정보에는 이 방식이 통하지 않았습니다. 인증 정보는 페이지 데이터가 아니라 앱 전역 값이라, 헤더나 전역 프로바이더처럼 페이지보다 위에 있는 컴포넌트들이 이미 useQuery로 구독하고 있습니다. 이렇게 상위에서 먼저 구독 중인 쿼리에는 페이지의 HydrationBoundary가 서버 렌더 시점에 값을 채워 주지 못합니다. 실제로 이 방식으로 재현해 보니 인증 게이트는 여전히 스켈레톤을 그렸고, 본문 텍스트는 위의 386자 그대로였습니다.
이를 피하는 길은 둘이었습니다. 하나는 HydrationBoundary를 구독자보다 위, 레이아웃까지 올리는 것입니다. 하지만 레이아웃에서 인증 정보를 prefetch하면 쿠키를 읽는 서버 호출이 전역에 걸려, 정적이어야 할 페이지까지 모두 동적 렌더링으로 바뀝니다. 다른 하나는 위에 있는 구독자를 전부 클라이언트 마운트 뒤로 미루는 것입니다. 본문은 살릴 수 있지만, 모든 곳에 게이트를 세우고 인증 정보를 읽는 컴포넌트가 새로 생길 때마다 그 규칙을 지켜야 했습니다.
그래서 인증 정보는 필요한 페이지에서만 서버에서 확정하고, HydrationBoundary를 거치지 않고 Context로 직접 내렸습니다.
// 이름은 설명을 위해 단순화했습니다
export async function ServerAuthProvider({ children }) {
const auth = await getServerAuth()
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
)
}
인증 훅은 이 Context 값을 initialData로 받습니다.
export function useAuth() {
const serverAuth = useContext(AuthContext)
return useQuery({
queryKey: authQueryKey,
queryFn: fetchAuth,
initialData: serverAuth, // 서버가 확정하지 못했으면 undefined
})
}
이제 인증 게이트는 첫 렌더부터 확정된 인증 정보를 봅니다. initialData는 상위에서 먼저 구독하고 있어도 값이 채워지기 때문에, 본문은 영향을 받지 않습니다. 훅의 반환 계약은 그대로라 이 훅을 쓰는 곳은 수정하지 않았습니다.
적용하면서 두 가지를 정했습니다.
적용 범위는 교집합으로 한정했습니다. sitemap에 있으면서 인증 분기가 있는 페이지, 정확히 세 개입니다. 나머지는 이미 SSR되고 있어 적용해도 TTFB만 늘고, 레이아웃 전역에 걸면 앞서 본 것처럼 모든 페이지가 동적으로 바뀝니다.
서버가 확신할 수 없으면 단정하지 않습니다. 인증 정보를 확정하지 못한 경우에는 초기값을 비워 두어 기존 클라이언트 조회 경로를 그대로 타게 했습니다. 서버에서 비로그인으로 단정해 버리면 클라이언트가 다시 확인할 기회가 사라지기 때문입니다.
앞의 콘솔 스크립트로 상품 마진 페이지를 다시 쟀습니다. 작업 전은 프로덕션, 작업 후는 이 시리즈의 작업을 모두 마친 최종 빌드를 로컬에서 프로덕션 모드로 띄운 값입니다.
| script 제외 | 본문 텍스트 | h1태그 수 |
|---|---|---|
| 13.6KB → 78.0KB | 386자 → 2,113자 | 0개 → 1개 |
헤드라인 문구도 이제 서버 HTML에서 1/1로 잡힙니다.
FAQ는 인증 분기만 풀어서는 절반만 돌아왔습니다. 질문은 HTML에 들어갔는데 답변이 빠져 있었습니다. Radix Accordion이 닫힌 항목의 내용을 아예 마운트하지 않기 때문입니다. 답변에 forceMount를 주어 닫힌 내용도 DOM에 두게 했습니다.
다른 주요 페이지에도 같은 방식을 적용했습니다. 다만 정산달력 페이지는 인증 분기를 고친 뒤에도 뷰포트 분기 때문에 본문이 비어 있었는데, 그 이야기는 3편에서 다룹니다.
크롤러를 위한 작업이었지만 브라우저의 요청 순서도 바뀌었습니다. 작업 전에는 브라우저가 인증 API 응답을 기다린 뒤에야 상품 마진 페이지의 데이터를 요청했습니다. 작업 후에는 그 대기가 서버로 옮겨 가면서 브라우저에서 인증 API 요청 자체가 사라졌습니다.
이 변화가 첫 화면에 얼마나 반영되는지도 같은 페이지에서 쟀습니다. 프로덕션과 같은 커밋과 작업 브랜치를 각각 로컬 프로덕션 빌드로 띄우고, 같은 조건(CPU 4x slowdown, Fast 4G, 비로그인)으로 Performance 패널을 녹화했습니다.
| FCP | LCP | 간격 | |
|---|---|---|---|
| 작업 전 | 360ms | 2,040ms | 1,680ms |
| 작업 후 | 400ms | 400ms | 0ms |
작업 전에는 스켈레톤으로 첫 페인트를 한 뒤 인증 API를 기다렸다가 본문을 그렸고, 작업 후에는 첫 페인트가 곧 가장 큰 콘텐츠입니다. 서버가 인증 API를 조회한 뒤 응답하니 FCP는 조금 늦어졌지만, LCP는 약 80% 줄었습니다.
줄어든 건 페이지마다 다른 렌더 비용이 아니라 인증 API를 기다리던 시간입니다. 같은 인증 게이트 뒤에 본문이 있던 다른 주요 페이지도, 따로 재지는 않았지만 개선의 방향은 같습니다.
App Router를 쓴다고 본문이 서버에서 그려지는 건 아니었습니다. 상위에 걸린 클라이언트 분기 하나가 그 아래 전체를 클라이언트 렌더로 돌려놓을 수 있고, 화면과 Elements 탭에서는 그게 보이지 않습니다. 이제 SSR이 필요한 페이지는 페이지 소스와 JavaScript를 끈 화면으로 확인합니다.
다만 본문을 HTML에 싣는 이 변경은 PR을 올리자 CI에서 막혔습니다. 그 이야기는 다음 편에서 이어집니다.
인증 분기를 서버로 옮겨도 본문은 여전히 비어 있었습니다. 화면 크기를 모르는 서버는 어느 레이아웃을 그려야 할까요?
인증 분기를 서버로 옮기자 모바일 e2e만 깨졌습니다. 클릭은 성공으로 기록됐는데, 화면에서는 아무 일도 일어나지 않았습니다.
배포가 끝나도 열려 있던 탭은 옛 코드로 계속 돌아갑니다. 그 탭에 새 빌드를 알리되, 알림이 틀려도 사용자에게 해가 없게 만든 과정을 정리했습니다.
데스크톱에서는 멀쩡한 인증 팝업이 iOS에서만 열리지 않았습니다. 원인은 금방 찾았지만, 고치는 일은 한 줄로 끝나지 않았습니다.
특정 요청도, 붐비는 시간대도 아닌데 502가 간헐적으로 떴고 다시 보내면 멀쩡했습니다. 무작위처럼 보이는 이 오류는 어디서 생긴 걸까요?
검색엔진은 페이지를 읽을 수는 있어도 그 안의 숫자가 무엇을 뜻하는지는 모릅니다. 메타 태그를 다 넣어도 검색 결과가 파란 링크 한 줄에 머무는 이유를 정리했습니다.
전역 Suspense로 감싸면 경고는 사라지지만 페이지 본문도 HTML에서 함께 사라집니다. 동기 훅이 왜 Suspense 경계를 요구하는지 소스코드로 따라갔습니다.
여러 Claude Code 세션의 상태를 한눈에 보는 위젯을 만들며, 믿기 어려운 신호를 다른 신호로 교차 검증한 과정을 정리했습니다.
문서는 Server Action이 큐에 쌓인다고만 말합니다. 그 큐가 어디까지 하나로 묶이는지는 직접 재 보기 전에는 알 수 없었습니다.