Next.js 이론 7강

API 설계와 SSOT

Route Handlers와 Server Actions를 활용한 API 설계, 그리고 Single Source of Truth 원칙을 학습합니다.

1. SSOT 원칙 이해

SSOT(Single Source of Truth)는 데이터의 단일 진실 공급원을 유지하는 원칙입니다. 모든 데이터는 하나의 출처에서만 관리되어야 합니다. 이커머스에서 상품 정보, 가격, 재고 등이 여러 곳에서 다르면 큰 문제가 됩니다. 이 강의에서는 SSOT를 실현하는 구체적인 방법을 학습합니다.

SSOT가 중요한 이유

❌ SSOT 없이
  • • 여러 곳에서 같은 데이터 정의
  • • 데이터 불일치 발생
  • • 수정 시 여러 곳 변경 필요
  • • 버그 추적 어려움
✅ SSOT 적용
  • • 하나의 출처에서 데이터 관리
  • • 항상 일관된 데이터
  • • 한 곳만 수정하면 됨
  • • 유지보수 용이

프론트엔드에서의 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';

SSOT 적용 영역

영역위치예시
타입 정의types/Product, Order
유효성 검증schemas/Zod 스키마
설정값config/상수, 환경변수
API 응답서버 정의응답 타입

이커머스에서 SSOT 위반 시

  • • 상품 가격이 페이지마다 다르게 표시
  • • 재고 정보 불일치로 주문 실패
  • • 고객 불만 및 신뢰도 하락

핵심 원칙

"같은 데이터는 한 곳에서만 정의하고, 나머지는 그것을 참조한다"

이 원칙을 지키면 데이터 불일치 버그를 원천 차단할 수 있습니다.

2. Route Handlers

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 사용 시기

  • • 외부 서비스에서 호출하는 API
  • • Webhook 엔드포인트 (결제 알림 등)
  • • 클라이언트에서 직접 호출하는 API
  • • 이커머스: 장바구니, 주문, 결제 API

3. Server Actions

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>

useFormState로 상태 관리

'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

  • Server Actions: 폼 제출, 간단한 뮤테이션
  • Route Handlers: 복잡한 API, 외부 호출
  • • 이커머스: 장바구니 추가는 Server Actions, 결제는 Route Handlers

4. API 설계 패턴

일관성 있고 유지보수하기 쉬운 API를 설계하는 패턴을 학습합니다. RESTful 원칙을 따르면 예측 가능하고 직관적인 API를 만들 수 있습니다.

RESTful 원칙

메서드경로동작
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 설계 원칙

  • • 일관된 URL 구조 (복수형 명사 사용)
  • • 표준 HTTP 상태 코드 사용
  • • 명확한 에러 메시지
  • • 버전 관리 (/api/v1/products)

5. 타입 안전한 API

Zod를 사용하여 서버와 클라이언트에서 동일한 스키마로 타입 안전성을 확보합니다. 이것이 SSOT의 핵심 구현 방법입니다.

Zod 스키마 정의

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

  • • Zod 스키마 하나로 타입 정의
  • • 서버 검증, 클라이언트 검증 모두 처리
  • • 스키마 변경 시 한 곳만 수정
  • • 타입 불일치 버그 원천 차단

6. 이커머스 예제: 장바구니 API

Route Handlers와 Server Actions를 조합한 장바구니 기능을 구현합니다. SSOT 원칙을 적용하여 타입 안전한 API를 만듭니다.

스키마 정의 (SSOT)

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

Server Action

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

구현 요약

Zod 스키마로 SSOT 달성
Server Actions로 간단한 뮤테이션
revalidatePath로 캐시 무효화
타입 안전한 클라이언트 코드

핵심 포인트

  • • 스키마를 한 곳에서 정의하고 공유 (SSOT)
  • • Server Actions로 API 없이 뮤테이션
  • • 자동 캐시 무효화로 UI 동기화
  • • 타입 안전성으로 런타임 에러 방지