BFF + Server-Only JWT 인증 구조 전환

최종 구현 기록 · 2026-03-24 · Next.js 16 + React 19 + Spring 백엔드
브랜치: refactor/bff-ssr-fix from develop · 총 37+ 파일 변경
✓ 구현 완료

개요

한줄 요약: NextAuth v4를 "순수 JWT 발급/검증 미들웨어"로만 사용하고, 모든 API 호출을 BFF 프록시 경유로 전환. 토큰이 브라우저 JS에 한 번도 노출되지 않는 구조 달성.

원래 문제

기존 구조는 NextAuth v4 + Credentials Provider로 Spring 백엔드의 Bearer 토큰을 받아 session.user.token으로 클라이언트에 노출하고 있었습니다.

제안서 → 구현 경로

선택지내용전환 비용결과
ANextAuth v4 유지 (현행)없음-
BAuth.js v5 전환중간보류
CBetter Auth 전환높음보류
DNextAuth v4 유지 + BFF 프록시 추가중간채택 · 구현 완료

Before / After 요약

항목BeforeAfter
토큰 저장JS 접근 가능한 sessionhttpOnly 쿠키 (서버만 접근)
API 호출 경로브라우저 → 백엔드 직접브라우저 → BFF → 백엔드
토큰 노출Network 탭에 Bearer 노출BFF가 서버에서 주입
유저 상태 관리useSession 7곳 분산useGetProfileQuery 단일
XSS 토큰 탈취가능불가능
인증 라이브러리NextAuth v4NextAuth v4 (변경 없음)
백엔드 변경-없음
SessionProvider사용제거
useSession() 호출7곳0곳

계획 대비 변경점

