- 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.
331 lines
9.1 KiB
Markdown
331 lines
9.1 KiB
Markdown
# 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 로 충분 |
|