Front-end#FE#로그인

Next.js에서 로그인 상태가 자꾸 풀리는 이유 : 인증 상태 분산 문제와 AuthContext 중앙화

localStorage·Cookie·Header 로컬 state 세 곳에 분산된 인증 상태가 만들어내는 버그와 AuthContext + SSR 쿠키 초기화로 해결한 과정을 담았습니다.

2026년 6월 8일7분 읽기조회 13
공유

들어가며

프로젝트에서 로그인 관련 버그가 계속 발생했습니다.

  • 분명히 로그인했는데 페이지 이동 시 "로그인" 버튼이 잠깐 번쩍였다가 사라진다
  • 아무것도 안 했는데 갑자기 로그아웃된다
  • 로그아웃했는데 헤더가 로그인 상태 그대로다
  • 시크릿 모드에서는 로그인이 되는데 일반 탭에서는 안 된다

처음엔 각 증상을 개별 버그로 보고 패치하려 했지만, 분석하면 할수록 근본 원인이 같다는 걸 알게 됐습니다. 바로 인증 상태가 세 곳에 분산되어 있었다는 것입니다.

이 글에서는 어떤 구조였고, 어떤 문제들이 생겼으며, 어떻게 해결했는지 그 과정을 정리했습니다.

개선할 점이나 다른 접근 방식이 있다면 댓글로 공유해 주세요! 🙌


문제 상황: 로그인 상태를 3곳에서 관리하고 있었다

기존 코드에서는 로그인 상태를 세 곳에서 각각 관리하고 있었습니다.

저장 위치사용처
localStorage.isLoginHeader 컴포넌트가 읽음
Cookie isLoginNext.js 미들웨어가 읽음
Header.tsx 로컬 stateReact 렌더링에 사용

하나의 로그인 상태를 여러 곳에서 관리하면서 상태의 기준이 분산되어 있었습니다.

이로 인해 각 저장소의 값이 서로 달라질 수 있었고, 실제로 상태 불일치가 발생하면서 예상치 못한 버그들이 나타났습니다.

결국 문제의 원인은 로그인 상태를 관리하는 단일 출처(Single Source of Truth)가 없었다는 점이었습니다.


문제 1: 로그인한 상태인데 로그인 버튼이 잠깐 나타나는 현상

// Header.tsx
const [isLogin, setIsLogin] = useState(false); // 항상 false로 시작!

useEffect(() => {
  // 클라이언트 hydration 이후에야 localStorage를 읽음
  const loginStatus = localStorage.getItem('isLogin');
  setIsLogin(loginStatus === 'true');
}, []);

Next.js의 서버 렌더링 시점에는 localStorage에 접근할 수 없습니다. 따라서 isLogin 상태는 항상 false로 초기화된 상태로 렌더링됩니다. 이후 클라이언트에서 Hydration이 완료되면 localStorage의 값을 읽어 실제 로그인 상태로 업데이트됩니다.

그 결과, 로그인한 사용자도 페이지를 이동할 때마다 잠시 동안 "로그인" 버튼이 표시되었다가 "로그아웃" 버튼으로 변경되는 UI 깜빡임(Flicker)이 발생했습니다.


문제 2: 로그인 상태가 유지되지 않고 갑자기 로그아웃되는 현상

// 🙅🏻‍♀️ instance.ts (기존)
if (error.response?.status === 401 || error.response?.status === 403) {
  localStorage.removeItem('isLogin');
  alert('로그인이 필요합니다.'); // 여러 API 동시 실패 시 중복 실행!
  window.location.href = '/login';
}

로그아웃 처리 로직에는 여러 문제가 함께 존재했습니다.

1. 403은 권한 없음이지 인증 실패가 아닙니다.

403 Forbidden은 인증 실패가 아니라 권한 부족을 의미합니다.

예를 들어 특정 리소스에 접근할 권한이 없거나, 알림 조회와 같은 기능에서 403이 발생할 수 있습니다. 하지만 기존 코드에서는 403 응답도 모두 인증 만료로 간주하여 사용자를 강제로 로그아웃시키고 있었습니다.

