본문으로 건너뛰기

안동민 개발노트

본문 시작

Route Handler 생성

App Router의 route.ts에서 GET·POST 핸들러를 만들고 동적 세그먼트로 사용자 API 엔드포인트를 구성합니다.

웹 애플리케이션은 클라이언트와 데이터를 주고받기 위해 서버 측 API가 필요합니다.

Next.js는 프론트엔드 렌더링뿐 아니라, App Router의 Route Handler로 HTTP 엔드포인트를 만들 수 있습니다.

덕분에 별도 백엔드를 따로 띄우지 않고도 프로젝트 내부에서 API 엔드포인트를 직접 만들고 관리할 수 있습니다.

이 절에서는 Route Handler가 필요한 경계와 GET·POST 요청을 처리하는 방법을 알아봅니다.

먼저 파일 경로, URL, HTTP 메서드 핸들러가 어떻게 하나의 API 엔드포인트로 연결되는지 보겠습니다.

API 라우트는 파일 경로를 HTTP 엔드포인트로 바꾼다

App Router의 `app/api/**/route.ts`는 요청을 받아 서버에서 처리하고 Response를 돌려준다.

  1. 1
    핵심 1

    API 라우트는 파일 경로를 HTTP 엔드포인트로 바꾼다

  2. 2
    핵심 2

    API 라우트는 파일 경로를 HTTP 엔드포인트로 바꾼다

  3. 3
    핵심 3

    App Router의 `app/api/**/route.ts`는 요청을 받아 서버에서 처리하고 Response를 돌려준다.

  4. 4
    핵심 4

    File app/api/users/route.ts 서버 코드 위치 URL /api/users 클라이언트 호출 경로 Method GET/POST 함수 HTTP 동작…


Route Handler란 무엇인가요?

Route Handler는 App Router의 app 디렉터리 아래에 route.ts 파일로 만드는 HTTP 엔드포인트입니다.

Pages Router의 pages/apiAPI Routes라는 별도 규칙이며 이 절에서는 다루지 않습니다.

각 파일은 서버리스 함수로 취급되며, 해당 파일 경로가 API 엔드포인트의 URL이 됩니다.

주요 특징
  • 파일 시스템 기반 라우팅: 파일 이름과 디렉토리 구조가 API 엔드포인트의 URL 경로에 직접 매핑됩니다. (예: app/api/users/route.ts/api/users 경로로 접근 가능)
  • 서버 실행: Route Handler는 배포 플랫폼의 서버 함수나 자체 호스팅 Node.js 프로세스에서 실행됩니다.
  • Node.js 환경: 기본 Node.js 런타임에서는 데이터베이스 연결과 외부 API 호출 같은 서버 작업을 수행할 수 있습니다.
  • HTTP 메서드 지원: GET, POST, PUT, DELETE 등 다양한 HTTP 메서드에 대한 핸들러를 정의할 수 있습니다.
  • Request 및 Response 객체: Express.js와 유사하게 요청(NextRequest) 객체와 응답(NextResponse) 객체를 통해 HTTP 요청 및 응답을 처리합니다.

왜 Route Handler가 필요한가요?

Route Handler는 HTTP 자체가 필요한 경계를 애플리케이션 안에 만들 때 사용합니다.

  • 풀스택 개발 용이성: 프론트엔드와 백엔드 로직을 하나의 Next.js 프로젝트 내에서 관리할 수 있어 개발 과정을 간소화하고 생산성을 높입니다.
  • 빠른 프로토타이핑: 별도의 백엔드 서버 설정 없이 빠르게 API를 만들고 테스트할 수 있습니다.
  • 외부 클라이언트 연동: 모바일 앱, 웹훅과 타 서비스가 호출할 안정적인 HTTP 계약을 제공할 수 있습니다.
  • 서버 컴포넌트와 역할 분리: 같은 애플리케이션의 서버 컴포넌트는 불필요하게 자기 Route Handler를 호출하지 않고 데이터 계층을 직접 사용합니다.
  • 인증 및 보안: Auth.js의 auth()와 연동하여 보호된 API 엔드포인트를 구축할 수 있습니다.
  • 쉬운 배포: Next.js 프로젝트와 함께 Route Handler도 배포 대상에 포함됩니다.

