# server/api/ 상세 가이드 ## 목차 1. [동작 원리](#동작-원리) 2. [파일명 네이밍 규칙](#파일명-네이밍-규칙) 3. [디렉토리 구조 패턴](#디렉토리-구조-패턴) 4. [동적 라우트](#동적-라우트) 5. [요청 데이터 읽기](#요청-데이터-읽기) 6. [응답 제어](#응답-제어) 7. [런타임 환경변수](#런타임-환경변수) 8. [고급 패턴](#고급-패턴) 9. [클라이언트 연동](#클라이언트-연동) 10. [자주 하는 실수](#자주-하는-실수) 11. [학습 예제 파일 목록](#학습-예제-파일-목록) --- ## 동작 원리 Nuxt는 `server/api/` 하위 파일을 **빌드 타임에 자동 스캔**하여 Nitro 서버에 라우트로 등록합니다. ``` 요청: GET /api/users/1 ↓ Nitro 라우터가 매칭 ↓ server/api/users/[id].get.ts 실행 ↓ defineEventHandler() 반환값 ↓ 자동 JSON 직렬화 → 클라이언트 응답 ``` ### 핵심 규칙 3가지 | 규칙 | 설명 | |------|------| | `/api` 접두사 자동 부여 | `server/api/hello.ts` → URL `/api/hello` | | `defineEventHandler()` default export 필수 | 모든 핸들러 파일에 필요 | | 반환값 자동 JSON 직렬화 | 객체/배열/문자열/숫자 모두 가능, `undefined` 반환 시 빈 응답 | ```ts // server/api/hello.ts — 가장 기본적인 형태 export default defineEventHandler((event) => { // event: 요청/응답 정보를 모두 담은 H3 이벤트 객체 return { hello: 'world' } // → { "hello": "world" } JSON 응답 }) ``` ### event 객체란? `event`는 H3(Nitro의 HTTP 프레임워크)의 핵심 객체로, 요청/응답과 관련된 모든 정보를 담고 있습니다. ```ts export default defineEventHandler((event) => { event.method // 'GET' | 'POST' | 'PUT' | 'DELETE' ... event.path // '/api/users/1?foo=bar' (전체 경로) event.context // 미들웨어에서 주입한 커스텀 데이터 event.node.req // Node.js 원본 IncomingMessage event.node.res // Node.js 원본 ServerResponse }) ``` --- ## 파일명 네이밍 규칙 ### HTTP 메서드 접미사 파일명 마지막에 `.메서드.ts`를 붙이면 해당 메서드 요청만 처리합니다. **지정된 메서드 외 요청이 오면 Nitro가 자동으로 405 반환**하므로, 별도 처리 코드가 필요 없습니다. | 파일명 | URL | 허용 메서드 | 다른 메서드 요청 시 | |--------|-----|------------|-------------------| | `hello.ts` | `/api/hello` | 전체 | 직접 처리 필요 | | `hello.get.ts` | `/api/hello` | GET | 405 자동 반환 | | `hello.post.ts` | `/api/hello` | POST | 405 자동 반환 | | `hello.put.ts` | `/api/hello` | PUT | 405 자동 반환 | | `hello.patch.ts` | `/api/hello` | PATCH | 405 자동 반환 | | `hello.delete.ts` | `/api/hello` | DELETE | 405 자동 반환 | ``` 💡 왜 메서드 접미사를 쓰나? → 같은 URL에 GET/POST 파일을 분리하면 역할이 명확해지고 Nitro가 자동으로 405 에러를 처리해줘서 코드가 단순해집니다. ``` ### 동적 세그먼트 파일명 | 파일명 | 매칭 URL 예시 | 파라미터 읽기 | |--------|--------------|--------------| | `[id].get.ts` | `/api/1`, `/api/abc` | `getRouterParam(event, 'id')` | | `users/[id].get.ts` | `/api/users/1` | `getRouterParam(event, 'id')` | | `[userId]/posts/[postId].get.ts` | `/api/1/posts/42` | `getRouterParams(event)` | | `files/[...].ts` | `/api/files/a/b/c` | `event.context.params._` | | `files/[...slug].ts` | `/api/files/a/b/c` | `event.context.params.slug` | --- ## 디렉토리 구조 패턴 ### REST API 구성 (권장 패턴) `index.[method].ts`를 폴더 대표 파일로 사용합니다. ``` server/api/ └── posts/ ├── index.get.ts → GET /api/posts 목록 조회 ├── index.post.ts → POST /api/posts 새 글 생성 ├── [id].get.ts → GET /api/posts/:id 단건 조회 ├── [id].put.ts → PUT /api/posts/:id 전체 수정 ├── [id].patch.ts → PATCH /api/posts/:id 부분 수정 └── [id].delete.ts → DELETE /api/posts/:id 삭제 ``` ```ts // server/api/posts/index.get.ts — 목록 조회 export default defineEventHandler(async () => { // DB에서 전체 조회 (실제 프로젝트에서는 ORM 사용) return await db.posts.findMany({ orderBy: { createdAt: 'desc' } }) }) // server/api/posts/index.post.ts — 생성 export default defineEventHandler(async (event) => { const body = await readBody<{ title: string; content: string }>(event) const post = await db.posts.create({ data: body }) setResponseStatus(event, 201) // 생성 성공 → 201 return post }) // server/api/posts/[id].get.ts — 단건 조회 export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') const post = await db.posts.findUnique({ where: { id } }) if (!post) throw createError({ statusCode: 404, statusMessage: '글을 찾을 수 없습니다.' }) return post }) // server/api/posts/[id].delete.ts — 삭제 export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') await db.posts.delete({ where: { id } }) setResponseStatus(event, 204) // No Content return null }) ``` ### 중첩 리소스 ``` server/api/ └── users/ └── [userId]/ ├── index.get.ts → GET /api/users/:userId └── posts/ ├── index.get.ts → GET /api/users/:userId/posts └── [postId].get.ts → GET /api/users/:userId/posts/:postId ``` ```ts // server/api/users/[userId]/posts/[postId].get.ts export default defineEventHandler((event) => { // 여러 파라미터를 한 번에 가져올 때 getRouterParams 사용 const { userId, postId } = getRouterParams(event) return { userId, postId } }) ``` --- ## 동적 라우트 ### 단일 파라미터 ```ts // server/api/users/[id].get.ts // 매칭: GET /api/users/1, /api/users/abc, /api/users/홍길동 export default defineEventHandler((event) => { const id = getRouterParam(event, 'id') // 항상 string | undefined // undefined 가능성을 항상 처리해야 함 if (!id) throw createError({ statusCode: 400, statusMessage: 'id가 필요합니다.' }) return { id } }) ``` ### Zod로 파라미터 검증 (공식 권장 방식) ```ts // 문자열 id를 숫자로 변환하면서 검증 import { z } from 'zod' export default defineEventHandler(async (event) => { // getValidatedRouterParams: 검증 실패 시 자동으로 400 Bad Request 반환 const { id } = await getValidatedRouterParams(event, z.object({ id: z.coerce.number() // '42' → 42 자동 변환 .int() // 정수만 허용 .positive() // 양수만 허용 })) // 여기서 id는 number 타입이 보장됨 return { id } }) ``` ### Catch-all 라우트 ``` 💡 언제 사용? - 프록시 API: 경로 전체를 외부 서비스로 전달할 때 - 파일 서버: /api/files/경로/파일명 형태로 동적 경로 처리 - 폴백 핸들러: 매칭되는 라우트가 없을 때 기본 응답 ``` ```ts // server/api/files/[...slug].ts // 매칭: /api/files/a, /api/files/a/b, /api/files/a/b/c export default defineEventHandler((event) => { const slug = event.context.params?.slug // 'a/b/c' (슬래시 포함 전체 경로) const fullPath = event.context.path // '/api/files/a/b/c' // 예: 경로를 그대로 외부 스토리지에 요청 return { slug, fullPath } }) ``` --- ## 요청 데이터 읽기 ### 1. 쿼리 파라미터 — `getQuery` ```ts // GET /api/search?keyword=nuxt&page=2&limit=10&tags=vue&tags=ts export default defineEventHandler((event) => { const query = getQuery(event) /* query = { keyword: 'nuxt', ← 항상 string page: '2', ← 숫자여도 string limit: '10', tags: ['vue', 'ts'] ← 같은 키 반복 시 배열 } */ // 타입 변환 필수 const keyword = String(query.keyword ?? '') const page = Number(query.page ?? 1) const limit = Math.min(Number(query.limit ?? 10), 100) // 최대값 제한 return { keyword, page, limit } }) ``` **Zod 검증 (공식 문서 권장)** ```ts // getValidatedQuery: 검증 실패 시 자동 400 반환 const { keyword, page, limit } = await getValidatedQuery(event, z.object({ keyword: z.string().optional().default(''), page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(10), })) // 이후 모든 값의 타입이 보장됨 ``` ### 2. 요청 본문 — `readBody` ```ts // POST /api/users // Content-Type: application/json export default defineEventHandler(async (event) => { // async 필수: readBody는 Promise 반환 const body = await readBody<{ name: string; email: string }>(event) /* ⚠️ 주의사항: - GET 요청에서 호출하면 405 에러 자동 발생 - Content-Type이 application/json이어야 자동 파싱 - 제네릭 타입(<...>)은 런타임 검증 없음 → Zod 사용 권장 */ return body }) ``` **Zod 검증 (공식 문서 권장)** ```ts const body = await readValidatedBody(event, z.object({ name: z.string().min(1, '이름은 필수입니다.'), email: z.string().email('올바른 이메일 형식이 아닙니다.'), age: z.number().int().min(18).optional(), })) // 검증 실패 시 자동으로 400 Bad Request + 에러 상세 반환 ``` ### 3. 요청 헤더 — `getHeader` ```ts export default defineEventHandler((event) => { const auth = getHeader(event, 'authorization') // 'Bearer token123' const contentType = getHeader(event, 'content-type') const userAgent = getHeader(event, 'user-agent') // Bearer 토큰 추출 패턴 const token = auth?.startsWith('Bearer ') ? auth.slice(7) : null return { hasToken: !!token } }) ``` ### 4. 쿠키 — `parseCookies` / `getCookie` ```ts export default defineEventHandler((event) => { const cookies = parseCookies(event) // { session: 'abc', theme: 'dark' } const sessionId = getCookie(event, 'session') // 'abc' | undefined // 응답 쿠키 설정 setCookie(event, 'visited', 'true', { httpOnly: true, secure: true, maxAge: 60 * 60 * 24, // 1일 (초 단위) sameSite: 'strict', }) return { sessionId } }) ``` --- ## 응답 제어 ### 상태 코드 ```ts // HTTP 상태 코드 의미와 사용 시점 setResponseStatus(event, 200) // OK 기본값, 조회 성공 setResponseStatus(event, 201) // Created POST로 리소스 생성 성공 setResponseStatus(event, 202) // Accepted 비동기 처리 수락 (아직 완료 안 됨) setResponseStatus(event, 204) // No Content 성공이지만 응답 본문 없음 (DELETE) ``` ### 응답 헤더 ```ts // 응답 헤더는 setHeader로 설정 (클라이언트/CDN에 메타 정보 전달) setHeader(event, 'X-Total-Count', '100') // 전체 개수 (목록 API) setHeader(event, 'Cache-Control', 'max-age=60') // 캐시 60초 setHeader(event, 'Content-Type', 'application/json; charset=utf-8') ``` ### 에러 처리 — `createError` ``` 에러 처리 흐름: throw createError({ statusCode: 404 }) ↓ Nitro가 catch ↓ { statusCode: 404, statusMessage: '...' } JSON 응답 ↓ 클라이언트: error.value.statusCode, error.value.data ``` ```ts // ① 기본 에러 throw createError({ statusCode: 404, statusMessage: '리소스를 찾을 수 없습니다.', }) // ② data 필드로 상세 정보 전달 throw createError({ statusCode: 422, statusMessage: '유효성 검사 실패', data: { // 클라이언트에서 error.value.data 로 접근 fields: { email: '이메일 형식이 올바르지 않습니다.', age: '18세 이상이어야 합니다.', } } }) // ③ try/catch 내부 에러를 HTTP 에러로 변환 try { const data = await externalApi.fetch() return data } catch (err) { throw createError({ statusCode: 502, // Bad Gateway: 외부 서비스 오류 statusMessage: '외부 서비스 오류', cause: err, // 서버 로그에 원인 기록 (클라이언트에 노출 안 됨) }) } ``` **클라이언트에서 에러 처리** ```ts const { data, error } = await useFetch('/api/users/999') if (error.value) { console.log(error.value.statusCode) // 404 console.log(error.value.statusMessage) // '리소스를 찾을 수 없습니다.' console.log(error.value.data) // data 필드가 있을 경우 } ``` ### 리다이렉트 ```ts // 302: 임시 이동 (기본값, 검색 엔진이 원본 URL 유지) // 301: 영구 이동 (검색 엔진이 새 URL로 업데이트) await sendRedirect(event, '/new-path', 302) ``` ### 스트림 응답 ```ts // 대용량 파일 다운로드 등에 사용 import fs from 'node:fs' import { sendStream } from 'h3' export default defineEventHandler((event) => { setHeader(event, 'Content-Disposition', 'attachment; filename="report.pdf"') return sendStream(event, fs.createReadStream('/path/to/report.pdf')) }) ``` --- ## 런타임 환경변수 ### 왜 runtimeConfig를 쓰나? ``` ❌ 잘못된 방법: API 키를 코드에 직접 작성 const token = 'ghp_xxxx' → 코드에 노출, Git에 커밋되면 유출 ✅ 올바른 방법: runtimeConfig + .env nuxt.config.ts에 키 정의 → .env에 실제 값 → 서버에서만 접근 ``` ### 설정 방법 ```ts // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { // 서버에서만 접근 가능 (클라이언트 번들에 포함 안 됨) githubToken: '', databaseUrl: '', jwtSecret: '', // public: 클라이언트/서버 모두 접근 가능 (민감 정보 넣지 말 것) public: { apiBase: '/api', siteUrl: 'https://example.com', } } }) ``` ```ini # .env (Git에 커밋하지 않음 — .gitignore에 추가) # 규칙: NUXT_접두사 + runtimeConfig 키를 SNAKE_CASE로 NUXT_GITHUB_TOKEN=ghp_xxxxxxxxxxxx NUXT_DATABASE_URL=postgresql://user:pass@localhost/db NUXT_JWT_SECRET=my-super-secret-key NUXT_PUBLIC_SITE_URL=https://my-site.com ``` ```ts // server/api/github-repos.get.ts export default defineEventHandler(async (event) => { // event를 인자로 넘겨야 .env 런타임 오버라이드가 적용됨 (공식 문서 권장) const config = useRuntimeConfig(event) return await $fetch('https://api.github.com/user/repos', { headers: { Authorization: `Bearer ${config.githubToken}` } // config.githubToken → .env의 NUXT_GITHUB_TOKEN 값 }) }) ``` --- ## 고급 패턴 ### 내부 API 호출 시 컨텍스트 전달 — `event.$fetch` ```ts // server/api/dashboard.get.ts // 여러 내부 API를 집계해서 반환하는 패턴 export default defineEventHandler(async (event) => { // ❌ $fetch: Authorization 헤더 등이 전달되지 않음 // → 인증이 필요한 API 호출 시 401 에러 발생 const user = await $fetch('/api/me') // ✅ event.$fetch: 현재 요청의 쿠키/헤더를 그대로 전달 // (transfer-encoding, connection, host 등 위험한 헤더는 자동 제외) const [user, orders, stats] = await Promise.all([ event.$fetch('/api/me'), event.$fetch('/api/orders'), event.$fetch('/api/stats'), ]) return { user, orders, stats } }) ``` ### 백그라운드 작업 — `event.waitUntil` ```ts // 응답을 먼저 보내고 무거운 작업을 뒤에서 처리 // 사용 케이스: 로그 기록, 분석 데이터 전송, 캐시 갱신 export default defineEventHandler(async (event) => { // 핵심 작업 먼저 처리 const result = await processMainTask() // 응답을 블로킹하지 않고 백그라운드 실행 event.waitUntil( Promise.all([ sendAnalytics({ event: 'api_call', path: event.path }), updateCache(result), ]) ) return result // 클라이언트는 백그라운드 작업을 기다리지 않고 즉시 응답 받음 }) ``` ### HTTP 메서드 분기 (단일 파일) ```ts // server/api/items/[id].ts // 파일명에 메서드 접미사 없음 → 모든 메서드 수신 // 주의: 허용하지 않는 메서드에 대해 직접 405 처리 필요 export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') ?? '' const method = event.method switch (method) { case 'GET': return await getItem(id) case 'PUT': return await updateItem(id, await readBody(event)) case 'DELETE': { await deleteItem(id) setResponseStatus(event, 204) return null } default: throw createError({ statusCode: 405, statusMessage: `${method} 메서드는 허용되지 않습니다.` }) } }) ``` ### 응답 캐싱 — `cachedEventHandler` ```ts // server/api/stats.get.ts // DB 부하를 줄이기 위해 응답을 캐싱 export default cachedEventHandler(async () => { // 이 함수는 캐시가 만료될 때만 실행됨 return await db.stats.aggregate({ _count: true, _sum: { views: true } }) }, { maxAge: 60 * 5, // 5분간 캐시 name: 'site-stats' // 캐시 키 (충돌 방지를 위해 유일해야 함) }) ``` `nuxt.config.ts`에서 라우트 단위로도 설정 가능: ```ts export default defineNuxtConfig({ routeRules: { '/api/stats': { cache: { maxAge: 60 * 5 } }, // 5분 '/api/products': { cache: { maxAge: 60 } }, // 1분 '/api/search': { cache: false }, // 캐시 비활성 '/api/me': { cache: false }, // 사용자 개인 데이터 } }) ``` --- ## 클라이언트 연동 ### useFetch vs $fetch 선택 기준 | 상황 | 사용 | 이유 | |------|------|------| | 컴포넌트/페이지 최상위 (setup) | `useFetch` | SSR 지원, 자동 캐싱, ref 반응형 | | 버튼 클릭 등 이벤트 핸들러 | `$fetch` | setup 외부에서 호출 가능 | | 조건부 호출 | `$fetch` | `useFetch`는 항상 즉시 실행됨 | | Pinia 액션 | `$fetch` | setup 컨텍스트 밖 | | 반응형 파라미터로 자동 재호출 | `useFetch` | `ref` 전달 시 변경 감지 | ```ts // ── useFetch: 반응형 쿼리 (keyword 변경 시 자동 재요청) ── const keyword = ref('Nuxt') const { data, pending, error, refresh } = await useFetch('/api/search', { query: { keyword }, // ref를 직접 전달 → 변경 감지 자동 // lazy: true // 페이지 로드를 블로킹하지 않고 로딩 상태 사용 }) // ── useFetch: 동적 URL (userId 변경 시 자동 재요청) ── const userId = ref('1') const { data: user } = await useFetch( () => `/api/users/${userId.value}` // 함수로 감싸야 반응형 작동 ) // ── $fetch: 이벤트 핸들러 내부 ── async function submit() { try { const result = await $fetch('/api/users', { method: 'POST', body: { name: '홍길동', email: 'hong@example.com' }, }) console.log(result) } catch (err: unknown) { if (err && typeof err === 'object' && 'statusCode' in err) { const e = err as { statusCode: number; data?: unknown } console.error(`${e.statusCode}:`, e.data) } } } ``` --- ## 자주 하는 실수 ### ❌ readBody를 GET 요청에서 사용 ```ts // server/api/users.get.ts export default defineEventHandler(async (event) => { const body = await readBody(event) // ❌ GET에서 호출 → 405 에러 }) // 해결: GET은 getQuery(), POST/PUT은 readBody() ``` ### ❌ 동기 함수에서 async 없이 readBody 호출 ```ts export default defineEventHandler((event) => { // async 빠짐 const body = readBody(event) // ❌ Promise 객체가 그대로 반환됨 }) // 해결: defineEventHandler(async (event) => { ... }) ``` ### ❌ getRouterParam의 undefined 처리 누락 ```ts export default defineEventHandler((event) => { const id = getRouterParam(event, 'id') return db.find(id) // ❌ id가 undefined일 수 있음 }) // 해결: const id = getRouterParam(event, 'id') ?? '' // 또는 getValidatedRouterParams로 Zod 검증 ``` ### ❌ 환경변수를 클라이언트 코드에서 직접 접근 ```ts // app/pages/index.vue const token = process.env.GITHUB_TOKEN // ❌ 클라이언트에 노출됨! // 해결: 서버 API를 통해 처리하고, public이 아닌 값은 runtimeConfig 서버 전용 사용 ``` ### ❌ 에러 응답에 민감한 정보 포함 ```ts throw createError({ statusCode: 500, data: { stack: err.stack, query: 'SELECT * FROM users WHERE...' } // ❌ 스택 트레이스, SQL 쿼리가 클라이언트에 노출됨 }) // 해결: data에는 사용자에게 보여줄 정보만, 내부 정보는 cause로 서버 로그에만 기록 throw createError({ statusCode: 500, statusMessage: '서버 오류', cause: err }) ``` --- ## 학습 예제 파일 목록 이 프로젝트 `server/api/`의 케이스별 학습 파일입니다. `/server-api-demo` 페이지에서 직접 테스트할 수 있습니다. | 파일 | URL | 핵심 학습 내용 | |------|-----|--------------| | `01-hello.get.ts` | `GET /api/01-hello` | `defineEventHandler` 기본 구조, JSON 자동 직렬화 | | `02-users.post.ts` | `POST /api/02-users` | `readBody`, `setResponseStatus(201)`, 기본 유효성 검사 | | `03-search.get.ts` | `GET /api/03-search?keyword=&page=` | `getQuery`, 타입 변환, 페이지네이션 구현 | | `04-users/[id].get.ts` | `GET /api/04-users/:id` | `getRouterParam`, 동적 라우트, 404 처리 | | `05-items/[id].ts` | `GET\|PUT\|DELETE /api/05-items/:id` | 단일 파일 메서드 분기, `event.method`, 인메모리 CRUD | | `06-error-handling.get.ts` | `GET /api/06-error-handling?type=` | `createError`, 에러 타입별 패턴, `data` 필드 활용 | | `07-products.get.ts` | `GET /api/07-products` | `getHeader`, `setHeader`, `server/utils` auto-import |