Published on

NextAuth.js JWT 세션이 랜덤 로그아웃될 때 점검

Authors

서비스 운영 중 가장 난감한 이슈 중 하나가 “가끔씩 로그아웃된다”입니다. 특히 NextAuth.js를 strategy: "jwt"로 쓰는 경우, 서버에 세션을 저장하지 않으니 더 단순할 것 같지만 실제로는 쿠키/토큰 만료, 서명 키 불일치, 시간 오차, 프록시 설정, 멀티 인스턴스 배포 등 여러 요인이 겹치며 랜덤 로그아웃처럼 보이는 현상이 발생합니다.

이 글은 “유저가 갑자기 로그인 페이지로 튕긴다”, “새로고침하면 세션이 null이다”, “특정 브라우저나 특정 환경에서만 재현된다” 같은 케이스에서, 원인을 빠르게 좁히고 재발 방지까지 하는 실전 점검 가이드입니다.

1) 증상을 먼저 분류: 정말 로그아웃인가, 세션 조회 실패인가

NextAuth에서 “로그아웃처럼 보임”은 크게 3가지로 나뉩니다.

  1. 쿠키가 브라우저에서 사라짐: next-auth.session-token(또는 secure prefix)이 삭제/미저장
  2. 쿠키는 있는데 서버가 토큰을 검증 못 함: 서명 키 불일치, 암호화 키 변경, 토큰 포맷 문제
  3. 토큰은 유효하지만 useSession()이 null로 나옴: 네트워크 실패, 캐시/프리패치, 라우팅/하이드레이션 타이밍

이 구분을 위해 먼저 다음을 확인합니다.

  • DevTools Application 탭에서 쿠키가 남아 있는지
  • 네트워크에서 GET /api/auth/session 호출이 실패하는지(상태 코드)
  • 서버 로그에 JWT 디코딩/검증 에러가 찍히는지

App Router를 쓴다면 하이드레이션/라우팅 타이밍 문제로 “잠깐 null”이 보일 수도 있습니다. 이 경우 랜덤 로그아웃이 아니라 렌더링 상태 관리 문제일 수 있으니, 관련해서는 Next.js App Router Hydration 오류 7가지 원인도 함께 확인하는 게 좋습니다.

2) 가장 흔한 원인 1: NEXTAUTH_SECRET이 배포마다 달라지는 문제

JWT 전략에서 토큰은 서버가 서명(또는 암호화)하고, 이후 요청에서 이를 검증합니다. 그런데 서버 인스턴스마다 NEXTAUTH_SECRET이 다르면, 어떤 인스턴스에서 발급한 토큰을 다른 인스턴스가 검증하지 못해 세션이 갑자기 null이 됩니다. 로드밸런서 뒤 멀티 인스턴스 환경에서 “랜덤”처럼 보이는 대표 원인입니다.

체크 포인트

  • 로컬에서는 문제 없는데 스테이징/프로덕션에서만 발생
  • 롤링 배포/오토스케일 이후 빈도가 증가
  • 서버 로그에 토큰 검증 실패(예: JWE/JWS 관련 에러)

해결

  • NEXTAUTH_SECRET환경변수로 고정하고, 모든 인스턴스가 동일 값을 사용하게 합니다.
  • Kubernetes라면 Secret 리소스로 고정 주입합니다.
# 예시: 고정 시크릿 생성
openssl rand -base64 32
# 예시: Kubernetes Secret
apiVersion: v1
kind: Secret
metadata:
  name: nextauth-secret
type: Opaque
data:
  NEXTAUTH_SECRET: "...base64..."
# 예시: Deployment env 주입
env:
  - name: NEXTAUTH_SECRET
    valueFrom:
      secretKeyRef:
        name: nextauth-secret
        key: NEXTAUTH_SECRET

3) 가장 흔한 원인 2: 쿠키 옵션 문제(SameSite, Secure, 도메인)

