Route Handler는 Next.js App Router에서 프론트엔드 프로젝트 내부에 서버 사이드 API 엔드포인트를 직접 구축할 수 있게 해주는 기능으로, 보안·CORS·웹훅·스트리밍 같은 서버 전용 작업을 담당하는 BFF 계층 역할을 한다.
등장 배경
-
전통적인 웹 개발 환경에서 프론트엔드는 이미 구축된 백엔드 서버의 API URL로 요청을 보내 데이터를 주고받는 역할만 수행해왔다.
-
이 구조에서 프론트엔드 개발자는 외부 API 엔드포인트를 호출하고 응답을 UI에 렌더링하는 클라이언트 중심 로직에만 집중하게 된다.
-
그런데 클라이언트가 직접 외부 API를 호출하는 방식에는 몇 가지 구조적인 한계가 존재한다.
-
첫째, API 호출에 필요한 시크릿 키가 브라우저에 노출되어 보안 취약점이 생길 수 있다.
-
예를 들어 클라이언트에서 OpenAI API를 직접 호출하면 요청 헤더에 담긴 API 키가 브라우저 네트워크 탭에 그대로 보이므로, 누구든 이 키를 복사해 자기 멋대로 API를 호출할 수 있게 된다.
-
둘째, 타 도메인 API 호출 시 발생하는 CORS 오류는 브라우저 보안 정책이라 클라이언트 단에서는 통제할 수 없다.
-
예를 들어 프론트엔드가
myshop.com에서 동작하는데 백엔드 API가api.partner.com에 있다면, 두 출처가 다르기 때문에 브라우저가 요청을 차단하고, 이 차단은 응답 서버가Access-Control-Allow-Origin헤더를 내려줘야만 풀린다. -
셋째, 여러 API를 조합하거나 데이터를 가공해야 할 때 이를 클라이언트에서 처리하면 브라우저 연산 부담이 커지고 로직도 복잡해진다.
-
예를 들어 마이페이지에서 유저 정보·주문 내역·추천 상품 API를 각각 호출해 클라이언트에서 합치고 정렬해야 한다면, 저사양 모바일 기기에서는 렌더링이 끊기고 컴포넌트 코드도 비대해진다.
-
이러한 문제를 해결하기 위해 Next.js App Router는 프론트엔드 프로젝트 내부에서 자체적인 서버 로직을 구현할 수 있는 Route Handler를 제공한다.
정의와 특징
-
Route Handler는
app디렉토리 안에route.ts파일을 두고, 웹 표준Request/ResponseAPI를 사용해 특정 경로로 들어오는 HTTP 요청을 처리하는 서버 사이드 엔드포인트다. -
이 파일의 코드는 클라이언트 자바스크립트 번들에 포함되지 않고 오직 서버에서만 실행된다.
-
따라서
.env에 보관된 DB 비밀번호나 결제 API 시크릿 키 같은 민감 정보를 이 파일 안에서 사용해도 브라우저 개발자 도구에 노출되지 않는다. -
또한 라우트 핸들러에서 외부 API를 호출하면 요청이 서버 대 서버로 이루어지므로, 브라우저의 CORS 제약을 자연스럽게 우회할 수 있다.
-
이러한 특성 때문에 Route Handler는 프론트엔드와 외부 백엔드 사이에 위치하는 BFF(Backend For Frontend) 계층 역할을 수행한다.
파일 시스템 구조
-
app/api하위에 폴더를 만들고 그 안에route.ts를 배치하는 방식으로 엔드포인트 경로가 결정된다.내 프로젝트 루트 폴더 ┗ 📂 app ┣ 📂 api ┃ ┗ 📂 mypage ┃ ┗ route.ts <-- 백엔드(BFF): /api/mypage 요청 처리 ┗ 📂 mypage ┗ page.tsx <-- 프론트엔드: /mypage 페이지 -
클라이언트 컴포넌트에서는 외부 백엔드 서버가 아닌 Next.js 내부의 라우트 핸들러로만 요청을 보내고, 민감한 처리는 라우트 핸들러가 대신 수행한다.
// app/mypage/page.tsx 'use client'; export default function MyPage() { useEffect(() => { // 외부 서버가 아닌 내부 라우트 핸들러로 요청한다. fetch('/api/mypage') .then((res) => res.json()) .then((data) => setData(data)); }, []); } -
라우트 핸들러 파일 안에서는
GET,POST등 HTTP 메서드 이름으로 함수를export하면, 해당 메서드의 요청이 들어왔을 때 그 함수가 실행되는 구조다.// app/api/mypage/route.ts import { NextResponse } from 'next/server'; export async function GET() { // 시크릿 키는 서버에서만 쓰이므로 브라우저에 노출되지 않는다. const res = await fetch(`${process.env.DJANGO_URL}/api/users/1`); const data = await res.json(); return NextResponse.json(data, { status: 200 }); }
서버 컴포넌트·서버 액션과의 비교
-
Next.js App Router가 등장하면서 과거 라우트 핸들러가 담당하던 역할 일부를 서버 컴포넌트와 서버 액션이 대체할 수 있게 되었다.
-
서버 컴포넌트는 컴포넌트 자체가 서버에서 실행되므로, 별도의 API 엔드포인트 없이
async/await로 직접 DB나 외부 API를 호출해 데이터를 가져올 수 있다. -
서버 액션은
'use server'지시어를 붙인 함수로, 버튼 클릭이나 폼 제출 같은 사용자 인터랙션 시 서버에서 실행되는 로직을 컴포넌트 안에서 바로 정의할 수 있게 해준다. -
두 방식 모두
fetch()호출과 엔드포인트 파일 작성 없이 서버 로직을 함수처럼 직접 호출하므로 코드가 훨씬 간결해진다. -
그래서 Next.js 공식 문서는 데이터 조회는 서버 컴포넌트, 데이터 변경은 서버 액션으로 처리할 것을 권장한다.
-
그럼에도 라우트 핸들러가 여전히 필요한 상황이 다섯 가지 정도 존재한다.
여전히 라우트 핸들러가 필요한 경우 1 - 외부 서비스로부터 웹훅 수신
-
웹훅이란 특정 이벤트가 발생했을 때 외부 서비스가 우리 서버로 HTTP POST 요청을 자동으로 보내주는 방식이다.
-
예를 들어 Stripe에서 결제가 완료되면, Stripe 서버가 우리가 등록해 둔 URL로 결제 완료 이벤트 데이터를 직접 전송한다.
-
이때 외부 서버는 Next.js 내부 구조를 알 수 없으므로 반드시 공개된 HTTP URL 엔드포인트가 필요하다.
-
서버 액션은 Next.js 앱 내부에서만 호출되는 함수이기 때문에 외부 서비스가 직접 접근하는 것이 구조적으로 불가능하다.
-
따라서 외부 서비스가 호출할 수 있는 실제 URL 엔드포인트는 라우트 핸들러만이 제공할 수 있다.
// app/api/webhooks/stripe/route.ts export async function POST(req: NextRequest) { const body = await req.text(); // Stripe가 보낸 서명 헤더로 요청의 진위를 검증한다. const signature = req.headers.get('stripe-signature'); // 검증 통과 시 이벤트 종류에 따라 DB 업데이트 등을 수행한다. return NextResponse.json({ received: true }, { status: 200 }); } -
Stripe 대시보드에 웹훅 URL을
https://내도메인/api/webhooks/stripe로 등록해두면, 결제 이벤트가 발생할 때마다 이 라우트 핸들러가 자동으로 호출된다.
여전히 라우트 핸들러가 필요한 경우 2 - 응답 헤더 직접 제어와 파일 다운로드
-
서버 컴포넌트는 HTML UI 렌더링이 목적이므로 HTTP 응답 자체를 자유롭게 조작하는 데에는 적합하지 않다.
-
예를 들어 사용자가 버튼을 눌렀을 때 CSV나 PDF 파일을 즉석에서 생성해 다운로드시키는 기능을 구현한다고 가정해보자.
-
이런 경우
Content-Disposition헤더를 직접 설정해 브라우저가 응답을 일반 페이지가 아닌 파일로 인식하게 만들어야 한다. -
이러한 응답 헤더 제어는 라우트 핸들러에서만 자연스럽게 처리할 수 있다.
// app/api/export/route.ts export async function GET() { const csvData = '이름,이메일\n홍길동,hong@example.com'; return new NextResponse(csvData, { headers: { // 이 헤더가 있어야 브라우저가 다운로드로 처리한다. 'Content-Disposition': 'attachment; filename="users.csv"', 'Content-Type': 'text/csv', }, }); }
여전히 라우트 핸들러가 필요한 경우 3 - CORS 헤더 직접 설정
-
별도로 운영 중인 React SPA나 모바일 WebView가 Next.js 서버의 API를 호출해야 하는 경우, CORS 정책을 서버에서 명시적으로 허용해주어야 한다.
-
라우트 핸들러에서는 응답 헤더에
Access-Control-Allow-Origin을 직접 설정해 어떤 도메인의 요청을 허용할지 제어할 수 있다. -
추가로 브라우저가 실제 요청을 보내기 전에 미리 보내는 Preflight 요청을 처리하기 위해
OPTIONS메서드 핸들러도 함께 정의해야 한다.// app/api/public-data/route.ts export async function GET() { return NextResponse.json( { message: '공개 데이터입니다.' }, { headers: { 'Access-Control-Allow-Origin': 'https://my-external-app.com', }, } ); } // Preflight 요청에 응답하기 위한 OPTIONS 핸들러 export async function OPTIONS() { return new NextResponse(null, { status: 204, headers: { 'Access-Control-Allow-Origin': 'https://my-external-app.com', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', }, }); }
여전히 라우트 핸들러가 필요한 경우 4 - 데이터 스트리밍
-
대용량 데이터나 AI 응답처럼 처리 시간이 긴 경우, 결과를 한 번에 반환하지 않고 청크 단위로 잘라 스트리밍해야 할 때가 있다.
-
서버 컴포넌트도 스트리밍을 지원하지만 이는 React UI 렌더링 스트리밍이며, 순수한 데이터 스트림을 외부로 노출하는 용도와는 다르다.
-
라우트 핸들러에서는 Web Streams API의
ReadableStream을 사용해 데이터를 청크 단위로 클라이언트에 밀어줄 수 있다.// app/api/stream/route.ts export async function GET() { const stream = new ReadableStream({ async start(controller) { const chunks = ['첫 번째 청크', '두 번째 청크', '세 번째 청크']; for (const chunk of chunks) { // 데이터를 한 번에 보내지 않고 순서대로 하나씩 전송한다. controller.enqueue(new TextEncoder().encode(chunk)); await new Promise((r) => setTimeout(r, 500)); } controller.close(); }, }); return new NextResponse(stream); } -
실제로 ChatGPT 같은 AI 챗봇 UI에서 답변이 한 글자씩 흘러나오는 효과가 바로 이 스트리밍 방식으로 구현된 것이다.
-
정리하면 서버 컴포넌트 스트리밍은 HTML 스트림, 라우트 핸들러 스트리밍은 데이터 스트림이라고 이해하면 된다.
여전히 라우트 핸들러가 필요한 경우 5 - 클라이언트 인터랙션 기반의 동적 데이터 조회
-
검색창에 키워드를 입력하면 실시간으로 결과를 가져오는 자동완성처럼,
useState로 상태를 관리하면서 동적으로 데이터를 요청해야 하는 경우가 존재한다. -
이런 구조는 컴포넌트가 사용자 상호작용에 의존하기 때문에 서버 컴포넌트로 전환하기가 어렵다.
-
이때는 클라이언트 컴포넌트 안에서
fetch로 라우트 핸들러를 호출하는 것이 가장 현실적인 방법이 된다.// app/search/page.tsx 'use client'; export default function SearchPage() { const [results, setResults] = useState([]); async function handleSearch(keyword: string) { // 키 입력마다 라우트 핸들러로 요청을 보내 결과를 받아온다. const res = await fetch(`/api/search?q=${keyword}`); const data = await res.json(); setResults(data); } return <input onChange={(e) => handleSearch(e.target.value)} />; }