초기 제안서보다 훨씬 더 session을 배제하는 방향으로 진행됨. Session callback 제거, SessionProvider 제거, useSession 0곳 달성. "NextAuth를 순수 JWT 발급/검증 미들웨어로만 사용"하는 최소 구조가 완성.
항목초기 제안서최종 구현
프록시 경로/api/proxy/*/api/bff/*
토큰 접근getServerSession()getToken() (JWT 직접 접근)
Session callbacktoken 미노출 유지callback 자체 제거
SessionProvider유지 전제제거
JWT 필드userId + role + accessTokenaccessToken만
Role 인가middleware에서 즉시 적용코드 작성 + 주석 처리
SSR 대응미고려useSuspenseQuery → useQuery 전환
body 전달await req.text()req.body ReadableStream
Source of truthsession.useruseGetProfileQuery()

TO-BE 아키텍처

전체 흐름

[로그인] signIn('credentials') → NextAuth authorize() → POST /api/auth/authenticate → { token } → JWT Callback: token.accessToken = user.token → httpOnly 쿠키에 암호화 저장 (1년)session.user.token 클라이언트 노출 ← 제거됨 [API 호출] 브라우저 → axios /api/bff/* → 쿠키만 자동 전송 (토큰 모름) → Next.js BFF Route Handler → getToken() → httpOnly 쿠키에서 JWT 복호화 → Authorization: Bearer {accessToken} 서버에서 주입 → Spring 백엔드 [유저 정보] useGetProfileQuery() → /api/bff/api/profile → Spring 백엔드 → 단일 source of truth [인증 체크] middleware → getToken() → token.accessToken 존재 여부 → useSession() 0곳

요청 경로 비교

AS-IS

브라우저
토큰 보유
Spring 백엔드
Bearer 직접

Network 탭에서 Bearer 토큰 확인 가능

TO-BE

브라우저
토큰 모름
Next.js BFF
Bearer 주입
Spring 백엔드

브라우저 Network 탭에 Bearer 미노출

모바일/B2B 확장 영향

백엔드는 여전히 Stateless Bearer. BFF 쿠키는 Next.js 레이어에만 존재. 모바일 앱이나 B2B 서버는 Spring 백엔드를 직접 호출 (Bearer 토큰)하면 되므로 확장에 영향 없음.

구현 상세

JWT 타입 최종안

// types/next-auth.d.ts
import 'next-auth'
import 'next-auth/jwt'

declare module 'next-auth' {
  interface User {
    id: string
    token: string | null      // authorize()에서 백엔드 응답 token 전달용
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    accessToken: string | null // 유일한 커스텀 필드. BFF에서만 사용
  }
}

// Session 타입 확장 없음 — 클라이언트에서 세션 접근하지 않음

설계 근거

  • userId 제거: 유저 정보는 /api/profile Query가 source of truth. JWT에 중복 저장 불필요
  • roleClaim 제거: 현재 백엔드 JWT에 role claim 미포함. 향후 추가 시 optional 필드로 확장
  • Session 타입 확장 삭제: SessionProvider 제거 + useSession() 0곳이므로 불필요
  • User.token: authorize() → jwt callback 전달 경로에서만 사용 (NextAuth 내부)

NextAuth 설정

// src/app/api/auth/auth-options.ts
import type { NextAuthOptions } from 'next-auth'

export const authOptions: Omit<NextAuthOptions, 'providers'> = {
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.accessToken = user.token  // 백엔드 토큰 → JWT에 저장
      }
      return token
    },
    async redirect({ url, baseUrl }) {
      if (url.startsWith('/')) return `${baseUrl}${url}`
      if (new URL(url).origin === baseUrl) return url
      return baseUrl
    },
    // session callback 없음 — 클라이언트에 세션 데이터 노출하지 않음
  },
  pages: { signIn: '/signin', signOut: '/', error: '/', verifyRequest: '/' },
  session: {
    strategy: 'jwt',
    maxAge: 365 * 24 * 60 * 60,  // 1년 — 백엔드 accessToken 만료 정책과 동일
  },
  secret: process.env.NEXTAUTH_SECRET,
}

주요 설계 결정

session callback이 없는 이유:

  • SessionProvider 제거 → 클라이언트에서 useSession() 미사용
  • 유저 정보는 useGetProfileQuery()로 BFF 경유 조회
  • getServerSession()도 사용하지 않음 — BFF에서 getToken()으로 JWT 직접 접근
  • session 객체 자체가 사실상 사용되지 않는 구조
maxAge 1년 설정: OWASP 권고(30분~24시간)보다 훨씬 김. 하지만 B2B 내부 딜러 CRM + refresh token 없는 구조에서, 잦은 재로그인이 UX를 크게 해치므로 백엔드 정책을 따름. 향후 refresh token 도입 시 maxAge 단축 권장.

BFF 프록시

// src/app/api/bff/[...path]/route.ts
import { type NextRequest, NextResponse } from 'next/server'
import { getToken } from 'next-auth/jwt'

const API_BASE_URL = process.env.API_BASE_URL
if (!API_BASE_URL) throw new Error('API_BASE_URL environment variable is required')

async function proxyRequest(
  req: NextRequest,
  { params }: { params: Promise<{ path: string[] }> }
) {
  const { path } = await params
  const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })

  if (!token?.accessToken) {
    return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
  }

  const backendPath = path.join('/')
  const url = new URL(`/${backendPath}`, API_BASE_URL)
  url.search = req.nextUrl.search

  const headers = new Headers()
  headers.set('Authorization', `Bearer ${token.accessToken}`)

  // 필요한 헤더만 선별 전달
  const contentType = req.headers.get('content-type')
  if (contentType) headers.set('Content-Type', contentType)
  const accept = req.headers.get('accept')
  if (accept) headers.set('Accept', accept)
  const acceptLanguage = req.headers.get('accept-language')
  if (acceptLanguage) headers.set('Accept-Language', acceptLanguage)
  const contentLength = req.headers.get('content-length')
  if (contentLength) headers.set('Content-Length', contentLength)

  let body: BodyInit | null = null
  if (req.method !== 'GET' && req.method !== 'HEAD') {
    body = req.body  // ReadableStream 스트리밍 전달
  }

  try {
    const response = await fetch(url.toString(), {
      method: req.method, headers, body,
      // @ts-expect-error — Node.js fetch ReadableStream body 전달 시 필요
      duplex: 'half',
    })
    const responseHeaders = new Headers(response.headers)
    responseHeaders.delete('transfer-encoding')
    return new Response(response.body, {
      status: response.status, headers: responseHeaders,
    })
  } catch (error) {
    console.error('[BFF] Backend request failed:', error)
    return NextResponse.json({ message: 'Backend unavailable' }, { status: 502 })
  }
}

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

왜 getToken()인가 (getServerSession이 아닌 이유)

getServerSession() → session callback → session 객체 (accessToken 없음) ✗ getToken() → JWT 복호화 → token.accessToken (직접 접근) ✓

session callback에서 accessToken을 의도적으로 제외했으므로, getServerSession()으로는 토큰에 접근 불가. Route Handler에서 JWT 필드에 직접 접근해야 하는 BFF 패턴에는 getToken()이 정확한 선택.

Middleware

// src/middleware.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { getToken } from 'next-auth/jwt'

// -- Role enum (백엔드 정의 기준) --
// type TRole = 'ROLE_ADMIN' | 'ROLE_MEMBER' | 'ROLE_FINANCE'

export async function middleware(req: NextRequest) {
  const { origin, pathname } = req.nextUrl

  const token = await getToken({
    req, secret: process.env.NEXTAUTH_SECRET,
  })

  const isLoggedIn = !!token?.accessToken
  const isPublicPath = pathname === '/signin' || pathname.startsWith('/upload/')

  // 로그인 상태에서 /signin 접근 → 메인으로
  if (isLoggedIn && pathname === '/signin') {
    return NextResponse.redirect(`${origin}/`)
  }

  // 공개 경로 통과
  if (isPublicPath) return NextResponse.next()

  // 미인증 → /signin (callbackUrl 포함)
  if (!isLoggedIn) {
    const callbackParam = pathname === '/' ? '' : `?callbackUrl=${pathname}`
    return NextResponse.redirect(`${origin}/signin${callbackParam}`)
  }

  // TODO: /settings/members 역할 기반 인가 체크
  // 백엔드 JWT에 role claim이 추가되면 활성화
  // if (pathname === '/settings/members') {
  //   const role = token.role as TRole | undefined
  //   if (role !== 'ROLE_ADMIN') {
  //     return NextResponse.redirect(`${origin}/`)
  //   }
  // }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!api|assets|_next/static|_next/image|favicon.ico).*)'],
}
Role 체크 주석 처리 이유: 현재 백엔드 JWT에 role claim 미포함. token.role에 접근 불가하므로 활성화하면 모든 사용자 접근 차단됨. 백엔드 협의 완료 후 주석 해제 + JWT 타입에 role 필드 추가.

삭제된 코드

삭제 항목이유
AuthTokenSync 컴포넌트useSession → setAuthToken 동기화. BFF가 서버에서 주입하므로 불필요
setAuthToken() / clearAuthToken() / getAuthToken()axios 헤더에 토큰 세팅하던 함수. BFF가 대체
SessionProvideruseSession() 0곳. 클라이언트 세션 불필요
useSession + enabled: isAuthenticated 가드 (7곳)BFF + middleware가 인증 처리
session.user.token 클라이언트 노출Session callback에서 token 필드 제외 (callback 자체 제거)
auth-token 레거시 쿠키 체크middleware 이중 체크 제거
shared/api/constants.tsAPI_BASE_URL export — dead code

커밋 목록 (총 37+ 파일)

#파일 수내용
111BFF 프록시 + 서버 전용 JWT 인증 구조 전환 (코어 인프라)
27API 엔드포인트 BFF 경유 전환 (각 도메인 hooks에서 useSession 제거)
319SSR "Invalid URL" 수정 + useSuspenseQuery → useQuery 전환
41TanStack Query Devtools 추가
51인증 전환 시 router.push → window.location.href 변경
CR2코드리뷰 반영: BFF Content-Length 전달, dead code 삭제

Session 제거 근거

왜 session 없이 가능한가

NextAuth의 session은 "클라이언트에 유저 정보를 전달하는 채널"이다. 우리 구조에서 session이 담당하던 모든 역할을 다른 메커니즘이 대체:

역할AS-ISTO-BE
유저 정보useSession() → session.useruseGetProfileQuery() → BFF 경유 /api/profile
인증 상태useSession() → statusmiddleware getToken() → accessToken 존재 여부
API 인증session.user.token → axios 헤더BFF getToken() → Bearer 주입

getServerSession이 필요 없는 이유

getServerSession() → session callback → session 객체 (accessToken 없음) ✗ getToken() → JWT 복호화 → token.accessToken (직접 접근) ✓

getServerSession()은 session callback을 거쳐 session 객체를 반환. 우리는 session callback에서 accessToken을 의도적으로 제외했으므로(callback 자체 제거), getServerSession()으로는 토큰에 접근 불가.

JWT에 userId 없이 accessToken만으로 충분한 이유

accessToken 자체가 백엔드에서 유저를 식별하는 키이므로, 프론트 JWT에 userId를 별도 저장할 이유가 없다.

SSR 버그와 해결

문제

BFF 전환 후 새로고침(F5) 시 "Invalid URL" 에러 발생.

SSR 렌더링 → useSuspenseQuery 실행 → axios 요청 → baseURL '/api/bff' (상대경로) → Node.js 서버에 origin 없음 → Invalid URL

근본 원인 (3가지)

해결 (4가지)

#해결상세
1axios baseURL SSR 분기서버에서는 NEXTAUTH_URL 기반 절대 URL, 브라우저에서는 상대경로
2useSuspenseQuery → useQuery인증 필요 화면에서 클라이언트 로딩 패턴으로 전환
3early return guard 패턴 통일isError → ErrorFallback, isLoading → Skeleton
4QueryErrorBoundary 제거 (7개 페이지)에러/로딩 처리가 컴포넌트 내부로 이동
핵심 교훈: BFF 패턴에서 인증 필요 API는 SSR에서 호출할 수 없음 (브라우저 쿠키가 서버에 전달되지 않으므로). useSuspenseQuery는 SSR에서 즉시 실행되므로, 인증 API에는 useQuery + 클라이언트 로딩이 적합.

getToken 운영 점검

결론: getToken() 구조 자체는 NextAuth 공식 권장 패턴. decode failure는 설정/운영 이슈 가능성이지, 구조적 문제가 아님.

NEXTAUTH_SECRET 일치 확인

위치사용 방식상태
auth-options.tssecret: process.env.NEXTAUTH_SECRETOK
middleware.tsgetToken({ req, secret: process.env.NEXTAUTH_SECRET })OK
bff/[...path]/route.tsgetToken({ req, secret: process.env.NEXTAUTH_SECRET })OK

세 곳 모두 동일한 환경변수 참조. secret mismatch 가능성 없음.

Custom JWT encode/decode

decode failure 가능 시나리오

시나리오가능성대응
NEXTAUTH_SECRET 환경변수 미설정배포 시.env 체크 + 앱 시작 시 에러
개발/배포 환경 간 secret 불일치배포 시동일 secret 사용 확인
NextAuth 버전 업 후 JWT 포맷 변경업그레이드 시버전 업 전 테스트
쿠키 만료 (maxAge 초과)1년 후재로그인 유도 (현재 동작)

Self-QA 체크리스트

인증 플로우

미인증 접근 차단

callbackUrl 복귀

토큰/쿠키 검증

로그아웃 / 세션 만료

401 응답 처리

BFF 프록시

새로고침 / SSR 안정성

Middleware 인증 검증 강화 — 무한 루프 버그 해결

발견된 문제: 기존 middleware가 JWT 쿠키의 존재 여부만 확인하고 내용물을 검증하지 않아, 토큰 구조가 변경되는 상황에서 무한 새로고침이 발생. 이번 전환에서 검증 로직을 강화하여 해결.

증상

브라우저 Network 탭에서 아래 패턴이 무한 반복되며 페이지가 정상 렌더링 불가:

unread-count 401 xhr session 200 fetch unread-count 401 xhr session 200 fetch ... (무한 반복)

근본 원인

기존 middleware의 인증 판단이 "쿠키가 있으면 로그인됨"이라는 느슨한 검증이었습니다:

// AS-IS: 쿠키 존재만 확인 — 내용물 검증 없음 const isLoggedIn = !!token || !!authToken

이 경우 JWT 쿠키 안의 토큰 필드명이 변경되거나, 토큰 값이 비어있거나, 구조가 달라지면:

  1. middleware: 쿠키 존재 → "로그인됨" 판단 → 보호된 페이지 접근 허용
  2. API 호출: 실제 토큰 없음/무효 → 백엔드 401 응답
  3. 401 인터셉터: /signin으로 리다이렉트
  4. middleware: 쿠키 존재 → "로그인됨" → 다시 /로 리다이렉트
  5. → 무한 루프

발생 가능 시나리오

이 문제는 브랜치 전환 같은 개발 환경에서만 발생하는 엣지 케이스가 아닙니다. 프로덕션에서 인증 구조를 변경/배포할 때마다 발생할 수 있는 구조적 결함이었습니다.
시나리오발생 조건기존 구조현재 구조
JWT 필드명 변경 배포 기존 사용자 쿠키에 이전 필드명이 남아있을 때 무한 루프 재로그인 유도
인증 로직 업데이트 배포 JWT 구조가 변경되었으나 기존 쿠키가 유효기간 내 잔존 무한 루프 재로그인 유도
NextAuth 버전 업 배포 JWT 인코딩/필드 구조 변경 시 무한 루프 재로그인 유도
쿠키 corruption 쿠키 데이터 손상 시 무한 루프 재로그인 유도

해결: 필드 단위 검증으로 강화

// AS-IS: 쿠키 존재만 확인 const authToken = req.cookies.get('auth-token')?.value const isLoggedIn = !!token || !!authToken // TO-BE: JWT 내부의 accessToken 필드 존재 여부까지 확인 const isLoggedIn = !!token?.accessToken

추가로 실제 동작하지 않던 auth-token 레거시 쿠키 fallback도 제거.

수정 후 동작

[JWT 구조 변경 배포 시] 기존 사용자 쿠키: { token: "xxx" } ← 이전 구조 middleware: token?.accessToken → undefined → isLoggedIn = false/signin으로 정상 리다이렉트 (무한 루프 없음) → 재로그인 → 새 JWT { accessToken: "xxx" } 발급 → 정상 동작

함께 수정된 기존 결함

결함AS-ISTO-BE영향
JWT 필드명 비표준 token.token (이름 충돌) token.accessToken 가독성 향상, 충돌 방지
레거시 쿠키 dead code auth-token 쿠키 체크 (세팅하는 곳 없음) 제거 코드 정리, 혼란 방지
middleware 검증 수준 쿠키 존재 여부만 필드 단위 검증 무한 루프 원천 차단
결론: 이번 전환으로 middleware 인증 검증이 "쿠키 존재 확인" → "토큰 필드 단위 검증"으로 강화됨. 향후 인증 구조 변경, NextAuth 버전 업, JWT 스키마 수정 등 프로덕션 배포 시에도 무한 루프가 발생하지 않고, 기존 사용자는 1회 재로그인으로 안전하게 전환됨.

향후 과제

항목우선순위선행 조건상세
/settings/members role 인가 활성화 백엔드 JWT role claim 포함 협의 middleware.ts 주석 해제 + JWT 타입에 role 필드 추가
JWT에 role 필드 추가 위와 동일 ROLE_ADMIN | ROLE_MEMBER | ROLE_FINANCE
Refresh token 도입 낮음 백엔드 refresh token API 현재 accessToken 1년 만료. refresh 도입 시 maxAge 단축
session maxAge 단축 낮음 refresh token 도입 후 OWASP 권고에 맞게 30분~24시간으로 조정
Auth.js v5 전환 평가 낮음 v5 API 안정화 확인 현재 v4로 충분히 동작. v5 안정화 시 재평가
Better Auth 전환 평가 낮음 백엔드 DB 통합 협의 Org/RBAC 빌트인 매력적이나, DB 이중화 문제 선결 필요
현재 달성한 것: NextAuth를 "순수 JWT 발급/검증 미들웨어"로 최소화. 토큰 JS 노출 0, useSession 0곳, SessionProvider 제거. 백엔드 코드 변경 0. 이 기반 위에서 role 인가, refresh token 등을 점진적으로 추가할 수 있는 구조.