Add initial Nuxt 4 project setup with essential configurations

- Created .gitignore to exclude build outputs, logs, and environment files.
- Added nuxt.config.ts for project configuration, enabling Tailwind CSS and Pinia modules.
- Initialized package.json with scripts for development and production, and added necessary dependencies.
- Generated pnpm-lock.yaml for dependency management.
- Created README.md with setup instructions and development guidelines.
- Implemented server API examples in server/api/ directory, demonstrating various use cases.
- Added middleware for logging requests and responses.
- Included example Vue components and pages for server API interaction.
- Established basic project structure for Nuxt 4 application development.
This commit is contained in:
hyeonggil
2026-04-08 23:59:29 +09:00
commit fc7d3d14cf
27 changed files with 9954 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
/**
* 케이스 1: 기본 GET 엔드포인트
*
* - 파일명에 .get.ts 접미사 → GET 요청만 처리
* - URL: GET /api/01-hello
* - eventHandler()로 요청 핸들러 정의
* - 객체/문자열/숫자 모두 반환 가능 (자동 JSON 직렬화)
*/
export default defineEventHandler(() => {
return {
message: "안녕하세요! Nitro 서버입니다.",
timestamp: new Date().toISOString(),
};
});

View File

@@ -0,0 +1,37 @@
/**
* 케이스 2: POST 요청 + readBody
*
* - 파일명에 .post.ts 접미사 → POST 요청만 처리
* - URL: POST /api/02-users
* - readBody(event): 요청 본문(JSON)을 파싱해서 반환
* - setResponseStatus(): 응답 상태 코드 설정 (기본값 200)
*/
interface CreateUserBody {
name: string;
email: string;
}
export default defineEventHandler(async (event) => {
// 요청 본문 파싱 (자동으로 JSON → 객체 변환)
const body = await readBody<CreateUserBody>(event);
// 간단한 유효성 검사
if (!body.name || !body.email) {
// createError: HTTP 에러 응답 생성 (케이스 6에서 자세히 다룸)
throw createError({
statusCode: 400,
statusMessage: "name과 email은 필수입니다.",
});
}
// 201 Created 상태 코드로 응답
setResponseStatus(event, 201);
return {
id: crypto.randomUUID(),
name: body.name,
email: body.email,
createdAt: new Date().toISOString(),
};
});

View File

@@ -0,0 +1,45 @@
/**
* 케이스 3: getQuery로 쿼리 파라미터 읽기
*
* - URL: GET /api/03-search?keyword=nuxt&page=2&limit=10
* - getQuery(event): URL 쿼리 파라미터를 객체로 반환
* - 모든 값은 string 타입이므로 필요 시 변환 필요
*
* 테스트:
* fetch('/api/03-search?keyword=nuxt&page=2&limit=5')
*/
// 상품 더미 데이터
const PRODUCTS = [
{ id: 1, name: "Nuxt 4 강의", category: "education" },
{ id: 2, name: "Vue 3 튜토리얼", category: "education" },
{ id: 3, name: "TypeScript 핸드북", category: "book" },
{ id: 4, name: "Tailwind CSS 가이드", category: "book" },
{ id: 5, name: "Pinia 상태관리", category: "education" },
];
export default defineEventHandler((event) => {
// 쿼리 파라미터 전체를 객체로 가져옴
const query = getQuery(event);
const keyword = String(query.keyword ?? "");
const page = Number(query.page ?? 1);
const limit = Number(query.limit ?? 3);
// 키워드 필터링
const filtered = PRODUCTS.filter((p) =>
keyword ? p.name.includes(keyword) : true
);
// 페이지네이션
const start = (page - 1) * limit;
const items = filtered.slice(start, start + limit);
return {
keyword,
page,
limit,
total: filtered.length,
items,
};
});

View File

@@ -0,0 +1,34 @@
/**
* 케이스 4: 동적 라우트 파라미터
*
* - 파일명의 [id] → URL의 동적 세그먼트
* - URL: GET /api/04-users/123
* - getRouterParam(event, 'id'): 단일 파라미터 읽기
* - getRouterParams(event): 전체 파라미터 객체 반환
*
* 중첩 동적 라우트 예시:
* server/api/04-users/[userId]/posts/[postId].get.ts
* → GET /api/04-users/1/posts/42
*/
const USERS: Record<string, { id: string; name: string; role: string }> = {
"1": { id: "1", name: "김철수", role: "admin" },
"2": { id: "2", name: "이영희", role: "user" },
"3": { id: "3", name: "박민준", role: "user" },
};
export default defineEventHandler((event) => {
// URL 파라미터 읽기
const id = getRouterParam(event, "id");
const user = USERS[id ?? ""];
if (!user) {
throw createError({
statusCode: 404,
statusMessage: `ID ${id}에 해당하는 사용자가 없습니다.`,
});
}
return user;
});

View File

