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:
330
server/README.md
Normal file
330
server/README.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# Nuxt 4 서버 API (Nitro) 학습 정리
|
||||
|
||||
## 목차
|
||||
|
||||
1. [디렉토리 구조](#디렉토리-구조)
|
||||
2. [server/api vs server/routes](#serverapi-vs-serverroutes)
|
||||
3. [케이스별 학습 예제](#케이스별-학습-예제)
|
||||
4. [핵심 유틸 함수](#핵심-유틸-함수)
|
||||
5. [클라이언트 연동](#클라이언트-연동)
|
||||
6. [성능 고려사항](#성능-고려사항)
|
||||
7. [서버 API 사용 기준](#서버-api-사용-기준)
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
server/
|
||||
├── api/ # /api/* 접두사 자동 부여
|
||||
├── routes/ # 접두사 없음 (URL 그대로)
|
||||
├── middleware/ # 모든 요청에 자동 실행
|
||||
└── utils/ # 서버 전용 auto-import 헬퍼
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## server/api vs server/routes
|
||||
|
||||
| | `server/api/` | `server/routes/` |
|
||||
|---|---|---|
|
||||
| URL 접두사 | `/api/` 자동 추가 | 없음 (파일 경로 그대로) |
|
||||
| 예시 | `api/hello.ts` → `/api/hello` | `routes/hello.ts` → `/hello` |
|
||||
| 사용 케이스 | 일반 API | Webhook, RSS, sitemap.xml |
|
||||
|
||||
### 파일명 메서드 접미사
|
||||
|
||||
```
|
||||
hello.get.ts → GET /api/hello
|
||||
hello.post.ts → POST /api/hello
|
||||
hello.ts → 모든 메서드 수신 (내부에서 분기)
|
||||
[id].get.ts → GET /api/[id] (동적 라우트)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 케이스별 학습 예제
|
||||
|
||||
### 케이스 1: 기본 GET (`01-hello.get.ts`)
|
||||
|
||||
```ts
|
||||
export default defineEventHandler(() => {
|
||||
return {
|
||||
message: "안녕하세요!",
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- `defineEventHandler()` 로 핸들러 정의
|
||||
- 반환값은 자동으로 JSON 직렬화
|
||||
|
||||
---
|
||||
|
||||
### 케이스 2: POST + readBody (`02-users.post.ts`)
|
||||
|
||||
```ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<{ name: string; email: string }>(event)
|
||||
|
||||
if (!body.name || !body.email) {
|
||||
throw createError({ statusCode: 400, statusMessage: "필수 항목 누락" })
|
||||
}
|
||||
|
||||
setResponseStatus(event, 201)
|
||||
return { id: crypto.randomUUID(), ...body }
|
||||
})
|
||||
```
|
||||
|
||||
- `readBody(event)` : 요청 본문 파싱 (JSON → 객체)
|
||||
- `setResponseStatus(event, 201)` : 응답 상태 코드 변경
|
||||
|
||||
---
|
||||
|
||||
### 케이스 3: getQuery 쿼리 파라미터 (`03-search.get.ts`)
|
||||
|
||||
```ts
|
||||
// GET /api/03-search?keyword=nuxt&page=2&limit=10
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
|
||||
const keyword = String(query.keyword ?? "")
|
||||
const page = Number(query.page ?? 1)
|
||||
const limit = Number(query.limit ?? 10)
|
||||
|
||||
// ...필터링 및 페이지네이션 처리
|
||||
})
|
||||
```
|
||||
|
||||
- `getQuery(event)` : URL 쿼리 파라미터를 객체로 반환
|
||||
- 모든 값이 `string` 타입이므로 Number(), String() 변환 필요
|
||||
|
||||
---
|
||||
|
||||
### 케이스 4: 동적 라우트 파라미터 (`04-users/[id].get.ts`)
|
||||
|
||||
```ts
|
||||
// GET /api/04-users/123
|
||||
export default defineEventHandler((event) => {
|
||||
const id = getRouterParam(event, "id")
|
||||
// 중첩: server/api/users/[userId]/posts/[postId].get.ts
|
||||
// const { userId, postId } = getRouterParams(event)
|
||||
})
|
||||
```
|
||||
|
||||
- 파일명의 `[id]` → URL 동적 세그먼트
|
||||
- `getRouterParam(event, 'key')` : 단일 파라미터
|
||||
- `getRouterParams(event)` : 전체 파라미터 객체
|
||||
|
||||
---
|
||||
|
||||
### 케이스 5: HTTP 메서드 분기 (`05-items/[id].ts`)
|
||||
|
||||
```ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const method = event.method // 'GET' | 'PUT' | 'DELETE' ...
|
||||
|
||||
if (method === "GET") { /* 조회 */ }
|
||||
if (method === "PUT") { /* 수정 */ }
|
||||
if (method === "DELETE") { /* 삭제 */ }
|
||||
|
||||
throw createError({ statusCode: 405, statusMessage: "허용되지 않는 메서드" })
|
||||
})
|
||||
```
|
||||
|
||||
- 파일명에 메서드 접미사 없음 → 모든 메서드 수신
|
||||
- `event.method` 로 분기
|
||||
|
||||
---
|
||||
|
||||
### 케이스 6: createError 에러 처리 (`06-error-handling.get.ts`)
|
||||
|
||||
```ts
|
||||
// 기본 에러
|
||||
throw createError({ statusCode: 404, statusMessage: "리소스 없음" })
|
||||
|
||||
// data 필드로 추가 정보 전달
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: "유효성 검사 실패",
|
||||
data: { fields: { email: "형식 오류" } }
|
||||
})
|
||||
```
|
||||
|
||||
- 클라이언트에서 `error.value.statusCode`, `error.value.data` 로 접근
|
||||
- `try/catch` 내부 예외는 500으로 래핑
|
||||
|
||||
---
|
||||
|
||||
### 케이스 7: server/utils 헬퍼 (`server/utils/response.ts`)
|
||||
|
||||
```ts
|
||||
// server/utils/response.ts 정의
|
||||
export function apiSuccess<T>(data: T): ApiResponse<T> { ... }
|
||||
export function extractBearerToken(event): string | null { ... }
|
||||
|
||||
// server/api/07-products.get.ts 에서 import 없이 사용
|
||||
export default defineEventHandler((event) => {
|
||||
const token = extractBearerToken(event) // auto-import
|
||||
return apiSuccess(products)
|
||||
})
|
||||
```
|
||||
|
||||
- `server/utils/` 하위 파일은 서버 전체에서 **auto-import**
|
||||
- 클라이언트에 노출되지 않는 서버 전용 헬퍼
|
||||
|
||||
---
|
||||
|
||||
### 케이스 8: server/routes Webhook (`08-webhook.post.ts`)
|
||||
|
||||
```ts
|
||||
// POST /08-webhook (/api 접두사 없음)
|
||||
export default defineEventHandler(async (event) => {
|
||||
const secret = getHeader(event, "x-webhook-secret")
|
||||
if (secret !== "my-secret") {
|
||||
throw createError({ statusCode: 401 })
|
||||
}
|
||||
const payload = await readBody(event)
|
||||
return { received: true }
|
||||
})
|
||||
```
|
||||
|
||||
- `getHeader(event, key)` : 요청 헤더 읽기
|
||||
- `setHeader(event, key, value)` : 응답 헤더 설정
|
||||
|
||||
---
|
||||
|
||||
### 케이스 9: 서버 미들웨어 (`server/middleware/09-logger.ts`)
|
||||
|
||||
```ts
|
||||
export default defineEventHandler((event) => {
|
||||
// event.context에 데이터 저장 → 이후 핸들러에서 접근
|
||||
event.context.requestedAt = new Date().toISOString()
|
||||
|
||||
// return 없음 → 다음 핸들러로 계속 진행
|
||||
// 값 반환 시 → 요청 처리 중단 (인증 게이트 패턴)
|
||||
})
|
||||
```
|
||||
|
||||
- `server/middleware/` 하위 파일은 모든 요청에 자동 실행
|
||||
- 실행 순서: 파일명 알파벳 순
|
||||
- 특정 경로만 처리: `getRequestURL(event).pathname` 으로 필터링
|
||||
|
||||
---
|
||||
|
||||
## 핵심 유틸 함수
|
||||
|
||||
| 함수 | 역할 |
|
||||
|------|------|
|
||||
| `readBody(event)` | POST 요청 본문 파싱 (JSON → 객체) |
|
||||
| `getQuery(event)` | URL 쿼리 파라미터 객체 반환 |
|
||||
| `getRouterParam(event, 'key')` | 동적 라우트 파라미터 단일 읽기 |
|
||||
| `getRouterParams(event)` | 동적 라우트 파라미터 전체 반환 |
|
||||
| `getHeader(event, 'key')` | 요청 헤더 읽기 |
|
||||
| `setHeader(event, key, val)` | 응답 헤더 설정 |
|
||||
| `setResponseStatus(event, 201)` | 응답 상태 코드 설정 |
|
||||
| `createError({ statusCode, statusMessage })` | HTTP 에러 던지기 |
|
||||
| `getRequestURL(event)` | 요청 URL 객체 반환 |
|
||||
|
||||
---
|
||||
|
||||
## 클라이언트 연동
|
||||
|
||||
### useFetch vs $fetch
|
||||
|
||||
| | `useFetch` | `$fetch` |
|
||||
|---|---|---|
|
||||
| SSR 지원 | O (서버에서 미리 호출) | X |
|
||||
| 자동 캐싱 | O | X |
|
||||
| 반응형 | O (ref 전달 시 자동 재호출) | X |
|
||||
| 사용 위치 | 컴포넌트 최상위 (setup) | 이벤트 핸들러, Pinia 액션 |
|
||||
|
||||
```ts
|
||||
// useFetch: 반응형 쿼리
|
||||
const keyword = ref('Nuxt')
|
||||
const { data } = await useFetch('/api/03-search', {
|
||||
query: { keyword } // keyword 변경 시 자동 재호출
|
||||
})
|
||||
|
||||
// useFetch: 동적 URL
|
||||
const id = ref('1')
|
||||
const { data } = await useFetch(() => `/api/04-users/${id.value}`)
|
||||
|
||||
// $fetch: 이벤트 핸들러 내부
|
||||
async function submit() {
|
||||
const result = await $fetch('/api/02-users', {
|
||||
method: 'POST',
|
||||
body: { name: '홍길동', email: 'hong@example.com' }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 성능 고려사항
|
||||
|
||||
### 이벤트 루프 블로킹 주의
|
||||
|
||||
```ts
|
||||
// ❌ 동기 처리 → 이벤트 루프 블로킹
|
||||
const data = fs.readFileSync('./file.json')
|
||||
|
||||
// ✅ 비동기 처리
|
||||
const data = await fs.promises.readFile('./file.json')
|
||||
```
|
||||
|
||||
### 무거운 CPU 작업
|
||||
|
||||
- 이미지 처리, 암호화 등 CPU 집약 작업 → `worker_threads` 분리 고려
|
||||
- Node.js 단일 스레드 특성상 해당 작업 중 다른 요청 처리 지연 발생
|
||||
|
||||
### 캐싱 활용
|
||||
|
||||
```ts
|
||||
// nuxt.config.ts: 라우트 레벨 캐싱
|
||||
routeRules: {
|
||||
'/api/products': { cache: { maxAge: 60 } }
|
||||
}
|
||||
|
||||
// 핸들러 레벨 캐싱
|
||||
export default cachedEventHandler(async () => {
|
||||
return await fetchHeavyData()
|
||||
}, { maxAge: 60, name: 'heavy-data' })
|
||||
```
|
||||
|
||||
### 트래픽이 높을 때
|
||||
|
||||
- Nitro는 기본 단일 프로세스 → PM2 클러스터 모드 또는 컨테이너 수평 확장
|
||||
- Vercel / Cloudflare Workers 배포 시 단일 스레드 제약 없음
|
||||
|
||||
---
|
||||
|
||||
## 서버 API 사용 기준
|
||||
|
||||
> **클라이언트에 노출되면 안 되는 것**이 있거나,
|
||||
> **클라이언트가 직접 접근할 수 없는 리소스**를 다룰 때 사용
|
||||
|
||||
### 반드시 서버에서 처리
|
||||
|
||||
| 케이스 | 이유 |
|
||||
|--------|------|
|
||||
| DB 직접 접근 (Prisma, Drizzle) | 연결 정보 노출 방지 |
|
||||
| 외부 API 시크릿 키 사용 | 환경변수 클라이언트 노출 방지 |
|
||||
| 인증 / 권한 검증 | 클라이언트 조작 불가 보장 |
|
||||
| 민감한 비즈니스 로직 | 역공학 방지 |
|
||||
|
||||
### 있으면 좋은 경우
|
||||
|
||||
| 케이스 | 이유 |
|
||||
|--------|------|
|
||||
| 여러 외부 API 집계 | 클라이언트 요청 수 감소 |
|
||||
| 응답 데이터 가공/필터링 | 불필요한 데이터 노출 방지 |
|
||||
| 서버 사이드 캐싱 | DB/외부 API 부하 감소 |
|
||||
|
||||
### 서버 API 불필요
|
||||
|
||||
| 케이스 | 이유 |
|
||||
|--------|------|
|
||||
| 공개 외부 API 직접 호출 | 숨길 키 없으면 클라이언트에서 직접 호출 가능 |
|
||||
| 정적 데이터 | `assets/` 또는 `public/` 에서 처리 |
|
||||
| 클라이언트 상태 관리 | Pinia 로 충분 |
|
||||
14
server/api/01-hello.get.ts
Normal file
14
server/api/01-hello.get.ts
Normal 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(),
|
||||
};
|
||||
});
|
||||
37
server/api/02-users.post.ts
Normal file
37
server/api/02-users.post.ts
Normal 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(),
|
||||
};
|
||||
});
|
||||
45
server/api/03-search.get.ts
Normal file
45
server/api/03-search.get.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
34
server/api/04-users/[id].get.ts
Normal file
34
server/api/04-users/[id].get.ts
Normal 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;
|
||||
});
|
||||
57
server/api/05-items/[id].ts
Normal file
57
server/api/05-items/[id].ts
Normal 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: "허용되지 않는 메서드" });
|
||||
});
|
||||
65
server/api/06-error-handling.get.ts
Normal file
65
server/api/06-error-handling.get.ts
Normal 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 "성공!";
|
||||
}
|
||||
36
server/api/07-products.get.ts
Normal file
36
server/api/07-products.get.ts
Normal 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
696
server/api/README.md
Normal 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 |
|
||||
34
server/middleware/09-logger.ts
Normal file
34
server/middleware/09-logger.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 케이스 9: 서버 미들웨어 (server/middleware/)
|
||||
*
|
||||
* - 모든 요청에 자동 실행 (순서: 파일명 알파벳 순)
|
||||
* - event.context에 데이터 저장 → 이후 핸들러에서 접근 가능
|
||||
* - return 없이 종료하면 다음 핸들러로 계속 진행
|
||||
* - 응답을 반환하면 요청 처리 중단 (인증 게이트웨이 패턴)
|
||||
*
|
||||
* 주의: 특정 경로에만 적용하려면 getRequestURL()로 필터링
|
||||
*/
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const start = Date.now();
|
||||
const method = event.method;
|
||||
const url = getRequestURL(event).pathname;
|
||||
|
||||
// /api/ 경로만 로깅
|
||||
if (url.startsWith("/api/")) {
|
||||
console.log(`[요청] ${method} ${url}`);
|
||||
|
||||
// event.context에 커스텀 데이터 저장 (이후 핸들러에서 접근)
|
||||
event.context.requestedAt = new Date().toISOString();
|
||||
event.context.startTime = start;
|
||||
|
||||
// 훅: 응답 완료 후 실행
|
||||
event.node.res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
const status = event.node.res.statusCode;
|
||||
console.log(`[응답] ${method} ${url} → ${status} (${duration}ms)`);
|
||||
});
|
||||
}
|
||||
|
||||
// return 없음 → 다음 핸들러로 진행
|
||||
});
|
||||
40
server/routes/08-webhook.post.ts
Normal file
40
server/routes/08-webhook.post.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 케이스 8: server/routes/ vs server/api/
|
||||
*
|
||||
* 차이점:
|
||||
* ┌─────────────────────────────────────────────────────────────┐
|
||||
* │ server/api/hello.ts → URL: /api/hello (/api 자동 접두사) │
|
||||
* │ server/routes/hello.ts → URL: /hello (접두사 없음) │
|
||||
* └─────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* server/routes/ 사용 사례:
|
||||
* - 외부 서비스 Webhook 수신 (/webhook/github, /webhook/stripe)
|
||||
* - RSS 피드 (/rss.xml)
|
||||
* - sitemap.xml (/sitemap.xml)
|
||||
* - 커스텀 URL 구조가 필요할 때
|
||||
*
|
||||
* URL: POST /08-webhook
|
||||
*
|
||||
* 테스트:
|
||||
* fetch('/08-webhook', {
|
||||
* method: 'POST',
|
||||
* headers: { 'Content-Type': 'application/json', 'X-Webhook-Secret': 'my-secret' },
|
||||
* body: JSON.stringify({ event: 'push', repo: 'my-project' })
|
||||
* })
|
||||
*/
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 웹훅 시크릿 검증 (실제로는 HMAC 서명 검증)
|
||||
const secret = getHeader(event, "x-webhook-secret");
|
||||
|
||||
if (secret !== "my-secret") {
|
||||
throw createError({ statusCode: 401, statusMessage: "잘못된 웹훅 시크릿" });
|
||||
}
|
||||
|
||||
const payload = await readBody(event);
|
||||
|
||||
console.log("웹훅 수신:", payload);
|
||||
|
||||
// 웹훅 응답은 빠르게 200 반환 (처리는 백그라운드에서)
|
||||
return { received: true };
|
||||
});
|
||||
34
server/routes/robots.txt.ts
Normal file
34
server/routes/robots.txt.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 케이스 8-c: robots.txt
|
||||
*
|
||||
* server/routes/ 사용 사례 — /api 접두사 없이 /robots.txt URL로 직접 제공
|
||||
*
|
||||
* URL: GET /robots.txt
|
||||
*/
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
// 텍스트 Content-Type 지정
|
||||
setHeader(event, "Content-Type", "text/plain");
|
||||
setHeader(event, "Cache-Control", "max-age=600");
|
||||
|
||||
const baseUrl = "https://example.com";
|
||||
console.log(event);
|
||||
|
||||
// 크롤러별 접근 규칙 설정
|
||||
return [
|
||||
"# 모든 크롤러 허용",
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
"",
|
||||
"# 관리자/내부 경로 차단",
|
||||
"Disallow: /admin",
|
||||
"Disallow: /api/",
|
||||
"Disallow: /_nuxt/",
|
||||
"",
|
||||
"# GPTBot (ChatGPT 크롤러) 차단 예시",
|
||||
"# User-agent: GPTBot",
|
||||
"# Disallow: /",
|
||||
"",
|
||||
`Sitemap: ${baseUrl}/sitemap.xml`,
|
||||
].join("\n");
|
||||
});
|
||||
40
server/routes/sitemap.xml.ts
Normal file
40
server/routes/sitemap.xml.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 케이스 8-b: sitemap.xml
|
||||
*
|
||||
* server/routes/ 사용 사례 — /api 접두사 없이 /sitemap.xml URL로 직접 제공
|
||||
*
|
||||
* URL: GET /sitemap.xml
|
||||
*/
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
// XML Content-Type 지정
|
||||
setHeader(event, "Content-Type", "application/xml");
|
||||
|
||||
const baseUrl = "https://example.com";
|
||||
|
||||
// 실제 프로젝트에서는 DB나 파일 시스템에서 동적으로 URL 목록을 가져옴
|
||||
const pages = [
|
||||
{ url: "/", lastmod: "2026-04-01", changefreq: "daily", priority: "1.0" },
|
||||
{ url: "/about", lastmod: "2026-03-15", changefreq: "monthly", priority: "0.8" },
|
||||
{ url: "/blog", lastmod: "2026-04-07", changefreq: "weekly", priority: "0.9" },
|
||||
{ url: "/blog/nuxt4-release", lastmod: "2026-04-01", changefreq: "yearly", priority: "0.7" },
|
||||
{ url: "/contact", lastmod: "2026-01-01", changefreq: "yearly", priority: "0.5" },
|
||||
];
|
||||
|
||||
const urls = pages
|
||||
.map(
|
||||
(page) => `
|
||||
<url>
|
||||
<loc>${baseUrl}${page.url}</loc>
|
||||
<lastmod>${page.lastmod}</lastmod>
|
||||
<changefreq>${page.changefreq}</changefreq>
|
||||
<priority>${page.priority}</priority>
|
||||
</url>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>`;
|
||||
});
|
||||
31
server/utils/response.ts
Normal file
31
server/utils/response.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 서버 유틸: 공통 응답 형식 헬퍼
|
||||
*
|
||||
* - server/utils/ 하위 파일은 서버 전체에서 auto-import
|
||||
* - import 없이 바로 사용 가능: apiSuccess(), apiError() 등
|
||||
* - 클라이언트 코드에는 노출되지 않음 (서버 전용)
|
||||
*/
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 성공 응답 래퍼 */
|
||||
export function apiSuccess<T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> {
|
||||
return { success: true, data, error: null, meta };
|
||||
}
|
||||
|
||||
/** 실패 응답 래퍼 (에러를 던지지 않고 응답으로 처리할 때) */
|
||||
export function apiFailure(message: string): ApiResponse<null> {
|
||||
return { success: false, data: null, error: message };
|
||||
}
|
||||
|
||||
/** Authorization 헤더에서 Bearer 토큰 추출 */
|
||||
export function extractBearerToken(event: Parameters<typeof getHeader>[0]): string | null {
|
||||
const auth = getHeader(event, "authorization");
|
||||
if (!auth?.startsWith("Bearer ")) return null;
|
||||
return auth.slice(7);
|
||||
}
|
||||
Reference in New Issue
Block a user