멀티 브랜드 B2B2C 리셀 마켓플레이스를 개발하면서 BFF(Backend For Frontend) 레이어를 직접 설계하고 도입한 경험을 공유한다!
현재 서비스는 하나의 코드베이스로 여러 브랜드의 B2B2C 리셀 마켓플레이스를 운영하는 모노레포 구조다. 각 브랜드마다 독립된 Next.js 앱이 있고, 백엔드 API 서버는 공통으로 쓴다.
기존엔 클라이언트에서 백엔드를 직접 호출했는데... 문제가 하나둘씩 터지기 시작했다!!
• 문제 1: 인증 토큰 노출 → JWT 토큰이 클라이언트 코드에 그대로 노출, XSS에 취약
• 문제 2: 백엔드 API 주소 노출 → 클라이언트 번들에 내부 API 주소가 그냥 박혀 있었음
• 문제 3: 서버 로그 요청 추적 불가 → 어떤 api가 호출되고 프론트엔드에서 응답을 받기까지 얼마나 걸렸는지 로그로 추적할 수 있는 중간 레이어가 없었음
BFF는 이 모든 문제를 한 번에 해결할 수 있는 자연스러운 선택이었다.
┌──────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ │
│ React Component │
│ ↓ │
│ RTK Query Hook (prepareHeaders: token, brand) │
│ ↓ │
│ /api/v1/products?page=1 (Same-Origin 요청) │
└───────────────────┬──────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────┐
│ BFF (Next.js API Routes) │
│ │
│ 1. /api/v1/[...path] catch-all 라우트 매칭 │
│ 2. Request ID 생성 (UUID) │
│ 3. 헤더 추출 (Authorization, Brand-Domain) │
│ 4. 백엔드로 프록시: API_BASE_URL/v1/products │
│ 5. 구조화된 로깅 (latency, status, IP) │
│ 6. 응답 스트리밍 │
└───────────────────┬──────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────┐
│ BACKEND API Server │
│ │
│ - JWT 토큰 검증 │
│ - Brand 컨텍스트 확인 │
│ - 비즈니스 로직 처리 │
│ - CommonResponse<T> 형태로 응답 │
└──────────────────────────────────────────────────┘
설계할 때 딱 세 가지를 기준으로 잡았다:
1. 투명 프록시(Transparent Proxy)
BFF는 요청을 변환하지 않고 그대로 전달한다.
대신 인증 헤더 주입, 로깅, 에러 핸들링 같은 횡단 관심사(Cross-Cutting Concerns)를 처리한다.
2. 모노레포 공유 라이브러리
BFF 로직은 bff-shared 라이브러리에 한 번만 작성하고 모든 브랜드 앱에서 재사용한다.
3. Same-Origin 통신
클라이언트는 자신의 도메인 /api/* 로만 요청하니까 CORS 이슈가 깔끔하게 사라진다.
/
├── apps/
│ ├── brand-a/
│ │ └── src/app/api/
│ │ ├── v1/[...path]/route.ts # → bff-shared 핸들러 위임
│ │ ├── v2/[...path]/route.ts # → bff-shared 핸들러 위임
│ │ └── health/ # 헬스체크 엔드포인트
│ ├── brand-b/
│ │ └── src/app/api/... # 동일 구조
│ └── brand-c/
│ └── src/app/api/... # 동일 구조
│
├── libs/
│ ├── bff-shared/ # BFF 공통 로직
│ │ └── src/
│ │ ├── utils/
│ │ │ ├── route-handler.ts # catch-all 핸들러 팩토리
│ │ │ └── api-client.ts # 백엔드 HTTP 클라이언트
│ │ └── types/
│ │ └── api.ts # BFF 응답 타입
│ │
│ └── shared/ # 클라이언트 공통 로직
│ └── src/
│ └── services/
│ └── baseQueryWithReauth.ts # RTK Query + 토큰 자동 갱신
BFF의 핵심은 catch-all 라우트 핸들러다!! Next.js App Router에서 [...path] 동적 세그먼트를 쓰면 모든 API 요청을 하나의 핸들러로 처리할 수 있다.
// libs/bff-shared/src/utils/route-handler.ts
import { NextRequest, NextResponse } from 'next/server'
import { fetchBackend } from './api-client'
type RouteContext = {
params: Promise<{ path: string[] }>
}
export function createCatchAllHandler(apiVersion: string) {
async function handler(req: NextRequest, { params }: RouteContext): Promise<NextResponse> {
const startTime = Date.now()
const requestId = crypto.randomUUID()
try {
// 1. 경로 조립: ['products', '123'] → '/v1/products/123'
const { path } = await params
const backendPath = `/${apiVersion}/${path.join('/')}`
// 2. 쿼리 파라미터 보존
const queryString = req.nextUrl.searchParams.toString()
const fullPath = queryString ? `${backendPath}?${queryString}` : backendPath
// 3. 요청 바디 추출 (Content-Type에 따라 분기)
const body = await extractBody(req)
// 4. 백엔드로 프록시
const response = await fetchBackend(fullPath, {
method: req.method,
headers: extractHeaders(req),
body,
})
// 5. 구조화된 로깅
const latency = Date.now() - startTime
logRequest({
rid: requestId,
method: req.method,
path: backendPath,
status: response.status,
latency: `${latency}ms`,
ip: req.headers.get('x-forwarded-for') ?? 'unknown',
})
// 6. 응답 전달
return new NextResponse(response.body, {
status: response.status,
headers: { 'Content-Type': response.headers.get('Content-Type') ?? 'application/json' },
})
} catch (error) {
return NextResponse.json(
{
success: false,
error: {
code: 'BFF_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
},
{ status: 500 },
)
}
}
// 모든 HTTP 메서드 지원
return {
GET: handler,
POST: handler,
PUT: handler,
PATCH: handler,
DELETE: handler,
}
}
async function extractBody(req: NextRequest): Promise<BodyInit | null> {
if (['GET', 'HEAD'].includes(req.method)) return null
const contentType = req.headers.get('content-type') ?? ''
// multipart/form-data: 파일 업로드 등
if (contentType.includes('multipart/form-data')) {
return await req.formData()
}
// application/json: 일반 JSON 요청
if (contentType.includes('application/json')) {
return JSON.stringify(await req.json())
}
// 그 외: raw body
return await req.text()
}
브랜드별 앱에서는 진짜 딱 한 줄로 끝난다!!!
// apps/brand-a/src/app/api/v1/[...path]/route.ts
import { createCatchAllHandler } from '@my-org/bff-shared'
export const { GET, POST, PUT, PATCH, DELETE } = createCatchAllHandler('v1')
이렇게 하면 /api/v1/products, /api/v1/orders/123, /api/v1/auth/login 등 모든 v1 API 경로가 자동으로 백엔드에 프록시된다. 진짜 편하다!!
BFF를 도입하면서 가장 크게 달라진 부분이 바로 인증 토큰 관리다. 토큰을 클라이언트가 아닌 서버 사이드에서 안전하게 다룰 수 있게 됐다.
로그인 성공
↓
accessToken, refreshToken → httpOnly Cookie에 저장
↓
클라이언트 API 호출 시
↓
브라우저가 자동으로 Cookie 전송 (Same-Origin)
↓
BFF에서 Cookie → Authorization 헤더로 변환
↓
백엔드에 전달
// libs/bff-shared/src/utils/api-client.ts
const API_BASE_URL = process.env.API_BASE_URL
export async function fetchBackend(path: string, init: RequestInit = {}, req?: NextRequest): Promise<Response> {
const url = `${API_BASE_URL}${path}`
const headers = new Headers(init.headers)
// 클라이언트 요청에서 인증 헤더 전파
if (req) {
const authHeader = req.headers.get('Authorization')
const refreshHeader = req.headers.get('refreshtoken')
const brandHeader = req.headers.get('Brand-Domain')
if (authHeader) headers.set('Authorization', authHeader)
if (refreshHeader) headers.set('refreshtoken', refreshHeader)
if (brandHeader) headers.set('Brand-Domain', brandHeader)
}
return fetch(url, {
...init,
headers,
})
}
클라이언트 측에서는 RTK Query의 baseQueryWithReauth 패턴으로 토큰 만료 시 자동 갱신을 구현했다:
// libs/shared/src/services/baseQueryWithReauth.ts
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { getCookie, setCookie, deleteCookie } from 'cookies-next'
export function createBaseQueryWithReauth(basePath: string) {
const baseQuery = fetchBaseQuery({
baseUrl: basePath,
prepareHeaders: (headers, { getState }) => {
const accessToken = getCookie('accessToken')
const refreshToken = getCookie('refreshToken')
const brand = (getState() as RootState).brand
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
if (refreshToken) headers.set('refreshtoken', `Bearer ${refreshToken}`)
headers.set('Brand-Domain', brand.brandDomain)
return headers
},
})
const baseQueryWithReauth: BaseQueryFn = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions)
// 응답 성공 시 userSession 업데이트
if (result.data?.statusCode === 200) {
api.dispatch(setUserSession(result.data.userSession))
}
// 401 또는 403 → 토큰 갱신 시도
if (result.data?.statusCode === 401 || result.data?.statusCode === 403) {
const refreshResult = await attemptTokenRefresh(api)
if (refreshResult.success) {
// 새 토큰으로 원래 요청 재시도
result = await baseQuery(args, api, extraOptions)
} else {
// 갱신 실패 → 로그아웃 처리
handleLogout(api)
}
}
return result
}
return baseQueryWithReauth
}
async function attemptTokenRefresh(api: BaseQueryApi) {
const accessToken = getCookie('accessToken')
const refreshToken = getCookie('refreshToken')
const refreshResult = await fetch(`/api/v1/auth/refresh?accessToken=${accessToken}&refreshToken=${refreshToken}`)
if (refreshResult.ok) {
const data = await refreshResult.json()
setCookie('accessToken', data.accessToken)
setCookie('refreshToken', data.refreshToken)
api.dispatch(setLoginState(data))
return { success: true }
}
return { success: false }
}
function handleLogout(api: BaseQueryApi) {
deleteCookie('accessToken')
deleteCookie('refreshToken')
api.dispatch(clearAuthState())
setCookie('recent-route', window.location.pathname)
window.location.href = '/login'
}
이 구조 덕분에 개별 컴포넌트에서는 인증을 전혀 신경 안 써도 된다. RTK Query 훅만 쓰면 토큰 주입이랑 갱신이 알아서 처리된다!!
하나의 코드베이스로 여러 브랜드를 운영하는 구조에서 BFF는 핵심 허브 역할을 한다.
앱 초기화 (layout.tsx)
↓
DomainDispatcher: Redux에 brandDomain 설정
↓
RTK Query prepareHeaders: Brand-Domain 헤더 자동 주입
↓
BFF: 헤더를 백엔드로 그대로 전달
↓
백엔드: Brand-Domain 기반으로 브랜드별 데이터 분리
// 각 앱의 DomainDispatcher.tsx
'use client'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { setBrandDomain } from '@my-org/root-store'
export default function DomainDispatcher() {
const dispatch = useDispatch()
useEffect(() => {
dispatch(setBrandDomain('brand-a')) // 브랜드별로 다른 값
}, [dispatch])
return null
}
// 루트 layout.tsx
export default function RootLayout({ children }) {
return (
<StoreProvider>
<DomainDispatcher />
{children}
</StoreProvider>
)
}
이렇게 세팅해두면 해당 앱에서 나가는 모든 API 요청에 자동으로 Brand-Domain 헤더가 포함된다. 개발자가 API 호출할 때마다 브랜드를 명시할 필요가 없다는 게 진짜 편하다!!
Next.js App Router의 Server Component에서는 RTK Query를 쓸 수 없다. 이 경우엔 fetchBackend() 함수를 직접 호출하면 된다.
// apps/brand-a/src/app/product/[id]/page.tsx
import { fetchBackend } from '@my-org/bff-shared'
import type { Metadata } from 'next'
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
// Server에서 직접 백엔드 호출 (BFF를 거치지 않고 직접)
const response = await fetchBackend(`/v1/products/${id}`, {
headers: { 'Brand-Domain': 'brand-a' },
})
const product = await response.json()
return {
title: product.data.name,
description: product.data.description,
openGraph: {
images: [product.data.images[0]],
},
}
}
export default function ProductPage({ params }) {
return <ProductDetail params={params} />
}
한 가지 주의할 점은 Server Component에서는 Brand-Domain 헤더를 직접 넣어줘야 한다. Redux 스토어를 서버 컨텍스트에서 못 쓰기 때문이다.
BFF는 클라이언트와 백엔드 사이에 딱 끼어 있기 때문에 모든 API 호출을 관찰하고 기록할 수 있는 최적의 포인트다!!
function logRequest(info: {
rid: string // Request ID (UUID)
method: string
path: string
status: number
latency: string
ip: string
}) {
const timestamp = new Date().toISOString()
console.log(`[${timestamp}] rid=${info.rid} method=${info.method} ` + `path=${info.path} status=${info.status} ` + `latency=${info.latency} ip=${info.ip}`)
}
// 출력 예시:
// [2024-12-01T10:30:45.123Z] rid=a1b2c3d4 method=GET path=/v1/products status=200 latency=45ms ip=203.0.113.1
Kubernetes 같은 환경에서 쓰기 위해 표준 헬스체크도 구현했다:
// Liveness: 앱이 살아있는지
// /api/health/liveness
export async function GET() {
return Response.json({ status: 'UP' })
}
// Readiness: 백엔드 연결이 가능한지
// /api/health/readiness
export async function GET() {
try {
const response = await fetchBackend('/health', { method: 'GET' })
const isHealthy = response.ok
return Response.json({ status: isHealthy ? 'UP' : 'DOWN' }, { status: isHealthy ? 200 : 503 })
} catch {
return Response.json({ status: 'DOWN' }, { status: 503 })
}
}
// Combined: 전체 상태
// /api/health
export async function GET(request: Request) {
const { origin } = new URL(request.url)
const [livenessRes, readinessRes] = await Promise.all([fetch(`${origin}/api/health/liveness`), fetch(`${origin}/api/health/readiness`)])
const liveness = await livenessRes.json()
const readiness = await readinessRes.json()
if (liveness.status !== 'UP') {
return Response.json({ status: 'DOWN' }, { status: 503 })
}
return Response.json({ status: readiness.status }, { status: readiness.status === 'DOWN' ? 503 : 200 })
}
// /api/metrics
import { Registry, collectDefaultMetrics } from 'prom-client'
const registry = new Registry()
collectDefaultMetrics({ register: registry })
export async function GET() {
const metrics = await registry.metrics()
return new Response(metrics, {
headers: { 'Content-Type': registry.contentType },
})
}
1. 보안 강화!!
백엔드 API 주소가 클라이언트에 노출 안 되고, 토큰 관리가 서버 사이드로 이동하면서 보안이 크게 개선됐다.
2. CORS 이슈 완전 해소
모든 API 호출이 Same-Origin이 되면서 CORS 설정 고민이 사라졌다. 이게 생각보다 정말 편했다!!
3. Observability 확보
Request ID 기반 추적, 구조화된 로깅, Prometheus 메트릭 등을 BFF에서 일괄 처리할 수 있게 됐다. 장애 났을 때 원인 파악이 훨씬 빨라졌다.
4. 모노레포와의 시너지
bff-shared 라이브러리 하나로 모든 브랜드 앱에 동일한 BFF 로직을 적용할 수 있었다. 새 브랜드 추가할 때 BFF 설정은 파일 딱 2개(v1, v2 라우트)만 추가하면 끝난다!!
5. 점진적 도입 가능
catch-all 프록시 구조 덕분에 기존 API 호출 경로만 /api/v1/...으로 바꾸면 됐다. 백엔드 수정 없이 적용할 수 있었던 것도 큰 장점이었다.
1. 추가 네트워크 홉
클라이언트 → BFF → 백엔드로 한 단계가 추가되면서 레이턴시가 약간 생긴다.
다만 실제로는 영향이 미미했다. 이유를 설명하면, Next.js는 배포 시 Node.js 서버로 실행되기 때문에 BFF도 결국 하나의 서버 프로세스다. 그리고 이 BFF 서버와 백엔드 서버는 같은 K8s 클러스터(혹은 VPC) 안에 배포된다.
[브라우저]
↓ 인터넷 (수십~수백 ms)
[Next.js 서버 = BFF] ← 내부 인프라
↓ 내부망 (1~5 ms)
[백엔드 API 서버] ← 내부 인프라
브라우저 → BFF 구간은 인터넷을 타지만, BFF → 백엔드 구간은 사설 내부망을 통하기 때문에 레이턴시가 수 ms 이내다. 프론트엔드 프로젝트가 서버로 배포되는 이상 백엔드와 같은 인프라 안에서 통신하므로, 추가 홉의 비용은 전체 응답 시간에서 무시할 수 있는 수준이었다.
2. Server Component에서의 브랜드 컨텍스트
Server Component에서 fetchBackend()를 직접 호출할 때는 Brand-Domain을 수동으로 전달해야 한다. Next.js의 headers() 함수나 별도 컨텍스트 관리로 개선할 수 있을 것 같은데, 아직 손을 못 댔다.
3. 현재는 투명 프록시에 가까움
BFF에서 데이터 집계나 변환을 안 하고 대부분 클라이언트에서 처리하고 있다. 향후 여러 API 응답을 조합하는 BFF 전용 엔드포인트를 추가하면 클라이언트 로직을 더 단순화할 수 있을 것 같다.
BFF 도입 고민하는 분들한테 이 글이 도움이 됐으면 한다! 🙌
확실히 이러면 보안적으로 더 좋아질 것 같네요. 좋은 글 감사합니다