Deallo

인증 · 미들웨어

대표 글

NextAuth → BFF 인증 전환 — 토큰을 클라이언트에서 숨기기

이 전환의 시작은 설계 회의가 아니라 버그였다. 브랜치를 갈아탔더니 화면이 무한 새로고침에 빠졌고, 원인을 파고들다 보니 "토큰을 클라이언트가 들고 있는 구조" 자체가 결함이라는 결론에 닿았다. 거기서 NextAuth를 순수 JWT 발급기로만 쓰고, 모든 API를 BFF(토큰 변환기) 뒤로 숨기는 작업이 시작됐다. 설계대로는 안 풀렸다. 구현하며 두 번 갈아엎었고, 한 달 뒤엔 욕심을 냈다가 한 번 더 되돌렸다.

설계 제안서 최종 구현 기록

발단 — 브랜치를 바꿨더니 화면이 무한 새로고침에 빠졌다

BFF 작업 브랜치에서 다른 기능 브랜치로 전환하고 dev 서버를 띄웠더니, 페이지가 렌더링되지 못하고 끝없이 리로드됐다. Network 탭을 열어보니 두 요청이 무한히 번갈아 떴다.

unread-count    401    xhr
session         200    fetch
unread-count    401    xhr
session         200    fetch
...

처음엔 캐시나 쿠키 문제로 의심하고 지워봤지만 그대로였다. 한참을 들여다보고서야 두 브랜치의 JWT 필드명이 다르다는 걸 잡았다. 같은 NextAuth 쿠키인데 안에 든 필드 이름이 달랐던 것이다.

기존 브랜치BFF 브랜치
JWT callbacktoken.token = user.tokentoken.accessToken = user.token
Session callback있음 (session.user.token)없음 (session 미사용)
클라이언트 인증useSession() → 동기화 컴포넌트 → axios 헤더BFF가 서버에서 getToken() → Bearer 주입
middleware 로그인 판단!!token (쿠키 존재만)!!token?.accessToken (필드 존재)

무한 루프는 이렇게 돌고 있었다.

[BFF 브랜치에서 로그인]  → 쿠키 생성: { accessToken: "xxx" }
 
[기존 브랜치로 전환, dev 서버 실행]
  1. useSession() → /api/auth/session → 200
  2. session callback이 token.token을 읽음 → undefined   ← 쿠키엔 accessToken으로 저장돼 있음
  3. 토큰 동기화 실패 → axios Authorization 헤더 미세팅
  4. LNB 뱃지가 unread-count API를 인증 없이 호출 → 401
  5. axios 인터셉터: 401 → window.location.href = '/signin'
  6. middleware: !!token → true (쿠키 자체는 있음) → "로그인됨" 판단 → '/'로 리다이렉트
  7. 다시 1번으로 → 무한 루프

핵심은 5번과 6번의 엇갈림이었다. axios는 "토큰이 없으니 로그인 페이지로 보내"라고 하는데, middleware는 "쿠키가 있으니 넌 로그인 상태야, 메인으로 가"라고 우긴다. 둘이 서로 다른 기준으로 인증을 판단하니 핑퐁이 멈추질 않았다.

흥미롭게도 반대 방향 전환은 멀쩡했다. 기존 쿠키({ token: "xxx" })를 들고 BFF 브랜치로 가면, middleware가 token?.accessToken을 보고 undefined → 비로그인으로 깔끔하게 판단 → /signin으로 보내 재로그인시킨다. 무한 루프가 안 생긴다. 같은 쿠키 불일치인데 한쪽만 터졌다는 사실이, 문제가 "쿠키 호환성"이 아니라 "middleware가 내용물을 안 보고 쿠키 존재만 본다" 는 데 있다는 걸 가리켰다.

기존 코드를 뜯어보니 결함이 셋 겹쳐 있었다.

