본문 바로가기
프로그래밍

Next.js를 BFF로: 토큰을 브라우저에서 지우는 stateless JWE 세션

by me_in_sk 2026. 5. 30.
반응형
NullVest Engineering 프론트엔드 아키텍처 · 1/2

Next.js를 BFF로: 토큰을 브라우저에서 지우는 stateless JWE 세션

2026-05-10 · ADR-0034 대상 코드 apps/web/src/lib/server · app/api
TL;DR
  • 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만).

lib/server/cookies.tsTypeScript
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를 다시 치지 않는다.

lib/server/jwe.tsTypeScript
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()로 넘긴다.

app/api/v1/core/[...slug]/route.tsTypeScript
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의 책임이 다 들어 있다 — 주석의 ①~⑤를 따라가 보자.

lib/server/backend-proxy.tsTypeScript
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 + 신원 컨텍스트 헤더로 바꿔 끼운다.

lib/server/api-fetch.tsTypeScript
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는 쿠키의 토큰과 헤더의 토큰이 일치할 때만 통과시킨다.

lib/server/auth-passthrough.tsTypeScript
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 헤더를 들고. 왜 두 개의 문이 필요한지, 그리고 둘을 잇는 단 하나의 끈 (dehydrateHydrationBoundary → 공유 query key)을 2편에서 코드로 따라간다.

NullVest Engineering Blog · 프론트엔드 아키텍처 시리즈 1/2

반응형

'프로그래밍' 카테고리의 다른 글

RSC 하이드레이션: 서버는 왜 BFF를 건너뛰는가  (0) 2026.05.30

댓글