Next.js App Router 환경에서 NextAuth(v4)를 사용한 JWT 기반 인증 시스템을 운영하면서 마주친 실전 문제들과 해결 과정을 공유합니다.
단순 "로그인 구현" 수준이 아닌, Refresh Token Rotation(RTR) 레이스 컨디션, 다중 탭 토큰 갱신 동기화, 401 폭주 방지 등 프로덕션에서 반드시 만나게 되는 문제들을 다룹니다.
자사 서비스(중고차 플랫폼)의 인증 요구사항은 다음과 같습니다.
accessToken + refreshToken (JWT)NextAuth의 기본 OAuth Provider를 쓰면 "구글이 발급한 토큰"을 그대로 세션에 넣게 됩니다.
하지만 우리는 소셜 토큰을 자체 백엔드에 넘겨서 "우리 서비스의 토큰"을 받아야 합니다.
이 간극을 어떻게 메울지가 설계의 출발점이었습니다.

핵심 설계 원칙:
| 원칙 | 이유 |
|---|---|
| 소셜 로그인도 CredentialsProvider로 합류 | 모든 인증 플로우가 하나의 authorize() → jwt() → session() 파이프라인 통과 |
| provider 토큰은 httpOnly 쿠키로만 전달 | 클라이언트 JS에서 소셜 토큰 접근 불가 → 토큰 탈취 리스크 최소화 |
refresh는 forceRefresh 플래그로만 트리거 | 선제적 refresh(만료 임박 시)를 제거하여 RTR 레이스 최소화 |
| 중복 방지는 3단계(탭 → 모듈 → 서버) | 어느 한 단계가 뚫려도 다음 단계에서 잡힘 |
일반적으로 NextAuth에서 소셜 로그인은 GoogleProvider, KakaoProvider 등을 각각 등록합니다.
하지만 이 방식은 "소셜이 발급한 토큰"이 세션에 바로 들어가기 때문에, 자체 백엔드 토큰 체계와 합치기가 까다롭습니다.
[소셜 버튼 클릭]
↓
[/api/auth/kakao/login] ← 서버 라우트: state 생성, 카카오 authorize URL로 redirect
↓
[카카오 인가] ← 사용자가 카카오에서 동의
↓
[/api/auth/kakao/callback] ← 서버 라우트: code → token 교환, httpOnly 쿠키에 저장
↓
[/oauth2] ← 클라이언트: signIn('credentials', { oauth2: '1' })
↓
[authorize()] ← 쿠키에서 카카오 토큰 읽기 → socialVerify → socialLogin
↓
[jwt callback] ← 자체 TokenBundle 저장 → 세션 확정
이 구조의 장점:
authorize() 하나에서 ID/소셜 모든 인증을 처리 → 코드 경로가 단순// authorize() 핵심 흐름 (간략화)
async function authorizeCredentials(credentials, req) {
let tokens;
// 1) ID/비밀번호 로그인
if (credentials.userId && credentials.password) {
tokens = await corporateLogin({ userId, password });
}
// 2) 소셜 로그인 (OAuth 브릿지 경유)
else if (credentials.oauth2 === '1') {
const kakaoToken = readCookieFromReq(req, 'kakao_access_token');
if (kakaoToken) {
const verified = await socialVerify({ socialType: 'kakao', accessToken: kakaoToken });
if (!verified?.exists) throw signInError('SOCIAL_SIGNUP_REQUIRED', verified);
tokens = await socialLogin({ socialType: 'kakao', socialKey: verified.socialKey, otp: verified.otp });
}
// ... google, naver, apple도 동일 패턴
}
const me = await getMe(tokens.accessToken);
return { tokens, user: me };
}
로그인까지는 비교적 단순합니다. 진짜 어려운 건 "토큰이 만료된 순간"부터 시작됩니다.
심지어 next-auth는 그들이 지금까지도 해결하지 못한 큰 이슈인 refresh token race condition 이슈를 가지고있습니다
https://github.com/nextauthjs/next-auth/discussions/3940
사용자가 서비스를 쓰다가 accessToken이 만료됩니다. 이 순간:
여기서 RTR(Refresh Token Rotation)이 적용되어 있다면?
정상적으로 서비스를 쓰던 사용자가 갑자기 로그아웃됩니다.
사용자가 탭 A, 탭 B를 열어두고 있습니다. accessToken이 만료되면:
이것도 마찬가지로 사용자 입장에서는 이해할 수 없는 로그아웃입니다.
위 문제를 해결하기 위해 3단계 방어선을 구축합니다.
┌───────────────────────────────────────────────────────────────┐
│ Layer 1: 클라이언트 탭 단위 (ensureSessionFresh) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Layer 2: 서버 프로세스 내 (withLock) │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ Layer 3: RTR 결과 캐시 (refreshCache) │ │ │
│ │ │ │ │ │
│ │ │ → 같은 old RT로 2번째 요청 → 캐시 히트 │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ → 같은 RT로 동시 요청 → Promise 공유 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ → 탭 A/B 동시 401 발생 → 하나만 실행, 나머지는 대기 │
└───────────────────────────────────────────────────────────────┘
// ensureSessionFresh.ts — 핵심 아이디어만 추출
let sessionRefreshGate: Promise<void> | null = null;
export async function ensureSessionFreshOrThrow() {
// 같은 탭 안에서 동시 호출 → 하나의 Promise를 공유
if (sessionRefreshGate) return await sessionRefreshGate;
sessionRefreshGate = withBrowserLock('charancha:session-refresh', async () => {
// lock 대기 중 다른 탭이 이미 세션을 복구했으면 조기 종료
const before = await getSession();
if (before?.tokens?.accessToken && !before?.error) return;
// NextAuth 세션 갱신 트리거
await fetch('/api/auth/session', {
method: 'POST',
body: JSON.stringify({ csrfToken, data: { forceRefresh: true } }),
});
// 갱신 결과 확인
const after = await getSession();
if (after?.error) throw new Error('REFRESH_FAILED');
}).finally(() => {
sessionRefreshGate = null;
});
return await sessionRefreshGate;
}
withBrowserLock은 navigator.locks API(Web Locks)를 사용합니다.
async function withBrowserLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
// Web Locks API 지원 시
if (navigator?.locks?.request) {
return await navigator.locks.request(name, { mode: 'exclusive' }, fn);
}
// 미지원 시 localStorage 기반 fallback
// → set-then-verify 패턴으로 best-effort 구현
}
이 설계가 막는 것:
sessionRefreshGate로 1회만 실행navigator.locks로 직렬화lastHardFailAt 쿨다운으로 차단클라이언트에서 아무리 잘 막아도, 서버리스 환경에서는 같은 프로세스 안에서 여러 요청이 동시에 처리될 수 있습니다.
// nextauth/refresh.ts
const locks = new Map<string, Promise<any>>(); // globalThis에 저장
async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = locks.get(key);
if (existing) return existing; // 이미 실행 중인 Promise를 그대로 반환
const promise = fn().finally(() => locks.delete(key));
locks.set(key, promise);
return promise;
}
핵심 아이디어: 같은 refreshToken으로 refresh가 동시에 시작되면, 두 번째 호출자는 첫 번째의 Promise를 그대로 기다립니다. 실제 백엔드 호출은 1번만 발생합니다.
in-process lock만으로는 시간차 문제를 못 막습니다.
T=0ms: 요청 A — refreshToken "abc"로 refresh 시작
T=5ms: 요청 A — refresh 성공, 새 refreshToken "xyz" 발급
T=6ms: JWT 쿠키 갱신 응답이 아직 브라우저에 도달하지 않음
T=7ms: 요청 B — 아직 old 쿠키를 들고 있어서 refreshToken "abc"로 jwt callback 진입
→ lock은 이미 해제됨 (A가 끝났으니까)
→ "abc"로 다시 refresh 치면 RTR에 의해 실패!
이 간극을 짧은 결과 캐시로 메웁니다.
// refreshToken "abc"로 refresh 성공 시:
writeRefreshResultCache("abc", newTokens); // 10초 TTL
// 이후 같은 "abc"로 들어온 요청:
const cached = readRefreshResultCache("abc");
if (cached) return cached; // 실제 refresh 없이 캐시 결과 반환
의미: "old refreshToken → new tokens" 매핑을 잠깐 캐시하여, JWT 쿠키 갱신이 반영되기 전의 요청도 정상 처리합니다.
export async function refreshB2COnce(refreshToken: string) {
// Layer 3: 캐시 히트면 즉시 반환
const cached = readRefreshResultCache(refreshToken);
if (cached) return cached;
// Layer 2: 같은 refreshToken 동시 호출 dedupe
return withLock(`b2c:refresh:${refreshToken}`, async () => {
// lock 대기 중 캐시가 채워졌을 수 있음
const cached2 = readRefreshResultCache(refreshToken);
if (cached2) return cached2;
// 실제 refresh API 호출 (여기까지 오는 건 최대 1회)
const nextTokens = await refreshAccessToken(refreshToken);
writeRefreshResultCache(refreshToken, nextTokens);
return nextTokens;
});
}
AuthSessionWatcher는 세션에 에러가 감지되면 자동으로 복구를 시도하는 컴포넌트입니다.
코드 리뷰 중 치명적인 버그를 발견했습니다.
// Before (버그)
const next = await update(); // ← forceRefresh가 없다!
NextAuth의 update() 함수는 jwt(trigger='update') 콜백을 호출합니다.
하지만 jwt 콜백 안에서 session?.forceRefresh를 체크하고, 이 플래그가 없으면 refresh를 시도하지 않습니다.
즉, update()를 호출해도 실제로는 아무 refresh도 일어나지 않고, 에러 상태가 그대로 유지되어, 결국 로그아웃으로 이어지는 구조였습니다.
// After (수정)
const next = await update({ forceRefresh: true }); // ← 실제 refresh 수행
이 한 줄 차이로:
export default function AuthSessionWatcher() {
const { data: session, status, update } = useSession();
const err = session?.error;
const gateRef = useRef<Promise<void> | null>(null); // 중복 호출 방지
const lastErrRef = useRef<typeof err>(undefined); // 같은 에러 반복 방지
useEffect(() => {
if (status !== 'authenticated') return;
if (err !== AUTH_ERROR_REFRESH_ACCESS_TOKEN) return;
if (gateRef.current) return; // 이미 처리 중
if (lastErrRef.current === err) return; // 같은 에러가 계속 → 무한 루프 방지
lastErrRef.current = err;
gateRef.current = (async () => {
try {
const next = await update({ forceRefresh: true });
if ((next as any)?.error) await clientSignOutAndRedirect('/login');
} catch {
await clientSignOutAndRedirect('/login');
} finally {
gateRef.current = null;
}
})();
}, [status, err, update]);
return null;
}
세 가지 방어선:
1. gateRef: 비동기 처리 중 재진입 방지 (React Strict Mode의 이중 실행에도 안전)
2. lastErrRef: 같은 에러가 세션에 계속 남아있을 때 무한 복구 루프 방지
3. 1회 시도 정책: 한 번 refresh 해보고, 실패하면 깔끔하게 로그아웃
로그인 자체는 간단합니다. 하지만 프로덕션에서는:
이 모든 경계 조건(edge case)을 고려하지 않으면, 사용자는 아무 이유 없이 로그아웃됩니다.
| 선택 | 장점 | 트레이드오프 |
|---|---|---|
| CredentialsProvider 통합 | 인증 파이프라인 단일화 | NextAuth가 권장하는 OAuth Provider 패턴에서 벗어남 |
| Web Locks API 사용 | 정확한 탭 간 동기화 | Safari 구버전 미지원 → localStorage fallback 필요 |
| in-process 캐시 | 추가 인프라 없이 RTR 레이스 완화 | 서버리스 멀티 인스턴스에서 100% 보장 불가 |
| 선제적 refresh 제거 | RTR 레이스 최소화 | 간헐적으로 첫 요청이 401을 맞고 재시도해야 함 |
| forceRefresh 플래그 | 명시적인 refresh 트리거 | NextAuth의 update() API를 우회적으로 사용 |
한 줄 요약: 인증 시스템은 "로그인 되게 만드는 것"보다 "로그아웃 안 되게 만드는 것"이 훨씬 어렵습니다.