인증과 Next.js (4) - BFF 패턴 & Proxy Handler, Next.js를 API 게이트웨이로

작성일: 2026년 3월 12일 오후 03:06(마지막 수정: 2026년 3월 27일 오후 06:48)
조회수: 36

시리즈 목차: 인증과 Next.js


https___dev-to-uploads.s3.amazonaws.com_uploads_articles_ds310ygvu82rb7yw74qo.webp

(4) BFF 패턴 & Proxy Handler — Next.js를 API 게이트웨이로

앞의 세 편에서 CORS, 쿠키 보안, withCredentials의 복잡한 조건들을 살펴봤다.

"이 모든 걸 다 맞춰야 동작한다고 생각하면, 너무 복잡한 것 같다."

맞다. 복잡하다. 그래서 Next.js는 이 복잡함을 아키텍처 수준에서 해결하는 방법을 제공한다. 바로 BFF(Backend for Frontend) 패턴Proxy 핸들러다.

이 글에서는 Next.js를 단순 프론트엔드 서버가 아닌 API 게이트웨이로 활용하는 방법과, 각 기능의 책임과 적합한 사용 시나리오를 정리합니다. 예시는 Next.js 앱을 기준으로 들지만, **"프론트엔드 서버를 BFF 레이어로 세운다"**는 관점은 다른 SPA 프레임워크(React, Vue, Svelte 등)에서도 그대로 응용할 수 있습니다.


1. BFF(Backend for Frontend) 패턴이란

BFF는 "프론트엔드를 위한 전용 백엔드"라는 의미입니다. 프론트엔드와 실제 백엔드 API 사이에 중간 레이어를 두는 아키텍처 패턴입니다.

기존 구조의 문제점

[브라우저] ─────────────► [백엔드 API 서버]
Client Component api.example.com
axios로 직접 호출

문제:

- CORS 설정 복잡함
- API Key가 클라이언트에 노출됨(NEXT_PUBLIC_)
- 여러 API를 조합하는 로직이 클라이언트에 존재할 수 있음
- 인증 토큰이 브라우저에 노출될 수 있음

BFF 구조

가장 간략한 구조를 그려보자면 다음과 같습니다.

[브라우저] ──► [Next.js BFF] ──► [백엔드 API 서버]
Route Handler api.example.com
(내 서버) (외부 서버)

이 구조는 BFF 레이어를 중심으로 더 확장될 수 있습니다.

BFF가 해결하는 것들

① CORS 완전 우회

CORS는 브라우저 즉, 클라이언트에서 발생하는 문제라고 했었죠?

중간에 BFF 레이어로써 작용하는 Next.js 서버가 들어오는 것만으로 CORS 문제가 구조적으로 사라질 수 있습니다.

1) 브라우저 ↔ Next.js: Same-Origin → CORS 없음

브라우저는 자신이 속한 Next.js 서버(my-app.com)와 통신하므로 Same-Origin이므로 CORS가 없습니다.

2) Next.js ↔ 외부 API: 서버 간 통신 → CORS 없음

실제 외부 API 호출은 서버에서 서버로 일어나므로 역시 CORS가 없습니다.

② 민감한 정보 은닉 서버를 거치기에 API Key, 내부 서버 주소, 토큰 등이 클라이언트에 전혀 노출되지 않습니다.

ts
// ✅ API Key가 서버에만 존재
const res = await fetch("https://api.example.com/data", {
  headers: {
    Authorization: `Bearer ${process.env.API_SECRET}`,
    // process.env.API_SECRET → 서버 전용, 클라이언트 번들에 포함 안 됨
  },
});

③ 데이터 집계 및 변환
클라이언트가 필요한 형태로 여러 API 응답을 조합하거나 불필요한 필드를 제거할 수 있습니다.

ts
// 클라이언트가 필요한 데이터만 골라서 전달
const [user, orders, notifications] = await Promise.all([
  fetch("/internal/user/42").then((r) => r.json()),
  fetch("/internal/orders?userId=42").then((r) => r.json()),
  fetch("/internal/notifications?userId=42").then((r) => r.json()),
]);

return Response.json({
  user: { name: user.name, email: user.email }, // 민감한 필드 제거
  orderCount: orders.total,
  hasUnread: notifications.unread > 0,
});

아래는 기존 글의 흐름을 유지하면서 독자가 헷갈리기 쉬운 부분을 보완한 버전이다. 특히 다음 부분을 보강했다.

  • 5단계 파이프라인 vs 3레이어 구조의 관계
  • Route Handler와 Server Component의 역할 차이
  • 왜 BFF가 Route Handler에 위치하는지
  • Middleware가 BFF가 아닌 이유