“쿠키가 아예 저장되지 않거나, 특정 상황에서만 빠진다”면 대개 쿠키 속성 문제입니다.

대표 시나리오

  • http 환경에서 secure: true로 설정해 쿠키가 저장되지 않음
  • 서브도메인 간 이동(예: app.example.comapi.example.com)에서 domain 미설정
  • OAuth 콜백에서 SameSite 정책 때문에 쿠키가 누락
  • 프록시/로드밸런서 뒤에서 NEXTAUTH_URL이 실제 URL과 불일치

점검/해결 예시

NextAuth 설정에서 쿠키를 명시적으로 조정할 수 있습니다.

// auth.ts 또는 [...nextauth].ts
import NextAuth from "next-auth";

export const { handlers, auth } = NextAuth({
  session: {
    strategy: "jwt",
  },
  cookies: {
    sessionToken: {
      name: process.env.NODE_ENV === "production"
        ? "__Secure-next-auth.session-token"
        : "next-auth.session-token",
      options: {
        httpOnly: true,
        sameSite: "lax",
        path: "/",
        secure: process.env.NODE_ENV === "production",
      },
    },
  },
});
  • OAuth 리다이렉트가 많고 크로스 사이트 이슈가 의심되면 sameSite: "none"이 필요할 수 있습니다. 단, 이 경우 secure: true가 필수입니다.
  • 프록시 뒤에서 프로토콜이 꼬이면 NextAuth가 쿠키를 잘못 설정할 수 있으니, NEXTAUTH_URL을 실제 외부 URL로 맞추고(예: https://app.example.com), 인프라가 X-Forwarded-Proto를 올바르게 전달하는지 확인합니다.

4) 원인 3: 토큰 만료/갱신 로직의 경계값 버그

JWT 세션에서 흔히 하는 패턴이 “액세스 토큰 만료 시 리프레시 토큰으로 재발급”입니다. 이 로직이 경계값에서 실패하면 사용자는 랜덤 로그아웃을 겪습니다.

흔한 실수

  • expires_at를 초 단위로 받았는데 밀리초로 비교
  • 서버 시간 오차로 인해 아직 유효한 토큰을 만료로 판단
  • 리프레시 요청 실패 시 바로 세션을 null 처리(재시도 없음)
  • 동시 요청이 여러 개 발생해 리프레시가 경합하며 한쪽이 실패

견고한 jwt 콜백 예시

아래는 만료 체크를 “현재 시간 + 안전 마진”으로 판단하고, 리프레시 실패 시에도 즉시 로그아웃 대신 에러 플래그를 세션에 전달하는 방식입니다.

import NextAuth from "next-auth";

async function refreshAccessToken(token: any) {
  const res = await fetch(process.env.OAUTH_TOKEN_ENDPOINT!, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: token.refreshToken,
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
    }),
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`refresh_failed: ${res.status} ${text}`);
  }

  const data = await res.json();
  return {
    ...token,
    accessToken: data.access_token,
    // expires_in은 보통 초 단위
    accessTokenExpiresAt: Date.now() + data.expires_in * 1000,
    refreshToken: data.refresh_token ?? token.refreshToken,
    refreshError: undefined,
  };
}

export const { handlers, auth } = NextAuth({
  session: { strategy: "jwt" },
  callbacks: {
    async jwt({ token, account }) {
      // 최초 로그인
      if (account) {
        return {
          ...token,
          accessToken: account.access_token,
          refreshToken: account.refresh_token,
          accessTokenExpiresAt: (account.expires_at ?? 0) * 1000,
        };
      }

      const safetyWindowMs = 30 * 1000;
      const expiresAt = token.accessTokenExpiresAt as number | undefined;

      if (expiresAt && Date.now() + safetyWindowMs < expiresAt) {
        return token;
      }

      // 만료되었거나 만료 시간이 없으면 갱신 시도
      try {
        return await refreshAccessToken(token);
      } catch (e: any) {
        return {
          ...token,
          refreshError: e?.message ?? "refresh_failed",
        };
      }
    },
    async session({ session, token }) {
      (session as any).accessToken = token.accessToken;
      (session as any).refreshError = (token as any).refreshError;
      return session;
    },
  },
});

