본문으로 건너뛰기

15.4 서버 사이드 — Nitro 서버, API Routes, 서버 미들웨어

Nitro란 무엇인가?​

Nuxt 3는 Nitro 라는 차세대 서버 엔진을 내장하고 있습니다. Nitro는 Nuxt 팀이 개발한 범용 JavaScript 서버 엔진으로, 단순한 개발 서버를 넘어 프로덕션 배포까지 담당합니다.

Nitro의 핵심 특징은 다음과 같습니다:

  • 유니버설 배포: Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge Functions 등 다양한 런타임에서 동작
  • 파일 기반 라우팅: server/ 디렉토리 아래 파일을 생성하면 자동으로 API 엔드포인트가 만들어짐
  • 자동 번들링: 서버 코드를 최적화된 단일 파일로 번들링
  • 핫 모듈 교체(HMR): 개발 중 서버 코드도 즉시 반영
  • 스토리지 추상화: 파일 시스템, Redis, DB 등을 동일한 인터페이스로 접근

Nitro는 Nuxt 애플리케이션의 서버 사이드 렌더링(SSR), API 서버, 서버리스 함수를 모두 처리합니다.


서버 디렉토리 구조​

Nuxt 3의 server/ 디렉토리는 다음과 같이 구성됩니다:

server/
├── api/ # API 엔드포인트 (/api/* 경로)
│ ├── users.get.ts # GET /api/users
│ ├── users.post.ts # POST /api/users
│ └── users/
│ └── [id].ts # GET/POST /api/users/:id
├── routes/ # 커스텀 서버 라우트 (비-API 경로)
│ └── sitemap.xml.ts # /sitemap.xml
├── middleware/ # 서버 미들웨어 (모든 요청에 실행)
│ └── auth.ts
├── plugins/ # Nitro 플러그인 (서버 시작 시 한 번 실행)
│ └── database.ts
└── utils/ # 서버 유틸리티 함수 (자동 임포트)
└── db.ts

API Routes 기초​

첫 번째 API 엔드포인트​

server/api/hello.ts 파일을 생성하면 즉시 /api/hello 엔드포인트가 생깁니다.

// server/api/hello.ts
export default defineEventHandler((event) => {
return {
message: 'Hello from Nitro!',
timestamp: new Date().toISOString(),
}
})

브라우저나 useFetch로 /api/hello를 호출하면 JSON 응답을 받을 수 있습니다.

HTTP 메서드별 엔드포인트​

파일명에 HTTP 메서드를 붙여서 특정 메서드만 처리하도록 할 수 있습니다:

server/api/users.get.ts    → GET /api/users
server/api/users.post.ts → POST /api/users
server/api/users.put.ts → PUT /api/users
server/api/users.delete.ts → DELETE /api/users
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
// 실제 앱에서는 DB에서 조회
const users = [
{ id: 1, name: '김철수', email: 'kim@example.com' },
{ id: 2, name: '이영희', email: 'lee@example.com' },
]
return users
})
// server/api/users.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)

// 입력값 검증
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'name과 email은 필수입니다.',
})
}

// 실제 앱에서는 DB에 저장
const newUser = {
id: Date.now(),
name: body.name,
email: body.email,
createdAt: new Date().toISOString(),
}

setResponseStatus(event, 201)
return newUser
})

동적 라우트​

파일명에 대괄호를 사용해 URL 파라미터를 받을 수 있습니다:

// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')

if (!id || isNaN(Number(id))) {
throw createError({
statusCode: 400,
statusMessage: '유효하지 않은 사용자 ID입니다.',
})
}

// 실제 앱에서는 DB에서 특정 사용자 조회
const user = { id: Number(id), name: '김철수', email: 'kim@example.com' }

if (!user) {
throw createError({
statusCode: 404,
statusMessage: '사용자를 찾을 수 없습니다.',
})
}

return user
})

쿼리 파라미터 처리​

// server/api/search.ts
export default defineEventHandler((event) => {
const query = getQuery(event)
// /api/search?q=nuxt&page=1&limit=10

const { q, page = '1', limit = '10' } = query

if (!q) {
throw createError({
statusCode: 400,
statusMessage: '검색어(q)를 입력하세요.',
})
}

const pageNum = parseInt(page as string)
const limitNum = parseInt(limit as string)

// 실제 검색 로직
return {
query: q,
page: pageNum,
limit: limitNum,
results: [],
total: 0,
}
})

실전 예제: 완전한 REST API​

블로그 게시글을 위한 완전한 CRUD API를 구현해보겠습니다.

데이터 레이어 설정​