블로그에 그대로 넣어도 자연스럽게 읽히도록 작성했다.


2. 사전 지식: Next.js의 세 가지 서버 레이어

더 자세히 들어가봅시다.

Next.js에서 서버 측으로 BFF를 처리하기 위해서는, Next.js에서 요청이 어떤 순서로 처리되는지부터 이해하는 것이 좋습니다.

Next.js 공식 문서 기준으로, 한 요청이 처리되는 파이프라인을 프레임워크 내부 관점에서 보면 대략 다음과 같습니다.

프레임워크 내부 요청 처리 파이프라인 (요약)

1. next.config.js의 headers
2. next.config.js의 redirects
3. Proxy/Middleware (proxy.ts 혹은 middleware.ts)
4. next.config.js의 rewrites
5 Rendered Route
   ├ route handler (API)
   └ page / server component (UI)

여기서 중요한 점은 1~4단계는 라우트가 실행되기 전 단계이고, 실제 애플리케이션 코드가 실행되는 시점은 **5단계(Rendered Route)**라는 것입니다.

즉, 우리가 작성하는 대부분의 서버 코드는 5단계 안에서 동작합니다.


Rendered Route 내부 구조

Rendered Route 단계에 도달하면 Next.js는 해당 경로가 API인지, 페이지인지를 판단하여 적절한 코드를 실행합니다.

Rendered Route
   ├ route handler (API endpoint)
   └ page / server component (UI rendering)
  • Route Handler/api/... 같은 경로에서 직접 HTTP 응답을 생성하는 API 엔드포인트입니다.
  • Page / Server Component는 UI를 렌더링하면서 서버에서 데이터를 가져오는 레이어입니다.

이 글에서 다루는 BFF(Backend For Frontend) 로직은 대부분 Route Handler에서 구현됩니다.


5단계? 좀 더 직관적으로, 앱 서버 관점에서의 3가지 레이어

하지만 실제 애플리케이션 구조를 이해할 때는 위의 프레임워크 파이프라인보다, 개발자가 작성하는 서버 레이어 기준으로 보는 것이 더 직관적입니다.

앱 서버 관점에서 보면 Next.js 요청 처리는 다음 세 가지 레이어로 나눌 수 있습니다.

  • proxy / middleware 라우트에 도달하기 전에 실행되는 전역 프록시. 인증 검사, 리다이렉트, 요청 헤더 수정 등의 공통 로직을 처리합니다.

  • route handler 특정 URL 경로에 대한 API 엔드포인트. BFF 로직, 외부 API 프록시, 인증 처리 등을 담당합니다.

  • page / server component UI를 렌더링하는 레이어. 서버에서 데이터를 직접 가져와 HTML을 생성합니다.

이 세 레이어의 관계는 다음과 같이 정리할 수 있습니다.

mermaid

왜 BFF는 Route Handler에 위치할까?

BFF는 보통 다음과 같은 역할을 수행합니다.

  • 외부 API 호출
  • 인증 토큰 처리
  • 응답 데이터 가공
  • 여러 API 응답을 하나로 합치기

이러한 작업은 HTTP 요청과 응답을 직접 제어해야 하는 로직이기 때문에 Route Handler에서 구현하는 것이 가장 자연스럽습니다.


Middleware는 왜 BFF가 아닐까?

많은 사람들이 처음에 헷갈리는 부분이 바로 이것입니다.

Middleware도 요청을 가로채기 때문에 BFF처럼 보일 수 있습니다.

하지만 Middleware는 다음과 같은 제약이 있습니다.

  • Edge Runtime에서 실행
  • 응답 생성 로직이 제한적
  • 무거운 비즈니스 로직에 부적합

그래서 Middleware는 보통 게이트웨이 역할에 가깝다고 볼 수 있습니다.

예를 들면 다음과 같은 작업을 할 수 있을 겁니다.

  • 로그인 여부 검사
  • 권한 없는 사용자 리다이렉트
  • 특정 경로 접근 차단

최종적으로 각 레이어의 역할을 정리하면 다음과 같습니다.

레이어역할
Middleware요청을 검사하고 라우팅을 제어하는 게이트웨이
Route Handler외부 API와 통신하는 BFF 레이어
Server Component데이터를 사용해 UI를 렌더링하는 프레젠테이션 레이어

3. Route Handler — BFF의 핵심

