본문 바로가기
(Frontend) 프론트엔드

(Next.js) BFF란?BFF 프록시 패턴으로 인증 구현하기 1탄 - httpOnly 쿠키와 catch-all 프록시 (App Router + TypeScript)

by 공부가싫다가도좋아 2026. 6. 16.
반응형

포스팅 목차

  1. 이 포스팅에서 다루는 것
  2. BFF(Backend For Frontend) 패턴이란?
  3. 쿠키 동작 원리 - 도메인과 httpOnly
  4. React(Vite) vs Next.js 보안 비교
  5. 실무 구현 코드
  6. 공부하면서 생겼던 의문점 (Q&A)
  7. 참고 자료

1. 이 포스팅에서 다루는 것

Next.js App Router로 실무 프로젝트를 하다 보면, 처음엔 간단해 보였던 인증 흐름이 의외로 손이 많이 갑니다.

저도 처음엔 "그냥 토큰 저장해서 헤더에 붙이면 되는 거 아냐?" 싶었는데, 막상 만들어 보니 쿠키 도메인 문제에 서버/클라이언트 분기, 보안 이슈까지 한꺼번에 쏟아졌습니다.

그때 직접 부딪혀 가며 풀었던 BFF 프록시 패턴을, 개념부터 실무 코드까지 정리해 봤습니다.

- Next.js App Router (13+) 기준으로 작성되었습니다.
- 백엔드는 별도 서버(Spring, NestJS 등)가 있는 구조를 가정합니다.
- TypeScript 기준으로 작성되었습니다.


2. BFF(Backend For Frontend) 패턴이란?

2-1. 왜 BFF가 필요한가?

일반적인 Next.js + 외부 백엔드 구조는 아래와 같습니다.

Next.js (프론트)   →  localhost:3000
백엔드 (Spring 등) →  localhost:8080  (또는 api.example.com)

이 상태에서 브라우저가 백엔드를 직접 호출하면 어떤 일이 벌어질까요?

  1. 쿠키가 전송되지 않습니다. 브라우저의 쿠키는 같은 도메인에만 자동으로 전송됩니다. localhost:3000에서 받은 쿠키는 localhost:8080에 보내지지 않습니다.
  2. CORS 문제가 발생합니다. 브라우저는 다른 출처(Origin)로의 요청을 기본적으로 차단합니다.
  3. API 엔드포인트가 클라이언트에 노출됩니다. 백엔드 서버 주소가 그대로 브라우저 네트워크 탭에 나타납니다.

이 문제를 해결하기 위해 Next.js 서버를 중간 다리(프록시)로 활용하는 것이 BFF 패턴입니다.

 

2-2. BFF 패턴의 전체 흐름

# (X) BFF 없이 직접 호출하는 경우
브라우저(3000) ──────────────────────→ 백엔드(8080)
                    쿠키 전송 안 됨 (X)
                    CORS 문제 발생 (X)
                    API 주소 노출 (X)

# (O) BFF 프록시를 경유하는 경우
브라우저(3000) ──→ Next.js 서버(3000) ──→ 백엔드(8080)
  같은 도메인        ↑                    서버↔서버 통신
  쿠키 자동 전송 (O)  쿠키 읽어서          도메인 제한 없음 (O)
                    헤더에 토큰 부착 (O)

브라우저 입장에서는 항상 같은 도메인(Next.js 서버)과만 통신하고, 실제 백엔드와의 통신은 Next.js 서버가 대신해줍니다.


3. 쿠키 동작 원리 - 도메인과 httpOnly

3-1. 브라우저의 쿠키는 도메인 단위로 관리됩니다

쿠키에는 "이 쿠키는 어느 도메인에서만 사용 가능한지"가 명시되어 있습니다.

localhost:3000에서 설정한 쿠키는 localhost:3000으로 보내는 요청에만 자동으로 전송됩니다. localhost:8080으로 보내는 요청에는 전송되지 않습니다. 포트가 다르면 다른 출처로 판단합니다.

