Next.js + NextAuth로 안전한 토큰 갱신 시스템 구축하기

Wonhyo LEE·2026년 2월 10일

Access Token / Refresh Token 기반 인증에서 발생하는 레이스 컨디션, 중복 호출, 멀티 도메인 문제를 해결한 실무 경험을 정리합니다.


배경

사내 서비스는 Admin, B2B, B2C 세 개의 Next.js 앱으로 구성되어 있고, 백엔드에서 Access Token(AT) + Refresh Token(RT) 쌍을 발급합니다. AT가 만료되면 RT로 새 AT만 재발급받는 구조입니다.

이 글에서는 NextAuth.js의 JWT 전략 위에서 토큰 갱신 시스템을 설계하면서 만난 문제들과 해결 방법을 코드와 함께 공유합니다.


1. 전체 아키텍처

┌─────────────────────────────────────────────────────────┐
│                      브라우저                        │
│                                                     │
│  SessionProvider ──(60초마다)──→ /api/auth/session   │
│       │                              │              │
│       ▼                              ▼              │
│  useSession()              JWT 콜백 (토큰 갱신 판단)   │
│       │                              │              │
│       ▼                              ▼              │
│  axios 인터셉터             refreshAccessToken(RT)   │
│  (401 → 강제 갱신)                   │               │
│                                      ▼              │
│                              백엔드 API 서버          │
└──────────────────────────────────────────────────────────┘

핵심 포인트는 토큰 갱신이 두 겹으로 동작한다는 것입니다:

  1. 선제적 갱신: 60초 주기로 세션을 확인하면서, AT 만료 3분 전에 미리 갱신
  2. 반응적 갱신: 그럼에도 401이 발생하면, axios 인터셉터에서 즉시 세션을 강제 갱신 후 재시도

2. 토큰 만료 판단 유틸리티

먼저 AT/RT의 만료 시점을 ISO 8601 문자열로 받아 판단하는 유틸리티를 만들었습니다.

// libs/auth/token.ts

export function toMs(iso: string) {
  const t = Date.parse(iso);
  return Number.isFinite(t) ? t : 0;
}

// skewMs: 시계 오차 보정 (기본 30초)
export function isExpired(iso: string, skewMs = 30_000) {
  const exp = toMs(iso);
  return !exp || Date.now() >= exp - skewMs;
}

// windowMs: 만료 N분 전부터 "곧 만료"로 판단
export function isExpiringSoon(iso: string, windowMs = 3 * 60_000) {
  const exp = toMs(iso);
  if (!Number.isFinite(exp)) {
    console.error('[token] invalid exp format', { iso });
    return false; // 잘못된 형식이면 폭주 방지를 위해 false
  }
  return Date.now() >= exp - windowMs;
}

isExpiringSoon에서 유효하지 않은 날짜 형식이 들어오면 false를 반환합니다. 이 방어가 없으면 매 세션 체크마다 refresh 요청이 폭주할 수 있습니다.


3. NextAuth JWT 콜백 — 선제적 토큰 갱신

NextAuth의 jwt 콜백은 세션이 조회될 때마다 실행됩니다. 여기서 AT 만료 임박 여부를 판단하고 자동 갱신합니다.

// libs/auth/authOptions.ts

const REFRESH_WINDOW_MS = 3 * 60_000;  // 만료 3분 전 갱신
const REFRESH_COOLDOWN_MS = 20_000;    // 갱신 후 20초간 재갱신 방지

