HTTP 메서드 처리
리소스 CRUD를 HTTP 메서드에 대응시키고 NextRequest·NextResponse로 입력·상태 코드·오류 응답을 처리합니다.
이전 절에서는 App Router Route Handler의 기본 생성 방식과 GET, POST 처리 예시를 살펴봤습니다.
클라이언트-서버 통신은 HTTP 기반으로 이루어지고, 서버에 어떤 작업을 요청하는지는 HTTP 메서드(Method)로 표현합니다.
RESTful API에서는 이 메서드를 정확히 사용해 리소스의 CRUD(Create, Read, Update, Delete) 작업을 명확하게 드러내는 것이 중요합니다.
이 절에서는 Next.js Route Handler에서 NextRequest 및 NextResponse 객체를 사용하여 GET, POST, PUT, DELETE와 같은 주요 HTTP 메서드를 어떻게 효율적으로 처리하는지 자세히 알아보고, 각 메서드의 역할과 실제 구현 시 유의할 점을 다루겠습니다.
먼저 URL은 리소스를 가리키고 HTTP 메서드는 그 리소스에 수행할 작업을 말한다는 점을 표로 정리해 보겠습니다.
HTTP 메서드의 역할과 RESTful API
RESTful API에서는 같은 URL이라도 메서드가 달라지면 전혀 다른 작업이 됩니다.
하나의 API 파일 안에서도 HTTP 메서드를 기준으로 읽기, 생성, 수정, 삭제 책임을 선명하게 나눈다.
- 읽기 계열GET은 안전하고 반복 가능
필터와 페이지네이션은 query로 받는다
- 쓰기 계열POST/PUT/PATCH는 body 검증
unknown body를 full·patch parser로 DTO화하고 실패는 400 반환
- 제한허용하지 않는 메서드 차단
Allow 헤더와 405를 함께 사용한다
REST(Representational State Transfer)는 웹 서비스를 설계하는 데 사용되는 아키텍처 스타일입니다.
RESTful API는 HTTP 메서드를 사용하여 리소스에 대한 표준화된 작업을 수행합니다.
GET: 서버로부터 리소스 조회를 요청합니다. 데이터를 변경하지 않고 읽기 전용 작업을 수행할 때 사용됩니다.- 예:
/api/users(모든 사용자 조회),/api/users/1(ID가 1인 사용자 조회)
- 예:
POST: 서버에 새로운 리소스 생성을 요청합니다. 요청 본문(body)에 생성할 데이터가 포함됩니다.- 예:
/api/users(새로운 사용자 생성)
- 예:
PUT: 서버의 기존 리소스 전체 업데이트를 요청합니다. 요청 본문에는 리소스의 모든 필드가 포함되어야 합니다.- 예:
/api/users/1(ID가 1인 사용자 정보 전체 업데이트)
- 예:
PATCH: 서버의 기존 리소스 부분 업데이트를 요청합니다. 요청 본문에는 변경할 필드만 포함됩니다.- 예:
/api/users/1(ID가 1인 사용자의 이메일만 업데이트)
- 예:
DELETE: 서버의 리소스를 삭제할 때 사용됩니다.- 예:
/api/users/1(ID가 1인 사용자 삭제)
- 예:
HEAD:GET과 동일하지만 응답 본문 없이 헤더만 받습니다. 리소스의 존재 여부나 메타데이터만 확인할 때 사용됩니다.OPTIONS: 특정 리소스에 대해 서버가 어떤 HTTP 메서드를 지원하는지 질의할 때 사용됩니다. CORS(Cross-Origin Resource Sharing) 사전 요청(Preflight Request)에 주로 사용됩니다.
Next.js Route Handler에서는 route.ts 파일 내에 각 HTTP 메서드 이름으로 함수를 export하면 해당 메서드에 대한 요청을 자동으로 처리합니다.
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// GET 요청 처리 로직
return NextResponse.json({ message: 'GET request received' });
}
export async function POST(request: NextRequest) {
// POST 요청 처리 로직
const data = await request.json();
return NextResponse.json({ message: 'POST request received', data });
}
export async function PUT(request: NextRequest) {
// PUT 요청 처리 로직
const data = await request.json();
return NextResponse.json({ message: 'PUT request received', data });
}
export async function DELETE(request: NextRequest) {
// DELETE 요청 처리 로직
return NextResponse.json({ message: 'DELETE request received' });
}
// 기타 메서드도 동일하게 export 할 수 있습니다.
// export async function PATCH(request: NextRequest) { ... }
// export async function HEAD(request: NextRequest) { ... }
// export async function OPTIONS(request: NextRequest) { ... }NextRequest와 NextResponse 객체 활용
Next.js App Router의 Route Handler는 표준 Request를 받을 수 있고, 쿠키나 nextUrl 같은 확장 기능이 필요하면 NextRequest를 사용합니다.
응답도 표준 Response 또는 편의 메서드를 제공하는 NextResponse로 반환할 수 있습니다.
NextRequest (요청 객체)
NextRequest는 표준 Web Request API를 확장한 객체로, HTTP 요청에 대한 다양한 정보를 제공합니다.
request.url: 요청 URL (Full URL)request.method: 요청 HTTP 메서드 (예: 'GET', 'POST')request.headers: 요청 헤더 (Headers객체)request.cookies: 요청 쿠키 (RequestCookies객체)request.body: 요청 본문 (ReadableStream).request.json()또는request.text()로 파싱합니다.request.nextUrl: Next.js 확장 URL 객체로,pathname과 쿼리 파라미터(searchParams)에 접근합니다.[id]같은 동적 경로 파라미터는 두 번째 인자인 Route Context의await context.params에서 읽습니다.
// NextRequest 활용 예시
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const url = request.url; // 예: http://localhost:3000/api/data?name=test
const method = request.method; // 'GET'
const contentType = request.headers.get('Content-Type'); // 요청 헤더 접근
const myCookie = request.cookies.get('my_cookie')?.value; // 쿠키 접근
const nameParam = request.nextUrl.searchParams.get('name'); // 쿼리 파라미터 접근
return NextResponse.json({
url,
method,
contentType,
myCookie,
nameParam,
});
}
export async function POST(request: NextRequest) {
const body = await request.json(); // JSON 본문 파싱
// const textBody = await request.text(); // 텍스트 본문 파싱
return NextResponse.json({
message: 'Data received',
receivedBody: body,
});
}NextResponse (응답 객체)
NextResponse는 표준 Web Response API를 확장한 객체로, 서버 응답을 구성하는 데 사용됩니다.
NextResponse는 응답 본문, 상태 코드, 헤더, 쿠키 등을 설정할 수 있는 유용한 정적 메서드를 제공합니다.
NextResponse.json(data, init?): JSON 형식의 응답을 생성합니다.init객체로status,headers등을 설정할 수 있습니다.new Response(body, init?): 텍스트 형식의 표준 응답을 생성합니다.init객체로status,headers등을 설정할 수 있습니다.NextResponse.redirect(url, status?): 특정 URL로 리다이렉트 응답을 생성합니다.NextResponse.rewrite(url): 클라이언트의 URL을 변경하지 않고 내부적으로 다른 경로를 렌더링하도록 합니다. Proxy에서 주로 사용합니다.NextResponse.next(): Proxy에서 다음 라우트 처리 단계로 요청을 전달합니다.
// NextResponse 활용 예시
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// 200 OK 상태 코드와 JSON 데이터 반환
return NextResponse.json({ data: '성공적으로 데이터를 가져왔습니다.' }, { status: 200 });
}
export async function POST(request: NextRequest) {
// 201 Created 상태 코드와 커스텀 헤더 설정
const newUser = { id: 1, name: '새 사용자' };
return NextResponse.json(newUser, {
status: 201,
headers: {
'X-Custom-Header': 'Next.js API',
'Location': `/api/users/${newUser.id}`, // 생성된 리소스의 위치
},
});
}
export async function DELETE(request: NextRequest) {
// 404 Not Found 상태 코드와 오류 메시지
const id = request.nextUrl.searchParams.get('id');
if (id === 'invalid') {
return NextResponse.json({ message: '리소스를 찾을 수 없습니다.' }, { status: 404 });
}
// 204 No Content (성공적으로 처리했지만 반환할 내용이 없을 때)
return new NextResponse(null, { status: 204 });
}메서드별 핸들러를 작성할 때는 “어떤 입력을 읽고, 어떤 상태 코드와 응답 본문을 돌려줄지”를 먼저 정해 두면 구현이 훨씬 안정적입니다.
특히 생성은 201, 삭제 성공은 204처럼 메서드의 의미에 맞는 응답 형태를 고정해 두면 클라이언트 코드도 예측하기 쉬워집니다.
응답은 성공 여부뿐 아니라 입력 오류, 인증 실패, 권한 부족, 서버 오류를 구분해 클라이언트가 바로 판단하게 만든다.
- 입력 오류400 또는 422
필드 단위 메시지를 포함해 재입력을 돕는다
- 인증/권한401 또는 403
그인 필요와 권한 부족을 분리한다
- 서버 오류500 계열
내부 상세는 숨기고 추적 id를 남긴다
이 기준표를 기준으로 각 핸들러의 성공 응답과 오류 응답을 분리한 뒤, 실제 CRUD 코드에서는 검증과 조회 실패 처리만 빠짐없이 채우면 됩니다.
HTTP 메서드별 CRUD 구현 예시
이전 절에서 만든 users API를 확장하여 GET, PUT, DELETE 메서드를 특정 사용자 (/api/users/[id])에 적용하는 예시를 다시 한번 상세히 살펴보겠습니다.
src/app/api/users/[id]/route.ts 파일 (전체 코드)
import { NextRequest, NextResponse } from 'next/server';
// 가상의 사용자 데이터 (실제로는 데이터베이스)
// 🚨 중요: 이 예제는 서버가 재시작되면 데이터가 초기화됩니다.
// 실제 애플리케이션에서는 반드시 데이터베이스를 사용해야 합니다.
let users = [
{ id: 1, name: '김철수', email: 'chulsoo@example.com' },
{ id: 2, name: '이영희', email: 'younghee@example.com' },
{ id: 3, name: '박민수', email: 'minsu@example.com' },
];
type UserFields = { name: string; email: string };
function parseUserId(value: string): number | null {
if (!/^\d+$/.test(value)) return null;
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function normalizeName(value: unknown): string | null {
if (typeof value !== 'string') return null;
const name = value.trim();
return name && name.length <= 50 ? name : null;
}
function normalizeEmail(value: unknown): string | null {
if (typeof value !== 'string') return null;
const email = value.trim().toLowerCase();
return email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
? email
: null;
}
function parseFullUser(value: unknown): UserFields | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const name = normalizeName(record.name);
const email = normalizeEmail(record.email);
return name && email ? { name, email } : null;
}
function parseUserPatch(value: unknown): Partial<UserFields> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const keys = Object.keys(record);
if (keys.length === 0 || keys.some((key) => key !== 'name' && key !== 'email')) return null;
const patch: Partial<UserFields> = {};
if ('name' in record) {
const name = normalizeName(record.name);
if (!name) return null;
patch.name = name;
}
if ('email' in record) {
const email = normalizeEmail(record.email);
if (!email) return null;
patch.email = email;
}
return patch;
}
// 동적 라우트 파라미터 타입을 위한 인터페이스
interface Context {
params: Promise<{ id: string }>;
}
/**
* GET /api/users/[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 = users.find(u => u.id === id);
if (!user) {
// 사용자를 찾지 못한 경우 404 Not Found 응답
return NextResponse.json({ message: '사용자를 찾을 수 없습니다.' }, { status: 404 });
}
// 성공적으로 사용자를 찾은 경우 200 OK 응답
return NextResponse.json(user, { status: 200 });
}
/**
* PUT /api/users/[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 = parseFullUser(body);
if (!input) {
return NextResponse.json({ message: '이름과 이메일 형식을 확인해 주세요.' }, { status: 400 });
}
const userIndex = users.findIndex(u => u.id === id);
if (userIndex === -1) {
// 사용자를 찾지 못한 경우 404 Not Found 응답
return NextResponse.json({ message: '사용자를 찾을 수 없습니다.' }, { status: 404 });
}
// 사용자 정보 업데이트 (불변성을 유지하며 새로운 배열 생성)
users = users.map(user =>
user.id === id ? { ...user, ...input } : user
);
// 업데이트된 사용자 정보와 함께 200 OK 응답
return NextResponse.json(users[userIndex], { status: 200 });
}
/**
* PATCH /api/users/[id] - 특정 사용자 부분 업데이트
*/
export async function PATCH(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 patch = parseUserPatch(body);
if (!patch) {
return NextResponse.json({ message: '변경할 이름 또는 이메일 형식을 확인해 주세요.' }, { status: 400 });
}
const userIndex = users.findIndex(u => u.id === id);
if (userIndex === -1) {
return NextResponse.json({ message: '사용자를 찾을 수 없습니다.' }, { status: 404 });
}
// 기존 사용자 정보를 가져와서 전달된 필드만 업데이트
const existingUser = users[userIndex];
const updatedUser = {
...existingUser,
...patch,
};
users[userIndex] = updatedUser; // 배열 직접 수정 또는 새로운 배열 생성 방식 선택
return NextResponse.json(updatedUser, { status: 200 });
}
/**
* DELETE /api/users/[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 });
}
const initialLength = users.length;
// 사용자 삭제 (불변성을 유지하며 새로운 배열 생성)
users = users.filter(u => u.id !== id);
if (users.length === initialLength) {
// 삭제할 사용자를 찾지 못한 경우 404 Not Found 응답
return NextResponse.json({ message: '사용자를 찾을 수 없습니다.' }, { status: 404 });
}
// 성공적으로 삭제된 경우 200 OK 또는 204 No Content 응답
return NextResponse.json({ message: '사용자가 성공적으로 삭제되었습니다.' }, { status: 200 });
// return new NextResponse(null, { status: 204 }); // 204는 본문이 없음
}Route Handler 확인 방법
개발 서버(npm run dev)를 실행한 후, 다음 도구들을 사용하여 Route Handler의 요청과 응답을 확인할 수 있습니다.
- 웹 브라우저:
GET요청만 직접 테스트할 수 있습니다. (예:http://localhost:3000/api/users/1) - Postman / Insomnia: 다양한 HTTP 메서드와 요청 본문, 헤더를 설정하여 모든 종류의 API 요청을 테스트하기에 가장 적합한 도구입니다.
curl명령어: 터미널에서 간단한 API 요청을 보낼 때 유용합니다.- GET:
curl http://localhost:3000/api/users/1 - POST:
curl -X POST -H "Content-Type: application/json" -d '{"name":"새로운사용자","email":"new@example.com"}' http://localhost:3000/api/users - PUT:
curl -X PUT -H "Content-Type: application/json" -d '{"name":"업데이트된이름","email":"updated@example.com"}' http://localhost:3000/api/users/1 - PATCH:
curl -X PATCH -H "Content-Type: application/json" -d '{"email":"partial@example.com"}' http://localhost:3000/api/users/1 - DELETE:
curl -X DELETE http://localhost:3000/api/users/1
- GET:
- 클라이언트 컴포넌트 (
fetchAPI): React 컴포넌트 내에서fetchAPI를 사용하여 Route Handler에 요청을 보내는 방식으로도 확인할 수 있습니다. (다음 절에서 다룰 예정)
Route Handler 보안 및 최적화
- 인증 및 권한 부여: 중요한 데이터를 다루는 Route Handler는 Auth.js의
auth()로 요청을 감싸 로그인 여부와 사용자 역할을 서버에서 확인해야 합니다. - 입력 유효성 검사: 클라이언트로부터 받은 모든 입력 데이터는 서버 측에서 반드시 유효성 검사를 수행해야 합니다. 악의적인 데이터를 막고 애플리케이션의 안정성을 높이는 데 필수적입니다.
- 에러 처리: 예외 상황에 대한 명확하고 일관된 오류 응답을 제공해야 합니다. (예: 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error)
- 환경 변수 관리: 데이터베이스 연결 문자열, API 키 등 민감한 정보는
.env.local파일에 저장하고process.env.VAR_NAME으로 접근해야 합니다. - 로깅: API 요청 및 응답, 오류 발생 시 로그를 기록하여 디버깅 및 모니터링을 용이하게 합니다.
Route Handler 보안은 입력 검증, 인증/권한, 오류 응답, 로깅이 함께 맞아야 안정적으로 동작합니다.
보안은 인증만이 아니라 입력 제한, 캐시 정책, 외부 호출 보호, 응답 크기 관리까지 포함한다.
- 검증schema와 content-type 확인
content-type과 JSON을 확인하고 parser가 허용한 DTO만 통과
- 최적화캐시와 응답 크기 조절
GET은 캐시 가능성을 보고, 큰 목록은 페이지로 나눈다
- 보호rate limit와 timeout
외부 API 지연이 전체 요청을 붙잡지 않게 한다
아래 다이어그램은 HTTP 메서드별 CRUD 의도와 NextRequest, NextResponse 처리 기준을 함께 비교합니다.
핸들러 함수 안에서는 요청을 읽는 방식과 성공/실패 응답 형태가 메서드의 의도와 맞아야 한다.
- 1. 입력 읽기
입력 읽기 nextUrl.searchParams params.id request.json()
- 2. 서버 작업
서버 작업 조회, 생성, 수정, 삭제 검증과 권한 확인 DB 또는 외부 API 호출
- 3. 응답 고정
응답 고정 상태 코드 JSON body 헤더와 쿠키
| 메서드 | 성공 기준 | 실패 기준 |
|---|---|---|
| GET | 찾으면 200 | 없으면 404 |
| POST | 생성하면 201 | 검증 실패 400 |
| PUT/PATCH | 수정하면 200 | 대상 없음 404 |
| DELETE | 삭제하면 204 | 이미 없음 404 |
아래 다이어그램은 NextRequest와 NextResponse를 기준으로 GET, POST, PUT, DELETE 처리가 나뉘는 모습을 정리합니다.
HTTP 메서드는 라우트 이름보다 먼저 작업의 의미를 설명한다. 같은 URL도 메서드에 따라 다른 계약을 갖는다.
- 조회GET /items
query로 검색 조건을 받고 본문은 쓰지 않는다
- 생성POST /items
body를 검증해 새 자원을 만들고 201을 반환
- 변경/삭제PATCH, DELETE /items/:id
parseUserId 실패는 400, 대상 없음과 권한 부족은 404/403으로 분리
Next.js Route Handler에서 HTTP 메서드를 분리하면 RESTful 원칙에 맞는 요청 처리를 구성하기 쉽습니다.
NextRequest와 NextResponse의 역할을 구분해 응답 상태, 헤더, 본문을 일관되게 관리해야 합니다.
아래 다이어그램은 HTTP 메서드 처리에서 요청 진입점, 응답 형태, 오류 처리를 함께 확인합니다.
NextRequest는 쿠키, 헤더, URL 정보를 읽는 입구이고, 보안 응답은 인증 상태와 권한 원인을 분리해 돌려준다.
- 요청 해석parseUserId·전체 DTO·부분 DTO 분리
동적 ID와 unknown JSON을 각각 전용 parser로 검증
- 권한 판단role과 route policy 비교
그인 없음과 역할 부족을 다르게 처리
- 응답 선택JSON API와 화면 이동 구분
API는 401/403 JSON, 화면은 redirect가 자연스럽다