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

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 |