export const authOptions: NextAuthOptions = {
  session: {
    strategy: 'jwt',
    maxAge: 14 * 24 * 60 * 60,  // JWT 자체 수명: 14일
    updateAge: 60,               // 세션 갱신 최소 간격: 60초
  },

  callbacks: {
    async jwt({ token, user, trigger, session }) {
      // ① 최초 로그인: authorize()에서 받은 토큰을 JWT에 저장
      if (user) {
        token.tokens = (user as any).tokens;
        token.user = (user as any).user;
        token.error = undefined;
        (token as any).lastRefreshAt = Date.now();
        return token;
      }

      if (!token.tokens) return token;

      // ② 강제 갱신 트리거 (axios 인터셉터에서 호출)
      if (trigger === 'update' && (session as any)?.forceRefresh) {
        try {
          const me = await syncMeWithAutoRefresh(token);
          token.user = me ?? token.user;
          token.error = undefined;
        } catch {
          token.error = 'RefreshAccessTokenError';
        }
        return token;
      }

      // ③ RT 만료 체크
      if (isExpired(token.tokens.refreshTokenExpiredAt, 0)) {
        token.error = 'RefreshAccessTokenError';
        return token;
      }

      // ④ AT 만료 임박 → 자동 갱신
      if (isExpiringSoon(token.tokens.accessTokenExpiredAt, REFRESH_WINDOW_MS)) {
        const last = (token as any).lastRefreshAt as number | undefined;
        const isAtExpired = isExpired(token.tokens.accessTokenExpiredAt, 0);

        // 쿨다운: 이미 만료된 경우는 무조건 갱신, 아니면 20초 간격
        if (isAtExpired || !last || Date.now() - last >= REFRESH_COOLDOWN_MS) {
          try {
            const nextTokens = await refreshOnce(token.tokens.refreshToken);
            token.tokens = nextTokens;
            token.error = undefined;
            (token as any).lastRefreshAt = Date.now();
          } catch {
            token.error = 'RefreshAccessTokenError';
          }
        }
      }

      return token;
    },

    // ⑤ 세션 콜백: 클라이언트에 노출할 정보 제한
    async session({ session, token }) {
      (session as any).error = (token as any).error;

      // 에러 상태면 토큰 자체를 노출하지 않음
      if ((token as any).error) {
        (session as any).tokens = {};
        return session;
      }

      // RT는 절대 클라이언트에 내려주지 않음
      (session as any).tokens = {
        accessToken: (token as any).tokens?.accessToken,
        accessTokenExpiredAt: (token as any).tokens?.accessTokenExpiredAt,
      };

      return session;
    },
  },
};

왜 쿨다운이 필요한가?

SessionProviderrefetchInterval이 60초여도, 브라우저 탭 포커스 시 refetchOnWindowFocus로 추가 호출이 발생합니다. 탭 전환을 빠르게 반복하면 세션 체크가 연타되면서 같은 RT로 refresh API가 초당 수십 번 호출될 수 있습니다. REFRESH_COOLDOWN_MS로 이를 방지합니다.


4. Single-Flight Lock — 동시 갱신 레이스 방지

서버리스 환경에서 같은 RT로 동시에 refresh가 호출되면 토큰 충돌이 발생할 수 있습니다. withLock 패턴으로 프로세스 내 동시 요청을 1회로 제한합니다.

// libs/auth/authOptions.ts

function getAuthLocks(): Map<string, Promise<any>> {
  const g = globalThis as any;
  if (!g.__AUTH_LOCKS__) g.__AUTH_LOCKS__ = new Map();
  return g.__AUTH_LOCKS__;
}

async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const locks = getAuthLocks();

  // 이미 같은 키로 진행 중인 요청이 있으면 그 결과를 공유
  const existing = locks.get(key) as Promise<T> | undefined;
  if (existing) return existing;

  const promise = (async () => {
    try {
      return await fn();
    } finally {
      locks.delete(key);
    }
  })();

  locks.set(key, promise);
  return promise;
}

// 같은 RT에 대한 refresh는 1회만 실행
async function refreshOnce(refreshToken: string) {
  return withLock(`refresh:${refreshToken}`, () =>
    refreshAccessToken(refreshToken)
  );
}

핵심은 globalThisMap을 저장하는 것입니다. module 스코프 변수는 Webpack 번들링에 따라 여러 인스턴스가 생길 수 있지만, globalThis는 프로세스 내에서 하나가 보장됩니다.


5. Axios 인터셉터 — 반응적 401 복구

선제적 갱신으로 대부분 커버되지만, 네트워크 지연 등으로 AT가 만료된 상태에서 API를 호출할 수 있습니다. 이때 axios 인터셉터가 두 번째 안전망으로 동작합니다.

