Next.js API Routes 완벽 가이드: Route Handlers부터 오류 처리 모범 사례까지

지난 금요일 오후, 프로덕트 매니저가 다가와 “사용자 가입 API 하나 추가할 수 있을까요?”라고 물었습니다. 저는 늘 하던 방식대로 작성하려고 프로젝트의 pages/api 폴더를 열었는데, 폴더가 비어 있었습니다. 그제야 이 프로젝트가 App Router를 사용하는 새 프로젝트라서 API 작성 방식이 완전히 달라졌다는 사실이 떠올랐습니다.
Next.js 문서를 열어 보니 “Route Handlers”라는 용어가 눈에 들어왔습니다. 또 새로운 개념이 등장한 셈이죠. 오후 내내 문서와 예제를 살펴본 뒤에야 route.ts가 무엇인지, 익숙한 req와 res를 더는 사용할 수 없는 이유를 이해했습니다.
Next.js에서 백엔드 API를 작성하는 방법 때문에 혼란스럽다면 이 글이 개념을 정리하는 데 도움이 될 것입니다. Pages Router와 App Router의 API 작성 방식이 정확히 어떻게 달라졌는지 비교하고, 실전 예제를 통해 요청 처리, 응답 설계, 오류 처리 방법을 설명하겠습니다. 이 글을 다 읽고 나면 Next.js로 자신 있게 백엔드 API를 작성할 수 있을 것입니다.
API Routes 기초: 두 방식의 본질적인 차이
Pages Router 시절의 작성 방식
Next.js 13 이전에는 pages/api 폴더에서 API를 작성했습니다. 당시 방식은 Express와 꽤 비슷했고 Node.js의 req와 res 객체를 사용했습니다.
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.status(200).json({ message: 'Hello from Pages Router!' })
}
이 방식은 배우기 쉽다는 장점이 있습니다. 특히 Node.js나 Express 경험이 있다면 별도로 학습하지 않아도 바로 작성할 수 있습니다. 하지만 Node.js 전용 API에 의존하므로 엣지 환경(Edge Runtime)에 배포할 때 문제가 생길 수 있다는 단점도 있습니다.
App Router 시대의 Route Handlers
Next.js 13에서 App Router가 도입된 뒤 API 작성 방식은 완전히 달라졌습니다. 이제 app 디렉터리 아래에 route.ts 파일을 만들고 웹 표준 Request와 Response API를 사용합니다.
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello from Route Handlers!' })
}
저도 처음 이 코드를 봤을 때는 낯설었습니다. 왜 기존의 req와 res를 사용할 수 없을까요? 나중에야 이런 변화에는 다음과 같은 이유가 있다는 것을 알게 됐습니다.
- 웹 표준 채택: 브라우저 네이티브
Request와ResponseAPI를 사용하므로 코드의 범용성이 높아지고 현대적인 웹 개발 흐름에도 더 잘 맞습니다. - 향상된 타입 안전성: TypeScript 지원이 더 탄탄하며 별도의 타입 정의를 설치할 필요가 없습니다.
- Edge Runtime 지원: Vercel Edge, Cloudflare Workers 같은 엣지 환경에 배포할 수 있어 응답 속도가 더 빨라집니다.
핵심 차이 비교
| 특성 | Pages Router | App Router |
|---|---|---|
| 파일 위치 | pages/api/* | app/*/route.ts |
| API 설계 | Node.js req/res | 웹 표준 Request/Response |
| HTTP 메서드 | 하나의 기본 내보내기에서 req.method를 직접 판별 | 메서드마다 별도로 내보내기(GET, POST 등) |
| 캐시 동작 | 캐시하지 않음 | GET 요청을 기본적으로 캐시 |
솔직히 처음에는 왜 바꿔야 하는지 이해하지 못했습니다. 하지만 한동안 사용해 보니 새 방식이 확실히 더 명확했습니다. 특히 여러 HTTP 메서드를 처리할 때 if (req.method === 'GET') 같은 코드를 잔뜩 작성하지 않아도 됩니다.
Route Handlers 실전: 다양한 HTTP 요청 생성 및 처리
지원하는 HTTP 메서드
Route Handlers는 GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS 등 7가지 HTTP 메서드를 지원합니다. 각 메서드는 이름이 지정된 별도 함수로 내보냅니다. 저는 이 설계가 마음에 듭니다. 코드 구조만 봐도 API가 어떤 작업을 지원하는지 바로 알 수 있기 때문입니다.
다음은 완전한 사용자 관리 API 예제입니다.
// app/api/users/route.ts
// 사용자 목록 가져오기
export async function GET(request: Request) {
// URL에서 쿼리 매개변수 가져오기
const { searchParams } = new URL(request.url)
const page = searchParams.get('page') || '1'
return Response.json({
users: [
{ id: 1, name: '홍길동' },
{ id: 2, name: '김철수' }
],
page: parseInt(page)
})
}
// 새 사용자 생성
export async function POST(request: Request) {
// JSON 요청 본문 파싱
const body = await request.json()
return Response.json({
id: 3,
name: body.name
}, { status: 201 })
}
요청 데이터 처리
Next.js Route Handlers에서는 요청 데이터를 가져오는 여러 방법을 제공합니다. 처음에는 저도 자주 헷갈렸지만 다음과 같이 정리할 수 있습니다.
- URL 매개변수:
request.url과URL객체를 함께 사용합니다.
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q')
- 요청 본문(JSON):
await request.json()을 사용합니다.
const body = await request.json()
console.log(body.email) // 이메일 필드 가져오기
- 요청 본문(FormData):
await request.formData()를 사용합니다.
const formData = await request.formData()
const file = formData.get('avatar')
- 요청 헤더와 Cookies:
next/headers에서 가져옵니다.
import { headers, cookies } from 'next/headers'
export async function GET() {
const headersList = headers()
const cookieStore = cookies()
const token = headersList.get('authorization')
const userId = cookieStore.get('user_id')
// ...
}
- 동적 라우트 매개변수: 함수의 두 번째 매개변수로 가져옵니다.
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const userId = params.id
return Response.json({ userId })
}
여기에는 제가 직접 겪었던 함정이 하나 있습니다. Next.js 15+에서는 params가 비동기이므로 await params.id를 사용해야 합니다. 다만 대부분의 경우에는 그대로 사용해도 TypeScript가 필요한 내용을 알려 줍니다.
응답 구성
가장 흔한 경우는 JSON을 반환하는 것입니다. 이때는 Response.json()을 바로 사용하면 됩니다.
export async function GET() {
return Response.json({
success: true,
data: { message: '작업 성공' }
})
}
사용자 지정 상태 코드와 응답 헤더를 설정하는 방법도 간단합니다.
export async function POST(request: Request) {
const body = await request.json()
// 매개변수 검증 실패 시 400 반환
if (!body.email) {
return Response.json(
{ error: '이메일은 필수입니다' },
{ status: 400 }
)
}
// 생성 성공 시 201과 응답 헤더 반환
return Response.json(
{ id: 123, email: body.email },
{
status: 201,
headers: {
'X-Request-Id': 'abc-123',
'Cache-Control': 'no-cache'
}
}
)
}
실제 사례: 사용자 가입 API
지금까지 살펴본 내용을 하나로 묶어 완전한 사용자 가입 API를 작성해 보겠습니다.
// app/api/auth/register/route.ts
import { headers } from 'next/headers'
export async function POST(request: Request) {
// 요청 헤더 가져오기
const headersList = headers()
const contentType = headersList.get('content-type')
// Content-Type 확인
if (!contentType?.includes('application/json')) {
return Response.json(
{ error: 'JSON 형식으로 데이터를 제출하세요' },
{ status: 400 }
)
}
// 요청 본문 파싱
const body = await request.json()
const { username, email, password } = body
// 기본 검증
if (!username || !email || !password) {
return Response.json(
{ error: '사용자 이름, 이메일, 비밀번호는 필수입니다' },
{ status: 400 }
)
}
// 실제로는 여기서 데이터베이스를 호출해 사용자를 저장해야 합니다
// const user = await db.user.create({ username, email, password })
// 성공 응답 반환
return Response.json({
success: true,
data: {
id: 1,
username,
email
}
}, { status: 201 })
}
이 예제에는 요청 헤더 확인, JSON 파싱, 매개변수 검증, 오류 처리, 성공 응답까지 포함되어 있습니다. 대부분의 API가 기본적으로 이런 흐름을 따릅니다.
오류 처리 모범 사례: 더 안정적이고 신뢰할 수 있는 API 만들기
Try-Catch의 올바른 사용법
API를 처음 작성할 때는 가장 바깥쪽에 큰 try-catch 하나를 두는 방식에 익숙했습니다.
// ❌ 권장하지 않음: 하나의 큰 try-catch로 모든 로직을 감쌈
export async function POST(request: Request) {
try {
const body = await request.json()
// 여러 비즈니스 로직...
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: '작업 실패' }, { status: 500 })
}
}
이 방식에는 문제가 있습니다. 모든 오류가 500으로 반환되므로 프론트엔드는 구체적인 정보를 얻지 못하고 디버깅도 매우 어려워집니다. 이후에는 작업별로 오류를 나누어 처리하는 방식으로 바꿨습니다.
// ✅ 권장: 오류 유형을 구분해 처리
export async function POST(request: Request) {
let body
// JSON 파싱 오류를 별도로 처리
try {
body = await request.json()
} catch (error) {
return Response.json(
{ error: '요청 형식이 잘못되었습니다. JSON 형식을 확인하세요' },
{ status: 400 }
)
}
// 검증 오류는 바로 반환하므로 try-catch가 필요하지 않음
if (!body.email || !body.password) {
return Response.json(
{ error: '이메일과 비밀번호는 필수입니다' },
{ status: 400 }
)
}
// 데이터베이스 작업 오류를 별도로 처리
try {
const user = await db.user.create(body)
return Response.json({ success: true, data: user })
} catch (error) {
// 중복 이메일인지 확인
if (error.code === 'P2002') {
return Response.json(
{ error: '이미 가입된 이메일입니다' },
{ status: 409 }
)
}
// 그 밖의 데이터베이스 오류
console.error('Database error:', error)
return Response.json(
{ error: '서버 오류가 발생했습니다. 잠시 후 다시 시도하세요' },
{ status: 500 }
)
}
}
이렇게 바꾸면 오류 메시지가 훨씬 명확해지고 프론트엔드에서도 상태 코드에 따라 다르게 처리할 수 있습니다.
구조화된 오류 응답
여러 프로젝트를 진행하면서 오류 응답 형식이 제각각인 경우를 많이 봤습니다. 어떤 곳에서는 { error: '...' }, 다른 곳에서는 { message: '...' } 또는 { msg: '...' }를 사용합니다. 프론트엔드에서 연동하기가 매우 번거롭습니다.
그래서 다음과 같은 표준 형식을 정리해 사용하고 있습니다.
// 통일된 오류 응답 형식
interface ErrorResponse {
success: false
error: string // 사용자 친화적인 오류 메시지
code?: string // 프론트엔드 다국어 처리에 사용할 오류 코드
details?: any // 상세 오류 정보(개발 환경에서 사용)
requestId?: string // 요청 추적 ID
}
// 통일된 성공 응답 형식
interface SuccessResponse<T> {
success: true
data: T
requestId?: string
}
실제로 사용할 때는 보조 함수를 만들 수 있습니다.
// lib/api-response.ts
import { nanoid } from 'nanoid'
export function successResponse<T>(data: T, status: number = 200) {
return Response.json({
success: true,
data,
requestId: nanoid()
}, { status })
}
export function errorResponse(
error: string,
status: number = 500,
code?: string,
details?: any
) {
const isDev = process.env.NODE_ENV === 'development'
return Response.json({
success: false,
error,
code,
details: isDev ? details : undefined, // 프로덕션 환경에서는 상세 정보 미반환
requestId: nanoid()
}, { status })
}
이렇게 하면 API 코드가 훨씬 간결해집니다.
// app/api/users/[id]/route.ts
import { successResponse, errorResponse } from '@/lib/api-response'
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const userId = params.id
try {
const user = await db.user.findUnique({ where: { id: userId } })
if (!user) {
return errorResponse('사용자를 찾을 수 없습니다', 404, 'USER_NOT_FOUND')
}
return successResponse(user)
} catch (error) {
return errorResponse(
'사용자 정보를 가져오지 못했습니다',
500,
'INTERNAL_ERROR',
error
)
}
}
예상 가능한 오류와 예기치 않은 오류
API를 한동안 작성하다 보니 오류를 두 종류로 나눌 수 있다는 점을 알게 됐습니다.
- 예상 가능한 오류: 매개변수 형식 오류, 존재하지 않는 리소스, 권한 부족 등 정상적인 비즈니스 로직에 속하는 오류로, 4xx 상태 코드를 반환해야 합니다.
- 예기치 않은 오류: 데이터베이스 연결 실패, 서드파티 서비스 장애, 코드 버그 등 시스템 수준의 오류로, 5xx 상태 코드를 반환해야 합니다.
두 오류 유형은 처리 방식도 다릅니다.
export async function POST(request: Request) {
const body = await request.json()
// 예상 가능한 오류: 로그를 남길 필요 없이 바로 반환
if (!body.email?.includes('@')) {
return errorResponse('이메일 형식이 올바르지 않습니다', 400, 'INVALID_EMAIL')
}
try {
// 외부 API 호출
const response = await fetch('https://api.example.com/verify', {
method: 'POST',
body: JSON.stringify({ email: body.email })
})
if (!response.ok) {
// 서드파티 API가 반환한 오류는 예상 가능한 오류에 해당
return errorResponse('이메일 인증에 실패했습니다', 400, 'VERIFICATION_FAILED')
}
return successResponse({ verified: true })
} catch (error) {
// 네트워크 오류와 타임아웃 등은 예기치 않은 오류에 해당
console.error('Unexpected error:', error) // 로그 기록
// Sentry 같은 모니터링 시스템을 연동할 수 있음
// Sentry.captureException(error)
return errorResponse(
'현재 서비스를 사용할 수 없습니다. 잠시 후 다시 시도하세요',
503,
'SERVICE_UNAVAILABLE'
)
}
}
민감한 정보 유출 방지
초기 프로젝트에서 데이터베이스 오류 정보를 그대로 프론트엔드에 반환했다가 데이터베이스 테이블 구조가 노출된 적이 있습니다. 올바른 처리 방법은 다음과 같습니다.
try {
const user = await db.user.create(body)
return successResponse(user)
} catch (error) {
// ❌ 위험: 원본 오류를 그대로 반환
// return errorResponse(error.message, 500)
// ✅ 안전: 일반적인 오류만 반환하고 상세 정보는 로그에만 기록
console.error('Database error:', {
error,
userId: request.headers.get('user-id'),
timestamp: new Date().toISOString()
})
return errorResponse(
'사용자를 생성하지 못했습니다. 잠시 후 다시 시도하세요',
500,
'CREATE_USER_FAILED'
)
}
프로덕션 환경과 개발 환경도 구분해야 합니다.
const isDev = process.env.NODE_ENV === 'development'
return Response.json({
success: false,
error: '작업 실패',
// 개발 환경에서만 상세 오류 반환
stack: isDev ? error.stack : undefined,
details: isDev ? error : undefined
}, { status: 500 })
응답 형식 설계: 프론트엔드와 백엔드 협업의 핵심
RESTful API 설계 원칙
API를 설계할 때는 RESTful 원칙을 따르는 편입니다. 반드시 엄격하게 지켜야 한다는 뜻은 아니지만, 이 규칙을 따르면 API를 훨씬 쉽게 이해할 수 있습니다.
핵심은 다음과 같습니다.
-
HTTP 메서드로 작업 표현:
- GET: 리소스 가져오기
- POST: 리소스 생성
- PUT/PATCH: 리소스 업데이트
- DELETE: 리소스 삭제
-
URL로 리소스 표현:
/api/users- 사용자 목록/api/users/123- ID가 123인 사용자/api/users/123/posts- 해당 사용자의 게시물
-
상태 코드로 결과 표현:
- 200: 성공
- 201: 생성 성공
- 400: 클라이언트 매개변수 오류
- 401: 로그인하지 않음
- 403: 권한 없음
- 404: 리소스를 찾을 수 없음
- 500: 서버 오류
예를 들어 사용자 관리 API는 다음과 같이 설계할 수 있습니다.
// app/api/users/route.ts
export async function GET(request: Request) {
// GET /api/users?page=1&limit=20
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit
})
return Response.json({ success: true, data: users })
}
export async function POST(request: Request) {
// POST /api/users
const body = await request.json()
const user = await db.user.create({ data: body })
return Response.json(
{ success: true, data: user },
{ status: 201 } // 여기서는 201 사용
)
}
// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
// GET /api/users/123
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) {
return Response.json(
{ success: false, error: '사용자를 찾을 수 없습니다' },
{ status: 404 }
)
}
return Response.json({ success: true, data: user })
}
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
// PATCH /api/users/123
const body = await request.json()
const user = await db.user.update({
where: { id: params.id },
data: body
})
return Response.json({ success: true, data: user })
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
// DELETE /api/users/123
await db.user.delete({ where: { id: params.id } })
return Response.json({ success: true, data: null })
}
통일된 응답 형식
앞서 오류 처리를 설명하면서 통일된 응답 형식을 언급했습니다. 여기서 조금 더 완성해 보겠습니다. 현재 제가 사용하는 형식은 다음과 같습니다.
// 기본 응답 타입
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string; code?: string }
// 페이지네이션 응답
interface PaginatedResponse<T> {
success: true
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
// 목록 응답 예제
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const [users, total] = await Promise.all([
db.user.findMany({ skip: (page - 1) * limit, take: limit }),
db.user.count()
])
return Response.json({
success: true,
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
})
}
TypeScript 타입 정의
프론트엔드와 백엔드 모두 TypeScript를 사용한다면 타입 정의도 공유하는 편이 좋습니다. 저는 보통 프로젝트에 types 폴더를 만듭니다.
// types/api.ts
export interface User {
id: string
username: string
email: string
createdAt: string
}
export interface CreateUserRequest {
username: string
email: string
password: string
}
export interface CreateUserResponse {
success: true
data: User
}
// types/api-client.ts
import type { CreateUserRequest, CreateUserResponse } from './api'
export async function createUser(data: CreateUserRequest) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
const result: CreateUserResponse = await response.json()
if (!result.success) {
throw new Error(result.error)
}
return result.data
}
이렇게 하면 프론트엔드에서 API를 호출할 때 완전한 타입 힌트를 받을 수 있어 코드를 훨씬 편하게 작성할 수 있습니다.
자주 발생하는 문제와 해결 방법
GET 요청의 기본 캐시 문제
제가 가장 크게 고생한 문제입니다. 사용자 정보를 조회하는 API를 만든 적이 있는데 로컬 테스트에서는 모두 정상이었지만, 배포 후 사용자 정보를 수정해도 프론트엔드에는 이전 데이터가 계속 표시됐습니다. 한참을 조사한 끝에 Next.js가 GET 요청을 기본적으로 캐시한다는 사실을 알게 됐습니다.
왜 이렇게 동작할까요? Next.js 팀은 많은 GET 요청이 정적 데이터를 반환하므로 캐시를 사용하면 성능을 높일 수 있다고 판단한 것입니다. 하지만 실제 개발에서는 대부분의 GET 요청이 동적이며 실시간 데이터가 필요합니다.
해결 방법은 간단합니다. route.ts 파일에 설정 한 줄을 추가하면 됩니다.
// app/api/users/route.ts
export const dynamic = 'force-dynamic' // 캐시 비활성화
export async function GET() {
const users = await db.user.findMany()
return Response.json({ success: true, data: users })
}
다른 방법도 있습니다.
// 방법 2: revalidate를 0으로 설정
export const revalidate = 0
// 방법 3: 응답 헤더에 Cache-Control 설정
export async function GET() {
const users = await db.user.findMany()
return Response.json(
{ success: true, data: users },
{
headers: {
'Cache-Control': 'no-store, max-age=0'
}
}
)
}
캐시는 언제 필요할까요? 국가 목록이나 카테고리 목록처럼 거의 바뀌지 않는 데이터를 API가 반환한다면 캐시를 활용할 수 있습니다.
// app/api/countries/route.ts
export const revalidate = 3600 // 1시간 캐시
export async function GET() {
const countries = await db.country.findMany()
return Response.json({ success: true, data: countries })
}
배포 후 API 404 문제
로컬 개발에서는 모두 정상인데 Vercel이나 Netlify에 배포한 뒤 API가 전부 404를 반환하는 경우가 있습니다. 저도 여러 번 겪은 문제이며 원인은 대개 다음과 같습니다.
- 잘못된 파일 이름: 반드시
route.ts또는route.js여야 하며api.ts나 다른 이름은 사용할 수 없습니다. - 잘못된 위치:
route.ts와page.tsx를 같은 폴더에 둘 수 없습니다. - git에 커밋되지 않음:
.gitignore를 확인하고 route 파일이 커밋되었는지 확인하세요.
확인 목록은 다음과 같습니다.
# ✅ 올바른 구조
app/
api/
users/
route.ts # 올바름: GET /api/users
users/
[id]/
route.ts # 올바름: GET /api/users/123
# ❌ 잘못된 구조
app/
api/
users.ts # 잘못됨: route.ts여야 함
users/
page.tsx
route.ts # 잘못됨: page.tsx와 같은 수준에 둘 수 없음
놓치기 쉬운 또 다른 점은 next.config.js에서 app 디렉터리를 제외하지 않았는지 확인하는 것입니다.
// next.config.js
module.exports = {
// 이 설정이 있으면 app 디렉터리가 무시되므로 사용하지 마세요
// pageExtensions: ['page.tsx', 'page.ts'],
}
try-catch 안에서 Redirect를 사용할 때의 문제
Next.js의 redirect() 함수는 리디렉션을 구현하기 위해 특수한 오류를 던집니다. 이를 try-catch 안에 넣으면 해당 오류가 잡히므로 리디렉션이 작동하지 않습니다.
import { redirect } from 'next/navigation'
// ❌ 잘못된 방식
export async function GET() {
try {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login') // 이 리디렉션 오류를 catch가 가로챔
}
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: '작업 실패' }, { status: 500 })
}
}
// ✅ 올바른 방식
export async function GET() {
const isLoggedIn = await checkAuth()
if (!isLoggedIn) {
redirect('/login') // try-catch 밖에서 호출
}
try {
const data = await fetchData()
return Response.json({ success: true, data })
} catch (error) {
return Response.json({ error: '작업 실패' }, { status: 500 })
}
}
다만 솔직히 Route Handlers에서 redirect()를 사용할 일은 많지 않습니다. 대부분은 401 상태 코드를 바로 반환하고 프론트엔드에서 이동을 처리하도록 하면 됩니다.
Route Handlers가 필요하지 않은 경우
App Router를 처음 접했을 때는 모든 데이터 요청에 API를 작성해야 한다고 생각했습니다. 하지만 Server Components에서는 백엔드 로직을 직접 호출할 수 있으므로 HTTP 요청을 한 번 더 거칠 필요가 없습니다.
예를 들면 다음과 같습니다.
// ❌ 권장하지 않음: API를 만든 뒤 컴포넌트에서 다시 호출
// app/api/posts/route.ts
export async function GET() {
const posts = await db.post.findMany()
return Response.json({ success: true, data: posts })
}
// app/blog/page.tsx
async function BlogPage() {
const res = await fetch('http://localhost:3000/api/posts')
const { data } = await res.json()
return <div>{/* 게시물 목록 렌더링 */}</div>
}
// ✅ 권장: Server Component에서 직접 조회
// app/blog/page.tsx
async function BlogPage() {
const posts = await db.post.findMany() // 데이터베이스 직접 조회
return <div>{/* 게시물 목록 렌더링 */}</div>
}
Route Handlers가 필요한 경우는 언제일까요?
- 외부 호출: 모바일 앱이나 서드파티 서비스에 API를 제공할 때
- Webhook: 서드파티 서비스의 콜백을 받을 때
- 클라이언트 컴포넌트의 데이터 변경: 폼 제출, 삭제 작업 등을 처리할 때
- 복잡한 비즈니스 로직: 파일 업로드를 처리하거나 여러 외부 API를 호출해야 할 때
Route Handlers가 필요하지 않은 경우는 언제일까요?
- Server Components에서 데이터 가져오기: 데이터베이스를 직접 조회하는 편이 더 빠릅니다.
- 간단한 폼 제출: Server Actions를 사용하는 편이 더 간단합니다.
- 내부 페이지 이동: Next.js 라우팅을 사용하면 됩니다.
고급 기법: 더 전문적인 API 만들기
입력 검증
앞의 예제에서는 if 문으로 매개변수를 직접 검증했습니다. 이렇게 작성하면 번거롭기 때문에 현재는 Zod를 사용합니다.
import { z } from 'zod'
// 검증 규칙 정의
const createUserSchema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(18).optional()
})
export async function POST(request: Request) {
const body = await request.json()
// 데이터 검증
const result = createUserSchema.safeParse(body)
if (!result.success) {
return Response.json({
success: false,
error: '데이터 검증 실패',
details: result.error.errors // 상세 검증 오류 반환
}, { status: 400 })
}
// result.data는 검증을 마친 타입 안전 데이터
const user = await db.user.create({ data: result.data })
return Response.json({ success: true, data: user }, { status: 201 })
}
Zod의 장점은 검증과 타입 정의를 하나로 합칠 수 있다는 것입니다.
// schema에서 TypeScript 타입 추론
type CreateUserInput = z.infer<typeof createUserSchema>
// 다음과 동일:
// type CreateUserInput = {
// username: string
// email: string
// password: string
// age?: number
// }
미들웨어 패턴
API를 몇 개 작성하다 보면 인증, 로깅, 오류 처리처럼 반복되는 코드가 많다는 사실을 알게 됩니다. 이때 미들웨어로 추상화할 수 있습니다.
// lib/middleware.ts
type RouteHandler = (request: Request, context: any) => Promise<Response>
// 인증 미들웨어
export function withAuth(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const token = request.headers.get('authorization')
if (!token) {
return Response.json(
{ success: false, error: '로그인이 필요합니다' },
{ status: 401 }
)
}
// token 검증
const user = await verifyToken(token)
if (!user) {
return Response.json(
{ success: false, error: '유효하지 않은 Token입니다' },
{ status: 401 }
)
}
// 사용자 정보를 handler에 전달
context.user = user
return handler(request, context)
}
}
// 로깅 미들웨어
export function withLogging(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const start = Date.now()
const { method, url } = request
console.log(`[${method}] ${url} - 처리 시작`)
const response = await handler(request, context)
const duration = Date.now() - start
console.log(`[${method}] ${url} - 완료 (${duration}ms)`)
return response
}
}
// 조합해서 사용
// app/api/profile/route.ts
import { withAuth, withLogging } from '@/lib/middleware'
async function getProfile(request: Request, context: any) {
const user = context.user // 미들웨어에서 사용자 정보 가져오기
return Response.json({ success: true, data: user })
}
export const GET = withLogging(withAuth(getProfile))
이 패턴은 매우 유용합니다. 실제 프로젝트에서는 Zod 검증도 함께 사용합니다.
// lib/middleware.ts
export function withValidation<T>(
schema: z.Schema<T>,
handler: (request: Request, data: T, context: any) => Promise<Response>
): RouteHandler {
return async (request, context) => {
const body = await request.json()
const result = schema.safeParse(body)
if (!result.success) {
return Response.json({
success: false,
error: '데이터 검증 실패',
details: result.error.errors
}, { status: 400 })
}
return handler(request, result.data, context)
}
}
// 사용법
export const POST = withAuth(
withValidation(createUserSchema, async (request, data, context) => {
// data는 검증을 마친 타입 안전 데이터
const user = await db.user.create({ data })
return Response.json({ success: true, data: user }, { status: 201 })
})
)
Edge Runtime 선택
Next.js는 Node.js Runtime과 Edge Runtime이라는 두 가지 런타임을 지원합니다. 대부분의 경우 기본 Node.js Runtime이면 충분하지만, API가 전 세계 사용자에게 빠르게 응답해야 한다면 Edge Runtime을 고려할 수 있습니다.
// app/api/hello/route.ts
export const runtime = 'edge' // Edge Runtime 사용 지정
export async function GET() {
return Response.json({ message: 'Hello from Edge!' })
}
Edge Runtime은 코드를 사용자와 가장 가까운 엣지 노드에 배포하므로 응답이 빠르다는 장점이 있습니다. 하지만 다음과 같은 제약도 있습니다.
- Node.js API 사용 불가:
fs,path같은 모듈을 사용할 수 없습니다. - 기존 데이터베이스에 연결 불가: Prisma Data Proxy, PlanetScale처럼 HTTP 연결을 지원하는 데이터베이스가 필요합니다.
- 번들 크기 제한: 코드가 너무 크면 배포할 수 없습니다.
Edge Runtime은 언제 사용하면 좋을까요?
- 복잡한 의존성이 필요하지 않은 간단한 API
- 쓰기보다 읽기가 많은 경우
- 전 세계에서 짧은 지연 시간이 필요한 경우
Node.js Runtime은 언제 사용하면 좋을까요?
- 기존 데이터베이스에 연결해야 할 때
- Node.js 생태계의 라이브러리를 사용해야 할 때
- 복잡한 비즈니스 로직을 처리할 때
솔직히 저는 대부분의 프로젝트에서 기본 Node.js Runtime을 사용합니다. Edge Runtime은 현재로서는 특정 사례에 더 적합합니다.
결론
지금까지 Next.js API Routes의 핵심 내용을 모두 살펴봤습니다. 요약하면 다음과 같습니다.
- 작성 방식의 변화: Pages Router의
req/res에서 App Router의Request/Response로 전환해 웹 표준을 채택했습니다. - Route Handlers: HTTP 메서드마다 함수를 별도로 내보내므로 구조가 명확하며 GET, POST, PUT, PATCH, DELETE 등을 지원합니다.
- 요청 처리: URL 매개변수, JSON body, FormData, Headers, Cookies, 동적 라우트 매개변수 등 다양한 방식으로 데이터를 가져올 수 있습니다.
- 오류 처리: 예상 가능한 오류와 예기치 않은 오류를 구분하고 응답 형식을 통일하며 민감한 정보 유출을 방지해야 합니다.
- 응답 설계: RESTful 원칙을 따르고 상태 코드에 명확한 의미를 부여하며 TypeScript 타입을 공유합니다.
- 자주 발생하는 문제: GET 캐시, 배포 후 404, try-catch 안에서 작동하지 않는 redirect, Route Handlers의 과도한 사용을 주의해야 합니다.
솔직히 Next.js의 API 작성 방식은 Pages Router에서 App Router로 넘어오면서 상당히 많이 달라졌기 때문에 처음에는 다소 낯설 수 있습니다. 하지만 한동안 사용하면 새 방식이 현대적인 웹 개발의 방향에 더 잘 맞는다는 것을 알게 됩니다. 타입은 더 안전하고, 코드는 더 명확하며, 배포 방식은 더 유연합니다.
다음 단계:
- 바로 실습하기: 기존 프로젝트의 API 하나를 Route Handlers로 다시 작성하며 차이를 직접 경험해 보세요.
- 템플릿 만들기: 이 글의 오류 처리 및 응답 형식 코드를 바탕으로 재사용할 수 있는 API 템플릿을 정리하세요.
- 계속 학습하기: Next.js는 빠르게 발전하고 있으므로 공식 문서의 업데이트를 확인하고 Server Actions, Middleware 같은 새로운 기능을 익히세요.
API 작성에 만능 해법은 없으며 이 글에서 소개한 방법만이 유일한 정답도 아닙니다. 프로젝트의 실제 상황에 맞게 유연하게 조정하고 팀에 가장 잘 맞는 방식을 찾는 것이 중요합니다.
이 글이 도움이 됐다면 저장해 두었다가 문제가 생길 때 다시 확인해 보세요. Next.js 개발이 순조롭게 진행되길 바랍니다!
FAQ
Route Handlers와 Pages Router API Routes의 주요 차이점은 무엇인가요?
GET 요청이 최신 데이터를 반환하지 않는 이유는 무엇인가요?
Route Handlers와 Server Components는 각각 언제 사용해야 하나요?
API 오류를 깔끔하게 처리하려면 어떻게 해야 하나요?
로컬에서는 정상인데 배포 후 API가 모두 404를 반환하면 어떻게 해야 하나요?
9분 읽기 · 게시일: 2026년 1월 5일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js 다국어 SEO 최적화 가이드: 검색 엔진이 언어별 페이지를 올바르게 색인하게 만드는 법
Next.js에서 hreflang 태그, 다국어 Sitemap, URL 전략을 설정하는 방법을 설명합니다. 흔한 오류를 피하고 언어별 페이지가 검색 결과에 정확히 노출되도록 구성해 보세요.
45편 중 15편
다음
Next.js API 인증과 보안: JWT부터 속도 제한까지 완벽 실전 가이드
JWT 인증, CORS 설정, 속도 제한, 입력 검증부터 최신 보안 취약점 대응까지 안전하고 신뢰할 수 있는 프로덕션급 Next.js API를 구축하는 실전 가이드입니다.
45편 중 17편



댓글
GitHub로 로그인하여 댓글을 남기세요