Route Handler 생성하기

app 디렉터리 안에 API 경로를 만들고 그 끝에 route.ts를 둡니다.

기본 구조
route.ts # GET, POST 등 메서드 핸들러 정의
route.ts # 동적 라우트 예시 (e.g., /api/posts/123)
page.tsx
page.tsx

route.ts 또는 route.js 파일은 해당 경로의 API 요청을 처리합니다.

파일 내에서는 GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS와 같은 HTTP 메서드 이름의 함수를 export하여 해당 메서드 요청을 처리합니다.

GET Route Handler 생성

GET 요청은 주로 데이터를 조회할 때 사용됩니다.

실습: 사용자 목록을 반환하는 GET Route Handler
src/lib/users-repository.ts 파일 생성

목록 경로와 상세 경로가 같은 학습용 데이터를 보도록 저장소 모듈을 한 곳에 둡니다.

src/lib/users-repository.ts
export interface User {
  id: number;
  name: string;
  email: string;
}

export type UserInput = Omit<User, 'id'>;

let users: User[] = [
  { id: 1, name: '김철수', email: 'chulsoo@example.com' },
  { id: 2, name: '이영희', email: 'younghee@example.com' },
  { id: 3, name: '박민수', email: 'minsu@example.com' },
];
let nextId = 4;

export function parseUserId(value: string): number | null {
  if (!/^\d+$/.test(value)) return null;
  const id = Number(value);
  return Number.isSafeInteger(id) && id > 0 ? id : null;
}

export function parseUserInput(value: unknown): UserInput | null {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;

  const { name, email } = value as Record<string, unknown>;
  if (typeof name !== 'string' || typeof email !== 'string') return null;

  const normalizedName = name.trim();
  const normalizedEmail = email.trim().toLowerCase();
  if (!normalizedName || normalizedName.length > 50) return null;
  if (normalizedEmail.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) return null;

  return { name: normalizedName, email: normalizedEmail };
}

export function listUsers(search: string | null): User[] {
  return search ? users.filter((user) => user.name.includes(search)) : users;
}

export function findUser(id: number): User | undefined {
  return users.find((user) => user.id === id);
}

export function createUser(input: UserInput): User {
  const user = { id: nextId++, ...input };
  users = [...users, user];
  return user;
}

export function updateUser(id: number, input: UserInput): User | null {
  const index = users.findIndex((user) => user.id === id);
  if (index === -1) return null;

  const user = { id, ...input };
  users = users.map((current) => current.id === id ? user : current);
  return user;
}

export function deleteUser(id: number): boolean {
  const nextUsers = users.filter((user) => user.id !== id);
  if (nextUsers.length === users.length) return false;
  users = nextUsers;
  return true;
}

이 배열은 한 Node.js 프로세스 안에서 Route Handler의 데이터 흐름을 연습하기 위한 대역입니다.

프로세스 재시작이나 서버리스 인스턴스 분리 뒤에는 값이 유지되지 않으므로 운영 코드에서는 데이터베이스를 사용합니다.

src/app/api/users/route.ts 파일 생성
src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createUser, listUsers, parseUserInput } from '@/lib/users-repository';

/**
 * GET 요청을 처리하는 핸들러 함수
  * @param request NextRequest 객체 (선택 사항, 쿼리 파라미터 등 접근 시 사용)
  */
export async function GET(request: NextRequest) {
  // 쿼리 파라미터 예시: /api/users?search=김
  const searchParam = request.nextUrl.searchParams.get('search');

  return NextResponse.json(listUsers(searchParam), { status: 200 });
}

API 테스트: 개발 서버(npm run dev)를 실행한 후, 웹 브라우저나 Postman/Insomnia 같은 API 클라이언트에서 http://localhost:3000/api/users 경로로 접속합니다.

  • http://localhost:3000/api/users 로 접속하면 전체 사용자 목록이 JSON 형태로 반환됩니다.
  • http://localhost:3000/api/users?search=김 으로 접속하면 '김'이 포함된 사용자만 필터링되어 반환됩니다.