// 기존 브랜치 middleware
const authToken = req.cookies.get('auth-token')?.value
const isLoggedIn = !!token || !!authToken   // ① 쿠키 존재만 체크 ② auth-token은 dead code
// 기존 브랜치 JWT callback
token.token = user.token   // ③ token.token — NextAuth JWT 객체 자체가 token이라 이름 충돌

auth-token 쿠키는 세팅하는 코드가 프로젝트 어디에도 없었다 — 더 옛날 버전의 잔재였다. 이 셋을 각각 !!token?.accessToken(필드 단위 검증), 쿠키 제거, token.accessToken(명시적 이름)으로 고치는 게 BFF 브랜치였다. 버그 하나가 구조 전체를 다시 보게 만든 셈이다.

진짜 문제 — 토큰을 클라이언트가 들고 있었다

무한 루프는 증상이었고, 그 밑의 병은 따로 있었다. 기존 구조는 NextAuth가 백엔드 Bearer 토큰을 받아 session.user.token으로 클라이언트에 그대로 노출하고 있었다.

[AS-IS]
로그인 → authorize() → 백엔드 token 발급
  → JWT callback에 저장 → Session callback에서 session.user.token으로 노출
  → useSession() → axios Authorization 헤더 → 브라우저가 백엔드 직접 호출

문제는 둘이었다.

  • 보안: accessToken이 JS 메모리에 올라온다. httpOnly 쿠키를 쓰는 의미가 반감됐다. XSS가 터지면 토큰을 그대로 긁어갈 수 있다. (B2B 내부 툴이라 실질 위험은 낮았지만 구조적 결함이었다.) DevTools Network 탭에서 Authorization: Bearer ...가 맨눈에 보였다.
  • 상태 분산: useSession()이 7곳에 흩어져 있었고, 토큰을 axios에 밀어넣는 동기화 컴포넌트가 따로 필요했다. 인증 상태가 session·axios·각 쿼리에 복제돼 일관성이 떨어졌다.

그래서 원칙을 하나로 못 박고 시작했다. accessToken은 절대 클라이언트에서 접근할 수 없어야 한다. 토큰은 서버에서만 다룬다.

결정 — 라이브러리를 갈아탈까, 프록시를 끼울까

전환 방식으로 네 가지를 비교했다. 회의 자료로 제안서를 따로 만들어 트레이드오프를 적었다.

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

D를 고른 이유는 명확했다. 라이브러리를 건드리지 않고 보안 목표(토큰 JS 노출 제거)만 정확히 달성할 수 있어서다. Auth.js v5는 당시 API 변경이 잦아 따라가는 비용이 컸고, Better Auth는 DB 스키마 협의가 선행돼야 했다. 무엇보다 Organization/Role은 백엔드가 관리할 예정이라, 인증 라이브러리를 갈아 끼워 얻을 게 별로 없었다. 백엔드 코드 변경 0, 목표한 1~2주 안에 끝낼 수 있는 선택이 D였다.

BFF는 "토큰 변환기" — 그 이상도 이하도 아니다

BFF(Backend for Frontend)의 책임을 단 하나로 좁혔다 — httpOnly 쿠키를 Authorization: Bearer로 바꿔 백엔드로 forward. 그게 전부다.

