프로필 로고
2026-04-11

Route Handler

Next.js App Router의 Route Handler가 시크릿 키 보호·CORS 우회·웹훅 수신·스트리밍을 담당하는 BFF 계층 역할, 서버 컴포넌트·서버 액션과의 비교와 라우트 핸들러가 여전히 필요한 다섯 가지 경우

  • Next.js
  • Route Handler
  • BFF

Route Handler는 Next.js App Router에서 프론트엔드 프로젝트 내부에 서버 사이드 API 엔드포인트를 직접 구축할 수 있게 해주는 기능으로, 보안·CORS·웹훅·스트리밍 같은 서버 전용 작업을 담당하는 BFF 계층 역할을 한다.

등장 배경

  1. 전통적인 웹 개발 환경에서 프론트엔드는 이미 구축된 백엔드 서버의 API URL로 요청을 보내 데이터를 주고받는 역할만 수행해왔다.

  2. 이 구조에서 프론트엔드 개발자는 외부 API 엔드포인트를 호출하고 응답을 UI에 렌더링하는 클라이언트 중심 로직에만 집중하게 된다.

  3. 그런데 클라이언트가 직접 외부 API를 호출하는 방식에는 몇 가지 구조적인 한계가 존재한다.

  4. 첫째, API 호출에 필요한 시크릿 키가 브라우저에 노출되어 보안 취약점이 생길 수 있다.

  5. 예를 들어 클라이언트에서 OpenAI API를 직접 호출하면 요청 헤더에 담긴 API 키가 브라우저 네트워크 탭에 그대로 보이므로, 누구든 이 키를 복사해 자기 멋대로 API를 호출할 수 있게 된다.

  6. 둘째, 타 도메인 API 호출 시 발생하는 CORS 오류는 브라우저 보안 정책이라 클라이언트 단에서는 통제할 수 없다.

  7. 예를 들어 프론트엔드가 myshop.com에서 동작하는데 백엔드 API가 api.partner.com에 있다면, 두 출처가 다르기 때문에 브라우저가 요청을 차단하고, 이 차단은 응답 서버가 Access-Control-Allow-Origin 헤더를 내려줘야만 풀린다.

  8. 셋째, 여러 API를 조합하거나 데이터를 가공해야 할 때 이를 클라이언트에서 처리하면 브라우저 연산 부담이 커지고 로직도 복잡해진다.

  9. 예를 들어 마이페이지에서 유저 정보·주문 내역·추천 상품 API를 각각 호출해 클라이언트에서 합치고 정렬해야 한다면, 저사양 모바일 기기에서는 렌더링이 끊기고 컴포넌트 코드도 비대해진다.

  10. 이러한 문제를 해결하기 위해 Next.js App Router는 프론트엔드 프로젝트 내부에서 자체적인 서버 로직을 구현할 수 있는 Route Handler를 제공한다.

정의와 특징

  1. Route Handler는 app 디렉토리 안에 route.ts 파일을 두고, 웹 표준 Request/Response API를 사용해 특정 경로로 들어오는 HTTP 요청을 처리하는 서버 사이드 엔드포인트다.

  2. 이 파일의 코드는 클라이언트 자바스크립트 번들에 포함되지 않고 오직 서버에서만 실행된다.

  3. 따라서 .env에 보관된 DB 비밀번호나 결제 API 시크릿 키 같은 민감 정보를 이 파일 안에서 사용해도 브라우저 개발자 도구에 노출되지 않는다.

  4. 또한 라우트 핸들러에서 외부 API를 호출하면 요청이 서버 대 서버로 이루어지므로, 브라우저의 CORS 제약을 자연스럽게 우회할 수 있다.

  5. 이러한 특성 때문에 Route Handler는 프론트엔드와 외부 백엔드 사이에 위치하는 BFF(Backend For Frontend) 계층 역할을 수행한다.

파일 시스템 구조

  1. app/api 하위에 폴더를 만들고 그 안에 route.ts를 배치하는 방식으로 엔드포인트 경로가 결정된다.

    내 프로젝트 루트 폴더
     ┗ 📂 app
        ┣ 📂 api
        ┃ ┗ 📂 mypage
        ┃    ┗ route.ts      <-- 백엔드(BFF): /api/mypage 요청 처리
        ┗ 📂 mypage
           ┗ page.tsx        <-- 프론트엔드: /mypage 페이지
    
  2. 클라이언트 컴포넌트에서는 외부 백엔드 서버가 아닌 Next.js 내부의 라우트 핸들러로만 요청을 보내고, 민감한 처리는 라우트 핸들러가 대신 수행한다.

    // app/mypage/page.tsx
    'use client';
     
    export default function MyPage() {
      useEffect(() => {
        // 외부 서버가 아닌 내부 라우트 핸들러로 요청한다.
        fetch('/api/mypage')
          .then((res) => res.json())
          .then((data) => setData(data));
      }, []);
    }
  3. 라우트 핸들러 파일 안에서는 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 });
    }

