Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/web/LOGIN_INSTABILITY_INVESTIGATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Login Instability Investigation Guide

## Scope
- Symptoms: sudden redirect to `/login`, intermittent 401, or authenticated user UI showing as unauthenticated.
- Target routes: `/my`, `/mentor`, `/community/*`.

## Enable Auth Debug Logs
1. Open browser devtools console.
2. Run `localStorage.setItem("authDebug", "1")`.
3. Hard refresh.
4. Reproduce once.
5. Export logs with:
- `copy(JSON.stringify(window.__AUTH_DEBUG_LOGS__ ?? [], null, 2))`

Optional (server-side middleware logs):
- Set `NEXT_PUBLIC_AUTH_DEBUG=true` and run the app.
- Middleware prints `[AUTH_DEBUG][middleware.*]` logs in server output.

## Reproduction Matrix
1. Logged-in user hard refreshes `/my`.
2. Logged-in user hard refreshes `/mentor`.
3. Logged-in user navigates to `/community` and `/community/[boardCode]/create`.
4. User with expired access token but valid refresh token opens protected route.
5. User keeps app idle until access token expires, then triggers API call.

## What To Check
- `request.start` with `hasAccessToken=true` and `tokenExpired=true`.
- Immediate `response.401` after sending expired access token.
- Whether `reissue.start` appears before redirect.
- `auth.redirectToLogin` reason/message and preceding request URL.
- Middleware redirect events where `hasRefreshToken=false`.

## Interpretation Rules
- `tokenExpired=true` + no `reissue.start` + `response.401`:
likely missing proactive refresh for expired access tokens.
- Frequent `reissue.failed` while middleware has refresh cookie:
likely backend `/auth/reissue` or cookie attribute/domain issue.
- Multiple `reissue.start` from same user action:
possible reissue concurrency/race condition.
- Middleware redirects before app boot:
refresh token cookie missing/expired at edge layer.

## Deliverables
- Reproduction steps used.
- Exported `window.__AUTH_DEBUG_LOGS__` JSON.
- Network HAR covering `/auth/reissue` and first failing 401.
- Suspected root cause category:
- token expiry handling
- refresh failure/cookie issue
- race condition
- route guard mismatch
42 changes: 41 additions & 1 deletion apps/web/src/lib/zustand/useAuthStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { UserRole } from "@/types/mentor";
import { authDebugLog } from "@/utils/authDebug";

