본문으로 건너뛰기
Advertisement

13.2 Route Handlers — API 엔드포인트

Route Handlers란?

Route Handlers는 Next.js 15 App Router에서 API 엔드포인트를 만드는 방법입니다. app 디렉터리 내에 route.ts 파일을 생성하면 해당 경로가 API 엔드포인트가 됩니다.

Pages Router의 pages/api 방식을 대체하며, Web 표준 RequestResponse API를 사용합니다. 따라서 Node.js 환경뿐 아니라 Edge Runtime에서도 동작합니다.

Route Handlers vs. Server Actions

구분Route HandlersServer Actions
용도외부 시스템이 호출하는 REST/webhook API클라이언트 컴포넌트에서 서버 로직 직접 호출
호출 방식HTTP 요청 (fetch, curl, Postman 등)함수 호출 (컴포넌트 내부)
파일 위치app/.../route.tsapp/actions/*.ts 또는 컴포넌트 내부
적합한 경우Webhook, 모바일 앱 API, 외부 서비스 연동폼 처리, 데이터 변경, UI 인터랙션

기본 문법

HTTP 메서드 정의

route.ts 파일에서 HTTP 메서드 이름과 동일한 함수를 export합니다.

// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";

// GET /api/hello
export async function GET(request: NextRequest) {
return NextResponse.json({ message: "Hello, World!" });
}

// POST /api/hello
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ received: body }, { status: 201 });
}

// PUT /api/hello
export async function PUT(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ updated: body });
}

// DELETE /api/hello
export async function DELETE(request: NextRequest) {
return NextResponse.json({ deleted: true });
}

// PATCH /api/hello
export async function PATCH(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ patched: body });
}

지원되는 HTTP 메서드: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS

경로 매개변수 (Dynamic Routes)

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });

if (!user) {
return NextResponse.json({ error: "사용자를 찾을 수 없습니다." }, { status: 404 });
}

return NextResponse.json(user);
}

export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.user.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}

Request 처리

쿼리 파라미터 읽기

// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;

const query = searchParams.get("q") ?? "";
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "10", 10);
const category = searchParams.get("category");
const sortBy = searchParams.get("sortBy") ?? "createdAt";
const order = searchParams.get("order") === "asc" ? "asc" : "desc";

const where = {
...(query && { name: { contains: query, mode: "insensitive" as const } }),
...(category && { category }),
};

const [products, total] = await Promise.all([
db.product.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { [sortBy]: order },
}),
db.product.count({ where }),
]);

return NextResponse.json({
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
}

요청 바디 처리

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const CreateUserSchema = z.object({
name: z.string().min(2, "이름은 2자 이상이어야 합니다."),
email: z.string().email("올바른 이메일 형식이 아닙니다."),
password: z.string().min(8, "비밀번호는 8자 이상이어야 합니다."),
role: z.enum(["user", "admin"]).default("user"),
});

export async function POST(request: NextRequest) {
// Content-Type에 따른 파싱
const contentType = request.headers.get("content-type") ?? "";

let body: unknown;
if (contentType.includes("application/json")) {
body = await request.json();
} else if (contentType.includes("application/x-www-form-urlencoded")) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries());
} else {
return NextResponse.json(
{ error: "지원하지 않는 Content-Type입니다." },
{ status: 415 }
);
}

// 유효성 검사
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "입력값이 올바르지 않습니다.", details: result.error.flatten() },
{ status: 422 }
);
}

// 이메일 중복 확인
const existing = await db.user.findUnique({ where: { email: result.data.email } });
if (existing) {
return NextResponse.json(
{ error: "이미 사용 중인 이메일입니다." },
{ status: 409 }
);
}

// 비밀번호 해싱 후 저장
const hashedPassword = await hashPassword(result.data.password);
const user = await db.user.create({
data: { ...result.data, password: hashedPassword },
select: { id: true, name: true, email: true, role: true, createdAt: true },
});

return NextResponse.json(user, { status: 201 });
}

헤더 읽기 및 설정

// app/api/protected/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
// 요청 헤더 읽기
const authorization = request.headers.get("authorization");
const apiKey = request.headers.get("x-api-key");
const userAgent = request.headers.get("user-agent");

// Bearer 토큰 검증
if (!authorization?.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "인증 토큰이 필요합니다." },
{ status: 401 }
);
}

const token = authorization.slice(7);
const payload = await verifyToken(token);

if (!payload) {
return NextResponse.json(
{ error: "유효하지 않은 토큰입니다." },
{ status: 401 }
);
}

const data = await fetchProtectedData(payload.userId);

// 응답 헤더 설정
const response = NextResponse.json({ data });
response.headers.set("X-Response-Time", Date.now().toString());
response.headers.set("Cache-Control", "private, no-cache");

return response;
}

파일 업로드 처리

// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { writeFile } from "fs/promises";
import path from "path";

const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File | null;

if (!file) {
return NextResponse.json({ error: "파일이 없습니다." }, { status: 400 });
}

// 파일 타입 검증
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "허용되지 않는 파일 형식입니다. (JPEG, PNG, WebP, GIF만 가능)" },
{ status: 415 }
);
}

// 파일 크기 검증
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "파일 크기가 5MB를 초과합니다." },
{ status: 413 }
);
}

const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);

// 파일명 생성 (충돌 방지)
const ext = file.name.split(".").pop();
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const uploadPath = path.join(process.cwd(), "public/uploads", filename);

await writeFile(uploadPath, buffer);

return NextResponse.json({
url: `/uploads/${filename}`,
name: file.name,
size: file.size,
type: file.type,
});
}

Response 처리

다양한 응답 형식

// app/api/examples/route.ts
import { NextRequest, NextResponse } from "next/server";

// JSON 응답
export async function GET(request: NextRequest) {
const url = request.nextUrl.pathname;

if (url.includes("json")) {
return NextResponse.json({ hello: "world" });
}

// 텍스트 응답
if (url.includes("text")) {
return new NextResponse("Hello, World!", {
headers: { "Content-Type": "text/plain" },
});
}

// HTML 응답
if (url.includes("html")) {
return new NextResponse("<h1>Hello</h1>", {
headers: { "Content-Type": "text/html" },
});
}

// 스트리밍 응답
if (url.includes("stream")) {
const stream = new ReadableStream({
async start(controller) {
const messages = ["안녕", "하세", "요!"];
for (const msg of messages) {
controller.enqueue(new TextEncoder().encode(msg));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
return new NextResponse(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}

// 리다이렉트
return NextResponse.redirect(new URL("/api/examples/json", request.url));
}

캐시 제어

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";

// 정적 데이터 — 1시간 캐싱
export async function GET(request: NextRequest) {
const posts = await db.post.findMany({
where: { published: true },
select: { id: true, title: true, slug: true, publishedAt: true },
});

return NextResponse.json(posts, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}

// 동적 데이터 — 캐시 비활성화
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await db.post.create({ data: body });

return NextResponse.json(post, {
status: 201,
headers: {
"Cache-Control": "no-store",
},
});
}

// Next.js 캐시 설정
export const dynamic = "force-dynamic"; // 캐시 비활성화
// export const revalidate = 3600; // 1시간마다 재검증

미들웨어 활용

middleware.ts — 요청 전처리

// middleware.ts (프로젝트 루트)
import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";

export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;

// API 경로 보호
if (pathname.startsWith("/api/admin")) {
const token = request.headers.get("authorization")?.slice(7) ??
request.cookies.get("token")?.value;

if (!token) {
return NextResponse.json(
{ error: "인증이 필요합니다." },
{ status: 401 }
);
}

const payload = await verifyToken(token);
if (!payload || payload.role !== "admin") {
return NextResponse.json(
{ error: "관리자 권한이 필요합니다." },
{ status: 403 }
);
}

// 검증된 사용자 정보를 헤더로 전달
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.userId);
requestHeaders.set("x-user-role", payload.role);

return NextResponse.next({ request: { headers: requestHeaders } });
}

// Rate limiting (간단한 예시)
if (pathname.startsWith("/api/")) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
const key = `rate-limit:${ip}`;
// 실제 구현에서는 Redis 등을 사용
// const count = await redis.incr(key);
// if (count > 100) {
// return NextResponse.json({ error: "요청 한도 초과" }, { status: 429 });
// }
}

return NextResponse.next();
}

export const config = {
matcher: ["/api/:path*", "/admin/:path*"],
};

Route Handler 내부에서 미들웨어 패턴

// lib/api-middleware.ts
import { NextRequest, NextResponse } from "next/server";

type Handler = (req: NextRequest, context?: unknown) => Promise<NextResponse>;
type Middleware = (handler: Handler) => Handler;

// CORS 미들웨어
export const withCors: Middleware = (handler) => async (req, ctx) => {
const origin = req.headers.get("origin");
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") ?? [];

const response = await handler(req, ctx);

if (origin && allowedOrigins.includes(origin)) {
response.headers.set("Access-Control-Allow-Origin", origin);
}
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");

return response;
};

// 인증 미들웨어
export const withAuth: Middleware = (handler) => async (req, ctx) => {
const token = req.headers.get("authorization")?.slice(7);
if (!token) {
return NextResponse.json({ error: "인증이 필요합니다." }, { status: 401 });
}

const payload = await verifyToken(token);
if (!payload) {
return NextResponse.json({ error: "유효하지 않은 토큰입니다." }, { status: 401 });
}

return handler(req, ctx);
};

// 로깅 미들웨어
export const withLogging: Middleware = (handler) => async (req, ctx) => {
const start = Date.now();
const response = await handler(req, ctx);
const duration = Date.now() - start;

console.log(`[${req.method}] ${req.nextUrl.pathname} - ${response.status} (${duration}ms)`);
return response;
};

// 미들웨어 합성
export function compose(...middlewares: Middleware[]): Middleware {
return (handler) => middlewares.reduceRight((h, mw) => mw(h), handler);
}
// app/api/admin/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { compose, withCors, withAuth, withLogging } from "@/lib/api-middleware";

async function handler(request: NextRequest): Promise<NextResponse> {
const users = await db.user.findMany({
select: { id: true, name: true, email: true, role: true },
});
return NextResponse.json(users);
}

export const GET = compose(withLogging, withAuth, withCors)(handler);

실전 예제 — REST API 전체 구현

블로그 게시물에 대한 완전한 REST API를 구현합니다.

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";

const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).optional().default([]),
published: z.boolean().optional().default(false),
});

// GET /api/posts — 게시물 목록
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(50, parseInt(searchParams.get("limit") ?? "10", 10));
const tag = searchParams.get("tag");
const published = searchParams.get("published") !== "false";

const where = {
published,
...(tag && { tags: { has: tag } }),
};

const [posts, total] = await Promise.all([
db.post.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: "desc" },
include: { author: { select: { id: true, name: true } } },
}),
db.post.count({ where }),
]);

return NextResponse.json({
data: posts,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
}

// POST /api/posts — 게시물 생성
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}

let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "잘못된 JSON 형식입니다." }, { status: 400 });
}

const result = CreatePostSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "입력값 오류", details: result.error.flatten() },
{ status: 422 }
);
}

const slug = result.data.title
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-|-$/g, "");

const slugExists = await db.post.findUnique({ where: { slug } });
if (slugExists) {
return NextResponse.json({ error: "이미 존재하는 제목입니다." }, { status: 409 });
}

const post = await db.post.create({
data: {
...result.data,
slug,
authorId: session.user.id,
publishedAt: result.data.published ? new Date() : null,
},
include: { author: { select: { id: true, name: true } } },
});

return NextResponse.json(post, { status: 201 });
}
// app/api/posts/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";

const UpdatePostSchema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(10).optional(),
tags: z.array(z.string()).optional(),
published: z.boolean().optional(),
});

// GET /api/posts/:slug
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const post = await db.post.findUnique({
where: { slug },
include: {
author: { select: { id: true, name: true, image: true } },
comments: {
include: { user: { select: { id: true, name: true } } },
orderBy: { createdAt: "desc" },
take: 10,
},
},
});

if (!post) {
return NextResponse.json({ error: "게시물을 찾을 수 없습니다." }, { status: 404 });
}

// 조회수 증가 (비동기, 오류 무시)
db.post.update({ where: { slug }, data: { viewCount: { increment: 1 } } }).catch(() => {});

return NextResponse.json(post);
}

// PATCH /api/posts/:slug
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}

const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });

if (!post) {
return NextResponse.json({ error: "게시물을 찾을 수 없습니다." }, { status: 404 });
}

if (post.authorId !== session.user.id && session.user.role !== "admin") {
return NextResponse.json({ error: "수정 권한이 없습니다." }, { status: 403 });
}

const body = await request.json();
const result = UpdatePostSchema.safeParse(body);

if (!result.success) {
return NextResponse.json(
{ error: "입력값 오류", details: result.error.flatten() },
{ status: 422 }
);
}

const updated = await db.post.update({
where: { slug },
data: {
...result.data,
updatedAt: new Date(),
...(result.data.published && !post.published && { publishedAt: new Date() }),
},
});

return NextResponse.json(updated);
}

// DELETE /api/posts/:slug
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}

const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });

if (!post) {
return NextResponse.json({ error: "게시물을 찾을 수 없습니다." }, { status: 404 });
}

if (post.authorId !== session.user.id && session.user.role !== "admin") {
return NextResponse.json({ error: "삭제 권한이 없습니다." }, { status: 403 });
}

await db.post.delete({ where: { slug } });
return new NextResponse(null, { status: 204 });
}

Webhook 처리

외부 서비스(Stripe, GitHub, Slack 등)에서 보내는 Webhook을 처리합니다.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
const body = await request.text(); // raw body 필요
const signature = request.headers.get("stripe-signature");

if (!signature) {
return NextResponse.json({ error: "서명이 없습니다." }, { status: 400 });
}

let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook 서명 검증 실패:", err);
return NextResponse.json({ error: "서명 검증 실패" }, { status: 400 });
}

// 이벤트 타입별 처리
switch (event.type) {
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentSuccess(paymentIntent);
break;
}
case "payment_intent.payment_failed": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentFailure(paymentIntent);
break;
}
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionChange(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCanceled(subscription);
break;
}
default:
console.log(`미처리 이벤트 타입: ${event.type}`);
}

return NextResponse.json({ received: true });
}

async function handlePaymentSuccess(intent: Stripe.PaymentIntent) {
const orderId = intent.metadata.orderId;
await db.order.update({
where: { id: orderId },
data: { status: "PAID", paidAt: new Date() },
});
}

async function handlePaymentFailure(intent: Stripe.PaymentIntent) {
const orderId = intent.metadata.orderId;
await db.order.update({
where: { id: orderId },
data: { status: "PAYMENT_FAILED" },
});
}

고수 팁

1. Edge Runtime 활용

// app/api/geo/route.ts
export const runtime = "edge"; // 엣지 런타임 사용

import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
// Edge에서는 지리적 위치 정보 사용 가능
const country = request.geo?.country ?? "unknown";
const city = request.geo?.city ?? "unknown";
const region = request.geo?.region ?? "unknown";

return NextResponse.json({ country, city, region });
}

2. SSE (Server-Sent Events) 구현

// app/api/events/route.ts
import { NextRequest } from "next/server";

export async function GET(request: NextRequest) {
const encoder = new TextEncoder();

const stream = new ReadableStream({
async start(controller) {
const sendEvent = (event: string, data: unknown) => {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(message));
};

// 실시간 업데이트 시뮬레이션
let count = 0;
const interval = setInterval(() => {
sendEvent("update", { count: ++count, timestamp: new Date().toISOString() });

if (count >= 10) {
clearInterval(interval);
controller.close();
}
}, 1000);

// 클라이언트 연결 종료 시 정리
request.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
},
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}

클라이언트에서 SSE 구독:

// app/dashboard/RealtimeUpdates.tsx
"use client";

import { useEffect, useState } from "react";

export default function RealtimeUpdates() {
const [updates, setUpdates] = useState<Array<{ count: number; timestamp: string }>>([]);

useEffect(() => {
const eventSource = new EventSource("/api/events");

eventSource.addEventListener("update", (e) => {
const data = JSON.parse(e.data);
setUpdates((prev) => [...prev, data].slice(-20)); // 최근 20개만 유지
});

eventSource.onerror = () => {
console.error("SSE 연결 오류");
eventSource.close();
};

return () => eventSource.close();
}, []);

return (
<div>
<h2>실시간 업데이트</h2>
<ul>
{updates.map((update, i) => (
<li key={i}>
#{update.count}{new Date(update.timestamp).toLocaleTimeString("ko-KR")}
</li>
))}
</ul>
</div>
);
}

3. OPTIONS 핸들러로 CORS Preflight 처리

// app/api/data/route.ts
import { NextRequest, NextResponse } from "next/server";

const CORS_HEADERS = {
"Access-Control-Allow-Origin": "https://your-frontend.com",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};

export async function OPTIONS(_request: NextRequest) {
return NextResponse.json({}, { headers: CORS_HEADERS });
}

export async function GET(request: NextRequest) {
const data = await fetchData();
return NextResponse.json(data, { headers: CORS_HEADERS });
}

4. 응답 캐싱 전략

// app/api/stats/route.ts
import { NextResponse } from "next/server";

// ISR 방식으로 10분마다 재생성
export const revalidate = 600;

export async function GET() {
const stats = await db.$transaction([
db.user.count(),
db.post.count({ where: { published: true } }),
db.comment.count(),
]);

return NextResponse.json({
users: stats[0],
posts: stats[1],
comments: stats[2],
generatedAt: new Date().toISOString(),
});
}

5. 타입 안전한 API 클라이언트 생성

// lib/api-client.ts
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string; details?: unknown };

async function apiRequest<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});

if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
return { success: false, error: error.error ?? "Request failed", details: error.details };
}

const data = await response.json();
return { success: true, data };
} catch (error) {
return { success: false, error: "Network error" };
}
}

// 사용 예시
export const postsApi = {
list: (params?: { page?: number; limit?: number }) =>
apiRequest<{ data: Post[]; meta: PaginationMeta }>(
`/api/posts?${new URLSearchParams(params as Record<string, string>)}`
),

get: (slug: string) => apiRequest<Post>(`/api/posts/${slug}`),

create: (data: CreatePostInput) =>
apiRequest<Post>("/api/posts", {
method: "POST",
body: JSON.stringify(data),
}),

update: (slug: string, data: UpdatePostInput) =>
apiRequest<Post>(`/api/posts/${slug}`, {
method: "PATCH",
body: JSON.stringify(data),
}),

delete: (slug: string) =>
apiRequest<void>(`/api/posts/${slug}`, { method: "DELETE" }),
};
Advertisement