logo

DowanKim

브라우저에게 백엔드를 알리지 마라 - BFF 형태의 장점

2026년 7월 25일

PLick

저희 앱은 브라우저가 백엔드(Spring BE)를 직접 부르지 않는 구조입니다. 모든 요청은 Next 서버를 거칩니다.

image.png

서버에서의 fetch, 브라우저에서의 fetch

저희 앱에서 api요청은 두가지로 나눌 수 있습니다.

서버 컴포넌트가 렌더 중에 하는 fetch와, 브라우저에서 TanStack Query(서버 데이터를 캐싱·동기화해 주는 클라이언트 fetch 라이브러리) 같은 걸로 하는 fetch.

둘 다 같은 apiFetch 래퍼를 쓰는데, base URL을 고르는 분기로 나뉘게 구현했습니다.

function baseUrl(): string { if (typeof window === "undefined") { return process.env.API_BASE_URL ?? "http://localhost:8080"; } return BE_PROXY_PREFIX; // "/be" }

서버에서 돌면 BE의 절대 URL을 사용합니다(실 서버 주소).

서버 컴포넌트 fetch는 Next 서버 프로세스 안에서 실행되는 서버 대 서버 통신이라 CORS라는 개념 자체가 없고, 내부 주소로 바로 때리면 됩니다.

CORS는 브라우저에 내장된 안전 규칙입니다. 지금 있는 사이트가 아닌 다른 오리진으로 데이터 요청을 보내려면 상대 서버가 허락한다는 응답 헤더를 줘야 통과시켜 주는데, 이 검사를 하는 주체가 브라우저라서 서버끼리의 통신에는 애초에 존재하지 않습니다.

여기서 오리진(origin)은 https:// + 도메인 + 포트까지 묶은 "어느 사이트냐"의 단위입니다. https://m.plick.co.kr과 http://localhost:8080은 서로 다른 오리진입니다.

브라우저에서 돌면 /be라는 상대경로를 사용합니다. 자기 오리진의 /be/*로 요청하면 Next의 rewrites가 접두어를 떼고 BE로 그대로 넘겨주게 했습니다. 이 rewrites로 만든 전달 통로를 "/be 프록시"라고 지칭하겠습니다.

async rewrites() { const base = process.env.API_BASE_URL || "http://localhost:8080"; return [{ source: "/be/:path*", destination: `${base}/:path*` }]; }

rewrite는 리다이렉트가 아닙니다.

리다이렉트는 "저 주소로 다시 가세요"라고 브라우저를 돌려보내는 것이라 브라우저가 새 주소를 알게 되지만,

rewrite는 브라우저 모르게 Next 서버가 뒤에서 BE에 대신 갔다 옵니다. 브라우저 입장에서는 끝까지 자기 오리진과 통신한 것입니다.

상대경로 요청이 반드시 Next 서버에 도착하는 것도 특별한 처리가 아닙니다.

브라우저가 /be/…를 현재 페이지의 오리진 기준 절대 URL로 완성해서 보내는데, 그 도메인에서 듣고 있는 서버가 Next뿐이라 다른 데로 갈 수가 없습니다.

Next는 도착한 요청 중 매칭되는 페이지나 파일이 없는 /be/*를 rewrites 규칙과 대조해 BE로 대신 다녀오는 것입니다.

즉 서버 컴포넌트 fetch든 브라우저 fetch든, BE에 도착하는 요청의 출발지는 항상 Next 서버입니다. 브라우저는 BE의 주소를 모릅니다.

처음 이 프록시를 만든 동기는 우선적으로 CORS 문제였습니다.

브라우저가 BE 주소(localhost:8080 같은)를 직접 부르면 cross-origin이라 막힙니다. BE에 CORS 헤더를 열어달라고 할 수도 있지만, 허용 오리진 목록을 환경(로컬, 배포, 앞으로 생길 수도 있는 스테이징)마다 관리하는 일이 늘어납니다. same-origin 프록시를 두면 CORS라는 문제가 아예 존재하지 않게 됩니다.

토큰은 HttpOnly 쿠키에만 살게

소셜 로그인(카카오, 구글)을 붙이면서 토큰을 어디에 둘지 정해야 했습니다.

토큰은 "이 사람 로그인한 아무개 맞음"을 증명하는 문자열로, 요청마다 실어 보내는 단기 출입증인 access 토큰과 그게 만료됐을 때 재발급받는 데 쓰는 장기 재발급권인 refresh 토큰 두 가지가 있습니다.

localStorage(브라우저 JS가 자유롭게 읽고 쓰는 저장소)는 처음부터 배제했습니다. XSS가 한 번이라도 뚫리면 localStorage.getItem 한 줄로 토큰이 통째로 털립니다.

XSS는 공격자가 우리 페이지에 악성 JS를 몰래 심는 공격인데, 심긴 스크립트는 우리 코드와 똑같은 권한으로 돌기 때문에 JS가 읽을 수 있는 곳에 토큰이 있으면 그대로 훔쳐 갑니다.

결론은 HttpOnly 쿠키고, 자연스레 "토큰을 만지는 코드는 전부 서버에 있어야 한다"는 제약이 따라옵니다.

쿠키는 브라우저가 도메인별로 보관했다가 그 도메인으로 가는 요청에 자동으로 붙여 주는 작은 저장 값이고, HttpOnly는 거기에 다는 잠금 옵션입니다.

이 옵션이 붙은 쿠키는 브라우저 JS가 읽을 수도, 쓸 수도, 지울 수도 없습니다. 오직 HTTP 요청에 자동 첨부될 때만 쓰입니다.

훔칠 API 자체가 없으니 XSS가 뚫려도 토큰은 못 가져갑니다.

export const AUTH_COOKIE_BASE = { httpOnly: true, sameSite: "lax", path: "/", secure: process.env.NODE_ENV === "production", } as const;

그래서 로그인이 서버 액션입니다.

서버 액션을 한 문장으로 하면 "함수 호출처럼 생긴 API 요청"입니다.

"use server"를 붙인 함수는 클라이언트 컴포넌트가 평범한 함수처럼 import해서 부를 수 있지만, 함수 본문은 브라우저 번들에 들어가지 않습니다.

화면에서 login(provider, code)을 부르는 순간 Next가 뒤에서 인자를 실은 POST 요청을 서버로 보내고, 함수는 서버에서 실행되고, 반환값만 응답으로 돌아옵니다.

원래대로라면 API 라우트를 만들고 fetch 코드를 짰어야 할 일을, 프레임워크가 함수 호출 문법 뒤에 숨겨줍니다.

OAuth 콜백에서 인가 code(프로바이더가 "이 사람 동의함"의 증표로 주는 일회용 교환권)를 받으면

서버 액션 login이 BE의 /api/v1/auth/login에 code를 넘겨 토큰 쌍을 받고,

cookies()로 HttpOnly 쿠키를 심은 뒤 redirect()로 홈이나 온보딩으로 보냅니다.

여기서 cookies().set은 브라우저에 직접 쿠키를 쓰는 게 아니라 응답에 Set-Cookie 헤더 (브라우저에게 "이 값을 쿠키로 보관해 둬"라고 시키는 지시문)를 싣는 것이고,

redirect()도 페이지 리로드가 아니라 소프트 내비게이션(페이지 전체를 새로 받지 않고 필요한 부분만 갈아끼우는 이동)입니다.

중요한 건 이 왕복 전체에서 토큰 문자열이 브라우저 JS 코드에 단 한 번도 노출되지 않는다는 점입니다.

BE 호출, 응답 파싱, 쿠키 저장이 전부 Next 서버에서 끝나고, 브라우저에는 결과 화면만 갑니다.

로그아웃도 대칭으로 서버 액션이 쿠키를 지웁니다. HttpOnly라 클라이언트에서는 지우고 싶어도 못 지웁니다.

OAuth 인가 왕복의 CSRF 방지용 state도 같은 방식으로 HttpOnly 쿠키에 들고 있고,

프로바이더 client_id 같은 설정도 전부 서버 env에서 읽어 서버 액션이 인가 URL을 조립합니다.

CSRF는 다른 사이트가 사용자의 브라우저를 빌려 우리 사이트로 몰래 요청을 쏘게 만드는 공격이고,

state는 그 왕복이 내가 시작한 것이 맞는지 확인하는 난수 표식입니다.

클라이언트 번들에는 OAuth 설정이 들어가지 않습니다.

그런데 브라우저 fetch는 토큰을 어떻게 싣나

릴스 무한 스크롤의 다음 페이지처럼 브라우저에서 직접 하는 fetch가 있습니다.

PLick의 조회 API는 로그인 없이도 열리는 공개 API지만, Bearer 토큰을 실으면 응답의 likedByMe(내가 좋아요 눌렀는지)가 그 유저 기준으로 계산됩니다.

그러니 로그인 상태라면 브라우저 fetch에도 토큰이 실려야 합니다.

여기서 Bearer는 BE가 알아듣는 인증 방식입니다.

요청에 Authorization: Bearer <토큰> 헤더를 실으면 "이 토큰의 소지자(bearer)가 보낸 요청으로 처리하라"는 뜻입니다.

BE는 쿠키는 보지 않고 이 헤더만 봅니다.

그런데 토큰은 HttpOnly 쿠키에 있습니다.

브라우저 JS는 읽을 수 없으니 Authorization 헤더를 스스로 만들 수 없습니다.

쿠키는 same-origin 요청이니 자동으로 실려 가긴 하는데, BE는 쿠키를 보지 않고 Bearer 헤더만 봅니다.

쿠키로는 도착하는데 헤더로는 못 만드는 이 간극을 누가 메워야 하는가?

Next 미들웨어입니다.

미들웨어는 Next 서버에 들어오는 모든 요청이 라우트 처리 전에 통과하는 검문소 코드로, 요청을 열어 보고 헤더를 바꾸거나 쿠키를 심을 수 있습니다.

참고로 Next 16에서 파일 규칙 이름이 middleware.ts에서 proxy.ts로 바뀌었는데, 위의 "/be 프록시"(rewrites 전달 통로)와는 다른 것이라 이 글에서는 계속 미들웨어라고 부르겠습니다.

미들웨어는 /be/*로 나가는 요청이면 쿠키에서 access 토큰을 꺼내 요청 헤더에 심어서 통과시킵니다.

function authorized(request: NextRequest, accessToken: string): NextResponse { const headers = new Headers(request.headers); headers.set("Authorization", `Bearer ${accessToken}`); return NextResponse.next({ request: { headers } }); }

NextResponse.next({ request: { headers } })는 미들웨어가 직접 뭔가를 전송하는 게 아니라 "이 요청을 계속 진행시키되 요청 헤더는 이걸로 바꿔서"라는 지시를 돌려주는 것입니다.

미들웨어는 rewrites보다 먼저 돌기 때문에, 여기서 바꾼 요청 헤더가 /be 프록시를 타고 BE까지 그대로 갑니다.

브라우저는 쿠키만 자동으로 실어 보냈을 뿐인데, BE에 도착할 때는 Bearer 헤더가 달려 있습니다.

정리하면 토큰 주입 지점이 경로별로 다릅니다.

서버 컴포넌트 fetch는 렌더 중에 cookies()로 토큰을 꺼내는 getAccessToken() 헬퍼로 호출부가 직접 헤더에 싣고,

브라우저 fetch는 본인은 아무것도 모른 채 미들웨어가 대신 싣습니다.

주입 자리가 두 군데인 게 이상할 수 있지만, 각 경로에서 토큰에 접근 가능한 유일한 자리가 어디인지 따라가면 이렇게 나뉠 수밖에 없는 것 같습니다.

서버 컴포넌트 fetch는 미들웨어를 거치지 않고 절대 URL로 바로 나가니 미들웨어가 대신 실어줄 수 없고, 브라우저 fetch는 HttpOnly라 호출부가 실을 수 없습니다.

조용한 토큰 재발급, 그리고 미들웨어가 그 자리인 이유

access 토큰은 수명이 짧습니다(15분). 만료될 때마다 다시 로그인시킬 수는 없으니 refresh 토큰(14일)으로 재발급하는 흐름이 필요한데, 이걸 어디서 할까?

우리 BE 토큰은 exp 클레임을 디코드할 수 있는 JWT가 아니라 만료 정보가 없는 불투명 문자열입니다.

JWT는 만료 시각 같은 내용이 안에 적혀 있어 열어 볼 수 있는 토큰 형식인데,

우리 토큰은 열어 볼 것이 없는 그냥 난수 문자열이라는 뜻입니다.

그래서 만료 판정을 토큰이 아니라 쿠키 수명에 맡겨야 합니다.

access 쿠키의 maxAge(쿠키의 유효기간)를 토큰 수명과 같게 주면, 만료 시점에 브라우저가 쿠키를 알아서 버립니다.

요청에 access 쿠키가 없고 refresh 쿠키만 남아 있는 상태, 그게 곧 "재발급해야 함"이라는 신호가 됩니다.

별도의 만료 체크 로직 없이 브라우저의 쿠키 만료 메커니즘을 그대로 신호로 사용합니다.

이 판정 덕에 만료된 토큰을 실어 보내 401로 화면이 죽는 경우도 없습니다. 만료됐으면 쿠키가 이미 사라져서 익명 요청으로 나가게 됩니다.

재발급을 미들웨어에서 하는 이유는, 평범한 GET 내비게이션 도중에 응답 쿠키를 심을 수 있는 자리가 거기뿐이기 때문입니다.

서버 액션은 버튼 클릭 같은 POST에 붙는 거라 "페이지를 여는 순간"에는 개입할 수 없고, 서버 컴포넌트는 요청 쿠키를 읽을 수만 있지 Set-Cookie를 쓸 수 없습니다.

사용자가 링크를 눌러 페이지를 여는 그 요청에서 토큰을 갈아끼우려면 미들웨어밖에 없습니다.

그래서 미들웨어는 access가 없고 refresh만 있는 요청을 만나면 BE에 재발급을 요청하고, 새 토큰 쌍을 두 군데에 심습니다.

요청 쿠키에 심어서 이번 내비게이션의 다운스트림 렌더(서버 컴포넌트)가 새 access를 보게 하고, 응답 쿠키에 심어서 브라우저가 회전된 쌍을 저장하게 합니다.

refresh 토큰은 회전 방식(재발급할 때 refresh까지 새 값으로 갈아끼워, 쓴 재발급권은 바로 폐기되는 방식)이라 재발급 응답의 refresh도 새 값입니다.

재발급까지 실패하면(refresh도 만료) 쿠키를 지우고 로그인으로 리다이렉트하는데, 이때 한 가지 함정이 있었습니다.

/be/* fetch 요청까지 리다이렉트하면 JSON을 기다리던 fetch가 로그인 페이지 HTML을 받아 파싱 에러로 죽습니다. (const data = await res.json(); ... 이런식일텐데 json이 아니라 html이 와버림 -> 에러)

fetch는 리다이렉트 응답(302 + Location 헤더)을 받으면 조용히 따라가서 최종 응답을 돌려주는 게 기본 동작이라, 호출한 코드는 리다이렉트가 있었는지도 모른 채 JSON 자리에 로그인 페이지 HTML을 받아 들게 됩니다.

그렇다고 화면이 로그인 페이지로 이동하는 것도 아닙니다. 백그라운드 fetch가 따라간 것이지 브라우저 주소창과는 무관하기 때문입니다. 로그인 유도도 못 하고 화면만 깨지는, 양쪽 다 실패한 상황이 됩니다.

그래서 /be 프록시로 온 fetch 요청은 리다이렉트하지 않고 쿠키만 지운 채 익명으로 통과시킵니다.

공개 조회는 그대로 뜨고, 보호 API면 BE가 401을 주니 그때 처리하면 됩니다.

이 재발급 fetcher(refreshTokens)는 일부러 "use server"를 붙이지 않은 순수 함수로 뒀습니다.

유일한 소비자인 미들웨어가 edge 런타임(Node 전체가 아니라 제한된 API만 쓸 수 있는 경량 실행 환경)에서 도는데,

서버 액션이 기대는 next/headers 같은 API는 edge에서 안 돌기 때문입니다.

런타임은 "내 코드가 실제로 돌아가는 환경"입니다. Next의 서버 쪽에는 작업장이 두 개 있는데, 서버 컴포넌트 렌더와 서버 액션이 도는 Node 런타임(풀옵션 주방)과, 미들웨어가 도는 edge 런타임(fetch, Request, Response 같은 웹 표준 도구만 비치된 간이 주방)입니다.

미들웨어는 모든 요청마다 실행되는 코드라 가볍고 빨리 켜져야 하고, Vercel 같은 호스팅에서는 사용자와 가까운 CDN 서버(가장자리, 그래서 이름이 edge)에 배포해 돌리기 때문에 Node라는 무거운 전제를 버린 환경이 만들어졌습니다. 저희처럼 자체 서버에 배포해도 미들웨어는 기본적으로 이 제한된 환경에서 돕니다.

그리고 함수는 자기만의 런타임을 갖지 않습니다. import한 쪽에 딸려 들어가서, 부르는 쪽이 도는 곳에서 같이 돕니다.

refreshTokens는 미들웨어가 import하니 edge에서 돌게 되고, next/headers를 한 줄이라도 물고 있었다면 미들웨어 전체가 edge에서 안 돌아갑니다.

그래서 필요한 도구가 fetch(웹 표준이라 Node에도 edge에도 있음) 하나뿐인 순수 함수로 깎아 둔 것입니다.

쿠키 읽기와 심기는 미들웨어가 자기 API로 하고, 이 함수는 BE 호출과 응답 봉투 해제만 합니다.

"edge면 쿠키와 리다이렉트도 안 되는 것 아닌가" 싶을 수 있는데, 안 되는 건 next/headers라는 특정 API지 쿠키라는 능력이 아닙니다.

쿠키는 요청의 Cookie 헤더와 응답의 Set-Cookie 헤더, 리다이렉트는 302 상태코드와 Location 헤더일 뿐이라, 전부 웹 표준 객체만으로 되는 헤더 읽고 쓰기입니다.

next/headers의 cookies()가 특별한 건 인자 없이 불러도 "지금 렌더 중인 그 요청"의 쿠키를 찾아 준다는 점인데, 그 조회 장치가 Node 렌더 파이프라인의 시설이라 edge에는 없습니다.

반면 미들웨어는 요청이 함수 인자(request)로 직접 손에 쥐어지니, 물어볼 것 없이 request.cookies로 읽고 response.cookies로 심으면 됩니다. "자기 API로 한다"가 이 뜻입니다.

캐시문제

토큰을 실어 보내기 시작하면서 캐시 문제를 하나 만났습니다.

Next의 데이터 캐시(서버에서 한 fetch의 응답을 저장해 뒀다가 재사용하는 캐시)는 유저를 구분하지 않고 URL 단위로 공유됩니다.

토큰을 실은 조회 응답이 한 번 캐시되면, 같은 주소를 부른 다른 사람에게 그 응답이 그대로 나갈 수 있습니다.

likedByMe처럼 사람마다 다른 값이 섞이면 남의 좋아요 상태를 보게 되는 것입니다.

그래서 apiFetch가 Authorization 헤더가 있는 호출에는 cache: "no-store"(캐시에 넣지도, 꺼내 쓰지도 말라는 옵션)를 강제로 박게 했습니다.

기본 캐시 동작에 기대지 않고 래퍼 한 곳에서 못박아 두면, 나중에 누가 인증 조회를 추가해도 이 함정을 다시 밟지 않습니다.

인프라적 이점

현재 운영 구성은 Next 앱 둘(web, mobile)이 프라이빗 서브넷의 EC2에서 돌고, 사용자는 퍼블릭 ALB로만 들어옵니다.

BE(Spring)는 내부 ALB 뒤 프라이빗 서브넷에 있습니다.

프라이빗 서브넷은 인터넷에서 직접 닿을 수 없는 네트워크 구역이고, ALB는 요청을 받아 서버로 나눠 주는 AWS 로드밸런서(관문)입니다.


브라우저 ── 퍼블릭 ALB(TLS 종료) ── Next EC2(프라이빗)
                                        │  서버 컴포넌트 fetch
                                        │  브라우저 /be/* 프록시
                                        ▼
                                   내부 ALB ── BE EC2

브라우저가 BE를 직접 부르지 않는다는 원칙 덕에, BE로 나가는 요청의 출발지는 항상 Next EC2 하나입니다.

그래서 이런 것들이 따라왔습니다.

  1. 1.BE를 인터넷에 노출할 필요가 없습니다. 퍼블릭 IP도, 인터넷용 리스너도 없이 내부 ALB 뒤에만 두면 됩니다.
  2. 2.BE의 공격 표면이 "인터넷 전체"에서 "같은 VPC의 Next 서버"로 줄어듭니다. 인증이 뚫리네 마네 하기 전에, 애초에 밖에서 닿을 수 있는 주소가 없습니다.
  3. 3.CORS 설정이 BE에 하나도 없습니다. 브라우저 관점의 cross-origin 요청 자체가 존재하지 않으니 허용 오리진 목록, preflight(본 요청 전에 브라우저가 허락을 묻는 사전 요청), credentials 옵션 같은 걸 관리할 일이 없습니다. 프론트 도메인이 바뀌거나 늘어도 BE는 건드릴 게 없습니다.
  4. 4.BE 주소가 클라이언트 번들에 없습니다. API_BASE_URL은 서버 env로만 읽히고, 브라우저 코드에는 /be라는 상대경로 문자열만 들어갑니다. 내부 ALB의 DNS 이름 같은 인프라 세부가 밖으로 새지 않고, 환경별 주소 차이도 서버 설정만으로 흡수됩니다. 덧붙이면 rewrites의 destination은 서버가 뜰 때가 아니라 next build 때 평가돼 산출물에 문자열로 굳습니다.
  5. 5.같은 오리진이라 쿠키 모델이 단순합니다. 토큰 쿠키는 Next 도메인에만 존재하고 SameSite=Lax(다른 사이트에서 시작된 요청, 특히 POST에는 쿠키를 붙이지 않는 옵션)로 충분합니다. 프론트와 API가 도메인이 갈렸다면 SameSite=None에 서드파티 쿠키 취급, 크로스 도메인 쿠키 공유 같은 골치 아픈 영역으로 끌려갔을 것입니다. Lax는 다른 사이트에서 시작된 POST에 쿠키를 실어주지 않으므로 그 자체로 CSRF 방어선 역할도 합니다.
  6. 6.트래픽 관제 지점이 하나입니다. BE 입장에서 클라이언트는 Next 서버뿐이라, 로깅이든 레이트 리밋이든 IP 제한이든 걸 자리가 명확합니다. 나중에 캐싱 계층이나 요청 변환을 끼워 넣고 싶어도 Next 서버 한 곳만 손대면 됩니다.

BFF

BFF(Backend for Frontend) 패턴이라고 볼 수 있습니다.

사운드클라우드 엔지니어링에서 나와 샘 뉴먼이 정리한 패턴인데, 요지는 모든 클라이언트(웹, 모바일 앱, 서드파티)가 범용 API 서버 하나를 같이 쓰면 그 API는 누구의 요구에도 딱 맞지 않는 평균이 된다는 것입니다.

그러니 프론트엔드마다 자기 전용 백엔드 층을 하나씩 두고, 그 층은 백엔드 팀이 아니라 그 프론트엔드를 만드는 팀이 소유합니다.

클라이언트는 자기 BFF하고만 이야기하고, 진짜 백엔드와의 협상은 BFF가 대신합니다.

PLick에서는 Next 서버가 정확히 이 자리에 서 있습니다. 그것도 앱마다 하나씩입니다.

web(plick.co.kr)과 mobile(m.plick.co.kr)이 각자의 Next 서버를 띄우니, 웹의 BFF와 모바일의 BFF가 이미 분리돼 있는 셈입니다.

따로 게이트웨이 서버를 세운 적이 없는데도 Next 서버가 하고 있는 일을 나열해 보면 BFF의 역할 목록과 거의 겹칩니다.

토큰 보관소 역할

브라우저 JS에는 토큰을 절대 주지 않고 서버 쪽이 보관하며 나가는 요청에 대신 실어 주는 방식을 토큰 핸들러 패턴이라고 부르는데, 우리 구조가 그 문제의식 그대로입니다.

전형적인 토큰 핸들러는 브라우저에 불투명한 세션 쿠키만 주고 토큰은 서버 저장소에 두지만, 우리는 저장소 없이 HttpOnly 쿠키 자체에 토큰을 실어 두는 변형입니다.

그래도 저장소 인프라 없이 "브라우저 JS가 토큰을 만질 수 없다"는 목표는 같게 달성됩니다.

프로토콜 번역

브라우저 세계의 인증(쿠키 자동 첨부)과 BE 세계의 인증(Bearer 헤더)이 서로 통하지ㅣ 않는데, 미들웨어가 그 사이에서 통역합니다.

BE는 쿠키 파싱 코드를 한 줄도 가질 필요가 없고, 브라우저는 헤더를 만들 필요가 없습니다.

계약 번역

apiFetch가 BE 공통 응답 봉투 { code, message, data }를 벗기고, 각 앱의 _services fetcher가 스웨거 shape을 화면이 쓰는 도메인 타입(FeedPost 등)으로 손으로 매핑합니다.

화면 코드는 BE 응답 형태를 모르고 도메인 타입만 봅니다.

BE가 필드 이름을 바꾸거나 구조를 갈아엎어도 수정 범위는 fetcher의 매핑 한 층으로 격리됩니다.

BE 배포 주기와 프론트 배포 주기가 느슨하게 풀리는 게 여기의 장점입니다.

비밀 보관

OAuth client_id, redirect_uri, API_BASE_URL 같은 설정이 전부 서버 env에만 있습니다.

BFF가 없었다면 이 중 일부는 어쩔 수 없이 클라이언트 번들에 들어갔을 값들입니다.

응답 조립

서버 컴포넌트가 이미 이 역할을 합니다.

홈 화면은 피드와 핫이슈 캐러셀처럼 여러 API가 필요한데, 브라우저가 API를 여러 번 왕복하는 게 아니라 Next 서버가 BE를 병렬로 부르고 결과를 HTML 하나로 조립해 내려보냅니다.

BFF 문헌에서 말하는 aggregation(여러 백엔드 호출을 클라이언트 대신 합쳐 주는 것)이 서버 컴포넌트 렌더링이라는 이름으로 공짜로 일어나고 있는 것입니다.

나중에 화면에 맞춰 응답을 더 줄이거나(over-fetching 제거) 여러 마이크로서비스를 합치게 되더라도, 끼워 넣을 자리는 이미 있습니다.

웹과 모바일의 요구가 갈릴 때도 각자의 층에서 해결

실제로 두 앱은 같은 BE 계약을 쓰면서도 fetcher와 화면 조립이 따로라, 모바일이 릴스에 맞춘 무한 스크롤 페이지네이션을 가져가는 동안 웹은 데스크톱 레이아웃에 맞는 형태로 데이터를 접습니다.

BE에 "모바일용 응답도 만들어 주세요"라고 부탁할 일이 없습니다.


교과서적인 BFF는 전용 게이트웨이 서버를 따로 세우는 것이고, 그러면 지키고 배포할 서버가 한 대 늘어납니다.

Next는 서버 컴포넌트를 렌더링하느라 어차피 서버가 떠 있고, rewrites·미들웨어·서버 액션·HttpOnly 쿠키라는 기본 재료가 이미 있습니다.

BFF를 도입했다기보다, 렌더링 서버가 서 있는 자리에 BFF의 역할을 하나씩 얹다 보니 패턴이 완성돼 있었습니다.

정리

  1. 1.브라우저는 BE를 모릅니다. 모든 요청은 Next 서버가 출발지입니다.
  2. 2.토큰은 HttpOnly 쿠키에만 있고, 만지는 코드는 전부 서버에 있습니다. XSS로 스크립트가 주입돼도 토큰을 읽어갈 API가 없습니다.
  3. 3.토큰 주입은 경로별로 접근 가능한 유일한 자리에서 합니다. 서버 컴포넌트는 cookies()로, 브라우저 fetch는 미들웨어가 쿠키를 Bearer 헤더로 번역해서.
  4. 4.재발급은 GET 내비게이션 중 응답 쿠키를 심을 수 있는 유일한 자리인 미들웨어에서, 쿠키 만료를 토큰 만료 신호 삼아 조용히 합니다.
  5. 5.그 대가로 BE는 프라이빗 서브넷에 숨고, CORS는 사라지고, 쿠키는 단순해지고, 관제 지점은 하나가 됐습니다.
  6. 6.이 전체가 앱별 Next 서버를 BFF로 삼은 구조입니다. 토큰 보관, 프로토콜·계약 번역, 비밀 보관, 응답 조립을 전용 게이트웨이 서버 없이 렌더링 서버가 겸합니다.

CORS 피하려고 한게 토큰 보관 전략을 정하고, 토큰 보관 전략이 주입 지점을 정하고, 그 전체가 BE를 인터넷에서 치워버릴 수 있게 해줬습니다.

다 만들고 보니 BFF의 역할을 하는 프론트 서버가 되었습니다.

이 구조의 장점이 인프라와 보안 등 다양한 부분에서 이점을 가져왔고,

서버를 낀 프레임워크가 렌더링 말고도 얼마나 값어치를 하는지 배울 수 있었습니다.