# 쿠키가 전송되는 경우
브라우저(localhost:3000) → localhost:3000/api/...   (O) 쿠키 자동 전송

# 쿠키가 전송되지 않는 경우
브라우저(localhost:3000) → localhost:8080/api/...   (X) 쿠키 전송 안 됨

 

3-2. httpOnly 쿠키란?

쿠키는 크게 두 가지 종류가 있습니다.

구분 일반 쿠키 httpOnly 쿠키
JS 접근 가능 (document.cookie) 불가능
XSS 공격 시 탈취 가능 불가능
서버에서 읽기 가능 가능
설정 방법 JS 또는 서버 서버에서만

httpOnly 쿠키는 JavaScript에서 읽을 수 없어서, XSS(Cross-Site Scripting)로 토큰이 새는 걸 막아줍니다. 인증 토큰을 둘 자리로는 사실상 가장 안전한 선택입니다.

XSS란?
악성 스크립트가 페이지에 삽입되어 document.cookie로 토큰을 훔쳐가는 공격입니다. httpOnly 쿠키는 JS에서 접근 자체가 불가능하기 때문에 이 공격에 안전합니다.

 

3-3. 그래서 프록시가 필요한 이유

httpOnly 쿠키는 JavaScript에서 읽을 수 없습니다. 그런데 클라이언트(브라우저)에서 백엔드를 직접 호출하면, 토큰을 헤더에 수동으로 붙여야 하는데 그럴 수가 없습니다.

그래서 같은 도메인의 Next.js 서버를 경유시킵니다. 브라우저 → Next.js 서버 요청 시에는 쿠키가 자동 전송되고, Next.js 서버가 그 쿠키를 읽어서 백엔드에 토큰 헤더를 붙여 전달합니다.


4. React(Vite) vs Next.js 보안 비교

React(Vite) 같은 순수 클라이언트 앱은 서버가 없습니다. 그래서 인증 방식이 Next.js와 근본적으로 다릅니다.

구분 React (Vite/CRA) Next.js (App Router)
서버 존재 여부 없음 (브라우저만) 있음 (Route Handler 등)
토큰 저장 방식 localStorage / 일반 쿠키 httpOnly 쿠키
JS에서 토큰 접근 가능 불가능
XSS 시 토큰 탈취 가능 불가능
백엔드 직접 호출 가능 (토큰 직접 헤더 부착) 프록시 경유 필요
프록시 필요 여부 불필요 필요

React는 서버가 없으니 localStorage에서 토큰을 꺼내 헤더에 직접 붙여서 백엔드를 호출합니다. 구현은 단순하지만, JS에서 토큰을 읽을 수 있으므로 XSS 공격에 취약합니다.

Next.js는 서버(Route Handler)가 있기 때문에 httpOnly 쿠키를 활용할 수 있습니다. 보안이 강화되는 대신, 클라이언트에서 백엔드를 직접 호출할 수 없어 프록시 구현이 필요합니다.

사실 "직접 호출할 수 없다"는 건 도메인을 어떻게 두느냐에 달려 있습니다. 쿠키는 발급해 준 도메인에만 따라붙기 때문인데요, 두 가지 경우로 나눠 보면 이해가 쉽습니다.

먼저 프론트와 백엔드가 같은 사이트를 쓰고, 쿠키를 부모 도메인으로 발급한 경우입니다. 이때는 백엔드로 보내는 요청에도 쿠키가 알아서 붙기 때문에 프록시 없이 바로 호출해도 됩니다.

반대로 프론트와 백엔드 도메인이 아예 분리돼 있으면 이야기가 달라집니다. 실무에서 가장 흔한 경우인데, 이때는 쿠키가 프론트 도메인에만 붙고 httpOnly라 JS로 꺼내 헤더에 실을 수도 없습니다. 결국 Next.js 서버를 한 번 거치는 프록시가 필요해집니다.

