지금 주인장은 Nest.js 공부 중 ···
Sign In
프론트엔드

Nuxt 2와 Nuxt 3, 무엇이 달라진걸까?

현우
Last modified
레퍼런스
https://juheon.dev/vue/241018-nuxt-rendering-mode/#:~:text=Nuxt.js%20%EC%9D%98%20SWR%EC%9D%80%20Next.js%20%EC%9D%98%20ISR%20%EA%B0%9C%EB%85%90%EA%B3%BC,%EC%B0%A8%EC%9D%B4%EA%B0%80%20%EC%9E%88%EB%8A%94%EB%8D%B0%20%EB%B0%91%EC%97%90%EC%84%9C%20%EC%84%A4%EB%AA%85%ED%95%98%EA%B2%A0%EB%8B%A4).%20No%20TTL.%20swr%2Dno%2Dttl.
Vue2 → Vue3 마이그레이션을 진행하면서 Options API와 Composition API를 다양하게 사용해왔다. 그러면서 자연스레 Vue의 서버 사이드 렌더링에도 관심을 가지게 되었고, Nuxt에 대해 공부를 하고 싶어졌다. Nuxt의 신규 프로젝트를 스캐폴딩하여 만들어보니, 신규 프로젝트는 Nuxt3로 시작을 하게 되었고 Nuxt2와 Nuxt3의 차이는 무엇이 있는지 문득 궁금해졌다. (like Next의 Page 라우터와 App 라우터의 변천사처럼 ^,^..)

Nuxt가 어떻게 여기까지 왔을까?

Nuxt는 2016년에 등장했다. Next.js가 React 생태계에서 SSR을 풀어낸 방식에서 영감을 받아, Vue 커뮤니티에서 같은 개념을 구현한 것이다. Nuxt 1, Nuxt 2를 거치면서 Vue 생태계의 표준 SSR 프레임워크 위치를 굳혔다.
Nuxt 2는 2018년에 출시됐다. Vue 2 기반, Options API, Vuex, webpack, Connect 미들웨어 스택. 안정적이었고 생태계가 풍부했다. 지금도 레거시 코드베이스에서는 주류다.
Nuxt 3는 2022년 말에 정식 출시됐다. 단순한 메이저 버전 업이 아니다. Vue 3와 Composition API를 중심으로 프레임워크 전체를 다시 작성했다. 빌드 도구는 Vite로 교체됐고, 서버 엔진은 Nitro라는 완전히 새로운 것으로 바뀌었다. asyncData와 Vuex는 사라졌다.
그 과정에서 Nuxt 2 유지보수는 2024년 6월에 공식 종료됐다. 지금 Nuxt 2를 프로덕션에서 쓰고 있다면 보안 패치도 없는 상태다.

내부에서 실제로 무슨 일이 일어날까?

표면적인 API 차이보다 내부 동작의 차이가 더 중요하다고 한다.
Nuxt 2와 Nuxt 3가 어떻게 다르게 동작하는지를 이해하면, "왜 이런 식으로 써야 하는가"가 설명된다.

빌드 파이프라인: webpack vs Vite

Nuxt 2는 webpack을 사용한다. webpack은 모든 모듈을 번들링한 뒤 개발 서버를 시작한다. 프로젝트 규모가 커질수록 이 초기 번들링 시간이 길어진다. (사내의 백오피스 프로젝트도 Webpack 기반의 프로젝트였다가, 초기 번들링 시간이 너무 오래 걸려 각 모듈을 그대로 서빙해주는 Vite로 변경을 하게 되었다)
[Nuxt 2 개발 서버 시작 흐름] 모든 소스 파일 ↓ webpack - 전체 의존성 그래프 분석 ↓ 모듈 번들링 (vendor, app 분리) ↓ 메모리에 번들 유지 ↓ 개발 서버 시작 ← 이 시점에 HMR 가능
Nuxt 3는 기본적으로 Vite를 사용한다. Vite는 개발 모드에서 번들링을 하지 않는다.
대신 브라우저의 네이티브 ESM 지원을 활용해 각 모듈을 그대로 서빙한다.
[Nuxt 3 개발 서버 시작 흐름] 개발 서버 즉시 시작 ← 번들링 없음 ↓ 브라우저 요청 발생 ↓ 요청된 파일만 개별 변환 (on-demand) ↓ ESM import로 브라우저에 전달
💬
네이티브 ESM이란?
브라우저가 <script type="module">import/export 구문을 직접 처리하는 방식. Node.js 모듈 시스템(CJS)과 달리 브라우저가 import 그래프를 직접 탐색하기 때문에, 개발 서버가 사전에 모든 모듈을 번들링할 필요가 없다.
프로젝트가 커져도 Vite의 시작 시간은 거의 변하지 않는다. 요청된 파일만 그때그때 처리하기 때문이다. HMR도 변경된 파일과 그 의존성만 교체하면 되므로, 전체 번들을 다시 빌드하는 webpack 방식보다 훨씬 빠르다.
단, 프로덕션 빌드는 Nuxt 3도 Rollup(Vite 내장)으로 번들링한다. 브라우저의 수천 개 ESM 요청은 프로덕션에서 성능 문제가 되기 때문이다.

