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:
11
.claude/settings.json
Normal file
11
.claude/settings.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"feature-dev@claude-plugins-official": true,
|
||||||
|
"vue@nuxt-skills": true,
|
||||||
|
"writing-web-documentation@nuxt-skills": true,
|
||||||
|
"document-writer@nuxt-skills": true,
|
||||||
|
"vueuse@nuxt-skills": true,
|
||||||
|
"nuxt@nuxt-skills": true,
|
||||||
|
"commit-commands@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
17
.claude/settings.local.json
Normal file
17
.claude/settings.local.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm create nuxt@latest my-app -- --no-install 2>&1)",
|
||||||
|
"Bash(echo \"1\" | npx nuxi@latest init my-app 2>&1 | head -50)",
|
||||||
|
"Bash(npx nuxi@latest init my-app --template minimal 2>&1)",
|
||||||
|
"Bash(printf '\\\\n' | npx nuxi@latest init my-app --template minimal --packageManager pnpm 2>&1)",
|
||||||
|
"Bash(cd /Users/hyeonggilkim/01.Study/nuxt4/my-app && pnpm install 2>&1)",
|
||||||
|
"Bash(pnpm approve-builds 2>&1)",
|
||||||
|
"Bash(printf 'a\\\\n' | pnpm approve-builds 2>&1 || true)",
|
||||||
|
"Bash(npx nuxi module add @nuxtjs/tailwindcss 2>&1)",
|
||||||
|
"Bash(npx nuxi module add @pinia/nuxt 2>&1)",
|
||||||
|
"mcp__nuxt-remote__get-documentation-page",
|
||||||
|
"mcp__ide__getDiagnostics"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
400
app/pages/server-api-demo.vue
Normal file
400
app/pages/server-api-demo.vue
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 서버 API 학습 데모 페이지
|
||||||
|
* 모든 케이스를 브라우저에서 직접 테스트할 수 있습니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── 케이스 1: 기본 GET ────────────────────────────────────────
|
||||||
|
// useFetch는 SSR에서 서버 측 호출 → 클라이언트에서 재호출 없이 데이터 hydration
|
||||||
|
const { data: hello } = await useFetch("/api/01-hello");
|
||||||
|
|
||||||
|
// ── 케이스 2: POST + readBody ─────────────────────────────────
|
||||||
|
const newUserName = ref("");
|
||||||
|
const newUserEmail = ref("");
|
||||||
|
const createdUser = ref<{ id: string; name: string; email: string; createdAt: string } | null>(null);
|
||||||
|
const createUserError = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
createdUser.value = null;
|
||||||
|
createUserError.value = null;
|
||||||
|
try {
|
||||||
|
createdUser.value = await $fetch("/api/02-users", {
|
||||||
|
method: "POST",
|
||||||
|
body: { name: newUserName.value, email: newUserEmail.value },
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err && typeof err === "object" && "statusMessage" in err) {
|
||||||
|
createUserError.value = String((err as { statusMessage: string }).statusMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 케이스 3: getQuery 쿼리 파라미터 (반응형) ─────────────────
|
||||||
|
// query에 ref를 직접 전달 → keyword/page 변경 시 자동 재요청
|
||||||
|
const keyword = ref("Nuxt");
|
||||||
|
const page = ref(1);
|
||||||
|
const { data: searchResult } = await useFetch("/api/03-search", {
|
||||||
|
query: { keyword, page, limit: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 케이스 4: 동적 라우트 파라미터 ──────────────────────────
|
||||||
|
// URL을 함수로 감싸야 userId 변경 시 자동 재요청
|
||||||
|
const userId = ref("1");
|
||||||
|
const { data: user, error: userError } = await useFetch(
|
||||||
|
() => `/api/04-users/${userId.value}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 케이스 5: HTTP 메서드 분기 (GET / PUT / DELETE) ───────────
|
||||||
|
const itemId = ref("1");
|
||||||
|
const { data: item, error: itemError, refresh: refreshItem } = await useFetch(
|
||||||
|
() => `/api/05-items/${itemId.value}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT: 아이템 done 토글
|
||||||
|
async function toggleItem() {
|
||||||
|
if (!item.value) return;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/05-items/${itemId.value}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: { done: !(item.value as { done: boolean }).done },
|
||||||
|
});
|
||||||
|
await refreshItem(); // 변경 후 최신 데이터 재조회
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getErrorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: 아이템 삭제 후 id 1로 복귀
|
||||||
|
async function deleteItem() {
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/05-items/${itemId.value}`, { method: "DELETE" });
|
||||||
|
itemId.value = "1";
|
||||||
|
await refreshItem();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getErrorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 케이스 6: createError 에러 처리 ──────────────────────────
|
||||||
|
// 에러 타입별로 다른 응답 확인
|
||||||
|
type ErrorType = "notfound" | "forbidden" | "custom" | "random";
|
||||||
|
const errorType = ref<ErrorType>("notfound");
|
||||||
|
const errorResult = ref<{ result: string } | null>(null);
|
||||||
|
const errorResponse = ref<{
|
||||||
|
statusCode: number;
|
||||||
|
statusMessage: string;
|
||||||
|
data?: unknown;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
async function triggerError() {
|
||||||
|
errorResult.value = null;
|
||||||
|
errorResponse.value = null;
|
||||||
|
try {
|
||||||
|
const data = await $fetch<{ result: string }>(
|
||||||
|
`/api/06-error-handling?type=${errorType.value}`
|
||||||
|
);
|
||||||
|
errorResult.value = data; // 성공 (random의 경우 50% 확률)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err && typeof err === "object" && "statusCode" in err) {
|
||||||
|
errorResponse.value = err as {
|
||||||
|
statusCode: number;
|
||||||
|
statusMessage: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 케이스 7: server/utils auto-import + 헤더 ─────────────────
|
||||||
|
// Authorization 헤더 유무에 따라 응답이 다름 (가격 포함 여부)
|
||||||
|
const useAuthToken = ref(false);
|
||||||
|
const authToken = "my-token-12345";
|
||||||
|
|
||||||
|
const { data: products, refresh: refreshProducts } = await useFetch(
|
||||||
|
"/api/07-products",
|
||||||
|
{
|
||||||
|
headers: computed(() =>
|
||||||
|
useAuthToken.value ? { Authorization: `Bearer ${authToken}` } : {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// useAuthToken 변경 시 수동 재요청 (headers는 반응형 자동 감지 안 됨)
|
||||||
|
watch(useAuthToken, () => refreshProducts());
|
||||||
|
|
||||||
|
// ── 케이스 8: server/routes/ 웹훅 ────────────────────────────
|
||||||
|
const webhookSecret = ref("my-secret");
|
||||||
|
const webhookEvent = ref("push");
|
||||||
|
const webhookRepo = ref("my-project");
|
||||||
|
const webhookResult = ref<{ received: boolean } | null>(null);
|
||||||
|
const webhookError = ref<string | null>(null);
|
||||||
|
const webhookLoading = ref(false);
|
||||||
|
|
||||||
|
async function sendWebhook() {
|
||||||
|
webhookResult.value = null;
|
||||||
|
webhookError.value = null;
|
||||||
|
webhookLoading.value = true;
|
||||||
|
try {
|
||||||
|
webhookResult.value = await $fetch("/08-webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Webhook-Secret": webhookSecret.value },
|
||||||
|
body: { event: webhookEvent.value, repo: webhookRepo.value },
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
webhookError.value = getErrorMessage(err);
|
||||||
|
} finally {
|
||||||
|
webhookLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 공통 유틸 ─────────────────────────────────────────────────
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
if (err && typeof err === "object") {
|
||||||
|
if ("statusMessage" in err)
|
||||||
|
return `${(err as { statusCode: number }).statusCode}: ${(err as { statusMessage: string }).statusMessage}`;
|
||||||
|
if ("message" in err) return String((err as { message: string }).message);
|
||||||
|
}
|
||||||
|
return "알 수 없는 오류";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-6 max-w-2xl mx-auto font-sans">
|
||||||
|
<div class="border-b pb-4">
|
||||||
|
<h1 class="text-2xl font-bold">서버 API 학습 데모</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">
|
||||||
|
각 섹션에서 server/api/ 케이스를 직접 테스트할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 케이스 1: 기본 GET ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/01-hello</span>
|
||||||
|
<h2 class="font-semibold">케이스 1: 기본 GET</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-2">
|
||||||
|
<code class="bg-gray-100 px-1 rounded">useFetch</code>는 SSR 환경에서 서버 측에서 미리 호출되어 클라이언트로 hydration됩니다.
|
||||||
|
</p>
|
||||||
|
<pre class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ hello }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 케이스 2: POST + readBody ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-green-100 text-green-700 px-2 py-0.5 rounded">POST /api/02-users</span>
|
||||||
|
<h2 class="font-semibold">케이스 2: POST + readBody</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
이벤트 핸들러 안에서 <code class="bg-gray-100 px-1 rounded">$fetch</code>로 POST 요청합니다. name/email을 비워두면 400 에러가 반환됩니다.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 mb-3">
|
||||||
|
<input v-model="newUserName" placeholder="이름 (필수)" class="block w-full border px-2 py-1.5 rounded text-sm" />
|
||||||
|
<input v-model="newUserEmail" placeholder="이메일 (필수)" class="block w-full border px-2 py-1.5 rounded text-sm" />
|
||||||
|
</div>
|
||||||
|
<button class="bg-green-500 text-white px-4 py-1.5 rounded text-sm hover:bg-green-600" @click="createUser">
|
||||||
|
사용자 생성
|
||||||
|
</button>
|
||||||
|
<pre v-if="createdUser" class="text-sm bg-green-50 text-green-800 p-3 rounded mt-3 overflow-auto">{{ createdUser }}</pre>
|
||||||
|
<p v-if="createUserError" class="text-red-500 text-sm mt-2">⚠ {{ createUserError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 케이스 3: getQuery ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/03-search</span>
|
||||||
|
<h2 class="font-semibold">케이스 3: getQuery 쿼리 파라미터</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
<code class="bg-gray-100 px-1 rounded">useFetch</code>의 <code class="bg-gray-100 px-1 rounded">query</code>에 ref를 전달하면 값 변경 시 자동으로 재요청합니다.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<input v-model="keyword" placeholder="키워드" class="border px-2 py-1.5 rounded text-sm flex-1 min-w-24" />
|
||||||
|
<div class="flex gap-1 items-center">
|
||||||
|
<button
|
||||||
|
v-for="p in [1, 2]" :key="p"
|
||||||
|
class="border px-3 py-1.5 rounded text-sm"
|
||||||
|
:class="page === p ? 'bg-blue-500 text-white' : 'hover:bg-gray-50'"
|
||||||
|
@click="page = p"
|
||||||
|
>
|
||||||
|
{{ p }}페이지
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ searchResult }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 케이스 4: 동적 라우트 ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/04-users/:id</span>
|
||||||
|
<h2 class="font-semibold">케이스 4: 동적 라우트 파라미터</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
URL을 함수 <code class="bg-gray-100 px-1 rounded">() => `/api/04-users/${userId.value}`</code>로 감싸면 변경 시 자동 재요청됩니다. ID 999는 404를 반환합니다.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="id in ['1', '2', '3', '999']" :key="id"
|
||||||
|
class="border px-3 py-1.5 rounded text-sm"
|
||||||
|
:class="userId === id ? 'bg-blue-500 text-white' : 'hover:bg-gray-50'"
|
||||||
|
@click="userId = id"
|
||||||
|
>
|
||||||
|
ID: {{ id }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre v-if="user" class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ user }}</pre>
|
||||||
|
<div v-if="userError" class="bg-red-50 border border-red-200 rounded p-3 mt-2">
|
||||||
|
<p class="text-red-600 text-sm font-semibold">{{ userError.statusCode }} 에러</p>
|
||||||
|
<p class="text-red-500 text-xs">{{ userError.statusMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 케이스 5: HTTP 메서드 분기 ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-purple-100 text-purple-700 px-2 py-0.5 rounded">GET|PUT|DELETE /api/05-items/:id</span>
|
||||||
|
<h2 class="font-semibold">케이스 5: HTTP 메서드 분기</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
파일명에 메서드 접미사가 없으면 모든 메서드를 수신합니다. <code class="bg-gray-100 px-1 rounded">event.method</code>로 분기 처리합니다.
|
||||||
|
ID 999는 404를 반환합니다.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="id in ['1', '2', '999']" :key="id"
|
||||||
|
class="border px-3 py-1.5 rounded text-sm"
|
||||||
|
:class="itemId === id ? 'bg-purple-500 text-white' : 'hover:bg-gray-50'"
|
||||||
|
@click="itemId = id"
|
||||||
|
>
|
||||||
|
ID: {{ id }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="item" class="bg-gray-50 rounded p-3 mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<pre class="text-sm overflow-auto">{{ item }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4 shrink-0">
|
||||||
|
<button
|
||||||
|
class="text-xs bg-yellow-400 hover:bg-yellow-500 text-white px-2 py-1 rounded"
|
||||||
|
@click="toggleItem"
|
||||||
|
>
|
||||||
|
done 토글 (PUT)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-xs bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded"
|
||||||
|
@click="deleteItem"
|
||||||
|
>
|
||||||
|
삭제 (DELETE)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="itemError" class="bg-red-50 border border-red-200 rounded p-3">
|
||||||
|
<p class="text-red-600 text-sm font-semibold">{{ itemError.statusCode }} 에러</p>
|
||||||
|
<p class="text-red-500 text-xs">{{ itemError.statusMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 케이스 6: createError ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-red-100 text-red-700 px-2 py-0.5 rounded">GET /api/06-error-handling?type=</span>
|
||||||
|
<h2 class="font-semibold">케이스 6: createError 에러 처리</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
에러 타입을 선택하고 요청해보세요. <code class="bg-gray-100 px-1 rounded">error.value.statusCode</code>와 <code class="bg-gray-100 px-1 rounded">error.value.data</code>로 세부 정보에 접근합니다.
|
||||||
|
<span class="text-orange-500">random은 50% 확률로 성공/실패합니다.</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="t in ['notfound', 'forbidden', 'custom', 'random'] as const" :key="t"
|
||||||
|
class="border px-3 py-1.5 rounded text-sm"
|
||||||
|
:class="errorType === t ? 'bg-red-500 text-white' : 'hover:bg-gray-50'"
|
||||||
|
@click="errorType = t"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-xs text-gray-400 bg-gray-50 rounded p-2">
|
||||||
|
<template v-if="errorType === 'notfound'">→ <code>statusCode: 404</code> 반환</template>
|
||||||
|
<template v-else-if="errorType === 'forbidden'">→ <code>statusCode: 403</code> + <code>data: { required, current }</code> 반환</template>
|
||||||
|
<template v-else-if="errorType === 'custom'">→ <code>statusCode: 422</code> + <code>data.fields</code> 반환 (폼 유효성 에러 패턴)</template>
|
||||||
|
<template v-else-if="errorType === 'random'">→ 성공 시 <code>{ result: "성공!" }</code>, 실패 시 <code>statusCode: 500</code></template>
|
||||||
|
</div>
|
||||||
|
<button class="bg-red-500 text-white px-4 py-1.5 rounded text-sm hover:bg-red-600" @click="triggerError">
|
||||||
|
에러 요청 보내기
|
||||||
|
</button>
|
||||||
|
<pre v-if="errorResult" class="text-sm bg-green-50 text-green-800 p-3 rounded mt-3 overflow-auto">{{ errorResult }}</pre>
|
||||||
|
<div v-if="errorResponse" class="bg-red-50 border border-red-200 rounded p-3 mt-3">
|
||||||
|
<p class="text-red-600 text-sm font-semibold mb-1">{{ errorResponse.statusCode }}: {{ errorResponse.statusMessage }}</p>
|
||||||
|
<pre v-if="errorResponse.data" class="text-xs text-red-500 overflow-auto">data: {{ errorResponse.data }}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 케이스 7: server/utils + 헤더 ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-blue-100 text-blue-700 px-2 py-0.5 rounded">GET /api/07-products</span>
|
||||||
|
<h2 class="font-semibold">케이스 7: getHeader + server/utils auto-import</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
<code class="bg-gray-100 px-1 rounded">Authorization</code> 헤더 유무에 따라 응답이 달라집니다.
|
||||||
|
<code class="bg-gray-100 px-1 rounded">server/utils/response.ts</code>의 헬퍼가 import 없이 사용됩니다.
|
||||||
|
</p>
|
||||||
|
<label class="flex items-center gap-2 mb-3 cursor-pointer select-none">
|
||||||
|
<div
|
||||||
|
class="w-10 h-5 rounded-full transition-colors"
|
||||||
|
:class="useAuthToken ? 'bg-blue-500' : 'bg-gray-300'"
|
||||||
|
@click="useAuthToken = !useAuthToken"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-5 h-5 bg-white rounded-full shadow transition-transform"
|
||||||
|
:class="useAuthToken ? 'translate-x-5' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm">Authorization 헤더 포함</span>
|
||||||
|
<code v-if="useAuthToken" class="text-xs bg-blue-50 text-blue-600 px-1 rounded">Bearer {{ authToken }}</code>
|
||||||
|
</label>
|
||||||
|
<pre class="text-sm bg-gray-50 p-3 rounded overflow-auto">{{ products }}</pre>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
|
토큰 있을 때: 가격(price) 포함 + authenticated: true<br />
|
||||||
|
토큰 없을 때: 가격 숨김 + authenticated: false
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── 케이스 8: server/routes/ 웹훅 ── -->
|
||||||
|
<section class="border rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono bg-orange-100 text-orange-700 px-2 py-0.5 rounded">POST /08-webhook</span>
|
||||||
|
<h2 class="font-semibold">케이스 8: server/routes/ (접두사 없음)</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mb-3">
|
||||||
|
<code class="bg-gray-100 px-1 rounded">server/routes/</code>는 <code class="bg-gray-100 px-1 rounded">/api</code> 없이 URL을 직접 지정합니다.
|
||||||
|
시크릿을 "my-secret" 이외로 바꾸면 401 에러가 반환됩니다.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 mb-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs w-28 shrink-0 text-gray-600">X-Webhook-Secret</label>
|
||||||
|
<input v-model="webhookSecret" class="border px-2 py-1.5 rounded text-sm flex-1" placeholder="my-secret" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs w-28 shrink-0 text-gray-600">event</label>
|
||||||
|
<input v-model="webhookEvent" class="border px-2 py-1.5 rounded text-sm flex-1" placeholder="push" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-xs w-28 shrink-0 text-gray-600">repo</label>
|
||||||
|
<input v-model="webhookRepo" class="border px-2 py-1.5 rounded text-sm flex-1" placeholder="my-project" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="bg-orange-500 text-white px-4 py-1.5 rounded text-sm disabled:opacity-50 hover:bg-orange-600"
|
||||||
|
:disabled="webhookLoading"
|
||||||
|
@click="sendWebhook"
|
||||||
|
>
|
||||||
|
{{ webhookLoading ? "전송 중..." : "웹훅 전송" }}
|
||||||
|
</button>
|
||||||
|
<pre v-if="webhookResult" class="text-sm bg-green-50 text-green-800 p-3 rounded mt-3 overflow-auto">{{ webhookResult }}</pre>
|
||||||
|
<p v-if="webhookError" class="text-red-500 text-sm mt-2">⚠ {{ webhookError }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
11
nuxt.config.ts
Normal file
11
nuxt.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2025-07-15",
|
||||||
|
devtools: { enabled: true },
|
||||||
|
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"],
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
allowedHosts: true, // 또는 'all'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "my-app",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "6.14.0",
|
||||||
|
"@pinia/nuxt": "0.11.3",
|
||||||
|
"nuxt": "^4.4.2",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
7823
pnpm-lock.yaml
generated
Normal file
7823
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- esbuild
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
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);
|
||||||
|
}
|
||||||
55
skills-lock.json
Normal file
55
skills-lock.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"document-writer": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "7a2b9799ece651b94b288a6b7d799da7ed603f24384897a370c1bfc6e75d74bb"
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "45d0803ea76f82fa40af91858472fb4528a8ce4c2a9a710b785e796cd5b3fe98"
|
||||||
|
},
|
||||||
|
"nuxt": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "571d9ea31a6f18b94168894814f20df361773e16de4a6dc675b526aaf6d55a45"
|
||||||
|
},
|
||||||
|
"nuxt-better-auth": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "38670ad6846049050b531ee65cb1dc653098bccc0caf7e9ebcef6ef0f3d6980b"
|
||||||
|
},
|
||||||
|
"nuxt-content": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "2e6bbe566a1d907db8ff1d8a69637319a272cf7bebcdd51dd8c94a55cf70014f"
|
||||||
|
},
|
||||||
|
"nuxt-modules": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "628065a8cf5cab196853ff5ef5538d3a7ff553293dde146380ec1b57f33def48"
|
||||||
|
},
|
||||||
|
"nuxt-seo": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "bffc66809f3a21055d2fd39d0bfa32eefdc681d2e93f4613fc3360addb3d3677"
|
||||||
|
},
|
||||||
|
"nuxt-studio": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "97bc283ac7a8927cac035027f64b67a841933d40bb70709b2e6570984f9da8a9"
|
||||||
|
},
|
||||||
|
"nuxt-ui": {
|
||||||
|
"source": "nuxt/ui",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "c3dbbd219dc94e345faa0b5cc4c03638f4361f8b0efa9f34c7dfa2c6dba65a9d"
|
||||||
|
},
|
||||||
|
"vitest": {
|
||||||
|
"source": "onmax/nuxt-skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "04ed1b09da8ac778d693ad12fab49b1c48aadfe1758fe13eb4d6de3fdcbb22f4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user