11.4 Route Handlers — NextRequest/NextResponse 타입
Route Handlers란?
app/api/ 디렉토리에 위치한 route.ts 파일로 API 엔드포인트를 정의합니다.
app/
└── api/
├── users/
│ ├── route.ts # GET /api/users, POST /api/users
│ └── [id]/
│ └── route.ts # GET /api/users/:id, PUT, DELETE
└── auth/
└── route.ts
기본 Route Handler 타입
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
// GET /api/users
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = parseInt(searchParams.get('page') ?? '1');
const limit = parseInt(searchParams.get('limit') ?? '10');
const users = await getUsers({ page, limit });
return NextResponse.json({
data: users,
page,
limit,
});
}
// POST /api/users
export async function POST(request: NextRequest) {
const body = await request.json();
// body는 unknown 타입
const user = await createUser(body);
return NextResponse.json(user, { status: 201 });
}
동적 라우트 핸들러
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function GET(
request: NextRequest,
context: RouteContext
) {
const { id } = await context.params;
const user = await getUserById(id);
if (!user) {
return NextResponse.json(
{ error: '사용자를 찾을 수 없습니다.' },
{ status: 404 }
);
}
return NextResponse.json(user);
}
export async function PUT(
request: NextRequest,
context: RouteContext
) {
const { id } = await context.params;
const body = await request.json();
const updated = await updateUser(id, body);
return NextResponse.json(updated);
}
export async function DELETE(
request: NextRequest,
context: RouteContext
) {
const { id } = await context.params;
await deleteUser(id);
return new NextResponse(null, { status: 204 });
}
API 응답 타입 설계
// types/api.ts
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface ApiError {
error: string;
details?: Record<string, string[]>;
code?: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// 타입 안전한 응답 헬퍼
function successResponse<T>(data: T, status = 200) {
return NextResponse.json<ApiResponse<T>>({ data }, { status });
}
function errorResponse(error: string, status = 400, details?: Record<string, string[]>) {
return NextResponse.json<ApiError>({ error, details }, { status });
}
function paginatedResponse<T>(
data: T[],
page: number,
limit: number,
total: number
) {
return NextResponse.json<PaginatedResponse<T>>({
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
}
요청 유효성 검사 패턴
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().default(false),
tags: z.array(z.string()).max(10).default([]),
});
type CreatePostInput = z.infer<typeof CreatePostSchema>;
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: '잘못된 JSON 형식입니다.' },
{ status: 400 }
);
}
const result = CreatePostSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: '유효성 검사 실패',
details: result.error.flatten().fieldErrors,
},
{ status: 422 }
);
}
const post = await createPost(result.data);
return NextResponse.json(post, { status: 201 });
}
인증 미들웨어 패턴
// lib/auth-middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from './jwt';
type RouteHandler = (
request: NextRequest,
context: { params: Promise<Record<string, string>> }
) => Promise<NextResponse>;
interface AuthenticatedRequest extends NextRequest {
user?: { id: string; role: string };
}
export function withAuth(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json(
{ error: '인증이 필요합니다.' },
{ status: 401 }
);
}
try {
const user = await verifyToken(token);
// request는 읽기 전용이므로 헤더에 추가
const newHeaders = new Headers(request.headers);
newHeaders.set('x-user-id', user.id);
newHeaders.set('x-user-role', user.role);
const newRequest = new NextRequest(request, { headers: newHeaders });
return handler(newRequest, context);
} catch {
return NextResponse.json(
{ error: '유효하지 않은 토큰입니다.' },
{ status: 401 }
);
}
};
}
// 사용
export const GET = withAuth(async (request, { params }) => {
const userId = request.headers.get('x-user-id')!;
const data = await getUserData(userId);
return NextResponse.json(data);
});
NextResponse 유용한 메서드
// JSON 응답
NextResponse.json({ data: 'value' }, { status: 200 });
// 리다이렉트
NextResponse.redirect(new URL('/login', request.url));
// 리라이트
NextResponse.rewrite(new URL('/new-path', request.url));
// 헤더 설정
const response = NextResponse.json(data);
response.headers.set('Cache-Control', 'no-store');
response.headers.set('X-Custom-Header', 'value');
// 쿠키 설정
response.cookies.set({
name: 'session',
value: token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7일
});
// 빈 응답 (204 No Content)
return new NextResponse(null, { status: 204 });
고수 팁
1. CORS 처리
const CORS_HEADERS = {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN ?? '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export async function OPTIONS() {
return NextResponse.json({}, { headers: CORS_HEADERS });
}
export async function GET(request: NextRequest) {
const data = await fetchData();
return NextResponse.json(data, { headers: CORS_HEADERS });
}
2. 스트리밍 응답
export async function GET() {
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const encoder = new TextEncoder();
// 비동기 데이터 스트리밍
(async () => {
for await (const chunk of getLargeData()) {
await writer.write(encoder.encode(JSON.stringify(chunk) + '\n'));
}
await writer.close();
})();
return new Response(stream.readable, {
headers: { 'Content-Type': 'application/x-ndjson' },
});
}