Next.js + NextAuth 실전 인증 아키텍처: RTR 레이스부터 다중 탭 동기화까지

Wonhyo LEE·2026년 2월 10일
post-thumbnail

Next.js App Router 환경에서 NextAuth(v4)를 사용한 JWT 기반 인증 시스템을 운영하면서 마주친 실전 문제들과 해결 과정을 공유합니다.
단순 "로그인 구현" 수준이 아닌, Refresh Token Rotation(RTR) 레이스 컨디션, 다중 탭 토큰 갱신 동기화, 401 폭주 방지 등 프로덕션에서 반드시 만나게 되는 문제들을 다룹니다.


목차

  1. 배경: 왜 이 구조가 필요했나
  2. 아키텍처 전체 그림
  3. 소셜 로그인을 CredentialsProvider 하나로 합치기
  4. 진짜 어려운 문제: 토큰 갱신의 동시성
  5. 3단계 중복 방지 전략
  6. 세션 에러 복구 — 조용히 죽는 버그 잡기
  7. 교훈 정리

1. 배경: 왜 이 구조가 필요했나

자사 서비스(중고차 플랫폼)의 인증 요구사항은 다음과 같습니다.

  • ID/비밀번호 + 4개 소셜 로그인 (카카오, 구글, 네이버, 애플)
  • 자체 백엔드 토큰 체계: 백엔드가 발급하는 accessToken + refreshToken (JWT)
  • Refresh Token Rotation(RTR): refresh 시 refreshToken도 함께 교체
  • Next.js App Router: 서버 컴포넌트, 미들웨어, 클라이언트 컴포넌트가 혼재

NextAuth의 기본 OAuth Provider를 쓰면 "구글이 발급한 토큰"을 그대로 세션에 넣게 됩니다.
하지만 우리는 소셜 토큰을 자체 백엔드에 넘겨서 "우리 서비스의 토큰"을 받아야 합니다.
이 간극을 어떻게 메울지가 설계의 출발점이었습니다.


2. 아키텍처 전체 그림

핵심 설계 원칙:

원칙이유
소셜 로그인도 CredentialsProvider로 합류모든 인증 플로우가 하나의 authorize()jwt()session() 파이프라인 통과
provider 토큰은 httpOnly 쿠키로만 전달클라이언트 JS에서 소셜 토큰 접근 불가 → 토큰 탈취 리스크 최소화
refresh는 forceRefresh 플래그로만 트리거선제적 refresh(만료 임박 시)를 제거하여 RTR 레이스 최소화
중복 방지는 3단계(탭 → 모듈 → 서버)어느 한 단계가 뚫려도 다음 단계에서 잡힘

3. 소셜 로그인을 CredentialsProvider 하나로 합치기

일반적으로 NextAuth에서 소셜 로그인은 GoogleProvider, KakaoProvider 등을 각각 등록합니다.
하지만 이 방식은 "소셜이 발급한 토큰"이 세션에 바로 들어가기 때문에, 자체 백엔드 토큰 체계와 합치기가 까다롭습니다.

우리의 접근: OAuth 서버 라우트 + httpOnly 쿠키 + /oauth2 브릿지

[소셜 버튼 클릭]
    ↓
[/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() 내부 분기

// 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 };
}

4. 진짜 어려운 문제: 토큰 갱신의 동시성

로그인까지는 비교적 단순합니다. 진짜 어려운 건 "토큰이 만료된 순간"부터 시작됩니다.

심지어 next-auth는 그들이 지금까지도 해결하지 못한 큰 이슈인 refresh token race condition 이슈를 가지고있습니다
https://github.com/nextauthjs/next-auth/discussions/3940

시나리오: accessToken 만료 직후

사용자가 서비스를 쓰다가 accessToken이 만료됩니다. 이 순간:

  1. 화면에 3개의 API 호출이 동시에 날아갑니다
  2. 3개 모두 401 Unauthorized를 받습니다
  3. 3개가 동시에 "세션을 갱신해야겠다"고 판단합니다
  4. 3개가 동시에 refreshToken으로 refresh를 시도합니다

여기서 RTR(Refresh Token Rotation)이 적용되어 있다면?

  • 첫 번째 refresh: 성공 → 새 refreshToken 발급, 기존 refreshToken은 폐기
  • 두 번째 refresh: 폐기된 refreshToken으로 요청 → 실패
  • 세 번째도 마찬가지 → 실패 → 로그아웃

정상적으로 서비스를 쓰던 사용자가 갑자기 로그아웃됩니다.