Auto-import: .nuxt/ 디렉토리가 하는 일

Nuxt 3를 처음 접하게 되면서 가장 신기했던 것 중 하나가 auto-import다. ref, computed, useState, useFetch를 import 없이 쓸 수 있는데, 처음에는 어떻게 이게 가능한지 몰랐다.
Nuxt 3는 개발 서버 시작 시 .nuxt/ 디렉토리를 생성한다.
.nuxt/ imports.d.ts ← auto-import 타입 선언 components.d.ts ← components/ 타입 선언 nuxt.d.ts ← Nuxt 전역 타입 types/ schema.d.ts ← nuxt.config 타입 nitro.d.ts ← Nitro 서버 타입
imports.d.ts를 열어보면 이런 내용이 들어 있다.
// .nuxt/imports.d.ts (자동 생성됨) export {} declare global { const ref: typeof import('vue')['ref'] const computed: typeof import('vue')['computed'] const useState: typeof import('#app')['useState'] const useFetch: typeof import('#app')['useFetch'] const useRoute: typeof import('#app')['useRoute'] // ... composables/ 안의 파일들도 여기 추가됨 }
💬
unplugin-auto-import란?
Nuxt 3가 내부적으로 사용하는 라이브러리. 소스 코드를 AST(추상 구문 트리)로 분석해 선언 없이 사용된 식별자를 찾아내고, 빌드 단계에서 자동으로 import 구문을 삽입한다. 실제 번들 결과물에는 import 구문이 포함된다.
즉, 소스 코드에서는 import 없이 쓰지만, Vite 변환 단계에서 import 구문이 자동으로 추가된 결과물이 브라우저나 Node.js에 전달된다. 런타임에서 마법처럼 존재하는 게 아니라, 빌드 타임에 변환되는 것이다.
composables/ 디렉토리 안의 파일도 같은 방식으로 처리된다. 파일을 스캔해서 export된 함수를 imports.d.ts에 등록한다. 이 파일이 없으면 IDE에서 타입 오류가 나는 이유가 여기 있다.

SSR 데이터 페칭: 어떻게 서버 데이터가 클라이언트에 전달될까?

