Route Handlers와 Server Actions를 활용한 API 설계, 그리고 Single Source of Truth 원칙을 학습합니다.
SSOT(Single Source of Truth)는 데이터의 단일 진실 공급원을 유지하는 원칙입니다. 모든 데이터는 하나의 출처에서만 관리되어야 합니다. 이커머스에서 상품 정보, 가격, 재고 등이 여러 곳에서 다르면 큰 문제가 됩니다. 이 강의에서는 SSOT를 실현하는 구체적인 방법을 학습합니다.
// ❌ 나쁜 예: 타입이 여러 곳에 정의됨
// components/ProductCard.tsx
interface Product {
id: string;
name: string;
price: number;
}
// pages/products.tsx
interface Product {
id: string;
name: string;
price: number;
description: string; // 불일치!
}
// ✅ 좋은 예: 타입을 한 곳에서 정의
// types/product.ts
export interface Product {
id: string;
name: string;
price: number;
description: string;
}
// 모든 곳에서 import해서 사용
import { Product } from '@/types/product';| 영역 | 위치 | 예시 |
|---|---|---|
| 타입 정의 | types/ | Product, Order |
| 유효성 검증 | schemas/ | Zod 스키마 |
| 설정값 | config/ | 상수, 환경변수 |
| API 응답 | 서버 정의 | 응답 타입 |
이커머스에서 SSOT 위반 시
핵심 원칙
"같은 데이터는 한 곳에서만 정의하고, 나머지는 그것을 참조한다"
이 원칙을 지키면 데이터 불일치 버그를 원천 차단할 수 있습니다.
Route Handlers는 app/api 디렉토리에서 API 엔드포인트를 생성합니다. Next.js App Router에서 RESTful API를 구현하는 표준 방법입니다.
// app/api/products/route.ts
// GET /api/products
export async function GET() {
const products = await db.product.findMany();
return Response.json(products);
}
export async function POST(request: Request) {
const data = await request.json();
const product = await db.product.create({ data });
return Response.json(product, { status: 201 });
}// app/api/products/[id]/route.ts
// GET /api/products/123
type Props = {
params: { id: string };
};
export async function GET(request: Request, { params }: Props) {
const product = await db.product.findUnique({
where: { id: params.id }
});
if (!product) {
return Response.json(
{ error: 'Product not found' },
{ status: 404 }
);
}
return Response.json(product);
}
export async function PUT(request: Request, { params }: Props) {
const data = await request.json();
const product = await db.product.update({
where: { id: params.id },
data,
});
return Response.json(product);
}
export async function DELETE(request: Request, { params }: Props) {
await db.product.delete({
where: { id: params.id }
});
return new Response(null, { status: 204 });
}// Query Parameters
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const page = searchParams.get('page') || '1';
// /api/products?category=electronics&page=2
}
// Headers
export async function GET(request: Request) {
const authHeader = request.headers.get('authorization');
}
// Cookies
import { cookies } from 'next/headers';
export async function GET() {
const cookieStore = cookies();
const token = cookieStore.get('token');
}Route Handlers 사용 시기
Server Actions는 서버에서 실행되는 비동기 함수로, 폼 제출과 데이터 뮤테이션을 간단하게 처리합니다. API 엔드포인트 없이 서버 로직을 직접 호출할 수 있습니다.
// app/actions/cart.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function addToCart(productId: string) {
// 서버에서 실행됨
await db.cart.create({
data: { productId, userId: getCurrentUserId() }
});
// 캐시 무효화
revalidatePath('/cart');
}
// 컴포넌트에서 사용
import { addToCart } from '@/app/actions/cart';
export function AddToCartButton({ productId }: { productId: string }) {
return (
<form action={addToCart.bind(null, productId)}>
<button type="submit">장바구니 담기</button>
</form>
);
}// app/actions/product.ts
'use server';
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string;
const price = Number(formData.get('price'));
const description = formData.get('description') as string;
await db.product.create({
data: { name, price, description }
});
revalidatePath('/products');
}
// 폼에서 사용
<form action={createProduct}>
<input name="name" placeholder="상품명" />
<input name="price" type="number" placeholder="가격" />
<textarea name="description" placeholder="설명" />
<button type="submit">등록</button>
</form>'use client';
import { useFormState } from 'react-dom';
import { createProduct } from '@/app/actions/product';
const initialState = { message: '', errors: {} };
export function ProductForm() {
const [state, formAction] = useFormState(createProduct, initialState);
return (
<form action={formAction}>
<input name="name" />
{state.errors?.name && (
<p className="text-red-500">{state.errors.name}</p>
)}
<button type="submit">등록</button>
{state.message && <p>{state.message}</p>}
</form>
);
}Server Actions vs Route Handlers
일관성 있고 유지보수하기 쉬운 API를 설계하는 패턴을 학습합니다. RESTful 원칙을 따르면 예측 가능하고 직관적인 API를 만들 수 있습니다.
| 메서드 | 경로 | 동작 |
|---|---|---|
| GET | /api/products | 목록 조회 |
| GET | /api/products/:id | 단일 조회 |
| POST | /api/products | 생성 |
| PUT | /api/products/:id | 전체 수정 |
| DELETE | /api/products/:id | 삭제 |
// lib/api/response.ts
export function successResponse<T>(data: T, status = 200) {
return Response.json({ success: true, data }, { status });
}
export function errorResponse(message: string, status = 400) {
return Response.json({ success: false, error: message }, { status });
}
// 사용 예
export async function GET() {
try {
const products = await db.product.findMany();
return successResponse(products);
} catch (error) {
return errorResponse('Failed to fetch products', 500);
}
}
// 응답 형식
// 성공: { success: true, data: [...] }
// 실패: { success: false, error: "message" }// lib/api/errors.ts
export class ApiError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
}
}
export function handleApiError(error: unknown) {
if (error instanceof ApiError) {
return errorResponse(error.message, error.statusCode);
}
console.error('Unexpected error:', error);
return errorResponse('Internal server error', 500);
}
// 사용
export async function GET(request: Request, { params }: Props) {
try {
const product = await db.product.findUnique({
where: { id: params.id }
});
if (!product) {
throw new ApiError(404, 'Product not found');
}
return successResponse(product);
} catch (error) {
return handleApiError(error);
}
}API 설계 원칙
Zod를 사용하여 서버와 클라이언트에서 동일한 스키마로 타입 안전성을 확보합니다. 이것이 SSOT의 핵심 구현 방법입니다.
// lib/schemas/product.ts
import { z } from 'zod';
// 스키마 정의 (SSOT)
export const productSchema = z.object({
name: z.string().min(1, '상품명을 입력하세요'),
price: z.number().min(0, '가격은 0 이상이어야 합니다'),
description: z.string().optional(),
category: z.string(),
});
// 타입 추론
export type ProductInput = z.infer<typeof productSchema>;
// 응답 스키마
export const productResponseSchema = productSchema.extend({
id: z.string(),
createdAt: z.date(),
});
export type Product = z.infer<typeof productResponseSchema>;// app/api/products/route.ts
import { productSchema } from '@/lib/schemas/product';
export async function POST(request: Request) {
const body = await request.json();
// Zod로 검증
const result = productSchema.safeParse(body);
if (!result.success) {
return Response.json({
success: false,
errors: result.error.flatten().fieldErrors,
}, { status: 400 });
}
// result.data는 타입이 보장됨
const product = await db.product.create({
data: result.data,
});
return Response.json({ success: true, data: product });
}// components/ProductForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { productSchema, ProductInput } from '@/lib/schemas/product';
export function ProductForm() {
const form = useForm<ProductInput>({
resolver: zodResolver(productSchema), // 같은 스키마!
});
const onSubmit = async (data: ProductInput) => {
// data는 이미 검증됨
await fetch('/api/products', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register('name')} />
{form.formState.errors.name && (
<p>{form.formState.errors.name.message}</p>
)}
{/* ... */}
</form>
);
}SSOT 달성
Route Handlers와 Server Actions를 조합한 장바구니 기능을 구현합니다. SSOT 원칙을 적용하여 타입 안전한 API를 만듭니다.
// lib/schemas/cart.ts
import { z } from 'zod';
export const addToCartSchema = z.object({
productId: z.string(),
quantity: z.number().min(1).max(99),
});
export type AddToCartInput = z.infer<typeof addToCartSchema>;
export const cartItemSchema = z.object({
id: z.string(),
productId: z.string(),
quantity: z.number(),
product: z.object({
name: z.string(),
price: z.number(),
image: z.string(),
}),
});
export type CartItem = z.infer<typeof cartItemSchema>;// app/actions/cart.ts
'use server';
import { revalidatePath } from 'next/cache';
import { addToCartSchema } from '@/lib/schemas/cart';
export async function addToCart(input: unknown) {
const result = addToCartSchema.safeParse(input);
if (!result.success) {
return { success: false, errors: result.error.flatten() };
}
const { productId, quantity } = result.data;
await db.cartItem.upsert({
where: { productId_userId: { productId, userId: getCurrentUserId() } },
update: { quantity: { increment: quantity } },
create: { productId, quantity, userId: getCurrentUserId() },
});
revalidatePath('/cart');
return { success: true };
}
export async function removeFromCart(itemId: string) {
await db.cartItem.delete({ where: { id: itemId } });
revalidatePath('/cart');
}// components/AddToCartButton.tsx
'use client';
import { useState } from 'react';
import { addToCart } from '@/app/actions/cart';
export function AddToCartButton({ productId }: { productId: string }) {
const [quantity, setQuantity] = useState(1);
const [isPending, setIsPending] = useState(false);
const handleClick = async () => {
setIsPending(true);
const result = await addToCart({ productId, quantity });
setIsPending(false);
if (result.success) {
alert('장바구니에 추가되었습니다!');
}
};
return (
<div className="flex gap-2">
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
max={99}
className="w-16 border rounded px-2"
/>
<button
onClick={handleClick}
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
{isPending ? '추가 중...' : '장바구니 담기'}
</button>
</div>
);
}핵심 포인트