@@ -0,0 +1,57 @@
/**
* 케이스 5: 단일 파일에서 HTTP 메서드 분기
*
* - 파일명에 메서드 접미사 없음 → 모든 HTTP 메서드 수신
* - getMethod(event): 현재 요청의 HTTP 메서드 반환
* - URL:
* GET /api/05-items/1 → 아이템 조회
* PUT /api/05-items/1 → 아이템 수정
* DELETE /api/05-items/1 → 아이템 삭제
*
* 팁: 메서드별로 파일을 분리하는 것이 더 명확하지만,
* 관련 로직을 한 파일에 모을 때 이 패턴을 사용합니다.
*/
interface Item {
id: string;
title: string;
done: boolean;
}
// 인메모리 저장소 (실제 프로젝트에서는 DB 사용)
const items: Map<string, Item> = new Map([
["1", { id: "1", title: "Nuxt 서버 공부하기", done: false }],
["2", { id: "2", title: "Pinia 연습하기", done: true }],
]);
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id") ?? "";
const method = event.method; // 'GET' | 'POST' | 'PUT' | 'DELETE' ...
if (method === "GET") {
const item = items.get(id);
if (!item) throw createError({ statusCode: 404, statusMessage: "아이템 없음" });
return item;
}
if (method === "PUT") {
const body = await readBody<Partial<Item>>(event);
const item = items.get(id);
if (!item) throw createError({ statusCode: 404, statusMessage: "아이템 없음" });
const updated: Item = { ...item, ...body, id };
items.set(id, updated);
return updated;
}
if (method === "DELETE") {
const existed = items.delete(id);
if (!existed) throw createError({ statusCode: 404, statusMessage: "아이템 없음" });
setResponseStatus(event, 204);
return null;
}
// 허용하지 않는 메서드
throw createError({ statusCode: 405, statusMessage: "허용되지 않는 메서드" });
});

View File

@@ -0,0 +1,65 @@
/**
* 케이스 6: createError로 에러 처리
*
* - URL: GET /api/06-error-handling?type=notfound|forbidden|custom
* - createError(): HTTP 에러 응답 생성 (H3Error 던지기)
* - fatal: true → 에러 페이지로 이동 (CSR 환경)
* - data: 에러 응답 본문에 추가 데이터 포함 가능
*
* 클라이언트에서의 에러 처리:
* const { data, error } = await useFetch('/api/06-error-handling?type=notfound')
* if (error.value) console.log(error.value.statusCode) // 404
*/
export default defineEventHandler((event) => {
const { type } = getQuery(event);
if (type === "notfound") {
// 가장 일반적인 패턴
throw createError({
statusCode: 404,
statusMessage: "리소스를 찾을 수 없습니다.",
});
}
if (type === "forbidden") {
throw createError({
statusCode: 403,
statusMessage: "접근 권한이 없습니다.",
// data: 클라이언트에서 error.value.data로 접근 가능
data: { required: "admin", current: "user" },
});
}
if (type === "custom") {
throw createError({
statusCode: 422,
statusMessage: "유효성 검사 실패",
data: {
fields: {
email: "올바른 이메일 형식이 아닙니다.",
age: "18세 이상이어야 합니다.",
},
},
});
}
// 예기치 않은 에러는 try/catch로 처리
try {
// 외부 API 호출 등 예시
const result = riskyOperation();
return { result };
} catch (err) {
// 내부 에러는 500으로 래핑
throw createError({
statusCode: 500,
statusMessage: "서버 내부 오류",
cause: err,
});
}
});
function riskyOperation(): string {
if (Math.random() > 0.5) throw new Error("랜덤 오류 발생!");
return "성공!";
}

View File

@@ -0,0 +1,36 @@
/**
* 케이스 7: server/utils/ 헬퍼 활용
*
* - URL: GET /api/07-products
* - server/utils/response.ts의 apiSuccess()를 import 없이 사용 (auto-import)
* - getHeader(event, key): 특정 요청 헤더 값 읽기
* - setHeader(event, key, value): 응답 헤더 설정
*
* 테스트:
* fetch('/api/07-products', {
* headers: { 'Authorization': 'Bearer my-token' }
* })
*/
const PRODUCTS = [
{ id: 1, name: "Nuxt 4 전자책", price: 29000 },
{ id: 2, name: "Vue 3 강의", price: 49000 },
];
export default defineEventHandler((event) => {
// 요청 헤더 읽기
const token = extractBearerToken(event); // server/utils/response.ts에서 auto-import
// 응답 헤더 설정
setHeader(event, "X-Total-Count", String(PRODUCTS.length));
setHeader(event, "Cache-Control", "max-age=60");
// 인증 정보 포함 여부에 따라 응답 달리
if (token) {
return apiSuccess(PRODUCTS, { authenticated: true, token: token.slice(0, 6) + "***" });
}
// 공개 응답 (가격 숨김)
const publicProducts = PRODUCTS.map(({ id, name }) => ({ id, name }));
return apiSuccess(publicProducts, { authenticated: false });
});

696
server/api/README.md Normal file
View File

@@ -0,0 +1,696 @@
# 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 |