Route Handlers: Next.js에서 API 작성하기

1. 개요

Next.js의 App Router에서는 별도의 Express 서버를 구성할 필요 없이 API를 가질 수 있습니다. Route Handlers를 사용하면 app 폴더 내에서 HTTP 엔드포인트를 바로 작성할 수 있으며, 표준 Web API인 Request와 Response를 사용합니다.

이 글에서는 간단한 주문(Order) 관리 API를 구축합니다. HTTP 메서드 정의, 쿼리 스트링 및 body 읽기, 동적 세그먼트 처리, 헤더/쿠키 작업, 캐싱 제어, Vitest로 테스트 작성하는 방법을 배웁니다. 예제는 Next.js 15+ 이상 TypeScript를 사용합니다.

2. 첫 번째 Route Handler

Route Handler는 app 내에 있는 route.ts (또는 route.js)라는 파일입니다. 폴더 경로가 URL을 결정합니다. 각 HTTP 메서드는 대문자 이름으로 export된 함수입니다.

먼저 간단한 메모리 내 “저장소”가 필요합니다:

// lib/orders.ts
export type Order = {
  id: string;
  customer: string;
  total: number;
};

const orders: Order[] = [
  { id: "1", customer: "An", total: 250_000 },
  { id: "2", customer: "Bình", total: 1_200_000 },
];

export const orderStore = {
  findAll: () => [...orders],
  findById: (id: string) => orders.find((o) => o.id === id),
  create: (data: Omit<Order, "id">): Order => {
    const order = { id: String(orders.length + 1), ...data };
    orders.push(order);
    return order;
  },
  remove: (id: string) => {
    const index = orders.findIndex((o) => o.id === id);
    if (index === -1) return false;
    orders.splice(index, 1);
    return true;
  },
};

이제 GET /api/orders 엔드포인트를 만들어봅시다:

// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";

export async function GET() {
  return Response.json(orderStore.findAll());
}

설명:

  • 파일이 app/api/orders/route.ts에 있으므로 URL은 /api/orders입니다. api 접두사는 단지 관례일 뿐입니다. Next.js가 강제하지는 않습니다.
  • export async function GET()은 GET 요청을 처리합니다. Next.js는 export하지 않은 메서드에 대해 자동으로 405 Method Not Allowed를 반환합니다.
  • Response.json(...)은 표준 Web API입니다. 데이터를 직렬화하고 Content-Type: application/json 헤더를 설정합니다.

npm run dev를 실행한 후 시도해보세요:

curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]

주의: 같은 폴더에 route.ts와 page.tsx를 함께 가질 수 없습니다. 예를 들어 app/orders/page.tsx와 app/orders/route.ts는 충돌합니다. 이것이 API를 보통 app/api에 모으는 이유입니다.

3. 쿼리 스트링 및 body 읽기

3.1. 쿼리 스트링

핸들러의 매개변수는 표준 Request입니다. 쿼리 스트링을 읽으려면 request.url에서 URL을 생성합니다:

// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const minTotal = Number(searchParams.get("minTotal") ?? 0);

  const result = orderStore.findAll().filter((o) => o.total >= minTotal);
  return Response.json(result);
}

/api/orders?minTotal=500000을 호출하면 Bình의 주문만 반환됩니다:

[{"id":"2","customer":"Bình","total":1200000}]

원한다면 매개변수를 NextRequest(next/server에서 가져옴)로 선언하고 request.nextUrl.searchParams를 사용할 수 있습니다. 여기서는 테스트에서 생성하기 더 쉽기 때문에 표준 Request를 사용합니다.

3.2. JSON body 및 유효성 검사

클라이언트가 보낸 데이터를 신뢰하지 마세요. zod를 사용하여 body를 검증합니다. 라이브러리를 설치하세요:

npm install zod

같은 파일에 POST 핸들러를 추가합니다:

// app/api/orders/route.ts (추가 사항)
import { z } from "zod";

const CreateOrderSchema = z.object({
  customer: z.string().min(1),
  total: z.number().positive(),
});

export async function POST(request: Request) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "Body가 유효한 JSON이 아닙니다" }, { status: 400 });
  }

  const parsed = CreateOrderSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { error: "데이터가 유효하지 않습니다", issues: parsed.error.issues },
      { status: 422 },
    );
  }

  const order = orderStore.create(parsed.data);
  return Response.json(order, {
    status: 201,
    headers: { Location: `/api/orders/${order.id}` },
  });
}