// libs/client/axios.ts

type RetriableConfig = InternalAxiosRequestConfig & { _retry?: boolean };

// 동시에 여러 요청이 401을 받아도 세션 갱신은 1회만
let refreshGate: Promise<void> | null = null;

async function ensureSessionFreshOrThrow() {
  if (!refreshGate) {
    refreshGate = forceRefreshSessionOrThrow().finally(() => {
      refreshGate = null;
    });
  }
  await refreshGate;
}

function attach(instance: AxiosInstance) {
  // 요청 인터셉터: 매 요청마다 최신 AT 부착
  instance.interceptors.request.use(async (config) => {
    const session = await getSession();
    const at = (session as any)?.tokens?.accessToken;
    if (at) config.headers.Authorization = `Bearer ${at}`;
    return config;
  });

  // 응답 인터셉터: 401 → 세션 갱신 → 원래 요청 재시도
  instance.interceptors.response.use(
    (res) => res,
    async (error: AxiosError) => {
      const original = error.config as RetriableConfig | undefined;
      if (!original) throw error;

      if (error.response?.status === 401 && !original._retry) {
        original._retry = true; // 무한 루프 방지
        try {
          await ensureSessionFreshOrThrow();

          // 갱신된 AT로 원래 요청 재시도
          const session = await getSession();
          const at = (session as any)?.tokens?.accessToken;
          if (at) original.headers.Authorization = `Bearer ${at}`;

          return instance(original);
        } catch {
          await signOutOnce('/login');
          throw error;
        }
      }

      throw error;
    },
  );
}

refreshGate 패턴의 의미

리스트 화면에서 10개 API를 동시에 호출하는데, AT가 방금 만료되었다고 가정합니다. 10개 모두 401을 받겠지만, refreshGate 덕분에 세션 갱신은 딱 1회만 실행됩니다. 나머지 9개는 같은 Promise를 await하고, 갱신 완료 후 각자 재시도합니다.

forceRefreshSessionOrThrow의 동작

async function forceRefreshSessionOrThrow() {
  // NextAuth 세션 update API를 직접 호출하여
  // JWT 콜백의 trigger === 'update' 분기를 실행시킴
  const csrfToken = await getCsrfToken();
  await fetch('/api/auth/session', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ csrfToken, data: { forceRefresh: true } }),
  });

  // 갱신 후 새 세션 확인
  const session = await getSession();
  if ((session as any)?.error === 'RefreshAccessTokenError') {
    throw new Error('REFRESH_FAILED');
  }
}

이 함수가 NextAuth의 세션 update API를 호출하면, jwt 콜백에서 trigger === 'update' 분기를 탑니다. 거기서 syncMeWithAutoRefresh가 실행되어 RT로 AT를 갱신하고, 실패하면 에러를 세션에 기록합니다.


6. AuthSessionWatcher — 세션 에러 감지

서버에서 RT 만료가 감지되면 token.error = 'RefreshAccessTokenError'가 설정됩니다. 클라이언트에서 이를 감지하여 자동 로그아웃하는 컴포넌트입니다.

// components/common/AuthSessionWatcher.tsx

'use client';

import { useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { signOutOnce } from '@/libs/auth/signOutOnce';

export default function AuthSessionWatcher() {
  const { data: session, status } = useSession();

  useEffect(() => {
    if (status !== 'authenticated') return;

    const err = (session as any)?.error;
    if (err === 'Unauthorized' || err === 'RefreshAccessTokenError') {
      void signOutOnce('/login');
    }
  }, [status, session]);

  return null;
}

Providers에서 SessionProvider 안에 배치합니다:

<SessionProvider session={session} refetchInterval={60} refetchOnWindowFocus>
  <AuthSessionWatcher />
  {children}
</SessionProvider>

7. signOutOnce — 중복 로그아웃 방지

동시에 여러 API가 401을 반환하면, axios 인터셉터 + React Query 에러 핸들러 + AuthSessionWatcher에서 signOut이 동시에 호출될 수 있습니다. 전역 플래그로 이를 1회로 제한합니다.

// libs/auth/signOutOnce.ts

import { signOut } from 'next-auth/react';

let signingOut = false;

function resolveSameOriginUrl(input: string) {
  if (typeof window === 'undefined') return input;

  // NEXTAUTH_URL과 실제 origin이 다를 수 있으므로
  // 항상 현재 origin 기준으로 URL을 생성
  try {
    const base = window.location.origin;
    const u = new URL(input, base);
    return new URL(`${u.pathname}${u.search}${u.hash}`, base).toString();
  } catch {
    return new URL('/login', window.location.origin).toString();
  }
}

const AUTH_ERROR_MESSAGES: Record<string, string> = {
  'AUTH-11': '미사용 상태의 계정입니다.',
  'AUTH-12': '관리자 권한 그룹이 변경되어 재로그인이 필요합니다.',
  'AUTH-13': '관리자 권한 그룹의 메뉴가 변경되어 재로그인이 필요합니다.',
};

export async function signOutOnce(callbackUrl = '/login', errorCode?: string) {
  if (typeof window === 'undefined') return; // SSR 환경 방어
  if (signingOut) return;                     // 중복 호출 방지

  signingOut = true;
  try {
    // 강제 로그아웃 사유가 있으면 사용자에게 안내
    if (errorCode && AUTH_ERROR_MESSAGES[errorCode]) {
      alert(AUTH_ERROR_MESSAGES[errorCode]);
    }

    // NextAuth 세션만 정리하고, 리다이렉트는 직접 제어
    await signOut({ redirect: false });
    location.replace(resolveSameOriginUrl(callbackUrl));
  } finally {
    signingOut = false;
  }
}

resolveSameOriginUrl이 필요한 이유

NextAuth의 signOut({ callbackUrl }) 기본 동작은 NEXTAUTH_URL 환경변수 기준으로 절대 URL을 만듭니다. 하나의 서비스를 두 개 도메인(예: operator.example.com, cdms.example.com)에서 운영할 때, NEXTAUTH_URL이 한쪽으로 고정되어 있으면 다른 도메인 사용자가 엉뚱한 도메인으로 리다이렉트됩니다.

redirect: false로 리다이렉트를 끄고, window.location.origin 기준으로 직접 이동하면 이 문제를 해결할 수 있습니다.


8. Next.js 미들웨어 — 서버 사이드 라우트 보호

클라이언트의 AuthSessionWatcher가 감지하기 전에, 미들웨어에서 먼저 RT 만료 상태를 차단합니다.

// middleware.ts

import { withAuth } from 'next-auth/middleware';
import { NextRequest, NextResponse } from 'next/server';

export const middleware = withAuth(
  function middleware(req: NextRequest) {
    const token = (req as any).nextauth?.token as any;
    const { pathname } = req.nextUrl;

    // 공개 페이지는 통과
    if (pathname.startsWith('/login')) {
      if (token) return NextResponse.redirect(new URL('/', req.url));
      return NextResponse.next();
    }

    // 토큰이 없거나, RT 만료 에러가 있으면 로그인으로
    if (!token || token?.error === 'RefreshAccessTokenError') {
      const url = req.nextUrl.clone();
      url.pathname = '/login';
      url.searchParams.set('error', 'session');
      return NextResponse.redirect(url);
    }

    return NextResponse.next();
  },
  {
    callbacks: {
      // withAuth의 기본 인증 체크를 비활성화하고 커스텀 로직 사용
      authorized: () => true,
    },
  },
);

export const config = {
  matcher: ['/((?!api/|_next/|public/|assets/|images/|fonts/).*)'],
};

authorized: () => true의 의미

withAuth의 기본 동작은 authorized 콜백에서 false를 반환하면 NextAuth의 signIn 페이지로 리다이렉트합니다. 하지만 우리는 token.error 같은 세부 조건을 직접 체크해야 하므로, 기본 체크를 true로 우회하고 미들웨어 함수 안에서 직접 분기합니다.


9. React Query 전역 에러 핸들러 — 강제 로그아웃

백엔드에서 권한 변경 등의 이유로 특정 에러 코드를 반환하면 즉시 로그아웃시켜야 합니다.

// libs/client/reactQuery.ts

const AUTH_FORCE_LOGOUT_ERROR_CODES = new Set(['AUTH-11', 'AUTH-12', 'AUTH-13']);

const pickApiErrorCode = (data: unknown): string | null => {
  if (!data || typeof data !== 'object') return null;
  const d = data as any;
  return d.errorCode ?? d.code ?? null;
};

const handleAxiosError = async (error: unknown): Promise<boolean> => {
  if (!isAxiosError(error)) return false;

  const data = error.response?.data;

  // 강제 로그아웃 에러 코드 → 사유 안내 후 즉시 로그아웃
  const errorCode = pickApiErrorCode(data);
  if (errorCode && AUTH_FORCE_LOGOUT_ERROR_CODES.has(errorCode)) {
    await signOutOnce('/login', errorCode);
    return true;
  }

  // 일반 401 → 로그아웃
  if (error.response?.status === 401) {
    await signOutOnce('/login');
    return true;
  }

  // ...기타 에러 처리
};

export const reactQueryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (isAxiosError(error)) {
          const status = error.response?.status;
          if (status === 401 || status === 403) return false;

          // 강제 로그아웃 대상도 재시도하지 않음
          const code = pickApiErrorCode(error.response?.data);
          if (code && AUTH_FORCE_LOGOUT_ERROR_CODES.has(code)) return false;
        }
        return failureCount < 1;
      },
    },
  },
  queryCache: new QueryCache({ onError: reactQueryErrorHandler }),
});