// server/utils/db.ts
// 실제 앱에서는 Prisma, Drizzle 등의 ORM 사용
// 여기서는 메모리 저장소로 시연

export interface Post {
id: number
title: string
content: string
authorId: number
createdAt: string
updatedAt: string
published: boolean
}

const posts: Post[] = [
{
id: 1,
title: 'Nuxt 3 시작하기',
content: 'Nuxt 3는 Vue 3 기반의 풀스택 프레임워크입니다...',
authorId: 1,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
published: true,
},
]

let nextId = 2

export const db = {
posts: {
findAll: () => posts,
findById: (id: number) => posts.find((p) => p.id === id),
findPublished: () => posts.filter((p) => p.published),
create: (data: Omit<Post, 'id' | 'createdAt' | 'updatedAt'>) => {
const now = new Date().toISOString()
const post: Post = {
...data,
id: nextId++,
createdAt: now,
updatedAt: now,
}
posts.push(post)
return post
},
update: (id: number, data: Partial<Post>) => {
const index = posts.findIndex((p) => p.id === id)
if (index === -1) return null
posts[index] = {
...posts[index],
...data,
updatedAt: new Date().toISOString(),
}
return posts[index]
},
delete: (id: number) => {
const index = posts.findIndex((p) => p.id === id)
if (index === -1) return false
posts.splice(index, 1)
return true
},
},
}

목록 조회 및 생성​

// server/api/posts/index.ts
// GET /api/posts — 게시글 목록
// POST /api/posts — 게시글 생성

export default defineEventHandler(async (event) => {
const method = getMethod(event)

if (method === 'GET') {
const query = getQuery(event)
const publishedOnly = query.published === 'true'

const posts = publishedOnly ? db.posts.findPublished() : db.posts.findAll()

return {
data: posts,
total: posts.length,
}
}

if (method === 'POST') {
// 인증 확인 (미들웨어가 처리하지 않을 경우 여기서 직접 검증)
const body = await readBody(event)

if (!body.title || !body.content) {
throw createError({
statusCode: 422,
statusMessage: 'title과 content는 필수입니다.',
})
}

const post = db.posts.create({
title: body.title,
content: body.content,
authorId: body.authorId ?? 1,
published: body.published ?? false,
})

setResponseStatus(event, 201)
return post
}

throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
})

단건 조회, 수정, 삭제​

// server/api/posts/[id].ts
// GET /api/posts/:id — 단건 조회
// PUT /api/posts/:id — 수정
// DELETE /api/posts/:id — 삭제

export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const method = getMethod(event)

if (isNaN(id)) {
throw createError({ statusCode: 400, statusMessage: '잘못된 ID 형식입니다.' })
}

if (method === 'GET') {
const post = db.posts.findById(id)
if (!post) {
throw createError({ statusCode: 404, statusMessage: '게시글을 찾을 수 없습니다.' })
}
return post
}

if (method === 'PUT') {
const body = await readBody(event)
const updated = db.posts.update(id, body)
if (!updated) {
throw createError({ statusCode: 404, statusMessage: '게시글을 찾을 수 없습니다.' })
}
return updated
}

if (method === 'DELETE') {
const deleted = db.posts.delete(id)
if (!deleted) {
throw createError({ statusCode: 404, statusMessage: '게시글을 찾을 수 없습니다.' })
}
setResponseStatus(event, 204)
return null
}

throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
})

서버 미들웨어​

서버 미들웨어는 모든 요청(또는 특정 경로의 요청)이 처리되기 전에 실행됩니다. 인증, 로깅, 요청 변환 등에 활용합니다.

요청 로깅 미들웨어​

// server/middleware/logger.ts
export default defineEventHandler((event) => {
const start = Date.now()
const url = getRequestURL(event)
const method = getMethod(event)

// 응답 후크 — 요청 처리 완료 후 실행
event.node.res.on('finish', () => {
const duration = Date.now() - start
const status = event.node.res.statusCode
console.log(`[${new Date().toISOString()}] ${method} ${url.pathname} → ${status} (${duration}ms)`)
})
})

인증 미들웨어​

// server/middleware/auth.ts
export default defineEventHandler((event) => {
// 공개 경로는 인증 건너뜀
const publicPaths = ['/api/auth/login', '/api/posts']
const url = getRequestURL(event)

const isPublic = publicPaths.some((path) =>
url.pathname === path || (url.pathname.startsWith('/api/posts') && getMethod(event) === 'GET')
)

if (isPublic) return

// Authorization 헤더에서 토큰 추출
const authorization = getHeader(event, 'authorization')
if (!authorization?.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
statusMessage: '인증이 필요합니다.',
})
}