설명:

  • request.json()은 body가 비어있거나 구문이 잘못되면 throw합니다. 따라서 이를 try/catch로 감싸고 400을 반환합니다.
  • safeParse는 throw하지 않습니다. { success, data } 또는 { success, error }를 반환합니다. 타입이 잘못된 데이터는 422를 받습니다.
  • Response.json의 두 번째 매개변수로 status와 headers를 설정할 수 있습니다. 여기서는 Location 헤더와 함께 201 Created를 반환합니다.
curl -i -X POST http://localhost:3000/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json

{"id":"3","customer":"Chi","total":99000}

HTML 폼의 경우 await request.formData()를 사용하세요. 원본 데이터의 경우 await request.text()를 사용하세요. Body는 한 번만 읽을 수 있다는 점에 유의하세요.

4. 동적 세그먼트

GET /api/orders/:id를 만들려면 대괄호 안에 이름이 있는 폴더를 만듭니다:

// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";

type Context = { params: Promise<{ id: string }> };

export async function GET(_request: Request, { params }: Context) {
  const { id } = await params;
  const order = orderStore.findById(id);

  if (!order) {
    return Response.json({ error: `주문 ${id}을(를) 찾을 수 없습니다` }, { status: 404 });
  }
  return Response.json(order);
}

export async function DELETE(_request: Request, { params }: Context) {
  const { id } = await params;
  const removed = orderStore.remove(id);
  return new Response(null, { status: removed ? 204 : 404 });
}

설명:

  • 두 번째 매개변수에는 params가 포함됩니다. Next.js 15부터 params는 Promise이므로 이를 await해야 합니다.
  • DELETE는 body가 null인 204 No Content를 반환합니다. 204는 body를 가질 수 없기 때문에 이 경우 Response.json을 사용하면 안 됩니다.
  • params의 값은 항상 string입니다. ID가 숫자인 경우 직접 변환하고 검증해야 합니다.

catch-all [...slug] (받음 string[]) 또는 선택적 catch-all [[...slug]]도 사용할 수 있습니다.

주의: params.id를 await 없이 작성한 코드는 Next.js 15+에서 경고 또는 오류가 발생합니다. Next.js 14에서 업그레이드할 때 이것이 가장 일반적인 문제입니다.

5. 헤더, 쿠키 및 리다이렉트

request.headers에서 직접 헤더를 읽을 수 있습니다. 또한 Next.js는 next/headers에서 cookies() 및 headers() 함수를 제공합니다. 둘 다 async 함수입니다.

// app/api/me/route.ts
import { cookies, headers } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const headerList = await headers();

  const session = cookieStore.get("session")?.value;
  if (!session) {
    return Response.json({ error: "로그인되지 않았습니다" }, { status: 401 });
  }

  return Response.json({
    session,
    userAgent: headerList.get("user-agent"),
  });
}

export async function DELETE() {
  const cookieStore = await cookies();
  cookieStore.delete("session");
  return new Response(null, { status: 204 });
}
  • cookieStore.get(...)은 쿠키를 읽는 데 사용됩니다. set과 delete는 Route Handler 또는 Server Action에서만 작동하며, Server Component에서는 작동하지 않습니다.
  • cookies() 또는 headers()를 사용하면 경로가 동적이 되어 각 요청마다 렌더링됩니다.

리다이렉트가 필요한 경우 Response.redirect(new URL("/login", request.url), 307) 또는 next/navigation의 redirect() 함수를 사용합니다.

CORS의 경우 헤더를 직접 설정하고 preflight을 위해 OPTIONS를 추가로 export합니다:

// app/api/public/route.ts
const corsHeaders = {
  "Access-Control-Allow-Origin": "https://shop.example.com",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
};

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: corsHeaders });
}

export async function GET() {
  return Response.json({ status: "ok" }, { headers: corsHeaders });
}

6. 캐싱

이것이 많은 사람들이 혼동하는 부분입니다. Next.js 15부터 GET 핸들러는 더 이상 기본적으로 캐시되지 않습니다. 각 요청마다 함수를 다시 실행합니다. POST, PUT, DELETE 같은 다른 메서드는 절대 캐시되지 않습니다.

데이터가 거의 변경되지 않고 빌드할 때 미리 정적으로 만들고 싶다면 경로 세그먼트 구성으로 opt-in합니다:

// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // 최대 매시간 갱신

export async function GET() {
  const categories = ["전자제품", "책", "패션"];
  return Response.json({ categories, generatedAt: new Date().toISOString() });
}
  • dynamic = "force-static"은 경로를 빌드 시에 렌더링하도록 강제합니다. 그 후 결과는 정적 파일로 제공됩니다.
  • revalidate = 3600은 Next.js가 최대 매시간 응답을 다시 생성하도록 허용합니다 (ISR).
  • next dev 실행 중에는 경로가 여전히 각 요청에서 실행됩니다. generatedAt이 변하지 않는 것을 보려면 next build && next start가 필요합니다.

