Nuxt 3로 풀스택 Vue 앱 만들기: 파일 라우팅, Composables, Server Routes, SSR·SSG·CSR
이 글의 핵심
Nuxt 3 프로젝트 구조와 파일 기반 라우팅, useFetch 같은 Composables, 같은 프로젝트 안에서 API를 만드는 Server Routes, 렌더링 모드별 동작 차이와 배포를 다룹니다.
이 글의 핵심
Nuxt 3로 풀스택 Vue 앱을 구축하는 글입니다. Auto-imports, Composables, Nitro 엔진, Server Routes, 배포까지 실전 예제로 정리했으며, SSR·SSG·CSR 파이프라인, unimport·#imports, Nitro 프리셋·.output, 모듈·훅, 프로덕션 운영 패턴까지 내부 동작을 심화해 다룹니다.
Nuxt가 해결하려는 문제
Vue만으로 만든 SPA는 서버가 빈 HTML과 JS 번들만 내려보내고, 화면은 브라우저가 JS를 받아 실행한 뒤에야 그려집니다. 그래서 검색 엔진·SNS 미리보기 크롤러가 내용 없는 페이지를 보게 되고, 느린 기기에서는 첫 화면이 늦게 뜹니다. Nuxt는 같은 Vue 컴포넌트를 서버에서 먼저 렌더링해 HTML로 보내고, 브라우저에서 그 HTML에 이벤트를 연결(하이드레이션)하는 흐름을 프레임워크 차원에서 제공합니다. 여기에 파일 기반 라우팅, 자동 import, 같은 프로젝트 안의 API 서버(Nitro)를 묶어 “Vue로 풀스택 앱을 만드는 데 필요한 결정”을 대신 내려 주는 것이 Nuxt의 역할입니다.
그 대가로 코드가 서버와 브라우저 양쪽에서 실행된다는 점을 항상 의식해야 합니다. window나 localStorage를 컴포넌트 최상단에서 바로 쓰면 서버 렌더링 중에 window is not defined 에러가 나고, 서버와 브라우저가 서로 다른 HTML을 만들면 Hydration mismatch 경고와 함께 화면이 깜빡입니다. Nuxt를 처음 쓸 때 겪는 문제의 상당수가 이 “두 번 실행되는 코드”에서 나옵니다.
참고로 이 글은 Nuxt 3 기준입니다. 2025년에 나온 Nuxt 4는 기본 디렉터리 구조(app/ 폴더)와 데이터 페칭 기본값 일부가 바뀌었지만, 이 글에서 다루는 렌더링 모드·Nitro·자동 import·Composables의 개념은 그대로 이어집니다. 새 프로젝트 생성 명령도 최신 문서에서는 npm create nuxt@latest로 안내됩니다.
Nuxt 3란?
핵심 특징
Nuxt 3는 Vue 3 기반 풀스택 프레임워크입니다. 주요 장점:
- Auto-imports: 자동 import
- Nitro: 빠른 서버 엔진
- SSR/SSG: 렌더링 모드 선택
- Server Routes: API 내장
- TypeScript: 완벽한 지원
프로젝트 생성
npx nuxi@latest init my-app
cd my-app
npm install
npm run dev
파일 기반 라우팅
페이지
<!-- pages/index.vue -->
<template>
<div>
<h1>Home</h1>
<NuxtLink to="/about">About</NuxtLink>
</div>
</template>
<!-- pages/about.vue -->
<template>
<div>
<h1>About</h1>
</div>
</template>
동적 라우트
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
// URL을 함수로 넘겨야 같은 페이지 안에서 slug가 바뀔 때 다시 요청됨
const { data: post } = await useFetch(() => `/api/posts/${route.params.slug}`);
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
</article>
</template>
흔히 보이는 const slug = route.params.slug; useFetch(`/api/posts/${slug}`) 형태에는 함정이 있습니다. /blog/a에서 /blog/b로 이동할 때 Nuxt는 같은 페이지 컴포넌트를 재사용하므로, 처음 한 번 계산된 URL 문자열은 바뀌지 않고 이전 글이 그대로 보입니다. URL을 함수(또는 computed)로 넘기면 useFetch가 반응형으로 추적해 slug가 바뀔 때마다 다시 요청합니다. 또 요청이 실패하거나 아직 끝나지 않았을 때 post는 null이므로, v-if 없이 post.title에 접근하면 Cannot read properties of null 에러가 납니다.
Composables
useFetch
<script setup lang="ts">
const { data, pending, error, refresh } = await useFetch('/api/users');
</script>
<template>
<div>
<div v-if="pending">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="user in data" :key="user.id">
{{ user.name }}
</li>
</ul>
<button @click="refresh">Refresh</button>
</div>
</template>
useAsyncData
<script setup lang="ts">
const { data: posts } = await useAsyncData('posts', () =>
$fetch('/api/posts')
);
</script>
useFetch/useAsyncData와 $fetch의 차이를 이해하는 것이 Nuxt 데이터 페칭의 핵심입니다. $fetch는 단순한 HTTP 클라이언트라서 <script setup>에서 그냥 호출하면 서버 렌더링 때 한 번, 브라우저 하이드레이션 때 또 한 번, 총 두 번 요청이 나갑니다. useAsyncData는 서버에서 가져온 결과를 페이로드에 담아 HTML과 함께 보내고, 브라우저는 같은 키의 데이터를 페이로드에서 꺼내 쓰므로 요청이 한 번으로 끝납니다. 그래서 컴포넌트 초기 데이터는 useFetch/useAsyncData로, 버튼 클릭 같은 사용자 동작에 따른 요청은 $fetch로 나누는 것이 원칙입니다. useAsyncData의 첫 인자인 키가 겹치면 서로 다른 데이터가 같은 캐시를 공유해 엉뚱한 값이 표시되므로, 키는 데이터마다 고유하게 정해야 합니다.
커스텀 Composable
// composables/useAuth.ts
export const useAuth = () => {
const user = useState('user', () => null);
const login = async (email: string, password: string) => {
const response = await $fetch('/api/login', {
method: 'POST',
body: { email, password },
});
user.value = response.user;
};
const logout = async () => {
await $fetch('/api/logout', { method: 'POST' });
user.value = null;
};
return { user, login, logout };
};
Server Routes
API 엔드포인트
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
const users = await prisma.user.findMany();
return users;
});
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'));
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'User not found',
});
}
return user;
});
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const user = await prisma.user.create({
data: {
name: body.name,
email: body.email,
},
});
return user;
});
파일 이름의 .get.ts, .post.ts 접미사가 HTTP 메서드를 정합니다. 접미사 없이 users.ts로 만들면 모든 메서드에 응답하므로, 의도치 않게 GET 요청으로 생성 로직이 실행되는 일이 없도록 메서드를 명시하는 편이 안전합니다. 예제의 prisma는 server/utils/prisma.ts에서 클라이언트 인스턴스를 하나 만들어 export해 두면 서버 코드 전체에서 자동 import로 쓸 수 있는데, 개발 모드에서는 파일이 바뀔 때마다 모듈이 다시 로드되어 DB 연결이 계속 늘어나는 문제가 있어 globalThis에 인스턴스를 캐시하는 패턴을 흔히 씁니다. readBody로 받은 값은 검증되지 않은 외부 입력이므로, 실제 서비스에서는 Zod 같은 스키마로 검증한 뒤 DB에 넘겨야 합니다(h3의 readValidatedBody를 쓰면 한 번에 처리할 수 있습니다).
Middleware
인증 Middleware
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { user } = useAuth();
if (!user.value && to.path !== '/login') {
return navigateTo('/login');
}
});
사용
<!-- pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
});
</script>
<template>
<div>
<h1>Dashboard</h1>
</div>
</template>
이 미들웨어를 앞의 useAuth와 그대로 조합하면 흔히 겪는 문제가 있습니다. useState는 요청마다 새로 만들어지는 상태라서, 로그인 후 대시보드에서 새로고침하면 서버 렌더링 시점의 user는 다시 null입니다. 그러면 미들웨어가 서버에서 /login으로 리다이렉트해 버려, 로그인한 사용자가 새로고침할 때마다 로그인 화면으로 튕깁니다. 해결하려면 세션 쿠키를 서버에서 읽어 사용자 정보를 복원하는 단계가 미들웨어보다 먼저 실행되어야 합니다. 보통 plugins/auth.server.ts 같은 서버 플러그인에서 useRequestHeaders(['cookie'])로 쿠키를 넘겨 /api/me를 호출해 user 상태를 채우는 방식을 씁니다. 또 이 라우트 미들웨어는 화면 이동을 막는 UX 장치일 뿐 보안 경계가 아니므로, 실제 권한 검사는 server/api의 핸들러에서 다시 해야 합니다.
Layouts
아래는 기본 레이아웃 예제 코드입니다.
<!-- layouts/default.vue -->
<template>
<div>
<header>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
</nav>
</header>
<main>
<slot />
</main>
<footer>
<p>© 2026 My App</p>
</footer>
</div>
</template>
<!-- pages/index.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'default',
});
</script>
배포
Static (SSG)
npm run generate
Server (SSR)
npm run build
node .output/server/index.mjs
Docker
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output .output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
런타임 이미지에 node_modules를 복사하지 않는 것이 이 Dockerfile의 요점입니다. Nitro는 빌드할 때 서버 코드가 실제로 쓰는 의존성만 추적해 .output/server/node_modules에 넣어 두므로, .output 폴더 하나만 옮기면 실행됩니다. 덕분에 이미지가 작아지지만, 네이티브 모듈(sharp, bcrypt, Prisma 엔진 등)은 빌드한 플랫폼 기준으로 들어가므로 빌드 스테이지와 런타임 스테이지의 OS·libc(alpine의 musl vs Debian의 glibc)를 맞춰야 합니다. 맞지 않으면 컨테이너 시작 시 Error: Cannot find module ... .node 같은 에러가 납니다. 포트와 주소는 PORT, HOST(또는 NITRO_PORT, NITRO_HOST) 환경 변수로 바꿀 수 있고, runtimeConfig의 값도 NUXT_ 접두사 환경 변수로 실행 시점에 덮어쓸 수 있어 같은 이미지를 여러 환경에서 재사용할 수 있습니다.
렌더링 모드(SSR·SSG·CSR) 내부 동작
Nuxt 3는 한 코드베이스에서 클라이언트·서버 번들을 동시에 구성합니다. nuxt build 시 Vite가 클라이언트 엔트리와 서버 엔트리(Vue SSR용)를 각각 트리셰이킹하여 산출하며, 런타임에서는 요청 경로에 따라 HTML을 서버에서 조립할지, 정적 HTML만 내보낼지, 브라우저에서만 Vue를 기동할지가 갈립니다.
SSR(서버 사이드 렌더링)
요청이 들어오면 Nitro가 Node(또는 프리셋 런타임)에서 Vue 앱 인스턴스를 만들고, 라우트에 맞는 페이지 컴포넌트를 서버에서 한 번 렌더링합니다. 이때 useAsyncData·useFetch는 기본적으로 서버에서 데이터를 가져와 직렬화 가능한 페이로드(payload) 로 클라이언트에 넘깁니다. 같은 키로 호출된 데이터는 클라이언트 하이드레이션 시 중복 요청을 피하도록 설계되어 있어, 네트워크 탭에서 API가 두 번 찍히지 않는 것이 정상 동작인 경우가 많습니다.
routeRules로 경로별로 ssr: true를 유지하면서도 캐시 헤더나 ISR에 가까운 동작을 조합할 수 있어, 엣지·CDN과의 연계가 SSR 설계의 핵심이 됩니다.
SSG(정적 사이트 생성)와 프리렌더
nuxt generate(내부적으로 Nitro 프리렌더)는 빌드 타임에 HTML과 자산을 뽑아 public·정적 출력 디렉터리에 둡니다. 동적 경로는 nitro.prerender.routes나 크롤링으로 발견된 링크가 있어야 페이지가 생성됩니다. 데이터가 런타임에만 존재하는 API라면, 빌드 시점에 그 API가 접근 가능해야 하며, 그렇지 않으면 빈 페이지나 빌드 실패로 이어집니다. 이는 “SSG가 무조건 저렴하다”가 아니라 데이터 가용성·무효화 전략이 함께 와야 함을 뜻합니다.
CSR(클라이언트 전용)과 SPA 모드
ssr: false(앱 전역 또는 routeRules·페이지 메타)를 쓰면 해당 범위는 브라우저에서만 Vue 앱이 부팅됩니다. SEO가 필요 없는 관리자 콘솔, 지도·WebGL 등 브라우저 API에 강하게 묶인 화면에 적합합니다. 다만 초기 HTML은 거의 비어 있으므로 검색·SNS 크롤러 대응이 약해지고, 체감 FCP는 번들 다운로드 이후로 밀립니다.
하이브리드와 routeRules의 역할
Nuxt 3는 경로마다 ssr / prerender / headers / redirect 등을 선언해 한 프로젝트 안에서 모드 혼합이 가능합니다. 이는 “프레임워크가 단일 렌더링 모드만 지원한다”는 구식 가정과 달리, 엣지 캐시·오리진·정적 자산을 역할별로 쪼개는 실무 패턴과 맞닿아 있습니다.
Auto-imports 메커니즘
Auto-import는 “편의 기능”이 아니라 빌드 파이프라인 상의 계약입니다. Nuxt는 unimport 계열의 해석기로, 설정된 디렉터리(예: components/, composables/, utils/)를 스캔해 이름→심볼 매핑을 만듭니다. 소스 변환 단계에서 실제 import 문이 삽입되거나 가상 모듈 #imports를 통해 노출되며, TypeScript는 별도의 타입 스텁으로 자동 완성이 맞춰집니다.
충돌·우선순위
같은 이름의 composable과 Vue API가 겹치면 의도치 않은 그림자(shadowing) 가 납니다. 팀 규칙으로 use 접두사, 디렉터리 네이밍, 혹은 imports.presets·imports.dirs를 명시해 스캔 순서를 고정하는 편이 안전합니다. 외부 패키지를 자동 등록할 때는 imports 옵션으로 범위를 제한해 번들과 타입 노이즈를 줄입니다.
트리셰이킹과 비용
자동 import가 모든 심볼을 번들에 넣는 것은 아닙니다. 사용된 것만 클라이언트/서버 각각의 롤업 그래프에 들어가도록 설계되어 있으나, 서버 전용 코드가 클라이언트로 새는 실수(예: 비밀을 composable에 하드코딩)는 막지 못합니다. 민감 로직은 server/·runtimeConfig·환경 변수로 분리해야 합니다.
Nitro 서버 엔진 아키텍처
Nitro는 Nuxt 3의 서버 층 전부를 담당합니다. 개발 시에는 Vite와 통합된 경량 서버로 동작하며, 프로덕션 빌드에서는 Rollup으로 서버 엔트리·라우트·미들웨어를 하나의 실행 가능한 출력(.output/server/index.mjs 등)으로 묶습니다.
출력물과 프리셋
.output 아래에는 서버 번들, 퍼블릭 자산, 필요 시 Nitro 라우트 매니페스트가 들어갑니다. nitro.preset에 따라 타깃이 달라집니다. 예를 들어 node-server는 Node 프로세스에서 listen하며, cloudflare_pages·vercel 등은 플랫폼이 기대하는 핸들러 형태로 추출됩니다. 즉 “Nuxt를 배포한다”는 것은 동일한 라우트 정의를 다른 런타임 어댑터로 내보낸다는 의미에 가깝습니다.
서버 라우트와 미들웨어 스택
server/api, server/middleware, server/routes의 파일 기반 라우팅은 빌드 시 단일 라우터 테이블로 합쳐집니다. defineEventHandler는 Web 표준 Request/Response에 가까운 H3 이벤트 모델 위에서 동작하며, $fetch는 서버·클라이언트 모두에서 동일한 API로 내부 호출을 최적화(같은 프로세스 내 직접 호출 등)할 수 있습니다.
캐시·스토리지·드라이버
Nitro는 경로별 캐시, 스토리지 추상화, 일부 프리셋에서의 에지 키-값 연계를 제공합니다. 프로덕션에서는 “메모리 캐시가 인스턴스마다 따로다” 같은 수평 확장 가정을 항상 염두에 두어야 합니다.
Nuxt 모듈과 훅 시스템
nuxt.config의 modules 배열에 등록된 모듈은 빌드 초기화 시점에 로드되어 Nuxt의 내부 라이프사이클에 훅을 겁니다. defineNuxtModule로 감싼 모듈은 스키마 검증(meta.configKey 등)과 함께 설치되어, 플러그인·컴포넌트 자동 등록·라우트 주입·Nitro 설정 병합을 한 번에 처리할 수 있습니다.
자주 쓰는 확장 포인트
addPlugin/addComponent/addImports: 클라이언트·서버 공통 초기화와 DX 향상.addServerHandler/addRouteMiddleware: API·미들웨어를 코드로 등록(파일 기반과 병행 가능).nitro설정 훅: CORS, 압축, 라우트 규칙, 프리렌더 대상을 프로그래밍 방식으로 조정.
nuxt.hooks와 Nitro 훅
Nuxt 훅(hooks: { 'build:before': ... } 등)은 번들링·준비 단계에 개입하며, Nitro 훅은 서버 빌드·프리렌더에 개입합니다. 디버깅 시 “왜 이 라우트가 출력에 없지?”는 어느 훅 단계에서 빠졌는지 역추적하는 것이 빠릅니다.
커스텀 모듈을 작성할 때는 부수 효과 최소화와 옵션 스키마 명시가 유지보수 비용을 좌우합니다. 사내 모듈은 내부 패키지로 버전을 올리며, 앱 여러 개에 재사용하는 경우 peer 의존성을 문서화하는 것이 좋습니다.
프로덕션 Nuxt 패턴
런타임 설정과 비밀
runtimeConfig는 빌드 시 공개/비공개 키가 나뉘어 주입됩니다. API 키·DB URL 등은 서버 전용 키에만 두며, 클라이언트에 노출되는 public에는 브라우저에 줘도 되는 값만 넣습니다. .env는 배포 플랫폼의 시크릿과 동기화하며, 로컬과 프로덕션의 키 이름 불일치로 인한 장애를 방지합니다.
오류·로깅·헬스
error.vue, 전역 에러 훅, 서버 라우트의 createError로 일관된 HTTP 상태와 메시지를 반환합니다. 로그는 구조화(JSON)로 남기고, GET /health 같은 가벼운 헬스 체크를 Nitro 라우트로 두면 오케스트레이터와 로드밸런서가 안정적으로 판단합니다. PM2·컨테이너 재시작 정책과 맞출 때는 무중단 배포 전략(블루/그린, 롤링)과 세션 저장소를 함께 설계합니다.
캐시·성능·자산
정적 자산은 CDN 캐시 헤더와 파일명 해시에 의존하며, HTML/API는 routeRules·역프록시 캐시·애플리케이션 캐시를 계층화합니다. useAsyncData의 getCachedData·키 전략으로 서버 렌더 단계의 중복 페치를 줄이며, 이미지·폰트는 nuxt/image 등으로 포맷·크기 최적화를 자동화합니다.
관측 가능성
프로덕션에서는 Core Web Vitals, 서버 지표(지연, 오류율), Nitro 라우트별 지연을 함께 봅니다. Nuxt 자체보다 배포 프리셋과 런타임(콜드 스타트, CPU 크레딧, 지역)이 병목인 경우가 많으므로, 성능 이슈는 프론트 번들 크기와 서버/엣지 설정을 동시에 의심하는 것이 좋습니다.
정리 및 체크리스트
핵심 요약
- Nuxt 3: Vue 3 풀스택 프레임워크
- Auto-imports: unimport 기반 스캔·가상 모듈·타입 스텁
- Nitro: Rollup 서버 번들, 프리셋별 출력, H3 이벤트 모델
- useFetch / useAsyncData: SSR 시 페이로드 직렬화와 클라이언트 중복 제거
- Server Routes: 파일 기반 API가 단일 라우터 테이블로 합쳐짐
- SSR / SSG / CSR:
routeRules·ssr: false로 경로별 하이브리드 - 모듈·훅: 빌드/서버 단계 확장과 팀 표준화
구현 체크리스트
- Nuxt 3 프로젝트 생성
- 페이지 라우팅 구현
- useFetch로 데이터 페칭
- Server Routes 작성
- Middleware 구현
- Layouts 설정
- 렌더링 모드(
routeRules·SSG 대상 경로) 확정 -
runtimeConfig로 비밀·공개 설정 분리 - 배포
같이 보면 좋은 글
- Vue 3를 내부부터 이해하기: Proxy 반응형, Virtual DOM 패치, 컴파일러 최적화
- Next.js App Router 구조: 파일 규약, 서버·클라이언트 컴포넌트 경계, 캐싱
- SvelteKit으로 풀스택 앱 만들기
자주 묻는 질문 (FAQ)
Q. Hydration mismatch 경고는 왜 생기고 어떻게 고치나요?
A. 서버가 만든 HTML과 브라우저가 첫 렌더링에서 만든 가상 DOM이 다를 때 생깁니다. Date.now()나 Math.random()처럼 실행할 때마다 값이 달라지는 코드, window.innerWidth처럼 브라우저에서만 알 수 있는 값을 템플릿에 바로 쓰는 경우가 대표적입니다. 브라우저 전용 값은 onMounted 안에서 설정하거나 해당 부분을 <ClientOnly>로 감싸고, 시간 같은 값은 서버에서 정한 값을 useState로 넘겨 양쪽이 같은 값을 쓰게 합니다.
Q. 서버 전용 API 키를 composable에서 쓰면 안 되는 이유는?
A. composables/의 코드는 서버와 브라우저 번들 양쪽에 들어가므로, 여기에 키를 하드코딩하면 클라이언트 JS에 그대로 노출됩니다. 비밀 값은 runtimeConfig의 비공개 영역에 두고 server/ 디렉터리의 코드에서만 useRuntimeConfig(event)로 읽어야 합니다.
Q. Vite를 사용하나요?
A. 네, Nuxt 3는 기본 번들러로 Vite를 사용하고(설정으로 webpack을 쓸 수도 있습니다), 서버 번들은 Nitro가 Rollup으로 만듭니다.
Q. Nuxt 3 앱을 배포할 때 무엇을 함께 설계해야 하나요?
A. runtimeConfig로 비밀을 분리하고, routeRules로 캐시·렌더 모드를 경로별로 고정하고, 헬스 체크·로그·배포 프리셋(콜드 스타트·리전)까지 함께 설계하는 것이 운영 안정성에 직결됩니다.