이를 403 응답도 로그아웃 처리하도록 수정했습니다.

2. 토큰 갱신(refresh) 로직이 없었습니다.

Access Token이 만료되더라도 Refresh Token을 이용해 새로운 Access Token을 발급받는 과정이 없었습니다.

그 결과 토큰이 만료되는 순간 사용자는 즉시 로그아웃되었고, 다시 로그인해야만 서비스를 이용할 수 있었습니다.

3. 중복 로그아웃 처리가 없었습니다.

페이지에서 여러 API를 동시에 호출하는 경우, 여러 요청이 한꺼번에 401 응답을 반환할 수 있습니다.

이때 각 요청이 모두 로그아웃 로직을 실행하면서 동일한 알림이 여러 번 표시되거나, 페이지 이동이 중복으로 발생하는 문제가 있었습니다.


문제 3: 로그인 상태인데 로그인 페이지로 이동되는 현상

로그인 시 설정하는 isLogin 쿠키에 만료 시간이 없었습니다.

// 🙅🏻‍♀️ 기존 로그인 처리
document.cookie = 'isLogin=true; path=/'; // max-age 없음 = 세션 쿠키

세션 쿠키는 브라우저 탭을 닫으면 사라집니다. 그런데 localStorage는 그대로 남아 있습니다.

브라우저 탭 닫고 다시 열기
  → Cookie: isLogin 없음 (세션 쿠키 만료)
  → localStorage: isLogin=true (남아있음)
  → 미들웨어: isLogin 쿠키 없음 → 보호 경로 접근 시 /login 리다이렉트
  → Header: localStorage 읽고 로그인 상태로 표시
  → 사용자: "분명히 로그인했는데 왜 튕겨?"

문제는 로그인 상태를 판단하는 기준이 서로 달랐다는 점입니다.

Next.js 미들웨어는 서버에서 쿠키를 기준으로 인증 여부를 확인하고, Header 컴포넌트는 클라이언트에서 localStorage를 기준으로 로그인 상태를 표시했습니다.

결국 쿠키와 localStorage의 값이 서로 달라지면, 실제 인증 상태와 화면에 표시되는 로그인 상태가 불일치하는 문제가 발생했습니다.


해결 방향

세 가지 방향으로 접근했습니다.

  1. AuthContext 중앙화: 인증 상태 관리를 단일 Context로 모으기
  2. SSR 초기값 주입: 서버에서 쿠키를 읽어 hydration flash 제거
  3. Axios 인터셉터 개선: 401/403 분리 + 토큰 갱신 로직 추가

구현 코드

1. AuthContext(인증 상태 관리의 단일화)

// 🙆🏻‍♀️ src/app/_lib/auth/AuthContext.tsx
'use client';

