Post

React Router -> Next.js Router: 로그인 리다이렉트 루프 원인과 해결법

React Router -> Next.js Router: 로그인 리다이렉트 루프 원인과 해결법

🧩 문제 상황

다음과 같은 코드로 로그인 상태를 체크하고, 비로그인 사용자는 /login 페이지로 리다이렉트하도록 설정했다.

1
2
3
4
5
6
7
if (!isLoggedIn) {
  const newUrl = new URL(window.location.href);
  const searchParams = new URLSearchParams();
  searchParams.set('redirect', `${newUrl.pathname}?${newUrl.search}`);
  router.push(`/login?${searchParams.toString()}`);
  return;
}

그러나 Next.js 환경에서는 로그인되어 있음에도 불구하고 다음과 같은 로그가 반복됐다.

1
2
3
4
5
GET /challenge/296/135 200 in 80ms
GET /login?redirect=%2Fchallenge%2F296%2F135 200 in 77ms
GET /challenge/296/135 200 in 67ms
GET /login?redirect=%2Fchallenge%2F296%2F135 200 in 65ms
...

즉, 로그인 상태에서도 계속 로그인 페이지와 원래 페이지를 오가는 무한 루프가 발생했다.


🔍 원인 분석

결론부터 말하면, 원인은 Zustand persist의 hydration(복원) 지연이었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const useAuthStore = create(
  persist<AuthStore>(
    (set) => ({
      isLoggedIn: false,
      login: (accessToken, refreshToken) => {
        set({ accessToken, refreshToken, isLoggedIn: true });
      },
      logout: () => {
        set({ accessToken: undefined, refreshToken: undefined, isLoggedIn: false });
      },
    }),
    { name: 'userLoginStatus' },
  ),
);

Zustand persist는 localStorage에 저장된 상태를 복원하기 전에 기본값(isLoggedIn: false)을 먼저 반환한다. 즉, 사용자는 이미 로그인되어 있지만, hydration이 끝나기 전까지는 false로 인식되는 것이다.

React Router 시절에는 CSR로만 렌더링되어 문제가 드러나지 않았지만, Next.js는 SSR + CSR 전환 과정에서 이 시차가 명확히 드러난다.

그 결과:

  • 초기 렌더 시 → isLoggedIn === false
  • redirect 실행 → /login?redirect=...
  • 로그인 페이지 진입 → 또다시 redirect 조건 실행 → 무한 루프 발생

✅ 해결 방법

1. initialized 플래그 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthStore {
  isLoggedIn: boolean;
  initialized: boolean;
  accessToken?: string;
  refreshToken?: string;
  login: (accessToken: string, refreshToken: string) => void;
  logout: () => void;
}

const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      isLoggedIn: false,
      initialized: false,
      login: (accessToken, refreshToken) => {
        set({ accessToken, refreshToken, isLoggedIn: true });
      },
      logout: () => {
        set({ accessToken: undefined, refreshToken: undefined, isLoggedIn: false });
      },
    }),
    {
      name: 'userLoginStatus',
      onRehydrateStorage: () => {
        return () => {
          useAuthStore.setState({ initialized: true });
        };
      },
    }
  )
);

export default useAuthStore;

Hydration이 완료되면 initialized: true로 변경하여, isLoggedIn 값을 안전하게 사용할 수 있게 했다.


2. AuthGuard에서 초기 로딩 분리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"use client";

import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import useAuthStore from "@/store/useAuthStore";

export default function AuthGuard({ children }: { children: React.ReactNode }) {
  const router = useRouter();
  const pathname = usePathname();
  const { isLoggedIn, initialized } = useAuthStore();

  useEffect(() => {
    if (!initialized) return; // ✅ 아직 복원 중이면 아무 것도 하지 않음
    if (!isLoggedIn && !pathname.startsWith("/login")) {
      router.push(`/login?redirect=${pathname}`);
    }
  }, [isLoggedIn, initialized, pathname, router]);

  if (!initialized) return null; // 로딩 중에는 빈 화면 또는 스피너 표시
  return <>{children}</>;
}

이제 isLoggedIn 상태가 복원되기 전에는 redirect 로직이 실행되지 않는다.


🧠 배운 점

  1. Zustand persist초기 상태를 즉시 반환하고, hydration 이후에야 저장된 상태를 복원한다.
  2. Next.js는 서버-클라이언트 렌더 타이밍이 달라서, CSR 환경보다 이런 동기화 문제가 더 자주 드러난다.
  3. 상태 복원 전에는 절대 인증/리다이렉트 로직을 실행하지 말아야 한다.

💬 마무리

React Router에서는 전혀 문제 없던 코드가, Next.js Router로 넘어오면서 동작 타이밍 하나 차이로 큰 이슈를 만들 수 있다.

이번 경험을 통해 “hydration 전후 상태 관리의 중요성”을 다시 한번 체감했다. Next.js로 마이그레이션할 때는 단순한 라우터 변경 외에도 상태 관리 라이브러리의 동작 시점을 반드시 점검해야 한다.


END

This post is licensed under CC BY 4.0 by the author.