자 이제 BFF 역할을 할 Next.js의 Route Handler에 대해서 알아봅시다.

Route Handler는 Next.js에서 HTTP 엔드포인트를 만드는 기능입니다.
app/api/ 하위에 route.ts 파일을 생성하면 해당 경로가 API 엔드포인트가 됩니다(앱 라우터 기준).

app/
├── api/
│   ├── auth/
│   │   ├── login/route.ts     → POST /api/auth/login
│   │   └── logout/route.ts    → POST /api/auth/logout
│   └── user/
│       └── route.ts           → GET /api/user
└── dashboard/
    └── page.tsx

예시 1: 기본 Route Handler

ts
// app/api/user/route.ts (예시)
import { cookies } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get("session_id")?.value;

  if (!sessionId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  // 서버 간 통신 → CORS 없음, API Key 안전하게 사용 가능
  const backendResponse = await fetch(`${process.env.INTERNAL_API_BASE_URL}/user`, {
    headers: {
      "X-Session-Id": sessionId,
      "X-API-Key": process.env.API_SECRET!,
    },
  });

  if (!backendResponse.ok) {
    return Response.json({ error: "Failed to fetch user" }, { status: backendResponse.status });
  }

  const user = await backendResponse.json();

  // 민감한 필드는 제거하고 응답
  return Response.json({
    id: user.id,
    name: user.name,
    email: user.email,
  });
}

저 handler의 호출 흐름은 다음과 같습니다.

  • 브라우저는 /api/user를 호출한다.
    • Same-Origin이므로 CORS 없음.
  • 서버가 process.env.INTERNAL_API_URL로 내부 API를 호출한다.
    • 서버 간 통신이므로 역시 CORS 없음.
    • API Key는 서버 환경변수에 안전하게 보관됨.

예시 2: 로그인 처리 — 쿠키 설정

ts
// app/api/auth/login/route.ts (예시)
import { cookies } from "next/headers";

