기존 구조는 NextAuth v4 + Credentials Provider로 Spring 백엔드의 Bearer 토큰을 받아 session.user.token으로 클라이언트에 노출하고 있었습니다.
session.user.token으로 accessToken이 JS 메모리에 노출 → httpOnly 쿠키 보안 이점 반감useSession 7곳 분산, AuthTokenSync 컴포넌트로 상태 동기화 필요Authorization: Bearer 헤더 직접 확인 가능| 선택지 | 내용 | 전환 비용 | 결과 |
|---|---|---|---|
| A | NextAuth v4 유지 (현행) | 없음 | - |
| B | Auth.js v5 전환 | 중간 | 보류 |
| C | Better Auth 전환 | 높음 | 보류 |
| D | NextAuth v4 유지 + BFF 프록시 추가 | 중간 | 채택 · 구현 완료 |
| 항목 | Before | After |
|---|---|---|
| 토큰 저장 | JS 접근 가능한 session | httpOnly 쿠키 (서버만 접근) |
| API 호출 경로 | 브라우저 → 백엔드 직접 | 브라우저 → BFF → 백엔드 |
| 토큰 노출 | Network 탭에 Bearer 노출 | BFF가 서버에서 주입 |
| 유저 상태 관리 | useSession 7곳 분산 | useGetProfileQuery 단일 |
| XSS 토큰 탈취 | 가능 | 불가능 |
| 인증 라이브러리 | NextAuth v4 | NextAuth v4 (변경 없음) |
| 백엔드 변경 | - | 없음 |
| SessionProvider | 사용 | 제거 |
| useSession() 호출 | 7곳 | 0곳 |
| 항목 | 초기 제안서 | 최종 구현 |
|---|---|---|
| 프록시 경로 | /api/proxy/* | /api/bff/* |
| 토큰 접근 | getServerSession() | getToken() (JWT 직접 접근) |
| Session callback | token 미노출 유지 | callback 자체 제거 |
| SessionProvider | 유지 전제 | 제거 |
| JWT 필드 | userId + role + accessToken | accessToken만 |
| Role 인가 | middleware에서 즉시 적용 | 코드 작성 + 주석 처리 |
| SSR 대응 | 미고려 | useSuspenseQuery → useQuery 전환 |
| body 전달 | await req.text() | req.body ReadableStream |
| Source of truth | session.user | useGetProfileQuery() |
Network 탭에서 Bearer 토큰 확인 가능
브라우저 Network 탭에 Bearer 미노출
// 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 타입 확장 없음 — 클라이언트에서 세션 접근하지 않음
/api/profile Query가 source of truth. JWT에 중복 저장 불필요// 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이 없는 이유:
useGetProfileQuery()로 BFF 경유 조회getServerSession()도 사용하지 않음 — BFF에서 getToken()으로 JWT 직접 접근// 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
session callback에서 accessToken을 의도적으로 제외했으므로, getServerSession()으로는 토큰에 접근 불가. Route Handler에서 JWT 필드에 직접 접근해야 하는 BFF 패턴에는 getToken()이 정확한 선택.
// 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).*)'], }
token.role에 접근 불가하므로 활성화하면 모든 사용자 접근 차단됨. 백엔드 협의 완료 후 주석 해제 + JWT 타입에 role 필드 추가.
| 삭제 항목 | 이유 |
|---|---|
AuthTokenSync 컴포넌트 | useSession → setAuthToken 동기화. BFF가 서버에서 주입하므로 불필요 |
setAuthToken() / clearAuthToken() / getAuthToken() | axios 헤더에 토큰 세팅하던 함수. BFF가 대체 |
SessionProvider | useSession() 0곳. 클라이언트 세션 불필요 |
useSession + enabled: isAuthenticated 가드 (7곳) | BFF + middleware가 인증 처리 |
session.user.token 클라이언트 노출 | Session callback에서 token 필드 제외 (callback 자체 제거) |
auth-token 레거시 쿠키 체크 | middleware 이중 체크 제거 |
shared/api/constants.ts | API_BASE_URL export — dead code |
| # | 파일 수 | 내용 |
|---|---|---|
| 1 | 11 | BFF 프록시 + 서버 전용 JWT 인증 구조 전환 (코어 인프라) |
| 2 | 7 | API 엔드포인트 BFF 경유 전환 (각 도메인 hooks에서 useSession 제거) |
| 3 | 19 | SSR "Invalid URL" 수정 + useSuspenseQuery → useQuery 전환 |
| 4 | 1 | TanStack Query Devtools 추가 |
| 5 | 1 | 인증 전환 시 router.push → window.location.href 변경 |
| CR | 2 | 코드리뷰 반영: BFF Content-Length 전달, dead code 삭제 |
NextAuth의 session은 "클라이언트에 유저 정보를 전달하는 채널"이다. 우리 구조에서 session이 담당하던 모든 역할을 다른 메커니즘이 대체:
| 역할 | AS-IS | TO-BE |
|---|---|---|
| 유저 정보 | useSession() → session.user | useGetProfileQuery() → BFF 경유 /api/profile |
| 인증 상태 | useSession() → status | middleware getToken() → accessToken 존재 여부 |
| API 인증 | session.user.token → axios 헤더 | BFF getToken() → Bearer 주입 |
getServerSession()은 session callback을 거쳐 session 객체를 반환. 우리는 session callback에서 accessToken을 의도적으로 제외했으므로(callback 자체 제거), getServerSession()으로는 토큰에 접근 불가.
useGetProfileQuery() 응답에 포함accessToken 자체가 백엔드에서 유저를 식별하는 키이므로, 프론트 JWT에 userId를 별도 저장할 이유가 없다.
BFF 전환 후 새로고침(F5) 시 "Invalid URL" 에러 발생.
'/api/bff'는 브라우저에서만 유효 — Node.js에서는 origin이 없어 절대 URL 필요| # | 해결 | 상세 |
|---|---|---|
| 1 | axios baseURL SSR 분기 | 서버에서는 NEXTAUTH_URL 기반 절대 URL, 브라우저에서는 상대경로 |
| 2 | useSuspenseQuery → useQuery | 인증 필요 화면에서 클라이언트 로딩 패턴으로 전환 |
| 3 | early return guard 패턴 통일 | isError → ErrorFallback, isLoading → Skeleton |
| 4 | QueryErrorBoundary 제거 (7개 페이지) | 에러/로딩 처리가 컴포넌트 내부로 이동 |
useSuspenseQuery는 SSR에서 즉시 실행되므로, 인증 API에는 useQuery + 클라이언트 로딩이 적합.
getToken() 구조 자체는 NextAuth 공식 권장 패턴. decode failure는 설정/운영 이슈 가능성이지, 구조적 문제가 아님.
| 위치 | 사용 방식 | 상태 |
|---|---|---|
auth-options.ts | secret: process.env.NEXTAUTH_SECRET | OK |
middleware.ts | getToken({ req, secret: process.env.NEXTAUTH_SECRET }) | OK |
bff/[...path]/route.ts | getToken({ req, secret: process.env.NEXTAUTH_SECRET }) | OK |
세 곳 모두 동일한 환경변수 참조. secret mismatch 가능성 없음.
auth-options.ts에 jwt.encode / jwt.decode 커스텀 설정 없음getToken()은 기본 디코더를 사용하므로 호환성 문제 없음| 시나리오 | 가능성 | 대응 |
|---|---|---|
| NEXTAUTH_SECRET 환경변수 미설정 | 배포 시 | .env 체크 + 앱 시작 시 에러 |
| 개발/배포 환경 간 secret 불일치 | 배포 시 | 동일 secret 사용 확인 |
| NextAuth 버전 업 후 JWT 포맷 변경 | 업그레이드 시 | 버전 업 전 테스트 |
| 쿠키 만료 (maxAge 초과) | 1년 후 | 재로그인 유도 (현재 동작) |
/signin 유지/signin 접근 → /로 리다이렉트/contact/buyers 직접 접속 → /signin?callbackUrl=/contact/buyers/ 직접 접속 → /signin (callbackUrl 없이)/upload/*는 미인증에서도 접근 가능/signin?callbackUrl=/inventory 상태에서 로그인 → /inventory로 복귀next-auth.session-token이 httpOnly 플래그document.cookie에 session-token 미출력Authorization: Bearer 헤더 없음/api/bff/* 경유/signin 리다이렉트 + 쿠키 삭제next-auth.session-token 쿠키 수동 삭제 → 새로고침 → /signin 리다이렉트/signin으로 다시 리다이렉트/signin?refresh=true 리다이렉트브라우저 Network 탭에서 아래 패턴이 무한 반복되며 페이지가 정상 렌더링 불가:
기존 middleware의 인증 판단이 "쿠키가 있으면 로그인됨"이라는 느슨한 검증이었습니다:
이 경우 JWT 쿠키 안의 토큰 필드명이 변경되거나, 토큰 값이 비어있거나, 구조가 달라지면:
/signin으로 리다이렉트/로 리다이렉트| 시나리오 | 발생 조건 | 기존 구조 | 현재 구조 |
|---|---|---|---|
| JWT 필드명 변경 배포 | 기존 사용자 쿠키에 이전 필드명이 남아있을 때 | 무한 루프 | 재로그인 유도 |
| 인증 로직 업데이트 배포 | JWT 구조가 변경되었으나 기존 쿠키가 유효기간 내 잔존 | 무한 루프 | 재로그인 유도 |
| NextAuth 버전 업 배포 | JWT 인코딩/필드 구조 변경 시 | 무한 루프 | 재로그인 유도 |
| 쿠키 corruption | 쿠키 데이터 손상 시 | 무한 루프 | 재로그인 유도 |
추가로 실제 동작하지 않던 auth-token 레거시 쿠키 fallback도 제거.
| 결함 | AS-IS | TO-BE | 영향 |
|---|---|---|---|
| JWT 필드명 비표준 | token.token (이름 충돌) |
token.accessToken |
가독성 향상, 충돌 방지 |
| 레거시 쿠키 dead code | auth-token 쿠키 체크 (세팅하는 곳 없음) |
제거 | 코드 정리, 혼란 방지 |
| middleware 검증 수준 | 쿠키 존재 여부만 | 필드 단위 검증 | 무한 루프 원천 차단 |
| 항목 | 우선순위 | 선행 조건 | 상세 |
|---|---|---|---|
/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 이중화 문제 선결 필요 |