SSR 요청 흐름, Nuxt 컨텍스트, 하이드레이션 미스매치의 원인과 해결.
렌더링 파이프라인과 하이드레이션 내부 동작
입문편에서는 SSR, SPA, SSG 등 렌더링 모드를 선택하고 useFetch로 데이터를 가져오는 방법을 배웠습니다. 하지만 "서버에서 HTML이 만들어진다"는 사실이 내부적으로 어떤 과정을 거치는지, 그리고 클라이언트가 그 HTML을 받은 후 무엇을 하는지는 아직 블랙박스로 남아 있습니다.
이 레슨에서는 Nuxt의 SSR 요청이 HTML 응답으로 변환되는 전 과정을 단계별로 분해하고, 하이드레이션이 실제로 무엇을 하는지, 그리고 실무에서 자주 마주치는 하이드레이션 미스매치를 왜 피해야 하고 어떻게 해결할 수 있는지를 내부 메커니즘 수준에서 살펴봅니다.
학습 목표
- Nitro 서버 진입부터 HTML 전송까지의 SSR 렌더링 파이프라인 전체 흐름을 설명할 수 있다.
useNuxtApp,ssrContext, payload의 구조를 이해하고 서버·클라이언트 경계를 다룰 수 있다.- 하이드레이션이 실제로 수행하는 DOM 비교 과정과 재실행 비용을 설명할 수 있다.
- 하이드레이션 미스매치의 주요 원인을 파악하고
<ClientOnly>,onMounted,import.meta.client로 경계를 제어할 수 있다. - devalue 직렬화를 통해
useState와useAsyncData의 데이터가 payload에 실리는 원리를 이해할 수 있다.
SSR 렌더링 파이프라인: Nitro 진입부터 HTML까지
Nuxt 3의 서버 런타임은 Nitro입니다. 브라우저가 첫 페이지를 요청하면 다음 순서로 처리됩니다.
[브라우저 요청]
↓
[Nitro HTTP 서버 (h3 라우터)]
↓ 매칭된 핸들러 실행
[Nuxt SSR 핸들러 (render:html)]
↓ createApp() → Vue 앱 인스턴스 생성
[서버 플러그인 실행 (server only)]
↓
[라우터 미들웨어 실행]
↓
[페이지 컴포넌트의 setup() 실행]
↓ useAsyncData / useFetch → 데이터 패칭 완료 대기
[renderToString() — Vue VNode를 HTML 문자열로]
↓
[payload 직렬화 (devalue) → <script> 태그에 삽입]
↓
[최종 HTML 조립 → 클라이언트 전송]
핵심은 각 요청마다 Vue 앱 인스턴스가 새로 생성된다는 점입니다. 싱글턴 상태를 서버 사이드 모듈 스코프에 두면 요청 간 상태가 공유되어 보안 사고 및 데이터 오염이 발생합니다.
// ❌ 서버에서 절대 하면 안 되는 패턴 — 모듈 스코프 싱글턴
let cachedUser: User | null = null
export default defineNuxtPlugin(() => {
// 이 변수는 모든 요청이 공유하므로 A 사용자 데이터가 B에게 노출될 수 있음
cachedUser = useAuthUser()
})
// ✅ 올바른 패턴 — 요청 컨텍스트(nuxtApp) 안에 상태 저장
export default defineNuxtPlugin((nuxtApp) => {
// nuxtApp은 요청마다 격리된 인스턴스
nuxtApp.$user = useAuthUser()
})
⚠️ 주의 Node.js 모듈은 프로세스 단위로 캐싱됩니다. 서버 플러그인의 클로저 변수, 모듈 최상위 변수, 그리고 Vue 반응형 상태를
reactive()로 모듈 스코프에 선언하면 모든 HTTP 요청이 그 상태를 공유합니다.
useNuxtApp과 Nuxt 컨텍스트 구조
useNuxtApp()은 현재 요청(서버) 또는 페이지 인스턴스(클라이언트)에 연결된 Nuxt 앱 컨텍스트를 반환합니다. 이 객체의 내부 구조를 이해하면 플러그인·미들웨어·컴포저블이 어떻게 데이터를 공유하는지 명확해집니다.
// composables/useNuxtContext.ts — 컨텍스트 구조 탐색 예제
export const useNuxtContext = () => {
const nuxtApp = useNuxtApp()
// ssrContext: 서버에서만 존재하는 요청 전용 컨텍스트
if (import.meta.server) {
const ssrCtx = nuxtApp.ssrContext
// ssrCtx.event — h3 요청 이벤트 (헤더, 쿠키, URL 등)
// ssrCtx.payload — 클라이언트로 전달할 직렬화된 데이터 맵
// ssrCtx.head — unhead 인스턴스 (useHead로 등록된 head/meta 관리)
// (참고: renderMeta 등 head 태그 문자열 생성 관련 필드는 Nuxt 내부 구현이며 버전에 따라 존재 여부·형태가 달라질 수 있습니다)
console.log('Request URL:', ssrCtx?.event.node.req.url)
}
// payload: 서버→클라이언트 데이터 이관 저장소
// nuxtApp.payload.data — useAsyncData 캐시
// nuxtApp.payload.state — useState 값
// nuxtApp.payload.error — useFetch 오류
// nuxtApp.payload._errors — NuxtError 목록
return {
payload: nuxtApp.payload,
}
}
서버 측에서 nuxtApp.ssrContext.event를 통해 HTTP 요청 헤더나 쿠키에 접근할 수 있습니다. 서버 플러그인 안에서 요청별 인증 처리를 할 때 이 경로를 활용합니다.
// plugins/auth.server.ts
export default defineNuxtPlugin(async (nuxtApp) => {
const event = nuxtApp.ssrContext!.event
const token = getCookie(event, 'auth_token') // h3 유틸리티
if (token) {
try {
const user = await verifyToken(token)
// 이 요청 컨텍스트에만 국한된 사용자 정보 저장
nuxtApp.provide('user', user)
} catch {
// 토큰 무효 시 쿠키 삭제
deleteCookie(event, 'auth_token')
}
}
})
ssrContext vs payload 차이
| 속성 | 존재 환경 | 역할 |
|---|---|---|
nuxtApp.ssrContext | 서버 전용 | 현재 HTTP 요청의 원시 정보 (헤더, 쿠키, URL) |
nuxtApp.ssrContext.payload | 서버 전용 (쓰기) | 클라이언트로 전달할 데이터를 여기에 축적 |
nuxtApp.payload | 서버 + 클라이언트 | 직렬화된 데이터를 클라이언트에서 읽음 |
하이드레이션이 실제로 하는 일
클라이언트가 HTML을 받으면 브라우저는 이를 즉시 화면에 표시합니다. 그러나 이 시점의 DOM은 아직 "정적"입니다. Vue가 이 DOM에 반응형 연결을 수립하는 과정이 **하이드레이션(hydration)**입니다.
[서버 HTML 수신 → 브라우저 초기 렌더]
↓
[클라이언트 JS 번들 로드]
↓
[nuxtApp 생성 (클라이언트 모드)]
↓
[클라이언트 플러그인 실행]
↓
[payload 역직렬화 → 상태 복원]
↓
[createSSRApp(...).mount() (내부적으로 hydrate 수행)]
↓ VNode 트리를 기존 DOM과 비교
[이벤트 리스너 연결 + 반응형 바인딩 수립]
↓
[하이드레이션 완료 → 인터랙티브 상태]
하이드레이션 중 Vue는 실제로 DOM을 다시 만들지 않습니다. 대신 서버가 만든 DOM 노드를 재사용하면서 반응형 시스템과 연결합니다. 단, 서버 HTML과 클라이언트가 생성할 VNode 구조가 다르면 **미스매치(mismatch)**가 발생하고, Vue는 해당 서브트리를 클라이언트가 생성한 VNode 기준으로 DOM을 덮어써(교체) 일관성을 맞춥니다. 다만 텍스트·속성 정도의 차이는 dev 모드에서 경고만 출력하고 일부만 보정되기도 하며, prod 빌드에서는 경고가 생략되므로 미스매치가 조용히 넘어갈 수 있습니다.
하이드레이션 비용 이해하기
// pages/expensive.vue — 하이드레이션 비용이 큰 안티패턴
<script setup lang="ts">
// ❌ 컴포넌트 최상위에서 무거운 동기 계산
const list = Array.from({ length: 10000 }, (_, i) => ({
id: i,
value: Math.sqrt(i) * Math.PI,
}))
</script>
<template>
<ul>
<li v-for="item in list" :key="item.id">{{ item.value.toFixed(4) }}</li>
</ul>
</template>
// ✅ 서버에서 한 번 계산하고 payload로 전달
<script setup lang="ts">
const { data: list } = await useAsyncData('expensive-list', () => {
return Array.from({ length: 10000 }, (_, i) => ({
id: i,
value: Math.sqrt(i) * Math.PI,
}))
})
// useAsyncData 결과는 payload에 실려 클라이언트에서 재계산 없이 복원됨
</script>
💡 TIP
useAsyncData나useState에 넣은 데이터는 payload를 통해 클라이언트로 이관되므로, 클라이언트에서 동일한 비용의 재계산이나 재요청이 발생하지 않습니다. 이것이 SSR의 핵심 이점 중 하나입니다.
하이드레이션 미스매치의 원인과 해결
미스매치는 서버가 생성한 HTML 구조와 클라이언트가 초기에 생성하는 VNode 트리가 다를 때 발생합니다. 브라우저 콘솔에서 [Vue warn]: Hydration node mismatch 경고로 확인할 수 있습니다.
주요 원인 1: 시간과 난수
<script setup lang="ts">
// ❌ 서버와 클라이언트의 실행 시점이 다르므로 항상 다른 값
const now = new Date().toLocaleTimeString()
const randomId = Math.random().toString(36).slice(2)
</script>
<template>
<p>현재 시각: {{ now }}</p>
<div :id="randomId">콘텐츠</div>
</template>
<script setup lang="ts">
// ✅ 서버에서 생성한 값을 payload를 통해 클라이언트에서 재사용
const serverTime = useState('server-time', () => new Date().toLocaleTimeString())
// useState의 초기화 함수는 서버에서 한 번만 실행되고,
// 그 결과가 payload로 전달되어 클라이언트에서 동일한 값으로 초기화됨
const stableId = useId() // Nuxt/Vue가 SSR 안전하게 제공하는 ID 생성기
</script>
<template>
<p>서버 기준 시각: {{ serverTime }}</p>
<div :id="stableId">콘텐츠</div>
</template>
주요 원인 2: 브라우저 전용 API
<script setup lang="ts">
// ❌ window, localStorage, document는 서버에서 undefined
const width = window.innerWidth // 서버에서 ReferenceError
const theme = localStorage.getItem('theme') ?? 'light'
</script>
<script setup lang="ts">
// ✅ 방법 1: onMounted — DOM 마운트 이후에만 실행 (하이드레이션 완료 후)
const width = ref(0)
const theme = ref('light')
onMounted(() => {
width.value = window.innerWidth
theme.value = localStorage.getItem('theme') ?? 'light'
})
</script>
<script setup lang="ts">
// ✅ 방법 2: import.meta.client 가드 — 서버에서는 실행되지 않음
const theme = ref('light')
if (import.meta.client) {
theme.value = localStorage.getItem('theme') ?? 'light'
}
</script>
⚠️ 주의
process.client는 Nuxt 2 방식입니다. Nuxt 3에서는 Vite의 모듈 메타데이터를 사용하는import.meta.client와import.meta.server를 사용하세요. 빌드 시 트리 쉐이킹에도 유리합니다.
주요 원인 3: 조건부 렌더링과 <ClientOnly>
특정 컴포넌트 전체를 클라이언트에서만 렌더링해야 할 때는 <ClientOnly>를 사용합니다. 이 컴포넌트는 서버에서 아무것도 렌더링하지 않거나 fallback만 렌더링하므로 미스매치를 원천 차단합니다.
<template>
<div>
<h1>공통 제목</h1>
<!-- ❌ v-if로 직접 분기 — 서버: 렌더 안 함 vs 클라이언트: 렌더 함 → 미스매치 -->
<ChartComponent v-if="isClient" />
<!-- ✅ ClientOnly — 서버는 fallback을, 클라이언트는 실제 컴포넌트를 렌더링 -->
<ClientOnly>
<ChartComponent />
<template #fallback>
<div class="skeleton-chart" aria-busy="true">차트 로딩 중...</div>
</template>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
// ❌ 이 방식은 서버와 클라이언트 첫 렌더 사이에 미스매치를 일으킴
const isClient = ref(false)
onMounted(() => { isClient.value = true })
</script>
미스매치 디버깅 체크리스트
| 증상 | 가능한 원인 | 해결 방법 |
|---|---|---|
콘솔에 Hydration node mismatch | 서버/클라이언트 출력 불일치 | useState로 초기값 고정 |
| 특정 컴포넌트만 깜빡임 | window/localStorage 사용 | <ClientOnly> 또는 onMounted |
| 리스트 순서 뒤바뀜 | 서버에서 무작위 정렬 | 정렬 기준을 결정론적으로 고정 |
| 하이드레이션 후 즉시 재렌더 | Math.random() 등 비결정적 값 | useId() 또는 useState 활용 |
payload 직렬화: devalue와 useState/useAsyncData 원리
Nuxt는 서버에서 수집한 데이터(상태, 패칭 결과)를 클라이언트로 전달하기 위해 devalue 라이브러리로 직렬화합니다. JSON보다 강력하여 Date, Map, Set, undefined, 순환 참조도 처리할 수 있습니다.
직렬화가 HTML에 삽입되는 위치
서버에서 렌더링된 HTML 하단에는 다음과 같은 스크립트 블록이 자동으로 삽입됩니다.
Nuxt 3.4+ 기본값(renderJsonPayloads)에서는 payload를 plain JS 객체 리터럴로 인라인하지 않습니다. devalue로 직렬화한 데이터를 별도의 JSON 스크립트 태그에 넣고, window.__NUXT__에는 그 JSON을 읽어 상태를 복원하는 부트스트랩 함수가 채워집니다.
<!-- 실제 Nuxt 3 SSR 출력 HTML의 payload 부분 (예시) -->
<!-- devalue 직렬화 결과: 0번 인덱스가 루트이고, 값들이 인덱스 참조 배열로 평탄화됨 -->
<script type="application/json" id="__NUXT_DATA__" data-ssr="true">
[
{"data":1,"state":4,"error":7},
{"user-profile":2,"product-list":3},
{"id":8,"name":9,"role":10},
[11,14],
{"$stheme":5,"$scart-count":6},
"dark",
3,
null,
1,
"홍길동",
"admin",
{"id":12,"title":13},
10,
"상품A",
{"id":15,"title":16},
11,
"상품B"
]
</script>
<!-- window.__NUXT__는 위 JSON을 파싱/복원하는 부트스트랩 함수로 채워짐 -->
<script>window.__NUXT__={};window.__NUXT__.config={/* ... */}</script>
위 배열은 devalue가 만든 인덱스 기반 구조로, 0번 요소가 루트이고 각 값은 다른 인덱스를 가리키는 참조입니다(중복 값과 순환 참조를 압축하기 위함). 개념적으로 표현하면 다음과 같은 구조를 복원합니다.
// 개념적 구조 (실제 직렬화 출력은 위의 devalue 인덱스 배열 형식)
{
data: { "user-profile": { id: 1, name: "홍길동", role: "admin" },
"product-list": [{ id: 10, title: "상품A" }, { id: 11, title: "상품B" }] },
state: { "$stheme": "dark", "$scart-count": 3 },
error: null
}
useState가 payload에 실리는 원리
// composables/useTheme.ts
export const useTheme = () => {
// 첫 번째 인자가 payload의 키(key)가 됨
// useState는 키 앞에 '$s' prefix를 붙이므로(콜론 없음),
// 서버에서 초기화 함수가 실행되고 그 결과가 payload.state['$stheme']에 저장됨
const theme = useState<'light' | 'dark'>('theme', () => 'light')
return theme
}
<!-- pages/settings.vue -->
<script setup lang="ts">
const theme = useTheme()
// 클라이언트 하이드레이션 시:
// 1. payload.state['$stheme'] 값을 읽음 ('$s' prefix + 키, 콜론 없음)
// 2. 초기화 함수를 다시 실행하지 않고 payload 값으로 초기화
// 3. 서버 HTML과 동일한 값으로 시작 → 미스매치 없음
const toggle = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>
<template>
<button @click="toggle">현재 테마: {{ theme }}</button>
</template>
useAsyncData가 payload에 실리는 원리
// pages/profile/[id].vue
<script setup lang="ts">
const route = useRoute()
const { data: profile } = await useAsyncData(
`user-profile-${route.params.id}`, // 고유 키 → payload.data의 키로 사용
() => $fetch(`/api/users/${route.params.id}`)
)
// 서버:
// 1. 패칭 함수 실행 → 결과를 payload.data[key]에 저장
// 2. HTML 렌더링에 data 값 사용
// 클라이언트 하이드레이션:
// 1. payload.data[key] 존재 확인
// 2. 존재하면 패칭 함수를 재실행하지 않고 payload 값 사용
// 3. data ref를 payload 값으로 초기화
</script>
💡 TIP
useAsyncData의 키가 동일한 경우(같은 페이지 내 중복 사용 등), Nuxt는 캐시에서 결과를 반환합니다. 키가 달라지면(예: route param 변경) 새 요청이 발생합니다. 2강에서 이 캐싱 전략을 더 깊이 다룹니다.
직렬화 가능한 타입과 한계
// ✅ devalue가 지원하는 타입들
useState('example', () => ({
date: new Date(), // Date 객체 지원
set: new Set([1, 2, 3]), // Set 지원
map: new Map([['key', 'val']]), // Map 지원
nested: { arr: [1, [2, 3]] }, // 중첩 구조 지원
}))
// ❌ 직렬화 불가 — payload에 넣으면 안 되는 것들
useState('bad-example', () => ({
fn: () => console.log('함수'), // 함수는 직렬화 불가
promise: Promise.resolve(42), // Promise는 직렬화 불가
element: document.getElementById('app'), // DOM 노드는 직렬화 불가
}))
요약
- Nitro → Vue 앱 생성 → setup() 실행 → renderToString → payload 직렬화 → HTML 전송 순서로 SSR 파이프라인이 진행됩니다. 각 요청마다 Vue 앱 인스턴스가 새로 생성되므로 서버 사이드 싱글턴 상태를 절대 만들면 안 됩니다.
useNuxtApp()의ssrContext는 서버 전용 요청 컨텍스트이고,payload는 서버에서 클라이언트로 데이터를 이관하는 브리지입니다.- 하이드레이션은 DOM을 새로 만들지 않고 서버 HTML에 반응형을 연결합니다. 서버와 클라이언트의 VNode 구조가 다르면 해당 서브트리를 클라이언트가 생성한 VNode 기준으로 DOM을 교체합니다(dev에서는 경고, prod에서는 경고가 생략됨).
- 미스매치의 주요 원인은 시간/난수, 브라우저 전용 API, v-if 기반 클라이언트 분기이며,
useState,<ClientOnly>,onMounted,import.meta.client로 해결합니다. useState와useAsyncData의 데이터는 devalue로 직렬화되어<script type="application/json" id="__NUXT_DATA__">에 인덱스 기반 배열로 삽입되고,window.__NUXT__가 이 JSON을 읽어 클라이언트 하이드레이션 시 재실행 없이 복원합니다.
연습문제
-
아래 코드에서 하이드레이션 미스매치가 발생하는 이유를 설명하고, 수정하세요.
<script setup lang="ts"> const greeting = `안녕하세요! 접속 시각: ${new Date().toLocaleTimeString()}` </script> <template><p>{{ greeting }}</p></template>힌트 서버와 클라이언트의 실행 시점 차이와
useState초기화 함수의 실행 시점을 생각해 보세요. -
서버 플러그인에서 요청 헤더의
Accept-Language값을 읽어useState('locale')에 저장하는 코드를 작성하세요. 클라이언트에서도 이 값을 변경할 수 있어야 합니다.힌트
nuxtApp.ssrContext.event와 h3의getHeader유틸리티를 활용하세요. -
다음 컴포넌트는 클라이언트에서만 동작하는
Chart.js기반 차트 컴포넌트를 사용합니다. SSR 환경에서 에러 없이 동작하도록 수정하고, 로딩 중에는 높이 300px의 회색 플레이스홀더를 보여주세요.<template> <ChartComponent :data="chartData" /> </template>힌트
<ClientOnly>의#fallback슬롯을 활용하세요. -
useAsyncData로 패칭한 데이터가 클라이언트에서 재요청되지 않는 이유를 payload 직렬화 관점에서 설명하고, 재요청이 의도적으로 필요한 경우 어떻게 처리할 수 있는지 코드로 보여주세요.힌트
useAsyncData의 반환값 중refresh함수와 옵션의server/lazy플래그를 살펴보세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Nuxt.js 심화” 강좌에 대한 댓글입니다.