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 메서드를 어떻게 효율적으로 처리하는지 자세히 알아보고, 각 메서드의 역할과 실제 구현 시 유의할 점을 다루겠습니다.
HTTP 메서드의 역할과 RESTful API
REST(Representational State Transfer)는 웹 서비스를 설계하는 데 사용되는 아키텍처 스타일입니다.
RESTful API는 HTTP 메서드를 사용하여 리소스에 대한 표준화된 작업을 수행합니다.
GET: 서버로부터 리소스 조회를 요청합니다. 클라이언트가 상태 변경을 요청하지 않는 안전한 메서드입니다. 서버의 접근 로그 같은 부수 효과까지 금지한다는 뜻은 아닙니다.- 예:
/api/users(모든 사용자 조회),/api/users/1(ID가 1인 사용자 조회)
- 예:
POST: 리소스가 정한 방식으로 요청 내용을 처리하도록 요청합니다. 아래 API에서는 본문(body)의 데이터로 새 사용자를 생성합니다.- 예:
/api/users(새로운 사용자 생성)
- 예:
PUT: 지정한 URI의 리소스를 요청 표현으로 생성하거나 교체하도록 요청합니다. 아래 구현은 기존 사용자의 쓰기 가능한 필드인 이름과 이메일을 모두 요구하며, 서버가 관리하는 ID는 본문에서 받지 않습니다.- 예:
/api/users/1(ID가 1인 사용자 정보 전체 업데이트)
- 예:
PATCH: 리소스에 변경 사항을 적용하도록 요청합니다. 아래 API는 변경할 필드만 담는 JSON 형식을 선택했습니다.- 예:
/api/users/1(ID가 1인 사용자의 이메일만 업데이트)
- 예:
DELETE: 서버의 리소스를 삭제할 때 사용됩니다.- 예:
/api/users/1(ID가 1인 사용자 삭제)
- 예:
HEAD:GET과 동일하지만 응답 본문 없이 헤더만 받습니다. 리소스의 존재 여부나 메타데이터만 확인할 때 사용됩니다.OPTIONS: 특정 리소스에 대해 서버가 어떤 HTTP 메서드를 지원하는지 질의할 때 사용됩니다. CORS(Cross-Origin Resource Sharing) 사전 요청(Preflight Request)에 주로 사용됩니다.
PUT과 DELETE의 멱등성은 반복 요청의 의도한 최종 효과가 같다는 뜻입니다. DELETE가 처음에는 200, 다음에는 404를 반환해도 이 성질과 모순되지 않습니다. PATCH는 항상 멱등적인 메서드는 아니며, 아래처럼 값을 대입하는 구현의 성질을 따로 판단합니다.
Next.js Route Handler에서는 route.ts 파일 내에 각 HTTP 메서드 이름으로 함수를 export하면 해당 요청을 처리합니다. OPTIONS를 생략하면 Next.js가 Allow 헤더를 포함한 응답을 만들지만, 다른 출처를 허용하는 CORS 헤더 설정까지 대신하지는 않습니다.
다음 코드는 메서드 분기와 JSON 응답을 보여주는 독립 예제입니다. 저장·권한 검사·잘못된 JSON 처리까지 구현한 CRUD API는 아닙니다.
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또는null).request.json()과request.text()중 필요한 방식을 선택해 한 번 소비합니다.request.nextUrl: Next.js 확장 URL 객체로,pathname과 쿼리 파라미터(searchParams)에 접근합니다.[id]같은 동적 경로 파라미터는 두 번째 인자인 Route Context의await context.params에서 읽습니다.
예시: 다음 GET·POST는 앞의 핸들러와 교체해 살펴보는 예제이며 같은 파일에 중복 선언하지 않습니다.
// 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): Proxy에서 URL을 유지하며 다른 대상으로 요청을 전달합니다. Route Handler의 응답으로 사용하는 메서드는 아닙니다.NextResponse.next(): Proxy에서 다음 라우트 처리 단계로 요청을 전달합니다. Route Handler에서는 자체 응답을 반환합니다.
예시: 상태 코드·헤더·본문의 모양을 확인하는 대체 핸들러입니다. POST와 DELETE도 실제 저장소를 변경하지 않습니다.
// 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 });
}응답은 메서드 이름만으로 고정하지 않고 API 계약과 실제 처리 결과에 맞춥니다. 아래 전체 CRUD 코드의 DELETE는 메시지를 담은 200을 사용하며, 204를 선택할 때는 응답 본문을 보내지 않습니다.
HTTP 메서드별 CRUD 구현 예시
다음 코드는 PUT과 PATCH의 검증 차이를 읽기 위한 독립적인 /api/users/[id] 전체 파일 예제입니다. 이전 절의 users-repository.ts를 가져오는 대신 이 파일 안에 배열을 두므로, 앞 절 목록·POST 핸들러의 배열과 연결되지 않습니다. 같은 저장소로 확장하려면 앞 절처럼 데이터 계층을 일관되게 사용해야 합니다.
원문 PUT과 PATCH가 입력을 처리하는 차이
| 요청 본문 | PUT | PATCH |
|---|---|---|
| 이름·이메일 | 두 필드를 정규화해 교체 | 보낸 두 필드를 정규화해 수정 |
| 이메일만 | 400 · 이름 누락 | 이메일 수정 · 기존 이름 유지 |
| 추가 필드 포함 | 유효한 이름·이메일만 사용하고 추가 필드는 무시 | 400 · 허용하지 않은 키 |
| 빈 객체 | 400 · 필수 필드 누락 | 400 · 변경할 필드 없음 |
- 요청 본문: 이름·이메일
- PUT: 두 필드를 정규화해 교체
- PATCH: 보낸 두 필드를 정규화해 수정
- 요청 본문: 이메일만
- PUT: 400 · 이름 누락
- PATCH: 이메일 수정 · 기존 이름 유지
- 요청 본문: 추가 필드 포함
- PUT: 유효한 이름·이메일만 사용하고 추가 필드는 무시
- PATCH: 400 · 허용하지 않은 키
- 요청 본문: 빈 객체
- PUT: 400 · 필수 필드 누락
- PATCH: 400 · 변경할 필드 없음
유효한 ID로 존재하는 사용자를 요청하며, 전달한 이름·이메일 값은 형식 검사를 통과한다고 가정합니다. 이 절의 두 parser에서 도출한 결과입니다.
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)를 실행한 후 다음 도구로 요청과 응답을 확인할 수 있습니다. 아래 POST 명령은 앞 절의 목록 경로 예제용이며, 이 절 상세 경로의 별도 배열에 사용자를 추가하지 않습니다.
- 웹 브라우저 주소창: URL을 입력해
GET요청을 확인할 수 있습니다. 다른 메서드는 개발자 도구의fetch등으로 보냅니다. (예: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()는 이 검사를 구현하는 한 방법입니다. 위 CRUD 예제에는 인증 검사가 없습니다. - 입력 유효성 검사: 클라이언트로부터 받은 모든 입력 데이터는 서버 측에서 반드시 유효성 검사를 수행해야 합니다. 악의적인 데이터를 막고 애플리케이션의 안정성을 높이는 데 필수적입니다.
- 에러 처리: 예외 상황에 대한 명확하고 일관된 오류 응답을 제공해야 합니다. (예: 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error)
- 환경 변수 관리: 데이터베이스 연결 문자열, API 키 등 민감한 정보는 개발 환경의
.env.local이나 배포 플랫폼의 비밀 값 설정에서 관리하고 서버의process.env.VAR_NAME으로 접근합니다. - 로깅: API 요청 및 응답, 오류 발생 시 로그를 기록하여 디버깅 및 모니터링을 용이하게 합니다.
로그에는 진단에 필요한 정보만 남기고 세션 쿠키나 요청의 민감한 원문을 그대로 기록하지 않습니다.