본문으로 건너뛰기
Advertisement

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
}
Advertisement