export async function POST(request: Request) {
  const { email, password } = await request.json();

  // 백엔드 인증 API 호출
  const backendResponse = await fetch(`${process.env.INTERNAL_API_BASE_URL}/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });

  if (!backendResponse.ok) {
    return Response.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const { sessionId, refreshToken } = await backendResponse.json();

  const cookieStore = await cookies();

  // 액세스용 세션 쿠키
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60, // 1시간
    path: "/",
  });

  // 갱신용 리프레시 토큰
  cookieStore.set("refresh_token", refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 30, // 30일
    path: "/api/auth/refresh", // 갱신 엔드포인트에만 전송
  });

  return Response.json({ success: true });
}

살펴보시면, 직접 클라이언트에서 외부 API 서버에 요청을 할 때의 설정에 비해, 훨씬 간단합니다.

  • 클라이언트에서 SameSite: 'lax'와 같은 옵션을 사용할 수 있습니다.
  • 브라우저와 Next.js 서버가 Same-Origin이기 때문에 CORS 이슈가 없습니다.
  • 3편에서 설명한 SameSite=None; Secure의 복잡한 조건이 필요 없습니다.

(추가) 기존 클라이언트 → API 구조에서 BFF로 옮길 때, 백엔드에 요청해야 할 쿠키 옵션들

지금까지 예시는 Next.js의 Route Handler가 직접 쿠키를 설정하는 구조였습니다.
하지만 현실 세계에서는 이미 백엔드(API 서버)가 Set-Cookie로 세션/토큰을 내려주고,
프론트는 단순히 클라이언트 → API 서버로 직접 붙던 구조에서 출발하는 경우가 훨씬 많습니다.

이런 상황에서 **“이제는 프론트가 BFF(Next.js)를 거쳐서 API 서버로 가게 바꾸자”**라고 결정하면,
브라우저 입장에서는 라우팅 구조와 도메인이 바뀌기 때문에 쿠키 설정도 함께 정리해 줘야 합니다.
특히, 아래 옵션들은 백엔드 팀과 합의해서 변경을 요청해야 할 가능성이 높은 부분입니다.

저의 최근 경험입니다.

1) Domain — 브라우저가 실제로 붙는 도메인 기준으로

  • 과거 구조
    • 브라우저가 api.example.com에 직접 요청을 보내고, API 서버가 Set-Cookie를 내려줬을 수 있습니다.
    • 이때는 Domain=api.example.com 이거나, 아예 생략해서 “해당 호스트 한정”으로 두어도 문제가 없습니다(저는 생략했으며, 오히려 더 안전할 수 있습니다).
  • BFF 도입 후 구조
    • 브라우저는 이제 app.example.com / my-app.com 처럼 BFF가 올라간 도메인으로만 요청을 보냅니다.
    • 쿠키도 이 도메인 기준으로 설정되어야, 브라우저가 BFF 요청에 쿠키를 붙여서 보냅니다.

실무에서 백엔드 팀에 요청할 때는 보통 이렇게 정리해서 전달하면 좋습니다.

  • 프론트/BFF와 동일 호스트에서만 쓰는 경우
    • 예: 서비스가 app.example.com 하나에서만 동작하고, BFF도 여기에 올라가 있다면
      • Domain=app.example.com 또는 Domain 옵션을 생략(호스트 한정) 해도 됩니다.
  • 여러 서브도메인에서 공유해야 하는 경우
    • 예: app.example.com, admin.example.com 등 여러 앱에서 같은 세션을 쓰고 싶다면
      • Domain=.example.com 처럼 루트 도메인 기준으로 지정합니다.

중요한 포인트는 하나입니다.

여전히 Domain=api.example.com으로 고정해 두면,
브라우저가 app.example.com(BFF)으로 요청을 보낼 때 그 쿠키를 전송하지 않을 수 있다.

그래서 BFF로 마이그레이션할 때는,

  • “이제 쿠키를 기준으로 인증을 처리하는 주체는 api.example.com이 아니라
    브라우저가 실제로 붙는 app.example.com(BFF)다.”
  • “그러니 쿠키의 Domain도 그에 맞춰 조정해 달라.”

라는 메시지를 백엔드/인프라 쪽에 꼭 전달해 주는 게 좋습니다.

2) Path — BFF 경로 구조에 맞게 풀어주기

기존에는 API 서버의 라우트 구조에 맞춰 Path=/api/auth 같이 좁은 범위로 설정되어 있을 수 있습니다.
하지만 BFF를 도입하면, 브라우저 입장에서의 경로 구조가 바뀝니다.

  • 예전:
    • 브라우저: https://api.example.com/login, https://api.example.com/user
    • 쿠키: Path=/ 또는 Path=/api 등으로도 크게 문제 없었음
  • BFF 이후:
    • 브라우저: https://app.example.com/api/auth/login, https://app.example.com/api/user
    • 혹은 https://app.example.com/dashboard/... 와 같은 일반적인 페이지 접근 경로와 같이 인증과 전혀 다른 경로일 수도 있음(하지만 쿠키를 전달해야 함)

이때 가장 안전한 기본값은 Path=/ 입니다.

  • 특별히 경로를 제한해야 할 이유가 없다면,
    • “브라우저가 BFF에 보내는 모든 요청에 세션 쿠키가 붙어야 한다”는 쪽이 보통 더 자연스럽고,
    • 그럴 경우 Path=/로 완화해 달라고 요청하는 편이 좋습니다.
  • 특정 엔드포인트에서만 쿠키가 전송되어야 하는 정당한 이유(보안 정책 등)가 있다면
    • 그 정책을 BFF 경로 구조에 맞게 다시 설계해야 합니다.

3) Secure — 운영은 무조건 유지, 개발 환경 전략은 분리

운영 환경에서는 고민할 게 없습니다.
HTTPS + Secure 를 유지하는 것이 정석입니다.

다만, BFF를 도입하면 로컬/개발 환경에서의 개발 경험이 살짝 꼬일 수 있습니다.

  • 브라우저는 표준상 Secure 쿠키를 HTTPS 요청에만 보내지만,
    • 대부분의 현대 브라우저는 개발 편의를 위해 http://localhost 를 예외적으로 “secure context”로 취급합니다(이전 글 참고).
    • 그래서 http://localhost:3000 에서도 Secure 쿠키가 전송되는 경우가 많습니다.
  • 하지만 실제로 문제되는 구간은 보통 로컬이 아닌 개발/스테이징 도메인입니다.
    • 예: http://dev.example.com 처럼 HTTPS가 아닌 도메인,
    • 혹은 https://dev.example.com 이지만 인증서가 불완전하거나, 프록시/환경에 따라 브라우저가 신뢰하지 않는 경우 등.

이런 환경을 설계할 때 선택지는 대략 세 가지 정도가 있습니다.

  1. 로컬/개발 환경도 가능하면 HTTPS로 띄우는 구조를 만든다. (예: 로컬 리버스 프록시, mkcert 등)
  2. https://dev.example.com 같은 개발용 도메인에서 테스트하고, 항상 유효한 HTTPS만 사용한다.
  3. 정말 불가피할 때만 **“개발용 프로파일에서만 Secure를 끈다”**는 옵션을 둔다.

문서/팀 합의 차원에서는 이렇게 정리해 두면 좋습니다.

  • 운영(Production): Secure 항상 켜둔다.
  • 개발/스테이징:
    • “우리는 로컬/스테이징에서 어떤 방식으로 HTTPS를 확보할 것인가?”
    • “정말 안 되면 개발용 설정에서만 Secure를 끌 것인가?”를 명시적으로 합의.

4) HttpOnly — 그대로 유지 (오히려 BFF와 더 잘 맞음)

인증/세션 쿠키는 HttpOnly를 유지하는 쪽이 훨씬 안전합니다.
BFF 패턴의 장점도 사실 여기서 나옵니다.

  • 프론트 JS는 쿠키 값을 직접 읽지 않습니다.
  • 대신 서버(Next.js BFF)cookies() API를 통해서만 쿠키를 읽고,
    그 정보를 가지고 백엔드 API를 호출하거나 응답을 가공합니다.

그래서 백엔드 팀에게는 이렇게 요청하면 됩니다.

  • “인증/세션 쿠키의 HttpOnly는 기존처럼 그대로 유지해 달라.”
  • “토큰을 프론트 JS가 읽어서 로컬스토리지 등에 넣는 패턴은 지양하고,
    BFF 레이어에서만 쿠키를 읽도록 설계하겠다.”

5) SameSite — 인증/리다이렉트 플로우에 맞춰 선택

SameSite는 **“이 쿠키를 크로스 사이트 요청에도 보낼 것인가?”**를 결정하는 옵션입니다.
BFF 도입 후 구조에서는 기본적으로 브라우저 ↔ BFF는 Same-Origin이라 비교적 단순하지만,
외부 OAuth/SSO, 결제 리다이렉트 등이 있으면 이야기가 달라집니다.

  • 단순 SPA + BFF(같은 도메인 내) 구조
    • 예: app.example.com에서만 로그인과 화면 전환이 이루어지는 구조라면
    • SameSite=Lax 정도가 기본값으로 무난합니다.
  • 외부 OAuth/SSO, 결제 모듈 등 크로스 사이트 리다이렉트가 있는 경우
    • 예: accounts.google.comapp.example.com으로 리다이렉트되는 로그인 플로우
    • 이런 경우에는 세션 쿠키가 리다이렉트 이후에도 살아 있어야 하므로
      • SameSite=None; Secure 조합이 필요할 수 있습니다.

그래서 BFF로 옮기기 전에, 백엔드와 함께 다음을 먼저 정리하는 것이 좋습니다.

  • “이 서비스의 로그인/결제 플로우에 크로스 사이트 리다이렉트가 포함되어 있는가?”
  • 포함되어 있다면, 어느 쿠키는 SameSite=None; Secure로 설정해야 하는가?
  • 그렇지 않다면, 기본적으로 SameSite=Lax 정도로 두고 시작할 것인가?

덧붙여서, BFF를 도입했다고 해서 보안 위협 자체가 사라지는 것은 아니라는 점도 같이 기억해 두면 좋습니다.

  • 쿠키 기반 세션을 유지하는 한, 특히 SameSite=None 을 쓰는 경우에는 CSRF 위협 모델이 여전히 존재합니다.
  • BFF는 액세스 토큰을 프론트 JS로부터 숨길 수 있지만, XSS가 발생하면 BFF를 향한 요청 자체를 위조할 수 있기 때문에 XSS 방어는 별도의 과제로 남습니다.
  • 즉, BFF는 주로 CORS/비밀 노출/엔드포인트 복잡성을 줄이는 역할이고, CSRF·XSS 같은 웹 보안 기본기는 여전히 그대로 챙겨야 합니다.

4. Hybrid BFF — Server Component + Route Handler

방금까진 완전한 handler 역할의 집중된 예시를 보았습니다.

이제는 Server Component와 Route Handler를 상황에 맞게 조합할 수 있는 예시를 보겠습니다. Next.js는 이 둘을 조합할 수 있는 유연한 아키텍처를 제공합니다.

Server Component에서 직접 데이터 패칭

ts
// app/dashboard/page.tsx (Server Component, 예시)
import { cookies } from "next/headers";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get("session_id")?.value;

  if (!sessionId) redirect("/login");

  // Route Handler를 거치지 않고 직접 내부 API 호출
  // 서버 컴포넌트 → 서버 간 통신 → CORS 없음
  const user = await fetch(
    `${process.env.INTERNAL_API_BASE_URL}/user`,
    {
      headers: { "X-Session-Id": sessionId },
      // Next.js의 fetch 캐싱 설정
      next: { revalidate: 60 }, // 60초마다 갱신
    }
  ).then((res) => res.json());

  return <Dashboard user={user} />;
}

주의: 왜 Server Component에서 Route Handler를 호출하면 안 될까요?

공식 문서에서 명시적으로 주의를 주는 부분입니다.

❌ 나쁜 패턴: 금지는 아니지만, 최대한 피하는 것이 좋습니다.

Server Component → fetch('/api/user') → Route Handler → 외부 API

추가 HTTP 왕복 발생:
- Server Component에서 자기 서버의 Route Handler로 HTTP 요청
- 같은 서버 안에서 불필요한 네트워크 레이어 추가(같은 서버에서 또 다시 요청을 보내는 것)
- 빌드 타임에는 HTTP 서버가 실행 중이지 아닐 수 있으므로 빌드 자체가 실패할 수도 있음
- 무엇보다, Next.js의 Server Component 설계 철학인 'Server Component는 서버에서 직접 데이터를 가져온다'라는 점에 어긋납니다.

✅ 좋은 패턴:

Server Component → 직접 fetch(내부 API URL 또는 DB)

Route Handler → 클라이언트 요청을 받아서 → 내부 API

언제 어떤 것을 쓸까

데이터를 어디서 가져오는가?

초기 페이지 렌더링에 필요한 데이터
  → Server Component에서 직접 패칭
  → SEO에 유리, 초기 로딩 빠름
  → 빌드 타임 캐싱 가능

사용자 인터랙션에 반응하는 데이터 (버튼 클릭, 폼 제출 등)
  → Client Component + axios → Route Handler
  → 동적으로 필요할 때만 호출

주기적으로 업데이트가 필요한 데이터
  → Client Component + SWR/React Query → Route Handler
  → 폴링, 실시간 업데이트

5. Proxy 핸들러 — 요청의 첫 번째 관문

Next.js 16 변경사항
Next.js 16에서는 proxy.ts가 기존 middleware.ts를 대체하는 새로운 컨벤션으로 도입되었습니다.(난 15 사용 중임...)
이름만 바뀐 것이 아니라, 네트워크 경계(프록시 레이어)를 더 명확히 표현하는 역할을 하고, 기본적으로 Node.js 런타임에서 실행됩니다.
middleware.ts는 Edge 용도로는 여전히 동작하지만, 점차 proxy.ts 중심으로 옮겨가라는 메시지에 가깝습니다.
(이 글에서는 앞으로 proxy.ts 기준으로 설명하지만, Next 15에서는 middleware.ts / export function middleware() 패턴으로 읽어도 됩니다.)

3단계의 레이어에 대해 설명했듯이, Proxy는 모든 요청이 라우트에 도달하기 전에 실행되는 코드입니다.
일반적으로 페이지든 API든 정적 파일이든, 요청이 들어오면 Proxy를 먼저 통과하도록 설정할 수 있지만,
실제 서비스에서는 matcher로 어떤 경로들에만 적용할지를 신중하게 제한해 주는 편이 좋습니다.

ts
// proxy.ts (프로젝트 루트 또는 src/ 하위)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  // 모든 /dashboard 경로 요청이 여기를 통과
  const sessionId = request.cookies.get("session_id");

  if (!sessionId) {
    // 로그인 페이지로 리다이렉트
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // 다음 단계(Route Handler 또는 Page)로 진행
  return NextResponse.next();
}

// 어떤 경로에서 실행할지 지정
export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*"],
  // next.js 공식 문서 제안: api, _next/static, _next/image, favicon.ico 제외한 모든 경로
  //   matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Proxy에서 할 수 있는 것들

① 인증 체크 후 리다이렉트
위 예시처럼 로그인 여부를 확인하고 미인증 사용자를 로그인 페이지로 보낸다.
모든 보호된 페이지마다 인증 체크 코드를 넣지 않아도 된다.

② 요청 헤더 변조

ts
export function proxy(request: NextRequest) {
  const requestHeaders = new Headers(request.headers);

  // 요청에 사용자 정보를 헤더로 추가 (downstream에서 활용)
  const userId = getUserIdFromSession(request.cookies.get("session_id"));
  requestHeaders.set("X-User-Id", userId ?? "");

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

③ A/B 테스트 라우팅

ts
export function proxy(request: NextRequest) {
  const bucket = (request.cookies.get("ab_bucket")?.value ?? Math.random() > 0.5) ? "A" : "B";

  if (bucket === "B") {
    return NextResponse.rewrite(new URL("/landing-v2", request.url));
  }

  return NextResponse.next();
}

④ 봇 차단, 지역별 리다이렉트

ts
export function proxy(request: NextRequest) {
  const country = request.geo?.country ?? "US";

  if (country === "KR" && !request.nextUrl.pathname.startsWith("/ko")) {
    return NextResponse.redirect(new URL(`/ko${request.nextUrl.pathname}`, request.url));
  }

  return NextResponse.next();
}

Proxy에서 하면 안 되는 것들

❌ 느린 데이터 패칭
  Proxy는 설정에 따라 매우 많은 요청마다 실행된다.
  DB 조회나 외부 API 호출을 여기서 하면 해당 구간을 지나는 모든 요청이 느려진다.

❌ 무거운 연산
  같은 이유로 성능에 민감한 작업은 금물.

❌ 완전한 인증/인가 시스템
  Proxy는 낙관적 체크(Optimistic Check)용으로만 사용하는 편이 좋다.
  쿠키가 있는지 정도는 확인하지만, 쿠키가 실제로 유효한지 DB에서 검증하는 건
  Route Handler나 Server Component에서 해야 한다.

예: '매 요청마다 유저 인증 여부 확인해야지' 한다면, 쿠키 여부 정도만 체크하고, 실제 유저를 DB에서 조회하는 것은 지양하자. 이 부분은 Server Component나 Route Handler에서 처리하자.


6. 전체 아키텍처 — 3단계 레이어의 역할 분담

지금까지 다룬 내용을 하나의 다이어그램으로 정리하면 다음과 같다.

mermaid

각 레이어의 책임 요약

레이어파일역할특징
Proxyproxy.ts요청 필터링, 리다이렉트빠르고 가벼운 작업만
Server Componentpage.tsx초기 데이터 패칭 + 렌더링직접 내부 API 호출
Route Handlerroute.ts클라이언트 API 엔드포인트BFF 역할, 쿠키 조작 가능
Client Component*.tsx동적 UI, 사용자 인터랙션axios로 Route Handler 호출

실제 운영 관점에서 보면, BFF를 도입하는 것은 다음과 같은 트레이드오프도 함께 가져옵니다. 그래서 무작정 BFF로 모든 요청을 처리하는 것은 좋지 않습니다.

  • 요청 한 번당 한 홉이 추가되므로, 레이턴시와 서버 비용을 함께 고려해야 합니다.
  • Next.js의 fetch 캐시, revalidate 옵션, CDN 등을 활용해 캐싱 전략을 잘 짜지 않으면 BFF 레이어에서 병목이 생길 수 있습니다.
  • 백엔드 API 호출과 BFF 응답을 함께 추적할 수 있도록, 로그/트레이싱 상관관계 ID를 설계해 두면 운영·디버깅이 훨씬 수월합니다.
  • 권한 모델은 여전히 백엔드에서 최종 결정하고, BFF는 “대리 호출자”라는 사실을 명확히 해 두는 편이 좋습니다.

7. 실전 시나리오 — 로그인 플로우 전체 흐름

거의 다 왔습니다.

지금까지 배운 내용을 종합해 로그인부터 인증된 API 호출까지의 전체 흐름을 예시 시나리오로 정리해보겠습니다.

[1] 로그인 페이지 접근
브라우저 GET /login
  → Proxy: 쿠키 없음 → 통과 (로그인 페이지는 matcher에서 제외)
  → page.tsx 렌더링

[2] 로그인 폼 제출
브라우저 POST /api/auth/login (axios, Same-Origin)
  → Proxy: 통과
  → Route Handler /api/auth/login
    → 백엔드 인증 API 호출 (서버 간 통신)
    → 성공 시 Set-Cookie: session_id=abc; HttpOnly; Secure; SameSite=Lax
  → 브라우저가 쿠키 저장

[3] 대시보드 접근
브라우저 GET /dashboard
  → Proxy: session_id 쿠키 존재 확인 → 통과
  → page.tsx (Server Component)
    → cookies()로 session_id 읽기
    → 내부 API 직접 호출 (서버 간 통신)
    → 데이터 패칭 후 HTML 렌더링

[4] 대시보드에서 데이터 갱신 (버튼 클릭)
브라우저 PUT /api/user/profile (axios, Same-Origin)
  → Proxy: 쿠키 존재 확인 → 통과
  → Route Handler /api/user/profile
    → cookies()로 session_id 읽기
    → 백엔드 API 호출 + 응답 반환

[5] 세션 만료 후 갱신
브라우저 axios 요청 → 401 응답
  → 인터셉터: /api/auth/refresh 호출
    → Route Handler: refresh_token 쿠키로 새 session_id 발급
    → 새 쿠키 설정
  → 원래 요청 재시도

8. 정리 — 시리즈 마무리

네트워크 이야기를 했다가, Next.js 아키텍처 이야기로 넘어가네요. 읽느라 고생하셨습니다.

마지막으로 이 시리즈에서 다룬 내용의 큰 그림을 정리해봅시다.

1편: CORS의 뿌리 브라우저가 강제하는 SOP가 있고, CORS는 그 예외를 허용하는 메커니즘입니다. CORS 에러는 서버가 허용 헤더를 보내지 않아서 브라우저가 차단하는 것입니다.

2편: 쿠키 보안
HTTP의 Stateless 문제를 쿠키가 해결합니다. HttpOnly는 XSS, Secure는 MITM, SameSite는 CSRF를 방어합니다. 각 속성은 특정 공격 시나리오에 대한 방어막입니다.

3편: 크로스오리진 쿠키 인증
브라우저는 크로스오리진 요청에 기본적으로 쿠키를 포함하지 않습니다.
withCredentials: true와 서버의 Access-Control-Allow-Credentials: true,
그리고 SameSite=None; Secure가 모두 맞아야 동작합니다.
와일드카드 Origin과 credentials는 함께 사용할 수 없습니다.

4편: Next.js 아키텍처와 BFF 패턴
BFF 패턴으로 CORS 문제를 구조적으로 해결합니다.
Proxy는 요청 필터링, Route Handler는 BFF API, Server Component는 초기 렌더링에 사용합니다.
각 레이어의 책임을 명확히 하면 복잡한 인증 플로우도 깔끔하게 구현됩니다.


9. BFF 도입 체크리스트 (실전용 메모)

마지막으로, 실제 프로젝트에서 BFF를 도입하거나 마이그레이션할 때 체크해 보면 좋은 항목들을 간단히 정리해 둡니다.

  • 환경 변수 이름을 통일했는가?
    • 예: 내부 API 베이스 URL을 INTERNAL_API_BASE_URL 하나로 통일해서 Server Component와 Route Handler에서 함께 사용.
  • Route Handler의 fetch에 캐싱 정책을 명시했는가?
    • cache, next: { revalidate } 등을 통해, 언제까지 캐시/언제 다시 패칭할지 의도를 드러낸다.
  • Proxy/미들웨어의 matcher가 과도하게 넓지 않은가?
    • /api, /_next/static, /_next/image 등은 기본적으로 제외하고, 보호가 필요한 경로 위주로 한정.
  • 쿠키의 Domain/Path가 “브라우저가 실제로 붙는 호스트” 기준으로 설계되어 있는가?
    • 기존 api.example.com 기준 설정이 남아 있지 않은지 다시 한 번 점검.
  • OAuth/결제 리다이렉트가 있다면 SameSite=None; Secure + CSRF 방어 전략을 함께 설계했는가?
  • Server Component가 자기 서버의 /api/...를 거치지 않고, 가능한 한 직접 내부 API/DB를 호출하도록 정리했는가?

이 정도만 점검해도, “클라이언트에서 온갖 CORS/쿠키 설정으로 싸우던 것”보다 훨씬 예측 가능한 구조로 정리할 수 있습니다.


물론 이 글이 제안하는 것은 무조건적인 정답은 아닐 수 있습니다. 하지만 이 시리즈를 통해서 여러분들이 몰랐거나 놓쳤던 부분을 다시 한 번 생각해보고, 현재 상황에 맞게 적용해보는 계기가 되었으면 좋겠습니다.


📋 시리즈 전체 요약

제목핵심 키워드
1편Next.js + Axios와 CORS 에러 제대로 이해하기SOP, Preflight, rewrites
2편쿠키 완전 정복 — Secure·HttpOnly·SameSiteXSS, MITM, CSRF, 쿠키 생명주기
3편Axios + 쿠키 인증 — withCredentials와 CORS의 콜라보withCredentials, 와일드카드 금지, 인터셉터
4편BFF 패턴 & Proxy HandlerRoute Handler, proxy.ts, 레이어 설계
0개의 댓글
💬

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!