const token = authorization.slice(7)

// 실제 앱에서는 JWT 검증 라이브러리 사용
if (token !== 'valid-token') {
throw createError({
statusCode: 401,
statusMessage: '유효하지 않은 토큰입니다.',
})
}

// 이후 핸들러에서 사용할 수 있도록 컨텍스트에 사용자 정보 저장
event.context.user = { id: 1, name: '김철수', role: 'admin' }
})

CORS 미들웨어​

// server/middleware/cors.ts
export default defineEventHandler((event) => {
// 허용할 출처 목록
const allowedOrigins = ['https://myapp.com', 'https://admin.myapp.com']
const origin = getHeader(event, 'origin')

if (origin && allowedOrigins.includes(origin)) {
setHeader(event, 'Access-Control-Allow-Origin', origin)
}

setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
setHeader(event, 'Access-Control-Max-Age', '86400')

// OPTIONS 프리플라이트 요청 처리
if (getMethod(event) === 'OPTIONS') {
setResponseStatus(event, 204)
return ''
}
})

Nitro 플러그인​

플러그인은 서버가 시작될 때 한 번만 실행됩니다. DB 연결 초기화, 스케줄러 설정 등에 사용합니다.

// server/plugins/database.ts
export default defineNitroPlugin(async (nitroApp) => {
console.log('데이터베이스 연결 초기화 중...')

// 실제 앱에서는 Prisma, MongoDB 등 초기화
// await prisma.$connect()

// 서버 종료 시 정리
nitroApp.hooks.hookOnce('close', async () => {
console.log('데이터베이스 연결 종료 중...')
// await prisma.$disconnect()
})

console.log('데이터베이스 연결 완료')
})

서버 스토리지 (Nitro Storage)​

Nitro는 통합 스토리지 API를 제공합니다. 개발 환경에서는 파일 시스템, 프로덕션에서는 Redis나 KV 스토어를 사용하도록 쉽게 전환할 수 있습니다.

nuxt.config.ts 스토리지 설정​

// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
storage: {
// 개발: 파일 시스템 사용
cache: {
driver: 'fs',
base: './.nitro/cache',
},
// 프로덕션: Redis 사용 예시
// cache: {
// driver: 'redis',
// url: process.env.REDIS_URL,
// },
},
},
})

스토리지 활용 API​

// server/api/cache-demo.ts
export default defineEventHandler(async (event) => {
const storage = useStorage('cache')
const cacheKey = 'expensive-data'

// 캐시에서 먼저 조회
const cached = await storage.getItem(cacheKey)
if (cached) {
return { data: cached, source: 'cache' }
}

// 캐시 미스 — 실제 데이터 조회 (예: 외부 API 호출)
await new Promise((resolve) => setTimeout(resolve, 500)) // 무거운 작업 시뮬레이션
const data = { result: '비싼 연산 결과', timestamp: Date.now() }

// 캐시에 저장 (TTL: 60초)
await storage.setItem(cacheKey, data, { ttl: 60 })

return { data, source: 'computed' }
})

외부 API 호출 및 프록시​

// server/api/proxy/github.ts
// 클라이언트에게 API 키를 노출하지 않고 서버를 통해 외부 API 호출

export default defineEventHandler(async (event) => {
const username = getRouterParam(event, 'username') ?? getQuery(event).username

if (!username) {
throw createError({ statusCode: 400, statusMessage: 'username이 필요합니다.' })
}

// 서버에서 API 키를 사용해 외부 API 호출
const data = await $fetch(`https://api.github.com/users/${username}`, {
headers: {
Authorization: `token ${process.env.GITHUB_TOKEN}`,
'User-Agent': 'MyNuxtApp/1.0',
},
})

// 필요한 데이터만 선별해서 반환
return {
login: (data as any).login,
name: (data as any).name,
avatar_url: (data as any).avatar_url,
public_repos: (data as any).public_repos,
followers: (data as any).followers,
}
})

유효성 검사와 타입 안전성​

zod를 활용해 요청 바디를 타입 안전하게 검증할 수 있습니다.

npm install zod
// server/api/posts/index.post.ts
import { z } from 'zod'

const CreatePostSchema = z.object({
title: z.string().min(1, '제목은 필수입니다.').max(200, '제목은 200자를 초과할 수 없습니다.'),
content: z.string().min(10, '내용은 최소 10자 이상이어야 합니다.'),
published: z.boolean().optional().default(false),
tags: z.array(z.string()).optional().default([]),
})