[TO-BE]
브라우저 → 쿠키만 자동 전송 (토큰을 모름)
  → axios /api/bff/* (Next.js 서버)
  → BFF Route Handler: getToken() → httpOnly 쿠키에서 JWT 복호화
  → Authorization: Bearer {accessToken} 주입 → 백엔드
항목BeforeAfter
토큰 저장JS 접근 가능한 sessionhttpOnly 쿠키 (서버만 접근)
API 호출 경로브라우저 → 백엔드 직접브라우저 → BFF → 백엔드
토큰 노출Network 탭에 Bearer 노출BFF가 서버에서 주입, 브라우저는 모름
유저 상태useSession 7곳 분산프로필 Query 단일
XSS 토큰 탈취가능불가능

catch-all 라우트(/api/bff/[...path]) 하나로 모든 도메인을 받아 백엔드로 흘렸다. 토큰만 갈아끼우고 나머지는 손대지 않는 얇은 통로다.

// src/app/api/bff/[...path]/route.ts (요지)
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })
if (!token?.accessToken) {
  return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
}
const headers = new Headers()
headers.set('Authorization', `Bearer ${token.accessToken}`)
// body는 req.body(ReadableStream)를 그대로 스트리밍 → 파일 업로드까지 통과

처음엔 body를 await req.text()/req.formData()로 받아 다시 만들려 했는데, multipart 파일 업로드에서 깨졌다. req.body를 ReadableStream째로 흘리고 Node fetch에 duplex: 'half'를 주는 방식으로 바꾸니 GET부터 파일 업로드까지 한 경로로 통과했다.

설계보다 더 깎았다 — getServerSession 대신 getToken

여기서 제안서보다 한 발 더 들어갔다. 원래 계획은 서버에서 getServerSession()으로 토큰을 꺼내는 것이었는데, 막상 짜보니 앞뒤가 안 맞았다. 우리는 보안을 위해 session callback에서 토큰을 일부러 빼버렸다. 그런데 getServerSession()은 바로 그 session callback을 거쳐서 객체를 만든다 — 즉 토큰을 뺀 객체가 돌아온다. 토큰을 숨기려고 한 일이 토큰을 못 꺼내게 만든 것이다.

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

getToken()httpOnly 쿠키의 JWT를 곧장 복호화해 필드에 접근한다. BFF가 필요한 건 JWT 안의 accessToken 딱 하나뿐이니, 이게 정확한 도구였다. 이 깨달음이 도미노가 됐다 — session callback이 필요 없으면, 그걸 소비하는 SessionProvider도, useSession()도 전부 군더더기였다.

항목초기 제안서최종 구현
토큰 접근getServerSession()getToken() (JWT 직접)
Session callbacktoken 미노출로 유지callback 자체 제거
SessionProvider유지 전제제거
useSession()일부 유지0곳
JWT 필드userId + role + accessTokenaccessToken만
Source of truthsession.user프로필 Query 단일

JWT에 userId도 넣지 않았다. 유저 식별은 백엔드가 accessToken으로 하고, 프론트가 유저 정보를 알아야 하면 프로필 Query 응답에 들어 있다. JWT에 중복 저장하면 유저 정보가 바뀔 때 어긋날 뿐이다. 결국 NextAuth를 "순수 JWT 발급/검증기" 로만 쓰는 최소 구조가 남았다. 지워낸 목록은 이랬다.

삭제이유
토큰 동기화 컴포넌트useSession → axios 헤더 주입. 더 이상 클라이언트에 토큰 노출 안 함
setAuthToken/getAuthTokenaxios 헤더 세팅 함수. BFF가 서버에서 주입
SessionProvideruseSession 0곳
useSession + enabled 가드 (7곳)BFF·middleware가 인증 처리. 클라 인증 체크 불필요
session.user.token 노출session callback에서 token 필드 제외
레거시 auth-token 체크세팅하는 곳 없는 dead code

maxAge를 1년으로 둔 결정

백엔드 accessToken 만료가 1년이라, NextAuth session.maxAge도 1년으로 맞췄다. 짧게 두면 백엔드 토큰은 살아있는데 프론트 세션만 죽는 불일치가 생긴다. 1년은 OWASP 권고(보통 수십 분~24시간)보다 훨씬 길지만, refresh token이 없는 B2B 내부 CRM에서 잦은 재로그인은 UX를 크게 해친다. 그래서 지금은 백엔드 정책을 따르되, refresh token 도입 시 단축하기로 주석에 근거를 남겨뒀다.

두 번째 삽질 — 새로고침하면 "Invalid URL"

BFF로 옮기고 다 됐다 싶었는데, F5를 누르면 화면이 깨졌다. 서버 콘솔엔 Invalid URL이 떴다.

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

원인이 세 겹이었다. ① useSuspenseQuery는 Suspense가 서버에서도 데이터를 기다리느라 SSR 단계에서 즉시 실행된다. ② axios의 baseURL: '/api/bff'는 브라우저에서나 유효한 상대경로다 — Node에는 붙일 origin이 없어 절대 URL이 필요하다. ③ 애초에 인증이 필요한 쿼리는 서버→서버 요청이라 브라우저 쿠키가 안 실린다. 즉 SSR에서 구조적으로 항상 실패할 운명이었다.

해결은 두 갈래였다.

  1. axios baseURL을 환경별로 분기 — 서버에선 환경변수 기반 절대 URL, 브라우저에선 상대경로
  2. 로그인 보호 화면을 useSuspenseQueryuseQuery로 전환 — 어차피 쿠키가 안 실려 SSR이 못 푸는 화면이니, 클라이언트 로딩 패턴으로 내리고 isLoading → Skeleton, isError → ErrorFallback early-return으로 통일했다. 페이지마다 흩어진 ErrorBoundary 7개도 컴포넌트 내부로 흡수했다.

"보안을 위해 토큰을 서버에서 숨겼더니, 그 토큰이 없어 SSR이 데이터를 못 가져온다"는 건 결국 같은 설계의 양면이었다. 인증 화면은 클라이언트에서 푸는 게 맞다는 걸 버그로 배웠다.

배포 그리고 검증

배포 시 기존 사용자 쿠키 호환성이 걱정이었는데, 분석해보니 안전했다. 기존 { token: "xxx" } 쿠키는 새 middleware가 accessToken 필드를 못 찾아 비로그인으로 판단 → /signin으로 보내고 → 재로그인하면 새 JWT로 통째로 덮어쓴다. 전체 사용자 1회 강제 재로그인이 전부였고, 발단이 됐던 무한 루프나 잔여 쿠키 문제는 없었다(애초에 그 루프는 middleware가 쿠키 존재만 보던 옛 코드의 산물이었으니까).

검증은 체크리스트로 못 박았다. 특히 토큰이 진짜로 숨었는지가 핵심이라, 눈으로 확인했다.

  • DevTools → Application: next-auth.session-tokenhttpOnly 플래그 ✓
  • Console에서 document.cookie → session-token 안 보임
  • Network → 브라우저 요청에 Authorization: Bearer 헤더 없음 ✓, 모든 요청이 /api/bff/* 경유 ✓
  • 로그아웃 후 보호 페이지 뒤로가기 → /signin으로 리다이렉트(보호 페이지 미노출) ✓

프로필 데이터 — 불변은 쿠키, 가변은 쿼리

BFF로 토큰을 숨기고 나니 "그럼 프로필 정보는 어디서 읽나"가 다음 질문이었다. 프로필을 수명 기준으로 둘로 갈랐다.

  • 불변 값(userId·role 등) — 로그인 시점에 정해져 안 바뀐다. NextAuth 세션 쿠키에 담아 미들웨어가 쿠키만 읽어 인증·인가를 처리한다. 페이지 진입마다 API를 부르지 않는다.
  • 가변 값(이름·프로필 이미지 등) — 사용자가 언제든 바꾼다. 세션에 넣으면 변경이 즉시 반영 안 되니 useGetProfileQuery서버 최신 값을 조회한다.

"인증에 쓰는 불변 값"과 "화면에 보여주는 가변 값"은 수명이 다르니 사는 곳도 달라야 했다. 불변 값을 쿠키에 둔 덕에, 미들웨어가 매 페이지에서 백엔드를 안 불러도 됐다.

지금은 일부 페이지에서만 프로필을 조회해 페이지 이동 시 refetch가 생기지만, 헤더·사이드바 같은 레이아웃에 상시 노출하면 컴포넌트가 unmount되지 않아 이동 시에도 재요청 없이 유지된다.

후일담 — BFF가 욕심내면 안 되는 것

전환 자체는 깔끔했지만, 한 달쯤 뒤 반대 교훈을 한 번 더 배웠다. 퍼블릭 폼에서 외부 사용자가 주소를 입력하는데 자동완성이 안 떴다. 추적해보니 그 API 호출이 BFF에서 401로 끊기고 있었다.

원인은 BFF에 어느새 끼어든 화이트리스트였다. "이 경로는 로그인 없이 통과"를 BFF가 직접 판단하는 isPublicXxx 분기였다. 백엔드는 주소 검색 API를 anonymous로 풀어줬는데, BFF의 허용 목록엔 그게 빠져 있어 토큰이 없다고 차단해버린 것이다.

이건 단순 누락이 아니라 구조의 문제였다. BFF가 토큰 변환기 역할을 넘어 인증 정책 결정자까지 겸하면, 정책이 백엔드와 BFF 두 곳에 생긴다. 백엔드가 새 anonymous 경로를 풀 때마다 BFF도 따라 고쳐야 하는 sync 부채가 쌓인다. 자동 생성된 API 도메인이 수십 개로 불어나던 상황에선 이게 영구 부채였다.

그래서 화이트리스트를 통째로 들어내고 원래 설계(토큰 변환기)로 회귀시켰다. 토큰이 있으면 붙여서, 없으면 없는 채로 백엔드에 넘긴다 — 통과시킬지 막을지는 백엔드(Spring Security)가 단일 소스로 결정한다. BFF는 정책을 흉내 내지 않는다. 대신 진짜 BFF의 책임인 입력 검증만 남겼다.

  • path traversal 가드 (세그먼트 단위 — ..는 막되 form name Q1..Q2 같은 정상 입력은 통과)
  • 허용 HTTP method 제한
  • 거부할 땐 X-Auth-Source: bff 헤더를 붙여 백엔드 401과 구분 → 디버깅이 쉬워졌다
== Before (변환기 + 정책 엔진) ==
client ── /api/bff/... ──→ route.ts ── if !public && !token → 401  ← 여기서 끊김 (anonymous 누락)
 
== After (순수 토큰 변환기) ==
client ── /api/bff/... ──→ route.ts ── traversal 가드 → getToken() → forward  → BE가 인증 결정
                                                                              (anonymous 자동 통과)

물론 단순함이 늘 정답은 아니다. B2C로 전환되고 트래픽이 10배가 되거나, 외부 API per-call 비용이 생기거나, DDoS/abuse 사고가 나면 정책 레이어를 다시 검토한다. 다만 그때도 BFF 코드보다 CDN/WAF가 먼저다. 재도입을 정당화할 객관적 트리거를 미리 정의해 두는 것까지가 이 결정의 일부였다.

배운 점

  • 버그가 설계를 다시 보게 만든다. 무한 루프의 진짜 범인은 "middleware가 쿠키 내용을 안 보고 존재만 본 것"이었고, 거기서 "토큰을 클라이언트가 들고 있는 구조" 전체를 갈아엎게 됐다. 인증 검증은 쿠키 존재가 아니라 필드 단위로 해야 한다.
  • 설계서가 끝이 아니다. getServerSession으로 가려던 계획은 "토큰을 숨긴다"는 원칙과 직접 부딪혀서야 getToken으로 정정됐다. 한 결정이 맞물려 session callback·Provider·useSession을 전부 지우는 도미노가 됐다. 계획은 코드를 짜봐야 검증된다.
  • 이 레이어가 무엇을 책임지지 않는가를 정하는 게 설계의 절반이다. BFF에 정책을 넣고 싶은 유혹(화이트리스트)을 한 번 따랐다가 sync 부채로 되돌아왔다. 책임을 좁히는 게 곧 견고함이었다 — 토큰 변환은 BFF가, 인증 정책은 백엔드가, 입력 검증만 둘 사이에 둔다.