Nuxt 2의 asyncData와 Nuxt 3의 useFetch는 단순히 API가 바뀐 게 아니다.
SSR 데이터를 클라이언트에 전달하는 방식 자체가 달라졌다고 한다.
Nuxt 2의 asyncData 흐름
[서버] 1. 라우트 매칭 2. asyncData(context) 실행 - context.params, context.$axios 등 사용 - 반환값 = { post: {...} } 3. 반환값을 컴포넌트 data()와 병합 4. 컴포넌트 렌더링 (SSR) 5. HTML 생성 + window.__NUXT__ 인라인 삽입 [HTML 예시] <script> window.__NUXT__ = { data: [{ post: { id: 1, title: "Hello" } }], state: { /* Vuex state */ } } </script> [클라이언트] 6. window.__NUXT__.data[0]을 읽어서 컴포넌트 data 복원 7. hydration 완료 (asyncData 재실행 없음)
asyncData의 반환값은 window.__NUXT__.data 배열에 인덱스 기반으로 저장된다.
컴포넌트가 많아지면 이 배열의 인덱스 관리가 복잡해진다.
💬
Nuxt2는 Options API 기반이다?
asyncData는 컴포넌트가 생성되기 전에 실행된다고 한다.
그래서 export default 안에 존재하더라도, Vue 인스턴스에 해당되지 않아 this가 없다.
export default { async asyncData({ params, $axios }) { // ↑ 컴포넌트 인스턴스가 아닌 context 객체를 받음 const post = await $axios.$get(`/api/posts/${params.id}`) return { post } // ← 반환값이 data()와 자동 병합됨 }, data() { return { post: null, // asyncData 실행 전 초기값 } }, }

이를 위해 Nuxt가 넘겨주는 context 객체에서 필요한 것들을 꺼낸다. 허나 반환 값이 data에 병합된다.
async asyncData({ params, query, store, $axios, redirect, error }) { // params ← 라우트 파라미터 // query ← 쿼리스트링 // store ← Vuex store // $axios ← nuxtjs/axios 모듈 (설치한 경우) // redirect ← 다른 경로로 리다이렉트 // error ← 에러 페이지 표시 }
export default { // asyncData: 컴포넌트 생성 전 실행, this 없음, 반환값이 data에 병합 async asyncData({ $axios, params }) { const post = await $axios.$get(`/api/posts/${params.id}`) return { post } }, // fetch: 컴포넌트 생성 후 실행, this 있음, 반환값 없음 async fetch() { this.comments = await this.$axios.$get(`/api/comments`) // this.$fetchState.pending, this.$fetchState.error 로 상태 접근 가능 }, data() { return { post: null, comments: [], } }, }
asyncData의 경우 위에서 언급했던 것처럼 컴포넌트 생성 전에 실행되며, this가 존재하지 않는다. 이와 비슷하게 fetch 라는 훅이 존재했다. fetch 훅은 컴포넌트 생성 후 실행되지만 this가 존재하며 반환 값이 없는 형태이다.
관행적으로는 페이지 컴포넌트의 핵심 데이터는 asyncData를 사용하고, 자식 컴포넌트나 부가 데이터는 fetch로 구분한다고 한다. asyncData는 page/ 디렉토리의 컴포넌트 안에서만 쓸 수 있고, 자식 컴포넌트에서는 동작하지 않는다는 제약도 있었다.
Nuxt3에서 이 두 개의 훅이 useFetch · useAsyncData로 합쳐진 것은, Composition API 덕분에 this 없이도 reactivity를 다룰 수 있게 되었기 때문이다.
두 훅 모두 서버와 클라이언트 양쪽을 바라보기 때문에 첫 페이지 진입 시에만 서버로 실행이 되고, 그 다음 이벤트 부터는 클라이언트에서 실행이 된다.
Nuxt 3의 useFetch 흐름
[서버] 1. <script setup> 실행 시작 2. useFetch('/api/posts', { key: 'posts' }) 호출 - key = 'posts'로 캐시 식별 - 서버에서 $fetch('/api/posts') 실행 - Nitro가 내부 라우트 감지 → 네트워크 없이 직접 핸들러 호출 3. 응답 데이터를 payload에 저장 - useNuxtApp().payload.data['posts'] = { ... } 4. HTML 렌더링 [HTML 예시] <script type="application/json" id="__NUXT_DATA__"> { "posts": [{ "id": 1, "title": "Hello" }] } </script> [클라이언트] 5. #__NUXT_DATA__ 파싱 6. useFetch('/api/posts', { key: 'posts' }) 호출 - payload.data['posts'] 확인 → 캐시 hit - 네트워크 요청 없이 캐시에서 data 반환 7. hydration 완료
💬
여기서 말하는 payload란?
Nuxt 3에서 서버에서 클라이언트로 데이터를 전달하는 직렬화된 객체. useNuxtApp().payload로 접근 가능하며, useFetch / useAsyncData / useState의 값이 키-값 형태로 저장된다. HTML에 <script type="application/json"> 블록으로 인라인되어 전달된다.
Nuxt 3에서 중요한 차이는 Nitro 내부 라우트 최적화다. 서버에서 $fetch('/api/posts')를 호출하면 실제 HTTP 요청이 발생하지 않는다. Nitro가 해당 URL이 server/api/posts.ts에 매핑된다는 걸 알고, 네트워크 왕복 없이 핸들러를 직접 호출한다.

Nitro 서버 엔진: 런타임 중립성이 어떻게 작동할까?

Nuxt 2의 서버는 Connect 미들웨어 스택 위에서 동작한다. Node.js에 강하게 묶여 있다.
// Nuxt 2 서버 내부 (단순화) const connect = require('connect') const app = connect() app.use(nuxtMiddleware) // SSR 처리 app.use(serverMiddleware) // 사용자 정의 미들웨어 http.createServer(app).listen(3000)
Node.js의 http.IncomingMessage, http.ServerResponse 객체에 의존하기 때문에, Node.js 외 환경에서는 동작하지 않는다.
Nuxt 3의 Nitro는 H3라는 HTTP 라이브러리를 사용한다. H3는 Node.js, Deno, Bun, Cloudflare Workers, AWS Lambda 등 여러 런타임을 추상화한 이벤트 기반 인터페이스다.
// Nitro 핸들러 추상화 (단순화) interface H3Event { node?: { req: IncomingMessage, res: ServerResponse } // Node.js cloudflare?: { request: Request } // Cloudflare Workers deno?: { request: Request } // Deno // ... } export default defineEventHandler((event: H3Event) => { // 어떤 런타임이든 같은 인터페이스로 처리 const body = await readBody(event) // H3 헬퍼 사용 return { ok: true } })
💬
H3란?
Nuxt 3의 Nitro가 사용하는 HTTP 프레임워크. Node.js http뿐 아니라 Web Fetch API를 기반으로 하는 런타임에서도 동작하는 어댑터 패턴으로 설계됐다. readBody, getHeader, createError 같은 헬퍼가 런타임 차이를 추상화한다.
빌드 시 Nitro는 지정된 preset에 맞는 어댑터를 선택해 최종 번들을 생성한다.
[nuxt build --preset cloudflare-pages] 소스 코드 (H3 기반) ↓ Rollup 번들링 ↓ Cloudflare Workers 어댑터 적용 ← preset이 결정 ↓ _worker.js (Cloudflare Workers 포맷) [nuxt build --preset node-server] ↓ Node.js 어댑터 적용 ↓ server/index.mjs (Node.js 서버)
// nuxt.config.ts export default defineNuxtConfig({ nitro: { preset: 'cloudflare-pages', // 이것만 바꾸면 배포 환경 전환 }, })
Nuxt 2에서 Netlify Functions, Vercel, AWS Lambda에 배포하려면 각 플랫폼에 맞는 별도 래퍼를 작성했다. Nuxt 3는 preset 하나로 해결된다.

렌더링 모드: routeRules가 실제로 어떻게 동작할까?

Nuxt 2는 앱 전체 단위로 SSR 또는 SPA를 선택한다. Nuxt 3는 routeRules로 라우트별로 다르게 설정할 수 있다.
// nuxt.config.ts export default defineNuxtConfig({ routeRules: { '/': { prerender: true }, // 빌드 시 HTML 정적 생성 '/blog/**': { swr: 3600 }, // Stale-While-Revalidate '/admin/**': { ssr: false }, // SPA '/api/**': { cors: true }, // CORS 헤더 추가 }, })
prerender: truenuxt build 시점에 해당 라우트의 HTML을 미리 생성한다.
swr: 3600은 Stale-While-Revalidate 패턴이다. 첫 요청 시 서버에서 HTML을 렌더링하고 캐시한다. 1시간(3600초) 동안은 캐시된 HTML을 즉시 반환하면서, 백그라운드에서 새 HTML을 준비한다.
[SWR 동작 흐름] 첫 요청 (캐시 없음) → 서버 렌더링 → 캐시 저장 → HTML 반환 3600초 이내 재요청 → 캐시 hit → 즉시 HTML 반환 (렌더링 없음) 3600초 경과 후 재요청 → 캐시 stale 판정 → 캐시 HTML 즉시 반환 (사용자 대기 없음) → 백그라운드에서 서버 재렌더링 → 캐시 갱신
💬
Stale-While-Revalidate (SWR) 란?
캐시가 만료됐을 때 즉시 stale 데이터를 반환하고 백그라운드에서 갱신하는 캐싱 전략. 사용자가 대기 없이 응답을 받지만, 한 번의 요청은 오래된 데이터를 받을 수 있다.

ISR과의 차이는.. 큰 틀에서 거의 유사하지만 중요한 차이점은 Vercel · Netflify에서 제공해주는 CDN 캐시를 활용한다는 점이다. ISR은 플랫폼에 종속되지만 SWR은 어느 서버에서나 동작한다.

SWR은 CDN/프록시 캐싱 헤더에 캐싱되며, ISR은 CDN edge에 정적 파일로 저장된다.
Nuxt 2에서 이 전략을 구현하려면 CDN 설정이나 Nginx 캐시 헤더를 직접 관리해야 했다.
Nuxt 3에서는 routeRules 한 줄이다. (신세계 ^,^ ..)

API와 동작 방식 전체 비교

항목
Nuxt 2
Nuxt 3
Vue 버전
Vue 2
Vue 3
컴포넌트 스타일
Options API
Composition API + <script setup>
빌드 도구
webpack 4/5
Vite (dev) + Rollup (prod)
서버 엔진
Connect
Nitro (H3 기반)
데이터 페칭
asyncData, fetch 옵션
useFetch, useAsyncData composable
SSR payload 전달
window.__NUXT__ (인덱스 기반)
#__NUXT_DATA__ JSON (키 기반)
상태 관리
Vuex (내장)
Pinia + useState
API 라우트
serverMiddleware
server/api/ (Nitro 자동 등록)
동적 라우트 파일명
_id.vue (언더스코어)
[id].vue (대괄호)
TypeScript
@nuxt/typescript-build 별도 설치
기본 지원
Auto-import 범위
components/ (선택적)
components, composables, utils 전체
렌더링 단위
앱 전체
라우트별 (routeRules)
배포 환경
Node.js 종속
preset으로 다중 런타임 지원
메타 태그
head() 컴포넌트 옵션
useHead(), useSeoMeta() composable
설정 파일
nuxt.config.js (JS/CJS)
nuxt.config.ts (TypeScript)

어떤 상황에서 무엇을 선택해야 할까?

새 프로젝트라면

Nuxt 3를 선택하는 것이 좋다. TypeScript 기본 지원, Vite 기반 빠른 개발 환경, Nitro의 유연한 배포 옵션, Pinia의 간결한 API가 개발 경험을 크게 개선한다. Vue 3 Composition API에 익숙하다면 학습 비용도 낮다.
특히 routeRules로 라우트별 렌더링 전략을 다르게 가져갈 수 있는 점이 실무에서 유용하다. 마케팅 페이지는 prerender, 대시보드는 SPA, 블로그는 SWR 같은 조합이 CDN 설정 없이 가능하다. routeRule 로 서버 사이드 전략을 컨트롤하는 것은.. 너무 신세계다..

기존 Nuxt 2 프로젝트를 마이그레이션해야 한다면?

Vue2에서 Vue3의 마이그레이션 과정을 정말 힘겹게 했던 것처럼 이 또한 어렵게 느껴진다고 생각된다.
asyncDatauseFetch
useAsyncData
Vuex → Pinia
Options API → Composition API
_id.vue[id].vue, 플러그인 API 변경까지 범위가 넓다.
점진적 전환이 현실적이다. Nuxt 공식 팀이 제공하는 @nuxt/bridge를 쓰면 Nuxt 2 코드베이스에서 Nuxt 3 API 일부를 먼저 사용해볼 수 있다고 한다.
npm install -D @nuxt/bridge # Nuxt 2 위에서 Nuxt 3 API 일부 사용 가능
💬
@nuxt/bridge란?
Nuxt 2 기반 프로젝트에서 Nuxt 3의 Composition API, useFetch, Nitro 일부 기능을 사용할 수 있게 해주는 호환 레이어. Nuxt 2 → Nuxt 3 마이그레이션을 단계적으로 진행할 때 활용한다.
Vue2 → Vue3 마이그레이션을 진행하면서 경험한 건, 한 번에 모든 걸 교체하려 하면 테스트 범위를 통제할 수 없다는 것이다. Nuxt 2 → Nuxt 3도 마찬가지다. 기능 단위로 점진적으로 교체하고, 각 단계마다 동작을 확인하는 방식이 결과적으로 빠르다.

Nuxt 2 → Nuxt 3로 마이그레이션을 하는 이유

내가 다음으로 목표하는 기업에서는 현재 Nuxt의 마이그레이션을 진행하고 있다고 한다. "왜 마이그레이션을 진행하고 있을까?" 라는 생각을 했을 때 물론 기술 부채도 있겠지만 2024년 6월 EOL 이후 보안 패치가 없다고 한다. 취약점이 발견돼도 공식 수정이 없다. 프로덕션에서 계속 운영한다면 의존성 취약점 모니터링을 직접 관리해야 하는 부담이 생긴다.
Nuxt 2와 Nuxt 3를 내부 동작을 한번 검색해서 정리를 해보았는데 아직 깊은 이해가 가지는 않지만, 단순한 API 교체가 아님이 보인다. asyncData가 사라진 건 "컴포넌트 생성 전에 실행되던 별도 훅"이 "Composition API의 reactivity 안에서 실행되는 composable"로 패러다임이 바뀐 결과이다. Nitro가 생긴 건 Node.js에 종속된 서버 레이어를 런타임 중립적인 이벤트 핸들러로 추상화하기 위해서다.
Vue2 → Vue3 마이그레이션을 거치고 나서 이 전환들이 낯설지 않게 느껴진다. Options API에서 Composition API로 넘어갈 때 겪었던 사고 전환과 같은 종류다. 이제 Nuxt 3의 변화들이 왜 그 방향으로 갔는지가 보이기 시작한다. Nuxt3로 프로젝트를 한번 구상해보면서 이해가 안되는 부분들을 한 부분 한 부분 이해해가봐야겠다.
Subscribe to '悠悠自適'
Subscribe to my site to be the first to receive notifications and emails about the latest updates, including new posts.
Join Slashpage and subscribe to '悠悠自適'!
Subscribe
👍