주의: force-static 경로가 cookies(), headers() 또는 쿼리 스트링을 읽으면 빌드 시 이러한 값이 비어있을 것입니다. 사용자에게 의존하는 엔드포인트에는 static을 사용하지 마세요.

7. Vitest를 사용한 Route Handler 테스트

Route Handler는 단지 Request를 받고 Response를 반환하는 async 함수일 뿐이므로 서버를 시작하지 않고도 직접 호출할 수 있습니다. Vitest를 설치합니다:

npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: { environment: "node" },
});

vite-tsconfig-paths 플러그인은 테스트에서 @/lib/... 별칭을 작동하게 합니다. 이제 테스트를 작성합니다:

// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";

function jsonRequest(body: unknown) {
  return new Request("http://localhost/api/orders", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

describe("/api/orders", () => {
  it("minTotal로 주문을 필터링합니다", async () => {
    const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
    const data = await res.json();

    expect(res.status).toBe(200);
    expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
  });

  it("유효한 주문을 생성하고 201을 반환합니다", async () => {
    const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));

    expect(res.status).toBe(201);
    expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
    expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
  });

  it("음수 total을 422로 거부합니다", async () => {
    const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
    expect(res.status).toBe(422);
  });
});
npx vitest run
 ✓ app/api/orders/route.test.ts (3 tests)
 Test Files  1 passed (1)
      Tests  3 passed (3)

핸들러가 next/headers의 cookies() 또는 headers()를 사용하면 Next.js 요청 컨텍스트가 없어 직접 호출하면 throw됩니다. 이 경우 두 가지 선택지가 있습니다: vi.mock("next/headers", ...)으로 모듈을 모킹하거나, request.headers에서 값을 읽어 코드를 더 쉽게 테스트합니다.

8. 흔한 오류 및 사용하지 않아야 할 때

몇 가지 흔한 오류:

  • Server Component에서 Route Handler 가져오기. Server Component 내에서 fetch("/api/orders")를 호출하면 불필요한 HTTP 루프가 생성됩니다. 빌드할 때 서버가 아직 실행되지 않았으므로 이 호출은 실패합니다. 대신 orderStore.findAll() 또는 데이터 쿼리 레이어를 직접 호출하세요.
  • 내부 폼 mutation에 Route Handler 사용하기. 앱의 UI만 호출하는 경우 Server Actions이 더 간단합니다. 이들은 end-to-end type-safe이고 revalidatePath와 기본 통합됩니다.
  • Serverless 배포 시 모듈 변수에 state 저장하기, 위의 orderStore 같은 경우. 각 인스턴스는 자체 메모리를 가지며 언제든 재시작될 수 있습니다. 이 글의 예제는 설명을 위한 것입니다. 실제로는 데이터베이스를 사용해야 합니다.
  • Body가 한 번만 읽을 수 있다는 것을 잊기. 두 번 읽어야 하는 경우 request.clone()을 사용하세요.
  • 구조화되지 않은 오류 반환하기. 포착되지 않은 throw는 HTML 오류 페이지와 함께 500이 됩니다. 항상 명확한 status가 있는 JSON을 반환하세요.

Route Handler는 다음이 필요한 경우에 적합합니다:

  • 외부 클라이언트(예: 모바일 앱 또는 파트너)를 위한 엔드포인트.
  • Stripe 또는 GitHub에서와 같은 데이터를 수신하는 webhook.
  • 파일, RSS 또는 스트리밍 데이터를 반환하는 엔드포인트.
  • 클라이언트 측 코드가 fetch로 호출하는 엔드포인트.

9. 결론

Route Handlers는 각 route.ts 파일을 HTTP 엔드포인트로 변환하며, Web 표준 Request/Response를 사용합니다. 이 글에서 다음을 배웠습니다:

  • GET, POST, DELETE 메서드 정의.
  • 쿼리 스트링 및 body 읽기, zod를 사용한 body 검증.
  • Promise 형태의 params를 사용한 동적 세그먼트 처리.
  • 헤더, 쿠키 및 CORS 작업.
  • 필요할 때 정적 캐싱 활성화.
  • 서버 없이 Vitest를 사용한 핸들러 직접 테스트.

기억해야 할 핵심 원칙: 공개 API 또는 webhook에 Route Handler를 사용합니다. 내부 데이터의 경우 Server Component에서 직접 쿼리하세요. 앱 자체의 폼 mutation의 경우 Server Actions를 사용하세요.

nextjsroute-handlersapitypescriptapp-router