10. 전체 보호 레이어 요약

요청 ──→ [미들웨어: token.error 체크]
            │
            ▼
         [axios 요청 인터셉터: 최신 AT 부착]
            │
            ▼
         [백엔드 API]
            │
            ├── 200 OK → 정상 응답
            │
            ├── 401 → [axios 응답 인터셉터]
            │            ├── refreshGate (세션 강제 갱신, 단일화)
            │            ├── 성공 → 원래 요청 재시도
            │            └── 실패 → signOutOnce
            │
            └── AUTH-11/12/13 → [React Query 에러 핸들러]
                                  └── signOutOnce(errorCode) → alert + 로그아웃
세션 주기 체크 (60초마다)
      │
      ▼
   [JWT 콜백]
      ├── AT 만료 3분 전 → withLock + refreshOnce (쿨다운 20초)
      ├── RT 만료 → token.error 설정
      └── 정상 → pass
      │
      ▼
   [세션 콜백]
      ├── error 있으면 → 토큰 빈 객체 (폭주 방지)
      └── 정상 → AT만 클라이언트에 노출
      │
      ▼
   [AuthSessionWatcher]
      └── error 감지 → signOutOnce (중복 방지)

마무리

이 시스템의 핵심 설계 원칙을 정리하면:

  1. 이중 안전망: 선제적 갱신(JWT 콜백) + 반응적 갱신(axios 인터셉터)으로 빈틈을 없앴습니다.
  2. 동시성 제어: withLock(서버), refreshGate(클라이언트), signOutOnce(로그아웃) 세 곳에서 중복 호출을 방지합니다.
  3. 최소 노출: RT는 서버 JWT 내부에만 두고, 에러 상태에서는 AT조차 클라이언트에 내려주지 않습니다.
  4. 멀티 도메인 안전: resolveSameOriginUrl로 어떤 도메인에서든 현재 origin 기준으로 동작합니다.

가장 빠지기 쉬운 함정은 "하나만 잘하면 되겠지"라는 생각입니다. 토큰 갱신은 선제적으로 하더라도 401은 발생하고, 401을 잡아도 동시 요청이 중복 갱신을 일으키고, 갱신에 실패하면 로그아웃이 여러 번 호출됩니다. 각 단계마다 적절한 가드를 두는 것이 안정적인 인증 시스템의 핵심입니다.

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

0개의 댓글