export function AuthProvider({ children, initialIsLogin, initialUserRole }: AuthProviderProps) {
  const [isLogin, setIsLogin] = useState(initialIsLogin); // 서버에서 받은 초기값
  const [userRole, setUserRole] = useState<UserRole | null>(initialUserRole);

  const setAuth = (role: UserRole, maxAgeSeconds: number) => {
    localStorage.setItem('isLogin', 'true');
    localStorage.setItem('userRole', role);
    localStorage.setItem('rtExpiry', String(maxAgeSeconds)); // refresh token 수명 보관
    document.cookie = `isLogin=true; path=/; max-age=${maxAgeSeconds}`;
    document.cookie = `userRole=${role}; path=/; max-age=${maxAgeSeconds}`;
    setIsLogin(true);
    setUserRole(role);
    window.dispatchEvent(new Event('authChange'));
  };

  const clearAuth = () => {
    localStorage.removeItem('isLogin');
    localStorage.removeItem('userRole');
    localStorage.removeItem('rtExpiry');
    document.cookie = 'isLogin=; path=/; max-age=0';
    document.cookie = 'userRole=; path=/; max-age=0';
    setIsLogin(false);
    setUserRole(null);
    window.dispatchEvent(new Event('authChange'));
  };

  // 쿠키가 만료됐지만 localStorage에 로그인 정보가 남아있는 경우 → refresh로 세션 복구
  useEffect(() => {
    const localIsLogin = localStorage.getItem('isLogin') === 'true';
    const localRole = localStorage.getItem('userRole') as UserRole | null;
    const rtExpiry = localStorage.getItem('rtExpiry');

    if (!initialIsLogin && localIsLogin && localRole && rtExpiry) {
      (async () => {
        try {
          const csrfRes = await axios.get(`${API_BASE_URL}/api/v1/auth/csrf`, { withCredentials: true });
          const token = csrfRes.data?.token;
          const headerName = csrfRes.data?.headerName ?? csrfRes.data?.header_name;
          await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, {}, {
            headers: { ...API_HEADERS, [headerName]: token },
            withCredentials: true,
          });
          // 복구 성공 → 쿠키·상태 재설정
          document.cookie = `isLogin=true; path=/; max-age=${rtExpiry}`;
          document.cookie = `userRole=${localRole}; path=/; max-age=${rtExpiry}`;
          setIsLogin(true);
          setUserRole(localRole);
        } catch {
          // refresh token도 만료 → localStorage 정리
          localStorage.removeItem('isLogin');
          localStorage.removeItem('userRole');
          localStorage.removeItem('rtExpiry');
        }
      })();
    }
  }, []);

  useEffect(() => {
    // instance.ts 바깥에서 storage를 직접 지울 때 동기화
    const handleAuthChange = () => {
      setIsLogin(localStorage.getItem('isLogin') === 'true');
      setUserRole(localStorage.getItem('userRole') as UserRole | null);
    };
    // instance.ts에서 refresh 성공 후 발송하는 이벤트
    const handleAuthRefreshed = () => {
      setIsLogin(true);
      setUserRole(localStorage.getItem('userRole') as UserRole | null);
    };
    // 다른 탭에서의 변경도 동기화
    const handleStorageChange = (e: StorageEvent) => {
      if (e.key === 'isLogin') setIsLogin(e.newValue === 'true');
      if (e.key === 'userRole') setUserRole(e.newValue as UserRole | null);
    };
    window.addEventListener('authChange', handleAuthChange);
    window.addEventListener('authRefreshed', handleAuthRefreshed);
    window.addEventListener('storage', handleStorageChange);
    return () => {
      window.removeEventListener('authChange', handleAuthChange);
      window.removeEventListener('authRefreshed', handleAuthRefreshed);
      window.removeEventListener('storage', handleStorageChange);
    };
  }, []);

  return (
    <AuthContext.Provider value={{ isLogin, userRole, setAuth, clearAuth }}>
      {children}
    </AuthContext.Provider>
  );
}

AuthContext를 도입해 인증 상태를 한 곳에서 관리하도록 변경했습니다.

const [isLogin, setIsLogin] = useState(initialIsLogin); 
const [userRole, setUserRole] = useState<UserRole | null>(initialUserRole);

핵심은 setAuth와 clearAuth를 통해 인증 상태 변경 로직을 중앙화한 것입니다.

기존에는 로그인 상태를 변경할 때마다 localStorage, Cookie, React state를 각각 관리해야 했습니다. 이 과정에서 특정 저장소의 값이 누락되거나 서로 다른 시점에 갱신되면서 상태 불일치가 발생할 수 있었습니다.

이제는 로그인과 로그아웃을 setAuth, clearAuth 두 함수로만 처리하도록 통일하여 상태 변경 경로를 하나로 만들었습니다.

또한 storage 이벤트를 통해 다른 탭에서 발생한 로그인·로그아웃도 동기화할 수 있도록 구성했습니다.

2. layout.tsx 서버 쿠키로 SSR 초기값 주입

