Files
nuxt4-deep/server/api/README.md
hyeonggil fc7d3d14cf 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.
2026-04-08 23:59:29 +09:00

22 KiB

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 반환 시 빈 응답
// server/api/hello.ts — 가장 기본적인 형태
export default defineEventHandler((event) => {
  // event: 요청/응답 정보를 모두 담은 H3 이벤트 객체
  return { hello: 'world' }  // → { "hello": "world" } JSON 응답
})

event 객체란?

event는 H3(Nitro의 HTTP 프레임워크)의 핵심 객체로, 요청/응답과 관련된 모든 정보를 담고 있습니다.

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     삭제
// 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
// server/api/users/[userId]/posts/[postId].get.ts
export default defineEventHandler((event) => {
  // 여러 파라미터를 한 번에 가져올 때 getRouterParams 사용
  const { userId, postId } = getRouterParams(event)
  return { userId, postId }
})

동적 라우트

단일 파라미터

// 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로 파라미터 검증 (공식 권장 방식)

// 문자열 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/경로/파일명 형태로 동적 경로 처리
   - 폴백 핸들러: 매칭되는 라우트가 없을 때 기본 응답
// 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

// 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 검증 (공식 문서 권장)

// 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

// 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 검증 (공식 문서 권장)

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

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

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 }
})

응답 제어

상태 코드

// HTTP 상태 코드 의미와 사용 시점
setResponseStatus(event, 200)  // OK            기본값, 조회 성공
setResponseStatus(event, 201)  // Created       POST로 리소스 생성 성공
setResponseStatus(event, 202)  // Accepted      비동기 처리 수락 (아직 완료 안 됨)
setResponseStatus(event, 204)  // No Content    성공이지만 응답 본문 없음 (DELETE)

응답 헤더

// 응답 헤더는 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
// ① 기본 에러
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,                // 서버 로그에 원인 기록 (클라이언트에 노출 안 됨)
  })
}

클라이언트에서 에러 처리

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 필드가 있을 경우
}

리다이렉트

// 302: 임시 이동 (기본값, 검색 엔진이 원본 URL 유지)
// 301: 영구 이동 (검색 엔진이 새 URL로 업데이트)
await sendRedirect(event, '/new-path', 302)

스트림 응답

// 대용량 파일 다운로드 등에 사용
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에 실제 값 → 서버에서만 접근

설정 방법

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // 서버에서만 접근 가능 (클라이언트 번들에 포함 안 됨)
    githubToken:  '',
    databaseUrl:  '',
    jwtSecret:    '',

    // public: 클라이언트/서버 모두 접근 가능 (민감 정보 넣지 말 것)
    public: {
      apiBase:  '/api',
      siteUrl:  'https://example.com',
    }
  }
})
# .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
// 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

// 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

// 응답을 먼저 보내고 무거운 작업을 뒤에서 처리
// 사용 케이스: 로그 기록, 분석 데이터 전송, 캐시 갱신
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 메서드 분기 (단일 파일)

// 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

// 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에서 라우트 단위로도 설정 가능:

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 전달 시 변경 감지
// ── 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 요청에서 사용

// server/api/users.get.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)  // ❌ GET에서 호출 → 405 에러
})
// 해결: GET은 getQuery(), POST/PUT은 readBody()

동기 함수에서 async 없이 readBody 호출

export default defineEventHandler((event) => {  // async 빠짐
  const body = readBody(event)  // ❌ Promise 객체가 그대로 반환됨
})
// 해결: defineEventHandler(async (event) => { ... })

getRouterParam의 undefined 처리 누락

export default defineEventHandler((event) => {
  const id = getRouterParam(event, 'id')
  return db.find(id)  // ❌ id가 undefined일 수 있음
})
// 해결: const id = getRouterParam(event, 'id') ?? ''
//       또는 getValidatedRouterParams로 Zod 검증

환경변수를 클라이언트 코드에서 직접 접근

// app/pages/index.vue
const token = process.env.GITHUB_TOKEN  // ❌ 클라이언트에 노출됨!
// 해결: 서버 API를 통해 처리하고, public이 아닌 값은 runtimeConfig 서버 전용 사용

에러 응답에 민감한 정보 포함

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