서버끼리의 통신에는 쿠키 개념이 없습니다
쿠키는 브라우저만의 개념입니다. Next.js 서버 → 백엔드 통신은 그냥 일반 fetch이므로, cookies()로 토큰을 꺼내 Authorization 헤더에 직접 붙이면 됩니다. 도메인 제한도 없습니다.


5. 실무 구현 코드

실제 프로젝트에서 사용한 코드를 기반으로, 익명화하여 작성했습니다. 전체 흐름은 아래와 같습니다.

1. 로그인 → Next.js Route Handler → 쿠키에 accessToken 저장
2. API 호출 시 → apiFetch 함수 호출
   - 서버 컴포넌트/SSR: cookies()로 토큰 읽기 → 백엔드 직접 호출
   - 클라이언트 컴포넌트:  /api/proxy/* 호출 → Next.js 서버가 토큰 붙여서 백엔드 전달
3. 로그아웃 → Next.js Route Handler → 쿠키 삭제

5-1. 로그인 Route Handler - 쿠키 설정

로그인 시 백엔드에서 받은 토큰을 httpOnly 쿠키에 저장합니다. 이 작업은 반드시 서버(Route Handler)에서 해야 합니다.

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.json();

  // 백엔드에 로그인 요청
  const response = await fetch(`${process.env.BACKEND_URL}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    return NextResponse.json({ message: '로그인 실패' }, { status: response.status });
  }

  const data = await response.json();

  const nextResponse = NextResponse.json({ success: true });

  // httpOnly 쿠키에 저장 → JS에서 접근 불가능, XSS 안전
  nextResponse.cookies.set('accessToken', data.accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24, // 1일
    path: '/',
  });

  return nextResponse;
}

 

5-2. catch-all 프록시 Route Handler

클라이언트에서 발생하는 모든 API 요청을 받아서 백엔드로 전달하는 catch-all Route Handler입니다.

// app/api/proxy/[...path]/route.ts
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

async function proxyHandler(request: NextRequest) {
  const cookieStore = await cookies();
  const accessToken = cookieStore.get('accessToken')?.value;

  // URL에서 /api/proxy/ 이후 경로를 추출
  const pathname = request.nextUrl.pathname.replace('/api/proxy', '');
  const search = request.nextUrl.search;
  const targetUrl = `${process.env.BACKEND_URL}${pathname}${search}`;

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  };

  // 토큰이 있으면 Authorization 헤더 부착 (없으면 비회원으로 요청)
  if (accessToken) {
    headers['Authorization'] = `Bearer ${accessToken}`;
  }

  const body =
    request.method !== 'GET' && request.method !== 'HEAD'
      ? await request.text()
      : undefined;

  const backendResponse = await fetch(targetUrl, {
    method: request.method,
    headers,
    body,
  });

  const responseData = await backendResponse.text();

  return new NextResponse(responseData, {
    status: backendResponse.status,
    headers: {
      'Content-Type': backendResponse.headers.get('Content-Type') ?? 'application/json',
    },
  });
}

export const GET = proxyHandler;
export const POST = proxyHandler;
export const PUT = proxyHandler;
export const PATCH = proxyHandler;
export const DELETE = proxyHandler;

 

5-3. apiFetch - 서버/클라이언트 자동 분기

여기가 제일 중요합니다. 함수 하나가 지금 서버인지 클라이언트인지 알아서 판단해 분기해 주기 때문에, 기존 API 호출 코드는 손댈 필요가 없습니다.

// lib/api-fetch.ts
import { cookies } from 'next/headers';

type FetchOptions = Omit<RequestInit, 'body'> & {
  body?: Record<string, unknown> | string;
};

export async function apiFetch<T>(
  path: string,
  options: FetchOptions = {}
): Promise<T> {
  const isServer = typeof window === 'undefined';
  const { body, ...restOptions } = options;

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(restOptions.headers as Record<string, string>),
  };

  let url: string;

  if (isServer) {
    // 서버 환경: cookies()로 토큰 직접 읽어서 백엔드 직접 호출
    const cookieStore = await cookies();
    const accessToken = cookieStore.get('accessToken')?.value;

    if (accessToken) {
      headers['Authorization'] = `Bearer ${accessToken}`;
    }

    url = `${process.env.BACKEND_URL}${path}`;
  } else {
    // 클라이언트 환경: 프록시를 경유 (브라우저가 쿠키를 자동 전송)
    url = `/api/proxy${path}`;
  }

  const response = await fetch(url, {
    ...restOptions,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    throw new Error(`API 요청 실패: ${response.status}`);
  }

  // 204 No Content 대응
  if (response.status === 204) {
    return undefined as T;
  }

  return response.json() as Promise<T>;
}

 

5-4. 실제 사용 예시

apiFetch를 사용하는 호출부는 서버/클라이언트 여부와 상관없이 동일한 코드로 작성합니다.

// 서버 컴포넌트에서 사용 (page.tsx)
// → 내부적으로 백엔드를 직접 호출
import { apiFetch } from '@/lib/api-fetch';

type UserProfile = {
  id: number;
  name: string;
  email: string;
};

export default async function ProfilePage() {
  const profile = await apiFetch<UserProfile>('/members/me/profile');

  return <div>{profile.name}</div>;
}

// ─────────────────────────────────────────────────────

// 클라이언트 컴포넌트의 React Query 훅에서 사용
// → 내부적으로 /api/proxy를 경유
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api-fetch';

type ProductList = {
  items: Array<{ id: number; name: string }>;
};

export function useProductList() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => apiFetch<ProductList>('/products'),
  });
}

 

5-5. 로그인/로그아웃은 왜 따로 분리하나요?

로그인, 로그아웃, 토큰 갱신은 쿠키를 만들거나 삭제해야 하기 때문에, 단순히 데이터를 중계하는 /api/proxy가 아닌 전용 Route Handler를 사용합니다.

용도 사용 함수 이유
일반 API (목록, 상세 등) apiFetch 데이터 조회/변경만 필요
로그인 / 로그아웃 / 토큰 갱신 전용 Route Handler 쿠키 설정/삭제가 필요

6. 공부하면서 생겼던 의문점 (Q&A)

Q. 서버 컴포넌트에서는 왜 프록시 없이 백엔드를 직접 호출해도 되나요?

A. 서버끼리의 통신에는 쿠키 개념 자체가 없기 때문입니다. 쿠키는 브라우저만의 개념입니다. 서버에서는 cookies()로 토큰을 꺼내 Authorization 헤더에 직접 붙이면 되고, CORS 제한도 없습니다.

Q. 비회원은 어떻게 처리되나요?

A. accessToken 쿠키가 없으면 토큰 없이 요청이 전송됩니다. 백엔드가 비회원 허용 API라면 정상 응답을 주고, 인증이 필요한 API라면 401을 반환합니다. 프론트에서 별도 분기 처리가 필요하지 않습니다.

Q. catch-all Route Handler를 사용하면 보안에 문제가 없나요?

A. catch-all 프록시는 /api/proxy/* 경로만 처리하도록 제한되어 있고, 실제 백엔드 URL은 서버 환경변수(BACKEND_URL)에만 존재합니다. 클라이언트에 백엔드 주소가 노출되지 않으므로 오히려 보안이 강화됩니다.

Q. React(Vite)로 만든 프로젝트도 이 방식을 써야 하나요?

A. 정확히 말하면 React(Vite) 단독으로는 못 씁니다. Vite로 빌드한 React는 정적 SPA라서 자체적으로 도는 서버가 없고, httpOnly 쿠키를 발급하거나 프록시를 돌릴 주체가 그 안에 없기 때문입니다.

그래서 보통은 localStorage나 일반 쿠키에 토큰을 저장하고 헤더에 직접 붙이는 방식을 씁니다. 구현은 단순하지만 XSS에 취약하다는 점은 감안해야 합니다.

다만 "React는 BFF를 못 쓴다"는 건 아닙니다. 앞에 별도 서버를 하나 세우면(Node로 BFF 서버를 따로 띄우거나, nginx 같은 리버스 프록시를 두는 식으로) React여도 똑같이 BFF 패턴을 적용할 수 있습니다. Next.js는 이 서버를 따로 세우지 않아도 되게 처음부터 묶어 둔 것뿐이라고 보면 됩니다.

Q. BFF는 쿠키 프록시 용도로만 쓰나요?

A. 아닙니다. 사실 BFF의 더 본질적인 역할은 백엔드에서 받아온 데이터를 프론트가 쓰기 좋은 형태로 다듬어 주는 것</b >입니다. 쿠키 프록시는 그중 하나의 쓰임새일 뿐이에요.

대표적인 게 웹과 모바일 앱이 같은 백엔드 API를 함께 쓰는 경우입니다. 백엔드는 두 클라이언트를 모두 만족시켜야 하니 가능한 모든 필드를 담은 범용 응답을 내려줍니다. 하지만 막상 웹에서 필요한 데이터와 모바일 앱에서 필요한 데이터는 다르죠. 화면 크기도, 한 번에 보여줄 정보량도 다르니까요.

예를 들어 같은 /user/me 응답이라도, 웹 대시보드는 프로필 사진과 최근 주문 목록까지 한 화면에 펼쳐 보여주지만 모바일은 이름 정도만 간단히 띄우는 식입니다. 이걸 각 클라이언트에서 매번 가공하면 코드도 지저분해지고 안 쓰는 데이터까지 통째로 내려오게 됩니다.

이때 웹 쪽에 BFF를 두면, 백엔드의 범용 응답을 웹 화면에 딱 맞는 형태로 다듬어서 내려줄 수 있습니다. 여러 API를 합치거나(aggregation), 필요한 필드만 골라내거나, 구조를 바로 쓰기 좋게 바꾸는 식이죠. (모바일은 모바일대로 자기에게 맞는 BFF를 따로 두기도 하는데, 이게 BFF, 즉 "Backend For Frontend"라는 이름이 붙은 이유이기도 합니다.)

// app/api/proxy/dashboard/route.ts
// 웹 대시보드 전용 BFF — 범용 API 두 개를 합쳐 웹 화면에 맞는 형태로 가공
export async function GET() {
  // 모바일과 공용으로 쓰는 범용 백엔드 API
  const [user, orders] = await Promise.all([
    apiFetch('/user/me'),
    apiFetch('/orders?limit=5'),
  ]);

  // 웹 대시보드에 필요한 필드만 골라서 내려준다
  return NextResponse.json({
    name: user.name,
    profileImage: user.profileImage,
    recentOrders: orders.map((o) => ({ id: o.id, title: o.title })),
  });
}

이렇게 하면 웹 클라이언트는 화면에 딱 맞는 데이터만 받아서 그대로 그리면 됩니다. 보안(쿠키 프록시)과 클라이언트별 데이터 가공, 두 가지를 한 계층에서 같이 챙길 수 있다는 게 BFF의 진짜 장점이에요.


7. 마무리

실무에서 이 문제를 처음 만났을 땐 BFF라는 말 자체가 생소해서, 왜 굳이 이렇게 짜야 하는지 이해하는 데 시간이 꽤 걸렸습니다. "프록시가 왜 필요하지?", "그냥 fetch 쓰면 안 되나?" 하던 의문을 하나씩 짚어 가며 알게 된 내용을 그대로 옮겼습니다.

한 줄로 줄이면 이렇습니다.

브라우저의 쿠키는 같은 도메인에만 전송된다 → 그래서 Next.js 서버를 프록시로 활용한다 → 서버끼리는 쿠키 개념 없이 토큰을 헤더에 직접 붙인다</b >

Next.js App Router에서 인증 붙이다 막혔던 분들께 조금이나마 보탬이 됐으면 합니다.


참고 자료

반응형

댓글