dev.syw

SSR 요청 흐름, Nuxt 컨텍스트, 하이드레이션 미스매치의 원인과 해결.

렌더링 파이프라인과 하이드레이션 내부 동작

입문편에서는 SSR, SPA, SSG 등 렌더링 모드를 선택하고 useFetch로 데이터를 가져오는 방법을 배웠습니다. 하지만 "서버에서 HTML이 만들어진다"는 사실이 내부적으로 어떤 과정을 거치는지, 그리고 클라이언트가 그 HTML을 받은 후 무엇을 하는지는 아직 블랙박스로 남아 있습니다.

이 레슨에서는 Nuxt의 SSR 요청이 HTML 응답으로 변환되는 전 과정을 단계별로 분해하고, 하이드레이션이 실제로 무엇을 하는지, 그리고 실무에서 자주 마주치는 하이드레이션 미스매치를 왜 피해야 하고 어떻게 해결할 수 있는지를 내부 메커니즘 수준에서 살펴봅니다.

학습 목표

  • Nitro 서버 진입부터 HTML 전송까지의 SSR 렌더링 파이프라인 전체 흐름을 설명할 수 있다.
  • useNuxtApp, ssrContext, payload의 구조를 이해하고 서버·클라이언트 경계를 다룰 수 있다.
  • 하이드레이션이 실제로 수행하는 DOM 비교 과정과 재실행 비용을 설명할 수 있다.
  • 하이드레이션 미스매치의 주요 원인을 파악하고 <ClientOnly>, onMounted, import.meta.client로 경계를 제어할 수 있다.
  • devalue 직렬화를 통해 useStateuseAsyncData의 데이터가 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 useAsyncDatauseState에 넣은 데이터는 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.clientimport.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>
HTML

위 배열은 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로 해결합니다.
  • useStateuseAsyncData의 데이터는 devalue로 직렬화되어 <script type="application/json" id="__NUXT_DATA__">에 인덱스 기반 배열로 삽입되고, window.__NUXT__가 이 JSON을 읽어 클라이언트 하이드레이션 시 재실행 없이 복원합니다.

연습문제

  1. 아래 코드에서 하이드레이션 미스매치가 발생하는 이유를 설명하고, 수정하세요.

    <script setup lang="ts">
    const greeting = `안녕하세요! 접속 시각: ${new Date().toLocaleTimeString()}`
    </script>
    <template><p>{{ greeting }}</p></template>
    

    힌트 서버와 클라이언트의 실행 시점 차이와 useState 초기화 함수의 실행 시점을 생각해 보세요.

  2. 서버 플러그인에서 요청 헤더의 Accept-Language 값을 읽어 useState('locale')에 저장하는 코드를 작성하세요. 클라이언트에서도 이 값을 변경할 수 있어야 합니다.

    힌트 nuxtApp.ssrContext.event와 h3의 getHeader 유틸리티를 활용하세요.

  3. 다음 컴포넌트는 클라이언트에서만 동작하는 Chart.js 기반 차트 컴포넌트를 사용합니다. SSR 환경에서 에러 없이 동작하도록 수정하고, 로딩 중에는 높이 300px의 회색 플레이스홀더를 보여주세요.

    <template>
      <ChartComponent :data="chartData" />
    </template>
    

    힌트 <ClientOnly>#fallback 슬롯을 활용하세요.

  4. useAsyncData로 패칭한 데이터가 클라이언트에서 재요청되지 않는 이유를 payload 직렬화 관점에서 설명하고, 재요청이 의도적으로 필요한 경우 어떻게 처리할 수 있는지 코드로 보여주세요.

    힌트 useAsyncData의 반환값 중 refresh 함수와 옵션의 server/lazy 플래그를 살펴보세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Nuxt.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.