POST Route Handler 생성

POST 요청은 주로 새로운 데이터를 생성하거나 서버로 데이터를 전송할 때 사용됩니다.

실습: 새로운 사용자 정보를 추가하는 POST Route Handler

src/app/api/users/route.ts 파일에 POST 핸들러 추가: GET 핸들러와 같은 파일에 추가합니다.

하나의 route.ts 파일은 여러 HTTP 메서드 핸들러를 포함할 수 있습니다.

src/app/api/users/route.ts (GET 핸들러 아래에 추가)
export async function POST(request: NextRequest) {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ message: '올바른 JSON 본문이 필요합니다.' }, { status: 400 });
  }

  const input = parseUserInput(body);
  if (!input) {
    return NextResponse.json(
      { message: '이름과 이메일 형식을 확인해 주세요.' },
      { status: 400 },
    );
  }

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

API 테스트: Postman, Insomnia 또는 curl 명령어를 사용하여 POST 요청을 보냅니다.

curl 예시
curl -X POST \
  http://localhost:3000/api/users \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "새로운 사용자",
    "email": "newuser@example.com"
  }'

요청이 성공하면, 새로 생성된 사용자 정보와 함께 HTTP 상태 코드 201(Created)이 반환됩니다.

이후 http://localhost:3000/api/users로 GET 요청을 다시 보내면, 새로 추가된 사용자가 목록에 포함된 것을 확인할 수 있습니다.

동적 Route Handler 생성

동적 Route Handler는 URL의 일부를 파라미터로 받아 특정 리소스의 CRUD 요청을 나눕니다.

동적 세그먼트는 자원을, HTTP 메서드는 작업을 고른다

같은 /api/posts/[id] 경로에서도 메서드가 조회·수정·삭제 계약을 분리한다.

  1. GET
    단건 조회

    parseUserId로 양의 안전한 정수만 받은 뒤 200 또는 404 반환

  2. POST
    컬렉션 생성

    unknown body를 parser로 UserInput DTO로 좁힌 뒤 201 반환

  3. PATCH
    부분 수정

    허용 필드만 갱신하고 검증 실패를 400으로 구분

  4. DELETE
    단건 삭제

    대상을 확인해 삭제하고 204 또는 404 반환

특정 ID의 사용자 조회·수정·삭제처럼 리소스를 식별해야 할 때 동적 세그먼트를 사용합니다.

파일 또는 폴더 이름을 대괄호([])로 감싸서 동적 파라미터를 정의합니다. ([slug] 또는 [id])

실습: 특정 사용자 조회·수정·삭제 Route Handler
src/app/api/users/[id]/route.ts 파일 생성
src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import {
  deleteUser,
  findUser,
  parseUserId,
  parseUserInput,
  updateUser,
} from '@/lib/users-repository';

// 라우트 파라미터 타입 정의
interface Context {
  params: Promise<{ id: string }>;
}

/**
 * 특정 사용자 조회 (GET /api/users/[id])
  * @param request NextRequest 객체
  * @param context 동적 라우트 파라미터 (params.id)
  */