// 🙆🏻‍♀️ src/app/layout.tsx
export default async function RootLayout({ children }) {
  const cookieStore = await cookies(); // next/headers
  const initialIsLogin = cookieStore.get('isLogin')?.value === 'true';
  const initialUserRole = (cookieStore.get('userRole')?.value as UserRole | null) ?? null;

  return (
    <html>
      <body>
        <AuthProvider initialIsLogin={initialIsLogin} initialUserRole={initialUserRole}>
          <Providers>
            <Header />
            <main>{children}</main>
          </Providers>
        </AuthProvider>
      </body>
    </html>
  );
}

기존에는 클라이언트가 마운트된 이후에야 localStorage를 읽어 로그인 여부를 판단했습니다.

이 때문에 서버는 항상 비로그인 상태로 렌더링하고, 클라이언트는 이후 로그인 상태로 다시 렌더링하면서 UI 깜빡임(Flicker)이 발생했습니다.

이를 해결하기 위해 서버에서 쿠키를 읽어 인증 상태를 먼저 판단한 뒤, AuthProvider의 초기값으로 전달하도록 변경했습니다.

const cookieStore = await cookies();
const initialIsLogin = cookieStore.get('isLogin')?.value === 'true';

이제 서버와 클라이언트가 동일한 인증 상태를 기준으로 렌더링하기 때문에 Hydration 과정에서 UI가 변경되는 문제가 발생하지 않습니다.

// 🙆🏻‍♀️ 로그인 성공 처리
// refreshTokenExpiresIn을 사용해 isLogin 쿠키가 refresh token과 동일한 수명을 갖도록 설정
setAuth(response.data.role, response.data.refreshTokenExpiresIn);

로그인 시 refreshTokenExpiresIn 값을 max-age로 설정합니다. 기존에는 accessTokenExpiresIn을 사용했기 때문에 access token이 만료되는 순간 isLogin 쿠키도 함께 사라졌습니다. refresh token이 살아있어도 쿠키 기준으로 SSR이 로그아웃 상태를 렌더링하고, 마운트 시 세션 복구 로직도 없었기 때문에 로그인이 완전히 풀린 것처럼 보이는 문제가 있었습니다.

3. Axios 인터셉터 개선(401/403 분리 + 토큰 갱신)

// 🙆🏻‍♀️ src/app/_lib/instance.ts
let isRefreshing = false;
let failedQueue: QueueEntry[] = [];

// 401: access token 만료 → refresh 시도
if (error.response?.status === 401 && !originalRequest._retry) {
  if (isRefreshing) {
    // 이미 refresh 중이면 큐에 대기
    return new Promise((resolve, reject) => {
      failedQueue.push({ resolve, reject });
    });
  }

  originalRequest._retry = true;
  isRefreshing = true;

  try {
    await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, ...);
    csrfToken = null;
    // isLogin 쿠키 만료 시간을 refresh token 기준으로 연장
    const role = localStorage.getItem('userRole');
    const rtExpiry = localStorage.getItem('rtExpiry');
    if (role && rtExpiry) {
      document.cookie = `isLogin=true; path=/; max-age=${rtExpiry}`;
      document.cookie = `userRole=${role}; path=/; max-age=${rtExpiry}`;
      window.dispatchEvent(new Event('authRefreshed')); // AuthContext에 상태 동기화
    }
    processQueue(null);
    return apiInstance(originalRequest); // 원래 요청 재시도
  } catch {
    processQueue(refreshError);
    clearAuthStorage();
    window.location.href = '/login';
  } finally {
    isRefreshing = false;
  }
}

// 403: 권한 없음 — 강제 로그아웃 하지 않음
if (error.response?.status === 403) {
  return Promise.reject(error);
}

기존 인터셉터는 401과 403을 모두 인증 실패로 처리하고 즉시 로그아웃시켰습니다.

하지만 401과 403은 의미가 다릅니다.

  • 401: 인증 정보가 없거나 만료됨
  • 403: 인증은 되었지만 권한이 없음