const parseUserRoleFromToken = (token: string | null): UserRole | null => {
if (!token) return null;
Expand All @@ -20,6 +21,17 @@ const parseUserRoleFromToken = (token: string | null): UserRole | null => {

type RefreshStatus = "idle" | "refreshing" | "success" | "failed";

const parseTokenExpiry = (token: string | null): number | null => {
if (!token) return null;

try {
const payload = JSON.parse(atob(token.split(".")[1])) as { exp?: number };
return payload.exp ?? null;
} catch {
return null;
}
};

interface AuthState {
accessToken: string | null;
userRole: UserRole | null;
Expand All @@ -36,7 +48,7 @@ interface AuthState {

const useAuthStore = create<AuthState>()(
persist(
(set) => ({
(set, get) => ({
accessToken: null,
userRole: null,
isAuthenticated: false,
Expand All @@ -45,6 +57,13 @@ const useAuthStore = create<AuthState>()(
refreshStatus: "idle",

setAccessToken: (token) => {
const expiresAt = parseTokenExpiry(token);
authDebugLog("store.setAccessToken", {
hasToken: !!token,
expiresAt,
currentTimeSec: Math.floor(Date.now() / 1000),
});

set({
accessToken: token,
userRole: parseUserRoleFromToken(token),
Expand All @@ -56,6 +75,11 @@ const useAuthStore = create<AuthState>()(
},

clearAccessToken: () => {
authDebugLog("store.clearAccessToken", {
wasAuthenticated: get().isAuthenticated,
refreshStatus: get().refreshStatus,
});

set({
accessToken: null,
userRole: null,
Expand All @@ -67,14 +91,26 @@ const useAuthStore = create<AuthState>()(
},

setLoading: (loading) => {
if (get().isLoading !== loading) {
authDebugLog("store.setLoading", { previous: get().isLoading, next: loading });
}

set({ isLoading: loading });
},

setInitialized: (initialized) => {
if (get().isInitialized !== initialized) {
authDebugLog("store.setInitialized", { previous: get().isInitialized, next: initialized });
}

set({ isInitialized: initialized });
},

setRefreshStatus: (status) => {
if (get().refreshStatus !== status) {
authDebugLog("store.setRefreshStatus", { previous: get().refreshStatus, next: status });
}

set({ refreshStatus: status });
},
}),
Expand All @@ -87,6 +123,10 @@ const useAuthStore = create<AuthState>()(
onRehydrateStorage: () => (state) => {
// hydration 완료 후 isInitialized를 true로 설정
if (state) {
authDebugLog("store.rehydrate.complete", {
hasToken: !!state.accessToken,
isAuthenticated: state.isAuthenticated,
});
state.userRole = parseUserRoleFromToken(state.accessToken);
state.isInitialized = true;
Comment on lines 123 to 131
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Zustand persist middleware, does mutating statedirectly insideonRehydrateStoragenotify subscribers, or shouldsetState / store actions be used instead?

💡 Result:

Directly mutating the state object you receive in onRehydrateStorage will not notify Zustand subscribers. Zustand only notifies subscribers when the store’s setState runs (it updates the internal state reference and then calls all listeners.forEach(...)).[1]

So inside onRehydrateStorage, you should update via store actions (which call set) or call store.setState(...)—not by mutating state in-place.[2][3]

Sources:
[1] Zustand core setState implementation calls listeners only from setState (src/vanilla.ts) (github.com)
[2] Persist middleware describes onRehydrateStorage as a hook for custom logic around rehydration (zustand.docs.pmnd.rs)
[3] Example showing using an action inside onRehydrateStorage works (GitHub issue #830) (github.com)

Citations:


Zustand 하이드레이션 완료 상태를 구독자에게 올바르게 알려주세요.

onRehydrateStorage 콜백 내에서 state 객체를 직접 변경하면 Zustand의 구독자 알림이 작동하지 않습니다. 현재 코드처럼 state.userRolestate.isInitialized를 직접 대입하면, 이 값들을 구독하는 컴포넌트가 하이드레이션 완료를 감지하지 못해 UI가 업데이트되지 않을 수 있습니다.

하이드레이션 로직을 다음 중 하나로 개선해주세요:

  1. store 액션 활용: 기존 액션 함수(예: setUserRole, setInitialized)를 onRehydrateStorage 내에서 호출
  2. setState 경로 사용: store.setState()를 통해 상태 업데이트

이렇게 하면 Zustand가 올바르게 모든 구독자에게 변경을 알릴 수 있고, 로그인 안정성 조사 시 상태 전이를 명확하게 추적할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/lib/zustand/useAuthStore.ts` around lines 123 - 131, In
onRehydrateStorage, avoid mutating the incoming state directly; instead update
the store via its actions or setState so subscribers are notified: call the
existing action(s) (e.g., setUserRole(parseUserRoleFromToken(state.accessToken))
and setInitialized(true)) or invoke store.setState({ userRole:
parseUserRoleFromToken(state.accessToken), isInitialized: true }) rather than
assigning to state.userRole/state.isInitialized directly; keep the authDebugLog
call but perform updates through setUserRole/setInitialized or store.setState to
ensure Zustand notifies subscribers.

}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const COMMUNITY_LOGIN_REASON = "community-members-only";

export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const isAuthDebugEnabled = process.env.NEXT_PUBLIC_AUTH_DEBUG === "true";

// localhost 환경에서는 미들웨어 적용 X
// if (url.hostname === "localhost") {
Expand All @@ -27,6 +28,15 @@ export function middleware(request: NextRequest) {
return url.pathname === path || url.pathname.startsWith(`${path}/`);
});

if (isAuthDebugEnabled) {
console.info("[AUTH_DEBUG][middleware.check]", {
path: url.pathname,
needLogin,
hasRefreshToken: !!refreshToken,
cookieAuthEnabled: isServerSideAuthEnabled,
});
}

if (needLogin && !refreshToken) {
const isCommunityRoute = url.pathname === "/community" || url.pathname.startsWith("/community/");
url.pathname = "/login";
Expand All @@ -35,6 +45,12 @@ export function middleware(request: NextRequest) {
} else {
url.searchParams.delete("reason");
}
if (isAuthDebugEnabled) {
console.info("[AUTH_DEBUG][middleware.redirect]", {
to: url.pathname,
reason: isCommunityRoute ? COMMUNITY_LOGIN_REASON : "login-required",
});
}
return NextResponse.redirect(url);
}

Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/utils/authDebug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
type AuthDebugDetails = Record<string, unknown>;

type AuthDebugEvent = {
at: string;
event: string;
details: AuthDebugDetails;
};

declare global {
interface Window {
__AUTH_DEBUG_LOGS__?: AuthDebugEvent[];
}
}

const AUTH_DEBUG_STORAGE_KEY = "authDebug";

const isAuthDebugEnabled = (): boolean => {
const envEnabled = process.env.NEXT_PUBLIC_AUTH_DEBUG === "true";

if (typeof window === "undefined") {
return envEnabled;
}

try {
const storageEnabled = window.localStorage.getItem(AUTH_DEBUG_STORAGE_KEY) === "1";
return envEnabled || storageEnabled;
} catch {
return envEnabled;
}
};

export const authDebugLog = (event: string, details: AuthDebugDetails = {}): void => {
if (!isAuthDebugEnabled()) return;

const payload: AuthDebugEvent = {
at: new Date().toISOString(),
event,
details,
};

if (typeof window !== "undefined") {
window.__AUTH_DEBUG_LOGS__ = window.__AUTH_DEBUG_LOGS__ ?? [];
window.__AUTH_DEBUG_LOGS__.push(payload);
Comment on lines +41 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Do JavaScript globals attached to windowsurvive a full-page navigation triggered bywindow.location.href, or are they reset with the new document?

💡 Result:

They’re reset.

A navigation triggered by window.location.href loads a new document, which gets a fresh Window (global object) and JavaScript realm, so any globals you previously attached (e.g. window.myVar = ...) won’t be there in the new page. What stays “the same” is the stable reference (WindowProxy) that forwards to whichever document’s Window is currently active. (stackoverflow.com)

One important exception: if the user later returns via Back/Forward Cache (bfcache), the browser may restore the prior page from memory and preserve its JS state, including globals—but that’s a page restoration, not a normal full reload. (developer.mozilla.org)

Citations:


/login 리다이렉트 순간에 로그가 사라지는 문제

현재 debug buffer가 window 메모리에만 보관되어 있어서, axiosInstance.tsredirectToLogin처럼 window.location.href로 전체 페이지 네비게이션이 발생하면 __AUTH_DEBUG_LOGS__ 전체가 초기화됩니다. 웹 브라우저에서 새 문서가 로드될 때 새로운 Window 객체와 JavaScript realm이 생기기 때문에, 이전에 부착한 전역 변수들이 모두 리셋되는 것입니다.

이번 PR이 정확히 해결하려는 "갑작스러운 /login 리다이렉트" 시나리오에서 오히려 가장 필요한 로그를 건지지 못하게 되므로, 다음과 같이 개선하면 좋겠습니다:

  1. 마지막 N개 이벤트를 sessionStorage에 미러링해두기
  2. 새 문서 로드 후 복구 로직 추가하기
  3. 리다이렉트 직전의 이벤트 타임라인을 보존할 수 있게 하기

이렇게 하면 /login 페이지에서도 직전의 인증 관련 로그를 확인할 수 있어서, 디버깅이 훨씬 더 효과적이 될 것입니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/utils/authDebug.ts` around lines 41 - 43, The in-memory
window.__AUTH_DEBUG_LOGS__ is lost on full-page navigations (e.g.,
redirectToLogin in axiosInstance.ts); modify apps/web/src/utils/authDebug.ts to
mirror the last N events into sessionStorage and restore them on module
initialization: on every push to window.__AUTH_DEBUG_LOGS__ write a truncated
JSON array (limit N) to a fixed sessionStorage key (e.g.,
"__AUTH_DEBUG_LOGS_SESSION__"), and when the module loads, hydrate
window.__AUTH_DEBUG_LOGS__ from that sessionStorage key if present; ensure
serialization/deserialization with try/catch to avoid exceptions and keep the
same push logic so existing callers need no change.

}

console.info("[AUTH_DEBUG]", payload);
};
39 changes: 39 additions & 0 deletions apps/web/src/utils/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import axios, { type AxiosError, type AxiosInstance } from "axios";
import { postReissueToken } from "@/apis/Auth/server";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { toast } from "@/lib/zustand/useToastStore";
import { authDebugLog } from "@/utils/authDebug";
import { isTokenExpired } from "@/utils/jwtUtils";

// --- 글로벌 변수 ---
let reissuePromise: Promise<void> | null = null;
Expand All @@ -22,6 +24,13 @@ let isRedirecting = false;
const redirectToLogin = (message: string) => {
if (typeof window !== "undefined" && !isRedirecting) {
isRedirecting = true;
authDebugLog("auth.redirectToLogin", {
message,
path: window.location.pathname,
refreshStatus: useAuthStore.getState().refreshStatus,
isInitialized: useAuthStore.getState().isInitialized,
hasAccessToken: !!useAuthStore.getState().accessToken,
});
// Zustand 스토어 및 쿠키 상태 초기화
useAuthStore.getState().clearAccessToken();
try {
Expand Down Expand Up @@ -54,9 +63,24 @@ axiosInstance.interceptors.request.use(
async (config) => {
const { accessToken, setLoading, clearAccessToken, setInitialized, refreshStatus, setRefreshStatus } =
useAuthStore.getState();
const tokenExpired = isTokenExpired(accessToken);

authDebugLog("request.start", {
method: config.method,
url: config.url,
hasAccessToken: !!accessToken,
tokenExpired,
refreshStatus,
isInitialized: useAuthStore.getState().isInitialized,
});

// 토큰이 있으면 헤더에 추가하고 진행
if (accessToken) {
authDebugLog("request.attachAccessToken", {
method: config.method,
url: config.url,
tokenExpired,
});
config.headers.Authorization = convertToBearer(accessToken);
return config;
}
Expand All @@ -65,18 +89,22 @@ axiosInstance.interceptors.request.use(
try {
// 이미 reissue가 진행 중인지 확인
if (reissuePromise) {
authDebugLog("reissue.awaitExisting", { method: config.method, url: config.url });
await reissuePromise;
} else {
// 새로운 reissue 프로세스 시작 (HTTP-only 쿠키의 refreshToken 사용)
reissuePromise = (async () => {
authDebugLog("reissue.start", { triggeredByUrl: config.url, triggeredByMethod: config.method });
setRefreshStatus("refreshing");
setLoading(true);
try {
await postReissueToken();
setRefreshStatus("success");
authDebugLog("reissue.success", { triggeredByUrl: config.url, triggeredByMethod: config.method });
} catch {
clearAccessToken();
setRefreshStatus("failed");
authDebugLog("reissue.failed", { triggeredByUrl: config.url, triggeredByMethod: config.method });
} finally {
setLoading(false);
setInitialized(true);
Expand All @@ -90,15 +118,18 @@ axiosInstance.interceptors.request.use(
// reissue 완료 후 업데이트된 토큰으로 헤더 설정
const updatedAccessToken = useAuthStore.getState().accessToken;
if (updatedAccessToken) {
authDebugLog("request.attachReissuedToken", { method: config.method, url: config.url });
config.headers.Authorization = convertToBearer(updatedAccessToken);
}
} catch {
// 에러 발생 시에도 상태 정리는 promise 내부의 finally에서 처리됨
authDebugLog("reissue.errorUnhandled", { method: config.method, url: config.url });
}

// reissue 후 토큰이 있으면 헤더에 추가
const finalAccessToken = useAuthStore.getState().accessToken;
if (finalAccessToken) {
authDebugLog("request.proceedWithReissuedToken", { method: config.method, url: config.url });
config.headers.Authorization = convertToBearer(finalAccessToken);
return config;
}
Expand All @@ -108,6 +139,7 @@ axiosInstance.interceptors.request.use(

// 초기화는 되었지만 토큰이 없는 경우 로그인 필요
if (currentInitialized && !currentAccessToken) {
authDebugLog("request.rejectAuthenticationRequired", { method: config.method, url: config.url });
redirectToLogin("로그인이 필요합니다. 다시 로그인해주세요.");
return Promise.reject(new AuthenticationRequiredError());
}
Expand All @@ -125,6 +157,13 @@ axiosInstance.interceptors.response.use(
(error: AxiosError) => {
// 401 에러 시 로그인 페이지로 리다이렉트
if (error.response?.status === 401) {
authDebugLog("response.401", {
method: error.config?.method,
url: error.config?.url,
refreshStatus: useAuthStore.getState().refreshStatus,
isInitialized: useAuthStore.getState().isInitialized,
hasAccessToken: !!useAuthStore.getState().accessToken,
});
redirectToLogin("세션이 만료되었습니다. 다시 로그인해주세요.");
}

Expand Down
Loading