Next.js 모노레포에 BFF 패턴 적용기

seoyeonpp·2026년 2월 25일

Frontend

목록 보기
14/15
post-thumbnail

멀티 브랜드 B2B2C 리셀 마켓플레이스를 개발하면서 BFF(Backend For Frontend) 레이어를 직접 설계하고 도입한 경험을 공유한다!

1. 왜 BFF가 필요했는가??

현재 서비스는 하나의 코드베이스로 여러 브랜드의 B2B2C 리셀 마켓플레이스를 운영하는 모노레포 구조다. 각 브랜드마다 독립된 Next.js 앱이 있고, 백엔드 API 서버는 공통으로 쓴다.

기존엔 클라이언트에서 백엔드를 직접 호출했는데... 문제가 하나둘씩 터지기 시작했다!!

•	문제 1: 인증 토큰 노출 → JWT 토큰이 클라이언트 코드에 그대로 노출, XSS에 취약
•	문제 2: 백엔드 API 주소 노출 → 클라이언트 번들에 내부 API 주소가 그냥 박혀 있었음
•	문제 3: 서버 로그 요청 추적 불가 → 어떤 api가 호출되고 프론트엔드에서 응답을 받기까지 얼마나 걸렸는지 로그로 추적할 수 있는 중간 레이어가 없었음

BFF는 이 모든 문제를 한 번에 해결할 수 있는 자연스러운 선택이었다.


2. 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 + 토큰 자동 갱신

3. 구현: Catch-All Proxy Handler

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,
  }
}

Content-Type별 바디 추출

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 경로가 자동으로 백엔드에 프록시된다. 진짜 편하다!!


4. 인증 토큰 관리와 자동 갱신

BFF를 도입하면서 가장 크게 달라진 부분이 바로 인증 토큰 관리다. 토큰을 클라이언트가 아닌 서버 사이드에서 안전하게 다룰 수 있게 됐다.

토큰 흐름

로그인 성공
    ↓
accessToken, refreshToken → httpOnly Cookie에 저장
    ↓
클라이언트 API 호출 시
    ↓
브라우저가 자동으로 Cookie 전송 (Same-Origin)
    ↓
BFF에서 Cookie → Authorization 헤더로 변환
    ↓
백엔드에 전달

백엔드 HTTP 클라이언트

// 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 자동 토큰 갱신

클라이언트 측에서는 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 훅만 쓰면 토큰 주입이랑 갱신이 알아서 처리된다!!


5. 멀티 브랜드 지원

하나의 코드베이스로 여러 브랜드를 운영하는 구조에서 BFF는 핵심 허브 역할을 한다.

Brand Domain 전파 흐름

앱 초기화 (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 호출할 때마다 브랜드를 명시할 필요가 없다는 게 진짜 편하다!!


6. Server Component에서의 BFF 활용

Next.js App Router의 Server Component에서는 RTK Query를 쓸 수 없다. 이 경우엔 fetchBackend() 함수를 직접 호출하면 된다.

SSR 메타데이터 생성

// 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 스토어를 서버 컨텍스트에서 못 쓰기 때문이다.


7. 모니터링과 로깅

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 })
}

Prometheus 메트릭

// /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 },
  })
}

8. 회고: 좋았던 점과 아쉬운 점

좋았던 점

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 도입 고민하는 분들한테 이 글이 도움이 됐으면 한다! 🙌

2개의 댓글

comment-user-thumbnail
2026년 2월 26일

확실히 이러면 보안적으로 더 좋아질 것 같네요. 좋은 글 감사합니다

1개의 답글