따라서 401 응답에 대해서만 토큰 재발급을 시도하고, 403 응답은 단순히 에러로 전달하도록 수정했습니다.

또한 Access Token 만료 시 Refresh Token을 이용해 토큰을 갱신하고 원래 요청을 재시도하도록 변경했습니다.

여기에 isRefreshing 플래그와 요청 큐를 추가해 여러 API 요청이 동시에 401을 반환하더라도 Refresh 요청은 한 번만 수행되도록 보완했습니다.


추가 버그: 시크릿 모드에서만 로그인되는 현상

구현을 마치고 테스트하던 중 이상한 현상을 발견했습니다.

일반 브라우저에서는 로그인이 실패하지만 시크릿 모드에서는 정상적으로 로그인되는 현상이었습니다.

에러 메시지 = `[INVALID_TOKEN_EXCEPTION] 유효하지 않은 토큰입니다`.

원인은 브라우저에 남아 있는 구 세션의 HTTP-only 쿠키였습니다.

CSRF 토큰은 세션에 바인딩됩니다. 리팩터 이전 세션에서 발급된 HTTP-only 쿠키(access/refresh token)가 브라우저에 잔존해 있으면, 새로 fetch한 CSRF 토큰도 그 구 세션 기준으로 발급됩니다. 새 로그인 요청과 세션이 불일치하면 백엔드에서 거부합니다.

일반 탭 (구 쿠키 잔존):
  /api/v1/auth/csrf 호출 → 구 세션 기준 CSRF 토큰 발급
  로그인 POST → 백엔드: 세션 불일치 → INVALID_TOKEN_EXCEPTION

시크릿 모드 (쿠키 없음):
  /api/v1/auth/csrf 호출 → 새 세션 기준 CSRF 토큰 발급
  로그인 POST → 정상 처리

즉각적인 해결은 브라우저 사이트 데이터 삭제였습니다. 코드 레벨에서는 refresh 실패 후 /login으로 이동하기 전에 로그아웃 엔드포인트를 먼저 호출해 서버측 HTTP-only 쿠키를 정리하도록 보완했습니다.

물론 이것이 가장 우아한 해결책은 아닙니다. 서버 측에서 세션을 더 엄격하게 관리하거나, 로그인 시 기존 세션을 명시적으로 무효화하는 방식이 더 근본적입니다. 다만 현재 백엔드 구조를 고려해 클라이언트 레벨에서 취할 수 있는 최선의 방법으로 선택했습니다.

} catch (refreshError) {
  // 서버측 HTTP-only 쿠키 먼저 정리해 다음 로그인 시 CSRF 세션 불일치 방지
  try {
    const csrf = await fetchCsrfToken();
    await axios.post(`${API_BASE_URL}/api/v1/auth/logout`, {}, {
      headers: { ...API_HEADERS, [csrf.headerName]: csrf.token },
      withCredentials: true,
    });
  } catch { /* logout 실패는 무시 */ }

  clearAuthStorage();
  window.location.href = '/login';
}

추가 버그: isLogin 쿠키 수명 문제와 세션 복구 부재

위 구조를 운영하면서 또 다른 세 가지 문제가 드러났습니다.

1. isLogin 쿠키가 accessToken 수명으로만 설정되어 있었다

기존 로그인 처리에서 setAuth(role, accessTokenExpiresIn)을 호출하고 있었습니다. access token의 수명(예: 30분)이 지나면 isLogin 쿠키도 함께 만료됩니다. Axios 인터셉터가 401을 받아 refresh를 성공시켜 access token을 재발급해도, 이미 만료된 isLogin 쿠키는 갱신되지 않았습니다. 결국 다음 페이지 새로고침 시 SSR이 isLogin=false로 읽고 로그아웃 상태로 렌더링했습니다.

해결: 로그인 시 refreshTokenExpiresIn을 쿠키 max-age로 사용하도록 변경했습니다. refresh token이 살아있는 한 isLogin 쿠키도 유지됩니다.