시나리오: 다중 탭

사용자가 탭 A, 탭 B를 열어두고 있습니다. accessToken이 만료되면:

  • 탭 A: refresh 시도 → 성공 → 새 토큰 획득
  • 탭 B: 거의 동시에 refresh 시도 → 이미 폐기된 refreshToken → 실패 → 로그아웃

이것도 마찬가지로 사용자 입장에서는 이해할 수 없는 로그아웃입니다.


5. 3단계 중복 방지 전략

위 문제를 해결하기 위해 3단계 방어선을 구축합니다.


┌───────────────────────────────────────────────────────────────┐
│ Layer 1: 클라이언트 탭 단위 (ensureSessionFresh)            │
│                                                          │
│   ┌───────────────────────────────────────────────────────┐   │
│   │ Layer 2: 서버 프로세스 내 (withLock)                │   │
│   │                                                   │   │
│   │   ┌───────────────────────────────────────────────┐   │   │
│   │   │ Layer 3: RTR 결과 캐시 (refreshCache)      │   │   │
│   │   │                                           │   │   │
│   │   │  → 같은 old RT로 2번째 요청 → 캐시 히트       │   │   │
│   │   └───────────────────────────────────────────────┘   │   │
│   │                                                   │   │
│   │   → 같은 RT로 동시 요청 → Promise 공유               │   │
│   └───────────────────────────────────────────────────────┘   │
│                                                          │
│ → 탭 A/B 동시 401 발생 → 하나만 실행, 나머지는 대기           │
└───────────────────────────────────────────────────────────────┘

Layer 1: 클라이언트 — 탭 락 + Promise 게이트

// 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;
}

withBrowserLocknavigator.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 구현
}

이 설계가 막는 것:

  • 같은 탭 안에서 API 3개가 동시에 401 → sessionRefreshGate로 1회만 실행
  • 탭 A/B에서 동시에 refresh → navigator.locks로 직렬화
  • refresh 실패 후 즉시 재시도 → lastHardFailAt 쿨다운으로 차단

Layer 2: 서버 프로세스 내 — in-process lock

클라이언트에서 아무리 잘 막아도, 서버리스 환경에서는 같은 프로세스 안에서 여러 요청이 동시에 처리될 수 있습니다.

// 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번만 발생합니다.

Layer 3: RTR 결과 캐시

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 쿠키 갱신이 반영되기 전의 요청도 정상 처리합니다.

3개 레이어를 합치면

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;
  });
}

6. 세션 에러 복구 — 조용히 죽는 버그 잡기

발견한 버그: "아무것도 안 하는" 복구 시도

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 수행

이 한 줄 차이로:

  • Before: 세션 에러 → 복구 시도(실패) → 즉시 로그아웃 → 사용자 혼란
  • After: 세션 에러 → 실제 refresh 시도 → 성공 시 정상 복귀 / 실패 시에만 로그아웃

AuthSessionWatcher의 완전한 설계

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 해보고, 실패하면 깔끔하게 로그아웃


7. 교훈 정리

"동작하는 코드"와 "안전한 코드"는 다르다

로그인 자체는 간단합니다. 하지만 프로덕션에서는:

  • 토큰 만료 타이밍에 동시 요청이 발생하고
  • 사용자는 탭을 여러 개 열고
  • 네트워크는 언제든 불안정하고
  • 백엔드는 RTR로 토큰을 즉시 폐기합니다

이 모든 경계 조건(edge case)을 고려하지 않으면, 사용자는 아무 이유 없이 로그아웃됩니다.

기술적 선택에 대한 회고

선택장점트레이드오프
CredentialsProvider 통합인증 파이프라인 단일화NextAuth가 권장하는 OAuth Provider 패턴에서 벗어남
Web Locks API 사용정확한 탭 간 동기화Safari 구버전 미지원 → localStorage fallback 필요
in-process 캐시추가 인프라 없이 RTR 레이스 완화서버리스 멀티 인스턴스에서 100% 보장 불가
선제적 refresh 제거RTR 레이스 최소화간헐적으로 첫 요청이 401을 맞고 재시도해야 함
forceRefresh 플래그명시적인 refresh 트리거NextAuth의 update() API를 우회적으로 사용

한 줄 요약: 인증 시스템은 "로그인 되게 만드는 것"보다 "로그아웃 안 되게 만드는 것"이 훨씬 어렵습니다.

profile
프론트마스터를 꿈꾸는...

0개의 댓글