프론트에서는 refreshError가 있을 때만 재로그인 UI를 유도하면 “갑자기 튕김”을 줄일 수 있습니다.

5) 원인 4: 서버 시간(클록 스큐)과 NTP 문제

JWT는 exp 같은 시간 기반 클레임을 사용합니다. 서버 시간이 몇 분만 어긋나도 “어떤 요청은 되고 어떤 요청은 안 되는” 현상이 발생할 수 있습니다.

체크

  • 특정 노드에서만 로그아웃이 발생하는지
  • 컨테이너 노드 시간 동기화(NTP)가 정상인지
  • 클라우드 환경에서 시간 드리프트 알람이 있었는지

대응

  • 노드/VM의 시간 동기화를 강제(NTP, chrony)
  • 만료 판정에 안전 마진 적용(앞 절 예시)

6) 원인 5: 엣지/서버리스 환경에서의 분산 특성

Vercel/서버리스 또는 Edge Runtime을 섞어 쓰면 다음 같은 문제가 생길 수 있습니다.

  • 지역별 PoP에서 NEXTAUTH_SECRET이 다르게 설정됨
  • Edge에서 일부 Node API가 제한되어 JWT 처리 흐름이 달라짐
  • 환경변수 반영이 배포 타이밍에 따라 달라짐

가능하면 인증 관련 라우트는 런타임을 통일하고, 시크릿/URL을 배포 단위로 강제 고정합니다.

7) “랜덤”을 “재현 가능”으로 바꾸는 로깅/관측 포인트

이 문제는 관측이 없으면 영원히 랜덤입니다. 다음을 최소로 남기면 원인 좁히기가 빨라집니다.

  • /api/auth/session 응답 상태 코드와 지연 시간
  • JWT 콜백에서 리프레시 성공/실패 카운트
  • 토큰 검증 실패 메시지(민감 정보 제외)
  • 어떤 인스턴스(파드/서버)에서 실패했는지 식별자

Kubernetes에서 특정 파드에서만 이슈가 나면, 종료/재시작 타이밍이나 사이드카, 프록시 설정도 함께 의심해야 합니다. 운영 환경에서의 재현/분석 관점은 Kubernetes 사이드카 종료 순서 버그 해결 가이드도 참고할 만합니다.

8) 체크리스트: 빠른 원인 진단 순서

  1. 브라우저에서 세션 쿠키가 사라지는가
  2. NEXTAUTH_SECRET이 모든 인스턴스에서 동일한가
  3. NEXTAUTH_URL이 실제 외부 URL과 일치하는가
  4. 쿠키 sameSite/secure/domain이 환경에 맞는가
  5. 리프레시 토큰 로직에서 초/밀리초 단위 혼동이 없는가
  6. 서버 시간 동기화가 정상인가
  7. 특정 노드/리전/브라우저에서만 재현되는가

9) 결론: 대부분은 “시크릿 고정”과 “쿠키/만료 경계값”이다

NextAuth.js JWT 세션의 랜덤 로그아웃은 대개

  • 멀티 인스턴스에서 NEXTAUTH_SECRET 불일치
  • 프록시/도메인/HTTPS 조건에서 쿠키가 누락
  • 리프레시 로직의 경계값(시간 단위, 안전 마진, 동시성)

이 세 축에서 발생합니다. 위 체크리스트대로 “쿠키가 없어졌는지”와 “서버가 토큰을 못 읽는지”부터 분리하면, 랜덤처럼 보이던 문제를 빠르게 재현 가능한 버그로 바꿀 수 있습니다.