2. refresh 성공 후 isLogin 쿠키를 갱신하지 않았다

인터셉터가 refresh에 성공하더라도 processQueue → 원래 요청 재시도만 했을 뿐, 쿠키의 만료 시간은 그대로였습니다. 시간이 지나면 쿠키가 만료되고 새로고침 시 로그아웃으로 보이는 현상이 반복됐습니다.

해결: refresh 성공 직후 localStorage의 rtExpiry를 읽어 isLogin/userRole 쿠키를 재설정하고, authRefreshed 이벤트를 발송해 AuthContext React 상태도 동기화하도록 추가했습니다.

3. refresh token만 남아있으면 로그인이 아예 안 됐다

isLogin 쿠키가 만료되면 SSR에서 initialIsLogin=false로 시작합니다. 앱이 로그아웃 상태로 렌더링되기 때문에 어떤 API 요청도 발생하지 않고, 따라서 401 → refresh 인터셉터도 실행되지 않습니다. refresh token이 완벽하게 살아있어도 로그인 화면을 다시 통과해야만 했습니다.

해결: AuthProvider 마운트 시 initialIsLogin=false인데 localStorage에 로그인 정보가 있으면, 별도 axios로 /api/v1/auth/refresh를 호출해 세션 복구를 시도합니다. 성공하면 쿠키·상태를 재설정하고, 실패하면 localStorage도 정리합니다.


정리

증상원인해결
로그인 버튼 flashuseState(false) hydration 초기값SSR 쿠키 읽어 initialIsLogin 주입
갑자기 로그아웃401/403 인터셉터 과잉 반응, 토큰 갱신 없음401/403 분리 + refresh 로직 추가
보호 경로에서 튕김세션 쿠키 만료 vs localStorage 불일치max-age + userRole 쿠키 추가
로그아웃 후 UI 이상onError에 router.push 누락onError에도 clearAuth + push 추가
시크릿만 로그인됨구 HTTP-only 쿠키 CSRF 세션 불일치refresh 실패 시 logout 호출 후 이동
access token 만료 후 새로고침 시 로그아웃isLogin 쿠키 수명 = accessTokenExpiresInrefreshTokenExpiresIn으로 변경 + refresh 성공 시 쿠키 갱신
refresh token만 있으면 로그인 불가마운트 시 세션 복구 로직 없음AuthProvider 마운트 시 refresh 호출로 세션 자동 복구

마치며

이번 작업을 마무리하며 가장 크게 느낀 점은 상태 관리 포인트가 분산되었을 때의 위험성입니다.

localStorage, Cookie, React State… 인증 상태가 여러 곳에 흩어져 있으니, 업데이트가 한 번만 누락되어도 앱 전체의 신뢰도가 떨어졌습니다. SSR 환경 특유의 서버-클라이언트 상태 불일치(Hydration 문제)까지 겹치니 문제는 눈덩이처럼 불어났습니다.

이 문제들을 해결한 마법은 복잡한 로직이 아니라 단순화에 있었습니다. AuthContext 하나로 SSOT(Single Source of Truth)를 구성하고, 서버 쿠키 기반으로 초기값을 주입하자 얽혀있던 증상들이 한 번에 해소되었습니다. "눈앞의 증상 하나하나를 패치하는 것보다, 구조 자체를 정돈하는 것이 훨씬 빠르고 강력하다"는 것을 배운 경험이었습니다.

비슷한 이슈로 곤란을 겪고 계신 분이 있다면, 지금 바로 프로젝트의 로그인 상태가 몇 군데에 저장되고 있는지 추적해 보시는 것을 많이많이 권장합니다.

읽어주셔서 감사합니다. 포스팅 내용 중 개선할 점이나 다른 시각의 접근 방식이 있다면 편하게 댓글 남겨주세요! 🙌

관련 포스트

댓글

방명록을 남겨주세요