export default defineEventHandler(async (event) => {
const rawBody = await readBody(event)

// Zod로 검증
const result = CreatePostSchema.safeParse(rawBody)
if (!result.success) {
throw createError({
statusCode: 422,
statusMessage: '입력값이 올바르지 않습니다.',
data: result.error.flatten(),
})
}

const { title, content, published, tags } = result.data

// 검증된 데이터로 처리
const post = {
id: Date.now(),
title,
content,
published,
tags,
createdAt: new Date().toISOString(),
}

setResponseStatus(event, 201)
return post
})

H3 유틸리티 함수 정리​

Nitro는 h3 라이브러리를 기반으로 하며 다양한 내장 유틸리티를 제공합니다:

// server/api/utils-demo.ts
export default defineEventHandler(async (event) => {
// 요청 정보 읽기
const method = getMethod(event) // 'GET', 'POST' 등
const url = getRequestURL(event) // URL 객체
const query = getQuery(event) // 쿼리 파라미터
const body = await readBody(event) // 요청 본문 (JSON 자동 파싱)
const headers = getHeaders(event) // 모든 헤더
const cookie = getCookie(event, 'token') // 특정 쿠키
const ip = getRequestIP(event) // 클라이언트 IP

// 응답 설정
setResponseStatus(event, 200)
setHeader(event, 'X-Custom-Header', 'value')
setCookie(event, 'session', 'abc123', {
httpOnly: true,
secure: true,
maxAge: 60 * 60 * 24, // 24시간
sameSite: 'lax',
})
deleteCookie(event, 'old-token')

return { ok: true }
})

고수 팁​

1. 라우트 규칙로 캐싱 제어​

nuxt.config.ts에서 특정 경로의 캐싱 전략을 선언적으로 설정할 수 있습니다:

// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 정적 에셋 — 1년 캐시
'/assets/**': { headers: { 'cache-control': 'max-age=31536000' } },
// API 응답 — 1분 캐시 (CDN 포함)
'/api/posts': { cache: { maxAge: 60 } },
// 개인화 데이터 — 캐싱 금지
'/api/user/**': { headers: { 'cache-control': 'no-store' } },
// SSG 페이지
'/blog/**': { prerender: true },
// SPA 모드
'/dashboard/**': { ssr: false },
},
})

2. 서버 전용 환경 변수 타입 정의​

// server/utils/env.ts
// 런타임 설정을 통해 타입 안전한 환경 변수 접근

export function useServerEnv() {
const config = useRuntimeConfig()

return {
databaseUrl: config.databaseUrl as string,
jwtSecret: config.jwtSecret as string,
redisUrl: config.redisUrl as string,
}
}
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// 서버 전용 (클라이언트에 노출 안 됨)
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
redisUrl: process.env.REDIS_URL,
// 클라이언트에도 노출
public: {
apiBase: process.env.API_BASE_URL ?? '/api',
appName: 'My Nuxt App',
},
},
})

3. 이벤트 스트리밍 (Server-Sent Events)​

// server/api/sse.ts
export default defineEventHandler(async (event) => {
// SSE 헤더 설정
setHeader(event, 'Content-Type', 'text/event-stream')
setHeader(event, 'Cache-Control', 'no-cache')
setHeader(event, 'Connection', 'keep-alive')

const stream = new ReadableStream({
async start(controller) {
let count = 0
const interval = setInterval(() => {
if (count >= 10) {
controller.enqueue('data: [DONE]\n\n')
controller.close()
clearInterval(interval)
return
}
controller.enqueue(`data: ${JSON.stringify({ count: ++count, time: Date.now() })}\n\n`)
}, 1000)
},
})

return sendStream(event, stream)
})

4. 웹소켓 (Nitro WebSocket)​

// server/routes/_ws.ts
// Nuxt 3.10+ / Nitro 2.9+ 에서 지원

export default defineWebSocketHandler({
open(peer) {
console.log('클라이언트 연결:', peer.id)
peer.send(JSON.stringify({ type: 'welcome', message: '연결되었습니다!' }))
},
message(peer, message) {
console.log('메시지 수신:', message.text())
// 에코 서버
peer.send(JSON.stringify({ type: 'echo', data: message.text() }))
},
close(peer) {
console.log('클라이언트 연결 해제:', peer.id)
},
error(peer, error) {
console.error('WebSocket 오류:', peer.id, error)
},
})

5. 타입 공유 (서버↔클라이언트)​

// shared/types/post.ts — 서버와 클라이언트가 공유하는 타입
// Nuxt 3에서 shared/ 디렉토리는 서버와 클라이언트 모두에서 자동 임포트

export interface Post {
id: number
title: string
content: string
published: boolean
createdAt: string
}

export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
totalPages: number
}