export async function GET(_request: NextRequest, context: Context) {
  const { id: idParam } = await context.params;
  const id = parseUserId(idParam);

  if (id === null) {
    return NextResponse.json({ message: '올바른 사용자 ID가 필요합니다.' }, { status: 400 });
  }
  const user = findUser(id);

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

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

/**
 * 특정 사용자 업데이트 (PUT /api/users/[id])
  * @param request NextRequest 객체
  * @param context 동적 라우트 파라미터 (params.id)
  */
export async function PUT(request: NextRequest, context: Context) {
  const { id: idParam } = await context.params;
  const id = parseUserId(idParam);

  if (id === null) {
    return NextResponse.json({ message: '올바른 사용자 ID가 필요합니다.' }, { status: 400 });
  }

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json({ message: '올바른 JSON 본문이 필요합니다.' }, { status: 400 });
  }

  const input = parseUserInput(body);
  if (!input) {
    return NextResponse.json({ message: '이름과 이메일 형식을 확인해 주세요.' }, { status: 400 });
  }

  const user = updateUser(id, input);
  if (!user) {
    return NextResponse.json({ message: '사용자를 찾을 수 없습니다.' }, { status: 404 });
  }

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

/**
 * 특정 사용자 삭제 (DELETE /api/users/[id])
  * @param request NextRequest 객체
  * @param context 동적 라우트 파라미터 (params.id)
  */
export async function DELETE(_request: NextRequest, context: Context) {
  const { id: idParam } = await context.params;
  const id = parseUserId(idParam);

  if (id === null) {
    return NextResponse.json({ message: '올바른 사용자 ID가 필요합니다.' }, { status: 400 });
  }

  if (!deleteUser(id)) {
    return NextResponse.json({ message: '사용자를 찾을 수 없습니다.' }, { status: 404 });
  }

  return NextResponse.json({ message: '사용자가 성공적으로 삭제되었습니다.' }, { status: 200 });
}
API 테스트
  • GET: http://localhost:3000/api/users/1 로 접속하여 ID가 1인 사용자 정보를 조회합니다. 존재하지 않는 ID(예: /api/users/99)로 접속하면 404 응답을 받습니다.
  • PUT: Postman 등으로 http://localhost:3000/api/users/1에 PUT 요청을 보내고 본문에 { "name": "김철수(수정됨)", "email": "chulsoo@example.com" }처럼 전체 사용자 필드를 포함합니다.
  • DELETE: Postman 등으로 http://localhost:3000/api/users/2 에 DELETE 요청을 보내 ID가 2인 사용자를 삭제합니다.

GET, POST와 동적 경로를 만들었다면 Route Handler가 운영 코드로 바뀔 때 필요한 설계 기준을 점검해야 합니다.

API Route는 세 칸의 계약으로 설계한다

URL은 자원을 드러내고, 핸들러는 메서드별 책임을 나누며, 응답은 프론트가 해석할 상태와 본문을 고정한다.

  1. 1. Path

    /api/orders/[id] 명사형 자원과 동적 세그먼트만으로 요청 대상을 표현한다.

  2. 2. Handler

    GET 조회 전용, body 없음 PATCH 허용 필드만 부분 수정 DELETE 대상 확인 뒤 삭제

  3. 3. Response

    성공과 실패의 JSON 모양을 라우트마다 흔들리지 않게 맞춘다. { ok, data, error }

상황상태본문 기준
정상 처리200 / 201 / 204필요한 데이터만 반환하고 내부 필드는 숨긴다.
입력 오류400 / 422어느 필드가 왜 실패했는지 짧게 알려준다.
대상 없음404없는 자원과 권한 부족을 섞어 설명하지 않는다.

Route Handler 사용 시 고려사항

  • 데이터베이스 연동: 위 예시는 간단한 배열을 사용하여 데이터를 관리했지만, 실제 애플리케이션에서는 MongoDB, PostgreSQL, MySQL 등 데이터베이스와 연동하여 데이터를 영구적으로 저장하고 관리해야 합니다. Prisma, Drizzle ORM 등을 사용하여 데이터베이스 작업을 추상화할 수 있습니다.
  • 오류 처리: Route Handler는 적절한 HTTP 상태 코드와 일관된 오류 응답을 반환해야 합니다. 예상 가능한 업무 오류와 예상하지 못한 서버 오류를 구분합니다.
  • 데이터 유효성 검사: 클라이언트로부터 받은 데이터는 항상 유효성 검사를 수행해야 합니다. Zod, Joi 같은 라이브러리를 사용하여 스키마 기반 유효성 검사를 적용할 수 있습니다.
  • 인증 및 권한 부여: 보호된 리소스의 라우트 핸들러는 Auth.js의 auth()로 요청을 감싸 로그인 상태와 역할을 확인해야 합니다.
  • 파일 크기: 서버리스 함수는 일반적으로 실행 시간과 번들 크기에 제한이 있습니다. 불필요한 의존성을 줄여 최적화하는 것이 좋습니다.
  • Streamable Response: Next.js 13 이상에서는 NextResponse.json() 외에도 Response 객체를 직접 반환하여 스트리밍 응답을 구현할 수도 있습니다.
  • CORS (Cross-Origin Resource Sharing): 다른 출처의 클라이언트가 Route Handler에 접근해야 한다면 허용 출처와 메서드를 좁게 설정합니다.

운영 Route Handler는 데이터 접근, 오류 처리, 검증과 인증을 하나의 요청 경계로 점검합니다.

개발용 API를 운영 API로 바꿀 때 보는 신호

동작 여부가 아니라 장애와 남용을 버틸 수 있는지를 위험 신호와 대응으로 확인한다.

  1. body가 그대로 DB로 감

    검증 없이 create/update 실행

  2. schema

    필수값, 타입, 길이 검사

  3. 요청 초입에서 차단

    핵심 로직 전에 400/422 반환

  4. 400

    클라이언트 수정 필요

  5. 권한 없는 id 접근

    다른 사용자 자원 조회 가능

  6. auth + owner

    세션과 자원 소유자 비교

  7. 조회 조건에 owner 포함

    권한 검사를 데이터 접근과 묶음

  8. 401 / 403

    로그인과 권한 부족 분리

  9. 외부 API가 오래 멈춤

    응답 지연이 요청 전체를 잡음

  10. timeout

    호출 시간과 실패율 기록

  11. 짧은 제한과 대체 응답

    재시도 횟수를 제한하고 캐시 고려

  12. 502 / 504

    제공자 장애로 변환

  13. 민감값이 로그에 남음

    토큰, 키, 원문 body 노출

  14. logger

    request id 중심으로 추적

  15. 마스킹 후 저장

    필요한 원인만 남기고 값은 제거

  16. 500

    내부 상세 숨김

아래 다이어그램은 route.ts의 요청 검증, 작업 실행과 응답 계약을 정리합니다.

API 라우트는 표면·입력·작업·출력 네 경계로 흐른다

각 경계가 한 책임만 맡으면 route 파일이 커져도 검증과 오류 계약이 섞이지 않는다.

  1. URL
    Resource surface

    명사 경로와 동적 id로 대상을 고정

  2. Gate
    Input validation

    params id와 unknown body를 parser로 검증해 DTO로 좁힘

  3. Policy
    Server work

    권한 뒤 service·DB·외부 API 실행

  4. Contract
    HTTP response

    상태 코드와 JSON 오류 모양으로 결과 고정

아래 다이어그램은 route.ts, GET·POST 함수와 동적 세그먼트가 URL에 연결되는 방식을 보여줍니다.

API Route는 HTTP·업무·입출력 세 층으로 나눈다

라우트 파일을 얇게 유지하면 전송 형식, 업무 규칙, 외부 기술이 서로의 변경을 끌고 다니지 않는다.

  1. HTTP
    Handler

    method·session·schema를 해석하고 결과를 status로 변환

  2. DTO
    Validated input

    parseUserInput을 통과한 UserInput DTO와 검증된 id만 전달

  3. policy
    Service

    중복 확인·상태 전이·트랜잭션 같은 업무 규칙

  4. I/O
    Adapter

    DB·파일·외부 API 세부사항을 포트 뒤에 숨김

Route Handler는 같은 프로젝트 안에서 HTTP 엔드포인트와 화면 코드를 함께 관리하게 합니다.

이 기능을 통해 백엔드 로직을 라우트 단위로 구현하고 프론트엔드와 데이터 계약을 맞출 수 있습니다.

아래 다이어그램은 Route Handler의 요청 진입점, 응답 형태와 오류 처리를 함께 확인합니다.

Route Handler는 Web API 입력을 서버 계약으로 번역한다

Request를 한 번 해석하고 검증된 값만 서버 작업으로 보낸 뒤 명시적 Response로 끝낸다.

  1. Request
    URL · body · cookie

    params id는 전용 parser로, JSON body는 unknown으로 한 번 읽음

  2. Validate
    형식·권한

    parser가 만든 DTO만 통과시키고 잘못된 ID·본문은 400 반환

  3. Execute
    DB · cache · API

    검증된 서버 값으로 실제 작업 수행

  4. Response
    JSON · redirect

    status·header·cookie를 포함한 결과로 경계 종료