Route Handler 생성
App Router의 route.ts에서 GET·POST 핸들러를 만들고 동적 세그먼트로 사용자 API 엔드포인트를 구성합니다.
웹 애플리케이션은 클라이언트와 데이터를 주고받기 위해 서버 측 API가 필요합니다.
Next.js는 프론트엔드 렌더링뿐 아니라, App Router의 Route Handler로 HTTP 엔드포인트를 만들 수 있습니다.
덕분에 별도 백엔드를 따로 띄우지 않고도 프로젝트 내부에서 API 엔드포인트를 직접 만들고 관리할 수 있습니다.
이 절에서는 Route Handler가 필요한 경계와 GET·POST 요청을 처리하는 방법을 알아봅니다.
Route Handler란 무엇인가요?
Route Handler는 App Router의 app 디렉터리 아래에 route.ts 파일로 만드는 HTTP 엔드포인트입니다.
Pages Router의 pages/api는 API Routes라는 별도 규칙이며 이 절에서는 다루지 않습니다.
파일 경로가 URL에 대응하며, 실행 형태는 배포 환경에 따라 서버 함수나 자체 호스팅 Node.js 프로세스가 됩니다.
주요 특징- 파일 시스템 기반 라우팅: 파일 이름과 디렉터리 구조가 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 객체: 웹 표준
Request와Response를 사용하며,NextRequest와NextResponse는 이를 확장합니다.
왜 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를 둡니다.
위 users/route.ts는 /api/users의 GET·POST 등을, posts/[id]/route.ts는 /api/posts/123 같은 동적 경로를 처리합니다.
각 route.ts 또는 route.js 파일은 해당 경로의 API 요청을 처리합니다. 같은 세그먼트의 page.tsx와 route.ts는 같은 경로를 차지하므로 함께 둘 수 없습니다.
URL·메서드·입력을 따로 읽기
GET /api/users?search=김과 POST /api/users는 같은 파일에서 서로 다른 함수를 선택합니다. ?search=김은 경로 폴더가 아니라 조회 입력이며, POST의 JSON 본문도 URL 경로와 별개입니다. 함수가 반환한 Response의 상태 코드와 본문이 클라이언트로 전달됩니다. 이 절의 params: Promise<...>와 await context.params는 현재 App Router 방식이며 Next.js 15부터 바뀐 계약입니다. Pages Router의 req.query 예제를 이 코드와 섞지 않습니다.
파일 내에서는 GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS와 같은 HTTP 메서드 이름의 함수를 export하여 해당 메서드 요청을 처리합니다.
GET Route Handler 생성
GET 요청은 주로 데이터를 조회할 때 사용됩니다.
실습: 사용자 목록을 반환하는 GET Route Handlersrc/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: '[email protected]' },
{ id: 2, name: '이영희', email: '[email protected]' },
{ id: 3, name: '박민수', email: '[email protected]' },
];
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;
}이 배열은 같은 모듈 인스턴스 안의 데이터 흐름을 연습하는 대역입니다. 영속 저장소가 아니므로 운영 데이터는 데이터베이스에 저장합니다.
검증 함수가 보장하는 범위
unknown은 요청 본문을 아직 믿을 수 없다는 타입 표현입니다. 실제 안전장치는 parseUserInput의 객체·문자열·길이·형식 검사입니다. 공백을 제거하고 이메일을 소문자로 만드는 것은 이 예제의 저장 정책입니다. 간단한 정규식을 통과해도 그 메일함의 소유자가 확인되거나 이메일 중복이 금지되는 것은 아닙니다. { name, email, role: 'admin' }을 보내도 반환값에는 name·email만 남고 role은 저장되지 않습니다. 다만 필드 추출은 로그인이나 수정 권한을 확인하지 않습니다.
listUsers는 주어진 검색 문자열을 그대로 includes에 사용합니다. 빈 search는 전체 목록을 반환하고, 앞뒤 공백을 자동으로 없애거나 영문 대소문자를 통일하지 않습니다. 예를 들어 초기 이름에 없는 search=없는이름은 오류 대신 빈 배열과 200을 반환합니다.
NEXT · 11.1 · Route Handler
목록·상세 라우트는 같은 저장소 소스를 가져옵니다
users/route.ts의 GET·POST와 users/[id]/route.ts의 GET·PUT·DELETE가 users-repository.ts를 import합니다. 화살표는 소스 의존이며 HTTP 요청의 시간 순서가 아닙니다.
형식 검사와 배열 연산을 같은 모듈 소스에 둡니다. 같은 모듈 인스턴스의 예제 흐름이며, 배포된 서로 다른 인스턴스의 배열 공유·영속 저장·인증을 보장하지 않습니다.
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 Handlersrc/app/api/users/route.ts 파일에 POST 핸들러 추가:
GET 핸들러와 같은 파일에 추가합니다.
하나의 route.ts 파일은 여러 HTTP 메서드 핸들러를 포함할 수 있습니다.
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": "[email protected]"
}'요청이 성공하면, 새로 생성된 사용자 정보와 함께 HTTP 상태 코드 201(Created)이 반환됩니다.
실패가 어느 단계에서 생겼는지 확인하기
본문이 {처럼 잘못된 JSON이면 request.json()에서 실패해 첫 번째 400 응답을 반환합니다. []는 유효한 JSON이므로 읽기는 성공하지만 객체 필드 검사에서 실패해 두 번째 400 응답을 반환합니다. 두 경우 모두 createUser까지 도달하지 않아 nextId가 증가하지 않습니다. request.json()은 JSON 해석이며 별도의 Content-Type 허용 검사나 업무 스키마 검사가 아닙니다. 이 코드에는 미디어 타입을 제한하는 조건이 없습니다. JSON 전용 API로 제한하려면 허용 형식을 검사하고 지원하지 않는 형식의 응답 정책도 별도로 정합니다.
같은 저장소 모듈 인스턴스를 사용하는 동안에는 이후 http://localhost:3000/api/users의 GET 목록에도 새 사용자가 포함됩니다. 이 설명은 예제의 기대 동작이며, 별도 인스턴스 사이의 상태 공유를 보장하지 않습니다.
동적 Route Handler 생성
동적 Route Handler는 URL의 일부를 파라미터로 받아 특정 리소스의 CRUD 요청을 나눕니다.
특정 ID의 사용자 조회·수정·삭제처럼 리소스를 식별해야 할 때 동적 세그먼트를 사용합니다.
폴더 이름을 대괄호([])로 감싸 동적 파라미터를 정의합니다. ([slug] 또는 [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 });
}- 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": "[email protected]" }처럼 전체 사용자 필드를 포함합니다. - DELETE: Postman 등으로
http://localhost:3000/api/users/2에 DELETE 요청을 보내 ID가 2인 사용자를 삭제합니다.
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에 접근해야 한다면 허용 출처와 메서드를 좁게 설정합니다.
오류 로그에는 토큰이나 요청 본문 전체를 무조건 남기지 않고 필요한 식별 정보만 기록합니다. 현재 예제의 입력 검증과 배열 연산이 운영 인증·영속 저장·로깅 정책을 대신하지는 않습니다.
본인 정보만 수정하는 정책의 작은 예 학습용 API는 인증 없이 호출됩니다. 이름·이메일을 다루는 운영 API라면 목록 조회에도 공개 범위를 정하고, 수정·삭제는 요청마다 서버에서 검증한 신원과 대상의 권한을 확인해야 합니다. 화면에서 수정 버튼을 숨겨도 HTTP 요청은 직접 보낼 수 있습니다. 다음 함수는 관리자 예외 없이 본인만 수정한다는 정책만 표현합니다.
export function selfEditDenial(
trustedSessionUserId: number | null,
targetUserId: number,
): 401 | 403 | null {
if (trustedSessionUserId === null) return 401;
return trustedSessionUserId === targetUserId ? null : 403;
}targetUserId는 앞의 ID 검사에 통과한 값이고, trustedSessionUserId는 서버의 세션 검증을 거쳐 얻은 사용자 ID 또는 null이라는 전제입니다. 요청 JSON의 userId나 URL 값을 로그인한 신원으로 넣으면 이 전제가 깨집니다. 보호된 핸들러는 세션 검증과 이 정책을 수행해 거절 시 저장을 호출하지 않고 응답하며, 허용된 요청에서만 대상 조회·수정을 진행합니다. 이 함수를 추가하는 것만으로 기존 공개 핸들러가 보호되지는 않습니다. 로그인 없음은 이 정책에서 401, 검증된 사용자 1이 대상 2를 수정하려는 경우는 403입니다. 입력이 올바른 양의 정수라도 실제로 없는 사용자 99는 기존 상세 조회에서 404이며, 형식이 잘못된 1.0은 조회 전에 400입니다.
확인 문제
원 저장소의 초기 상태에서 정상 사용자 한 명을 생성하고, 같은 모듈 인스턴스의 상세 GET에 ID 문자열 01을 전달합니다. 새 사용자의 ID와 상세 GET의 대상 ID는 각각 얼마인가요? 1.0이나 9007199254740993을 ID로 보내면 어느 상태로 끝나나요? 그 뒤 저장소 모듈을 새로 초기화했을 때 생성한 사용자가 남는지도 원 선언을 따라 판단합니다.
문자열 ID와 모듈 상태 해설
nextId는 4로 시작하므로 새 사용자는 ID 4를 받습니다. 01은 숫자만으로 이루어졌고 Number로 변환하면 안전한 양의 정수 1이므로 기존 ID 1을 조회합니다. 1.0은 숫자만 허용하는 정규식에서, 9007199254740993은 안전한 정수 검사에서 거절되어 각각 400입니다. 저장소를 새로 초기화하면 코드에 선언한 세 사용자와 nextId 4로 돌아갑니다. 따라서 같은 소스를 import한다는 사실을 영구 데이터나 서로 다른 인스턴스의 공유 상태로 해석하지 않습니다.
추가 연습: 요청 처리 단계와 저장 전 조건 추적하기
- 각 실험은 저장소 초기 상태에서 시작합니다. POST 본문이
{,[],{ "name": " 새 사용자 ", "email": "[email protected]", "role": "admin" }일 때 상태와 저장 호출 여부는 어떻게 달라질까요? 마지막 요청에서 응답할 ID·이름·이메일·role을 판단하세요. - 같은 모듈 인스턴스에서 같은 이메일의 정상 POST를 두 번 보냅니다. 현재 코드가 이메일 중복을 거절할까요? 두 ID와 전체 목록 길이는 무엇일까요? search가 빈 문자열과
없는이름인 GET 응답도 비교하세요. - 유효한 JSON 본문에 Content-Type이 text/plain으로 지정됐습니다. 현재 POST에는 헤더를 읽는 조건이 없고
request.json()이 본문을 해석할 수 있다고 가정합니다. 미디어 타입 때문에 자동으로 거절될까요? PUT 대상 ID가 99이고 본문이 유효할 때와, 대상 ID가 1이고 본문이[]일 때 응답을 비교하세요. - 위 본인 수정 정책을 보호된 핸들러에서 저장 전에 호출합니다. 대상 ID는 검사에 통과한 2이며, 서버에서 검증된 세션 ID는 각각 null·1·2입니다. 거절 코드와 저장 가능 여부를 판단하세요. 클라이언트 JSON의 userId=2를 세션 ID 대신 넘기는 방식은 왜 잘못됐을까요?
파싱·검증·권한·저장 순서의 풀이
{는 JSON 읽기 실패로 400,[]는 읽기 후 객체 검사 실패로 400이며 둘 다 저장 호출은 없습니다. 마지막 요청은 필드 검사에 통과해 201이며 ID 4, 이름새 사용자, 이메일[email protected]을 반환합니다. role은 UserInput 반환값에 포함되지 않아 저장·응답에 없습니다. 앞의 실패 요청이 nextId를 소비하지 않는다는 점도 확인합니다.- 중복 조회·고유 제약이 없으므로 두 요청 모두 생성하며 ID는 4·5, 목록 길이는 3+2=5입니다. 이메일 형식 검사와 중복 금지는 별도 조건입니다. 빈 search는 전체 5명을, 이름에 매칭하지 않는
없는이름은 빈 배열을 반환하고 둘 다 200입니다. 다른 모듈 인스턴스나 데이터베이스 정책으로 일반화하지 않습니다. - 자동으로 거절하지 않습니다. 주어진 조건에서는 JSON 읽기와 필드 검사가 성공하면 생성으로 진행합니다. 미디어 타입을 제한하려면 명시적인 검사 조건이 필요합니다. PUT의 유효한 ID 99·유효한 본문은 updateUser에서 대상을 찾지 못해 404입니다. ID 1·본문
[]는 입력 검사에서 400이며 기존 사용자 1을 수정하지 않습니다. - null은 401, 1은 대상 2와 달라 403이며 둘 다 저장을 호출하지 않습니다. 2는 정책이 null을 반환해 다음 대상 조회·수정 단계로 진행할 수 있습니다. 이는 저장 성공 자체를 보장하지 않습니다. JSON userId는 요청자가 바꿀 수 있으므로 신원 검증 결과로 취급하면 다른 사용자가 대상 ID에 맞춰 권한 검사를 통과할 수 있습니다. 서버가 검증한 세션 신원을 요청마다 사용해야 합니다.