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 로직이 실행되지 않는다.
🧠 배운 점
- Zustand
persist
는 초기 상태를 즉시 반환하고, hydration 이후에야 저장된 상태를 복원한다. - Next.js는 서버-클라이언트 렌더 타이밍이 달라서, CSR 환경보다 이런 동기화 문제가 더 자주 드러난다.
- 상태 복원 전에는 절대 인증/리다이렉트 로직을 실행하지 말아야 한다.
💬 마무리
React Router에서는 전혀 문제 없던 코드가, Next.js Router로 넘어오면서 동작 타이밍 하나 차이로 큰 이슈를 만들 수 있다.
이번 경험을 통해 “hydration 전후 상태 관리의 중요성”을 다시 한번 체감했다. Next.js로 마이그레이션할 때는 단순한 라우터 변경 외에도 상태 관리 라이브러리의 동작 시점을 반드시 점검해야 한다.