서버 컴포넌트·서버 액션과의 비교

  1. Next.js App Router가 등장하면서 과거 라우트 핸들러가 담당하던 역할 일부를 서버 컴포넌트와 서버 액션이 대체할 수 있게 되었다.

  2. 서버 컴포넌트는 컴포넌트 자체가 서버에서 실행되므로, 별도의 API 엔드포인트 없이 async/await로 직접 DB나 외부 API를 호출해 데이터를 가져올 수 있다.

  3. 서버 액션은 'use server' 지시어를 붙인 함수로, 버튼 클릭이나 폼 제출 같은 사용자 인터랙션 시 서버에서 실행되는 로직을 컴포넌트 안에서 바로 정의할 수 있게 해준다.

  4. 두 방식 모두 fetch() 호출과 엔드포인트 파일 작성 없이 서버 로직을 함수처럼 직접 호출하므로 코드가 훨씬 간결해진다.

  5. 그래서 Next.js 공식 문서는 데이터 조회는 서버 컴포넌트, 데이터 변경은 서버 액션으로 처리할 것을 권장한다.

  6. 그럼에도 라우트 핸들러가 여전히 필요한 상황이 다섯 가지 정도 존재한다.

여전히 라우트 핸들러가 필요한 경우 1 - 외부 서비스로부터 웹훅 수신

  1. 웹훅이란 특정 이벤트가 발생했을 때 외부 서비스가 우리 서버로 HTTP POST 요청을 자동으로 보내주는 방식이다.

  2. 예를 들어 Stripe에서 결제가 완료되면, Stripe 서버가 우리가 등록해 둔 URL로 결제 완료 이벤트 데이터를 직접 전송한다.

  3. 이때 외부 서버는 Next.js 내부 구조를 알 수 없으므로 반드시 공개된 HTTP URL 엔드포인트가 필요하다.

  4. 서버 액션은 Next.js 앱 내부에서만 호출되는 함수이기 때문에 외부 서비스가 직접 접근하는 것이 구조적으로 불가능하다.

  5. 따라서 외부 서비스가 호출할 수 있는 실제 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 });
    }
  6. Stripe 대시보드에 웹훅 URL을 https://내도메인/api/webhooks/stripe로 등록해두면, 결제 이벤트가 발생할 때마다 이 라우트 핸들러가 자동으로 호출된다.

여전히 라우트 핸들러가 필요한 경우 2 - 응답 헤더 직접 제어와 파일 다운로드

  1. 서버 컴포넌트는 HTML UI 렌더링이 목적이므로 HTTP 응답 자체를 자유롭게 조작하는 데에는 적합하지 않다.

  2. 예를 들어 사용자가 버튼을 눌렀을 때 CSV나 PDF 파일을 즉석에서 생성해 다운로드시키는 기능을 구현한다고 가정해보자.

  3. 이런 경우 Content-Disposition 헤더를 직접 설정해 브라우저가 응답을 일반 페이지가 아닌 파일로 인식하게 만들어야 한다.

  4. 이러한 응답 헤더 제어는 라우트 핸들러에서만 자연스럽게 처리할 수 있다.

    // 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 헤더 직접 설정

  1. 별도로 운영 중인 React SPA나 모바일 WebView가 Next.js 서버의 API를 호출해야 하는 경우, CORS 정책을 서버에서 명시적으로 허용해주어야 한다.

  2. 라우트 핸들러에서는 응답 헤더에 Access-Control-Allow-Origin을 직접 설정해 어떤 도메인의 요청을 허용할지 제어할 수 있다.

  3. 추가로 브라우저가 실제 요청을 보내기 전에 미리 보내는 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 - 데이터 스트리밍

  1. 대용량 데이터나 AI 응답처럼 처리 시간이 긴 경우, 결과를 한 번에 반환하지 않고 청크 단위로 잘라 스트리밍해야 할 때가 있다.

  2. 서버 컴포넌트도 스트리밍을 지원하지만 이는 React UI 렌더링 스트리밍이며, 순수한 데이터 스트림을 외부로 노출하는 용도와는 다르다.

  3. 라우트 핸들러에서는 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);
    }
  4. 실제로 ChatGPT 같은 AI 챗봇 UI에서 답변이 한 글자씩 흘러나오는 효과가 바로 이 스트리밍 방식으로 구현된 것이다.

  5. 정리하면 서버 컴포넌트 스트리밍은 HTML 스트림, 라우트 핸들러 스트리밍은 데이터 스트림이라고 이해하면 된다.

여전히 라우트 핸들러가 필요한 경우 5 - 클라이언트 인터랙션 기반의 동적 데이터 조회

  1. 검색창에 키워드를 입력하면 실시간으로 결과를 가져오는 자동완성처럼, useState로 상태를 관리하면서 동적으로 데이터를 요청해야 하는 경우가 존재한다.

  2. 이런 구조는 컴포넌트가 사용자 상호작용에 의존하기 때문에 서버 컴포넌트로 전환하기가 어렵다.

  3. 이때는 클라이언트 컴포넌트 안에서 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)} />;
    }