Next.js를 BFF로: 토큰을 브라우저에서 지우는 stateless JWE 세션
- access token을 JS 메모리에 들고 있던 SPA 구조를 버리고, Next.js를 BFF(Backend-for-Frontend)로 세웠다.
- 토큰은 AES-256-GCM으로 암호화된 httpOnly 쿠키(JWE)에 봉인 — 자바스크립트로는 절대 못 읽는다. XSS 토큰 탈취 표면 0.
- Redis 세션 스토어는 도입하지 않았다. 브라우저 요청을 받은 BFF가 매번 쿠키를 복호화해
Authorization: Bearer+X-Org-Id같은 trust 헤더로 변환해 백엔드로 넘긴다. - 핵심은 함수 하나 —
proxyCoreRequest(). 세션 복호화 → CSRF 검증 → 헤더 주입 → stream proxy.
1.출발점의 문제
마이그레이션 전 apps/web은 전형적인 SPA였다. access token은 Zustand
메모리에, refresh 쿠키는 path=/api/v1/auth로 좁혀 두고, 클라이언트
http-client.ts가 토큰 주입 · 401 재시도 · refresh 큐를 직접 굴렸다. 두 가지가
근본적으로 막혀 있었다.
- Server Component에서 인증된 패칭이 불가능 — 토큰이 JS 메모리에 있으니 서버가 못 읽고, refresh 쿠키 path가 좁아 RSC의 일반 호출에 자동으로 붙지 않는다. RSC의 모든 이점(작은 JS 번들, streaming, PPR)이 봉인된 상태였다.
- XSS 토큰 탈취 표면 — access token이 JS에서 읽히는 한, 스크립트 인젝션 한 번이면 세션이 통째로 샌다.
엔터프라이즈 SaaS 런칭 직전이었고, 쿠키 path만 넓히는 임시방편이 아니라 근본 구조를 바꿔야 했다.
2.결정 — Next.js = BFF, 세션은 stateless JWE
결정은 두 줄로 요약된다. Next.js를 BFF로 둔다(브라우저는 same-origin
/api/*만 호출, CORS도 사라진다). 세션은 Redis 같은 서버 스토어 없이,
암호화된 쿠키 자체에 담는다(stateless).
왜 Redis 세션 스토어를 안 썼나
"엔터프라이즈니까 Redis 세션 스토어"가 첫 직감이었지만, 코드를 조사하니 결론이 뒤집혔다. auth-service에는 이미 refresh rotation의 fingerprint 기반 family invalidation(reuse detection + 15초 grace + 보안 알림 메일)과 JWT keyring(KID) rotation이 갖춰져 있었다. 여기에 Redis 세션 스토어를 얹으면:
- 매 요청마다 Redis 1 hop (RSC 데이터 패칭마다 룩업)
- refresh rotation의 reuse detection과 폐기 메커니즘이 이중화 → 동기화 부담
- BFF가 stateful해져 수평 확장에 sticky session/Redis 의존
얻는 건 "즉시 폐기 0초 vs ~15초"인데, NullVest 규모에서 이 차이는 RTO/RPO에 의미가 없다. 이 코드베이스에서 Redis 세션 스토어는 over-engineering이라는 게 결론이었다. JWE 암호화 쿠키가 깔끔하다.
3.쿠키 3종과 봉인된 세션
세션 상태는 쿠키 3개에 나뉘어 산다. __Host- prefix는 브라우저가 Secure + Path=/ +
Domain-less를 강제하게 만든다(production만).
const PREFIX = isProduction ? '__Host-' : ''; export const SESSION_COOKIE_NAME = `${PREFIX}nvst_at`; // access JWT (JWE로 암호화) export const REFRESH_COOKIE_NAME = `${PREFIX}nvst_rt`; // refresh JWT (auth-service 기존 자산 재사용) export const CSRF_COOKIE_NAME = `${PREFIX}nvst_csrf`; // double-submit token const BASE_COOKIE_OPTIONS = { httpOnly: true, // ← JS에서 절대 못 읽음 (XSS 표면 0) secure: isProduction, sameSite: 'lax', path: '/', // ← 좁은 path가 아니라 전 경로 → RSC 호출에도 자동 첨부 } as const;
핵심은 access token이 평문 쿠키가 아니라 JWE(JSON Web Encryption)라는 점이다.
dir + A256GCM(대칭키 AES-256-GCM)으로 봉인하고, BFF만 키링을
쥐고 복호화한다. 봉인된 payload에는 백엔드로 보낼 JWT뿐 아니라 org membership까지 동봉한다 —
login 때 한 번 fetch해 넣어두면, 이후 매 RSC 요청마다 DB를 다시 치지 않는다.
const SESSION_ALG = 'dir' as const; const SESSION_ENC = 'A256GCM' as const; // AES-256-GCM 대칭 암호화 export type SessionPayload = { accessToken: string; // 백엔드로 보낼 JWT (브라우저엔 안 노출) accessTokenExpiresAt: number; userId: string; orgMemberships: OrgMembership[]; // login 시 1회 fetch → JWE에 동봉 activeOrgId: string | null; // org 전환 = JWE 재봉인만 (토큰 재발급 X) };
4.BFF가 실제로 하는 일 — 함수 하나로 본다
브라우저가 /api/v1/core/*를 때리면 catch-all 라우트 핸들러가 받는다. 이 핸들러의
본체는 거의 없다 — webhook만 걸러내고 전부 proxyCoreRequest()로 넘긴다.
async function dispatch(request: Request, ctx: Ctx): Promise<Response> { const { slug: slugSegments } = await ctx.params; const slug = slugSegments.join('/'); // Stripe/Toss webhook은 upstream 서명을 쓰므로 BFF가 replay 불가 → core로 직행시킴 if (slug.startsWith(WEBHOOK_PREFIX)) { return NextResponse.json({ error: 'webhooks must bypass BFF' }, { status: 404 }); } const url = new URL(request.url); return proxyCoreRequest(request, `/${slug}${url.search}`); } export const GET = dispatch; export const POST = dispatch; // PUT · PATCH · DELETE 동일
진짜 일은 proxyCoreRequest()가 한다. 한 함수 안에 BFF의 책임이 다 들어 있다 —
주석의 ①~⑤를 따라가 보자.
export async function proxyCoreRequest(request: Request, upstreamPath: string): Promise<Response> { const store = await cookies(); const session = await getSessionFromStore(store); // ① JWE 쿠키 복호화 const method = request.method.toUpperCase(); const publicRequest = isPublicCoreRequest(method, upstreamPath); // 공개 카탈로그 등 if (!session && !publicRequest) { return NextResponse.json({ error: 'no session' }, { status: 401 }); } // ② 상태 변경 요청은 CSRF double-submit 검증 if (STATE_CHANGING_METHODS.has(method) && !publicRequest) { const failure = verifyCsrf(store, request); if (failure) return failure; } // ③ 세션 → 백엔드 trust 헤더로 변환 const headers = buildBackendHeaders(session); copyForwardedHeaders(request, headers); // ④ body는 buffering 없이 stream → 대용량 업로드가 BFF 메모리에 안 올라옴 const body = NO_BODY_METHODS.has(method) ? undefined : (request.body ?? undefined); const upstream = await fetch(`${CORE_BASE}${upstreamPath}`, { method, headers, body, ...(body ? { duplex: 'half' } : {}), // Node fetch에서 body stream에 필요 } as RequestInit); return passthroughResponse(upstream); // ⑤ 응답 그대로 브라우저로 }
(실제 코드는 ④ 주변에 client abort → 499, upstream 실패 → 502
핸들링을 두지만 본질은 위 5단계다.)
5.trust 헤더의 정체 — 쿠키를 JWT로 번역하기
③의 buildBackendHeaders()가 이 아키텍처의 심장이다. 브라우저는 암호화된 쿠키만
보내고, BFF가 그걸 풀어 백엔드가 이해하는 JWT Bearer + 신원 컨텍스트 헤더로 바꿔 끼운다.
export function buildBackendHeaders(session: SessionPayload | null, ...): Record<string, string> { const out: Record<string, string> = {}; if (options.skipAuth || !session) return out; out.Authorization = `Bearer ${session.accessToken}`; // 쿠키 안의 JWT를 표준 헤더로 out['X-User-Id'] = session.userId; const ctx = readAccessTokenContext(session.accessToken); // JWT claims 디코드 if (ctx.email) out['X-User-Email'] = ctx.email; if (ctx.isAdmin !== null) out['X-Admin'] = ctx.isAdmin ? 'true' : 'false'; // Org 컨텍스트 — JWE에 동봉돼 있어 매 요청 DB 조회 0 if (!options.skipOrgContext && session.activeOrgId) { out['X-Org-Id'] = session.activeOrgId; const role = session.orgMemberships.find((m) => m.orgId === session.activeOrgId)?.role; if (role) out['X-Org-Role'] = role; } return out; }
CSRF는 double-submit 방식이다. __Host-nvst_csrf는 유일하게 httpOnly가 아닌 쿠키라
JS가 읽어 헤더에 실어 보낼 수 있고, BFF는 쿠키의 토큰과 헤더의 토큰이 일치할 때만 통과시킨다.
export function verifyCsrf(store: CookieStore, request: Request): NextResponse | null { const csrfCookie = store.get(CSRF_COOKIE_NAME)?.value; // 쿠키의 토큰 const csrfHeader = request.headers.get(CSRF_HEADER_NAME); // 헤더의 토큰 if (validateCsrfToken(csrfCookie, csrfHeader)) return null; // 일치 → 통과 return NextResponse.json({ error: 'invalid csrf' }, { status: 403 }); }
6.중요 — trust 헤더는 인증이 아니다
production core-service는 JWT 검증을 주 인증 경계로 유지한다
(AUTH_TRUST_USER_HEADER=false). BFF가 주입하는
X-User-Id·X-Org-Id는 보조 컨텍스트 / dev-test
용도일 뿐이다.
그리고 외부에서 들어오는 X-* trust 헤더는 Traefik 게이트웨이가 strip한다.
안 그러면 아무나 X-User-Id: admin을 끼워 보내면 끝이다. "헤더 주입"은 어디까지나
BFF 내부에서, 복호화된 세션을 근거로만 일어나야 한다.
요약하면: 신원의 진실은 JWT 서명에 있고, trust 헤더는 BFF가 푼 컨텍스트를 백엔드로 편하게 전달하는 통로일 뿐이다. 이 경계를 흐리면 stateless의 장점이 그대로 구멍이 된다.
7.두 개의 우회 경로
BFF가 모든 트래픽을 강제로 통과시키는 건 아니다. 두 가지는 의도적으로 BFF를 건너뛴다.
| 경로 | 왜 우회하나 |
|---|---|
billing/webhooks/*(Stripe / Toss) |
upstream이 서명한 페이로드를 쓴다. BFF를 거치면 서명·replay 보장이 깨진다 → Traefik이 core-service로 직행. |
| API Key 요청 | 브라우저 세션이 아니라 발급된 키로 인증한다. Traefik이 헤더 매칭으로 auth/core-service에 직접 라우팅. |
그래서 "브라우저 사용자 트래픽은 BFF, 머신/서명 트래픽은 우회"라는 깔끔한 이분법이 선다.
8.트레이드오프
| 비용 | 완화 |
|---|---|
| +~30ms p95 (BFF 1 hop + JWE 복호화) | 임계 초과 시 connection keep-alive 튜닝. p95 +50ms가 ADR 재검토 trigger. |
| BFF가 SPOF — Next.js 죽으면 전체 다운 | HPA 2~10 인스턴스 + healthcheck. stateless라 자유롭게 수평 확장. |
| 즉시 폐기 ~15초 (Redis 0초 대비) | refresh rotation의 reuse detection이 도용 의심 시 평균 ~15초 내 family 전체 무효화. |
얻은 것은 분명하다 — XSS 토큰 탈취 표면 0, RSC 해금, 클라이언트 토큰 인터셉터 ~60% 제거, CORS 소멸, stateless 수평 확장.
RSC 하이드레이션: 서버는 왜 BFF를 건너뛰는가
BFF는 브라우저의 문이다. 그런데 Server Component가 초기 데이터를 prefetch할 때는
이 문을 쓰지 않고 coreServiceFetch()로 백엔드에 직접 들어간다 —
같은 trust 헤더를 들고. 왜 두 개의 문이 필요한지, 그리고 둘을 잇는 단 하나의 끈
(dehydrate → HydrationBoundary → 공유 query key)을
2편에서 코드로 따라간다.
'프로그래밍' 카테고리의 다른 글
| RSC 하이드레이션: 서버는 왜 BFF를 건너뛰는가 (0) | 2026.05.30 |
|---|
댓글