Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/web/src/apis/Auth/server/postReissueToken.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import useAuthStore from "@/lib/zustand/useAuthStore";
import { publicAxiosInstance } from "@/utils/axiosInstance";
import { isTokenExpired } from "@/utils/jwtUtils";

/**
* @description 토큰 재발급 서버사이드 함수
Expand All @@ -13,6 +14,9 @@ const postReissueToken = async (): Promise<string> => {
if (!newAccessToken) {
throw new Error("재발급된 토큰이 유효하지 않습니다.");
}
if (isTokenExpired(newAccessToken)) {
throw new Error("재발급된 토큰이 이미 만료되었습니다.");
Comment on lines +17 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove strict local expiry rejection for reissued token

Rejecting a freshly reissued access token based on isTokenExpired() can force unnecessary logout loops when client and auth-server clocks are skewed (for example, a user device clock ahead of server time). In that case /auth/reissue succeeds server-side but this client check throws and clears auth state anyway. Add skew tolerance or trust the server-issued token instead of hard-failing immediately.

Useful? React with 👍 / 👎.

}

// 재발급 성공 시, 새로운 토큰을 Zustand 스토어에 저장
useAuthStore.getState().setAccessToken(newAccessToken);
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/app/mentor/_ui/MentorClient/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { postReissueToken } from "@/apis/Auth";
import CloudSpinnerPage from "@/components/ui/CloudSpinnerPage";
import useAuthStore from "@/lib/zustand/useAuthStore";
import { UserRole } from "@/types/mentor";
import { tokenParse } from "@/utils/jwtUtils";
import { isTokenExpired, tokenParse } from "@/utils/jwtUtils";

// 레이지 로드 컴포넌트
const MenteePage = lazy(() => import("./_ui/MenteePage"));
Expand All @@ -16,6 +16,7 @@ const MentorClient = () => {
const router = useRouter();
const { isLoading, accessToken, isInitialized, refreshStatus, setRefreshStatus } = useAuthStore();
const [isRefreshing, setIsRefreshing] = useState(false);
const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken));

// 어드민 전용: 뷰 전환 상태 (true: 멘토 뷰, false: 멘티 뷰)
const [showMentorView, setShowMentorView] = useState<boolean>(true);
Expand All @@ -28,8 +29,8 @@ const MentorClient = () => {
return;
}

// 이미 초기화되었고 토큰이 없는 경우에만 재발급 시도
if (!isInitialized || accessToken || isRefreshing || refreshStatus === "refreshing") {
// 초기화 이후 유효한 access token이 없을 때만 재발급 시도
if (!isInitialized || hasValidAccessToken || isRefreshing || refreshStatus === "refreshing") {
return;
}

Expand All @@ -49,15 +50,15 @@ const MentorClient = () => {
};

attemptTokenRefresh();
}, [isInitialized, accessToken, isRefreshing, refreshStatus, setRefreshStatus, router]);
}, [isInitialized, hasValidAccessToken, isRefreshing, refreshStatus, setRefreshStatus, router]);

// 초기화 전이거나 로딩 중이거나 재발급 중일 때 스피너 표시
if (!isInitialized || isLoading || refreshStatus === "refreshing" || isRefreshing) {
return <CloudSpinnerPage />;
}

// 초기화 완료 후에도 토큰이 없으면 리다이렉트 (useEffect에서 처리되지만 fallback)
if (!accessToken) {
if (!hasValidAccessToken) {
return <CloudSpinnerPage />;
}

Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/lib/web-socket/useConnectWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SockJS from "sockjs-client";
import { normalizeChatMessage, type RawChatMessage } from "@/apis/chat/normalize";

import { type ChatMessage, ConnectionStatus } from "@/types/chat";
import { isTokenExpired } from "@/utils/jwtUtils";
import useAuthStore from "../zustand/useAuthStore";

interface UseConnectWebSocketProps {
Expand All @@ -26,14 +27,15 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U
const [submittedMessages, setSubmittedMessages] = useState<ChatMessage[]>([]);
const accessToken = useAuthStore((state) => state.accessToken);
const isInitialized = useAuthStore((state) => state.isInitialized);
const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken));

useEffect(() => {
if (!roomId) {
setConnectionStatus(ConnectionStatus.Disconnected);
return;
}

if (!isInitialized || !accessToken || accessToken.trim() === "") {
if (!isInitialized || !hasValidAccessToken) {
setConnectionStatus(ConnectionStatus.Pending);
return;
}
Expand Down Expand Up @@ -90,7 +92,7 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U
}
clientRef.current = null;
};
}, [roomId, clientRef, accessToken, isInitialized]);
}, [roomId, clientRef, accessToken, hasValidAccessToken, isInitialized]);

// 관리하는 connectionStatus를 반환
return { connectionStatus, submittedMessages, setSubmittedMessages };
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/lib/zustand/useAuthStore.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { UserRole } from "@/types/mentor";
import { isTokenExpired } from "@/utils/jwtUtils";

const parseUserRoleFromToken = (token: string | null): UserRole | null => {
if (!token) return null;
if (!token || isTokenExpired(token)) return null;

try {
const payload = JSON.parse(atob(token.split(".")[1])) as { role?: string };
Expand Down Expand Up @@ -87,7 +88,19 @@ const useAuthStore = create<AuthState>()(
onRehydrateStorage: () => (state) => {
// hydration 완료 후 isInitialized를 true로 설정
if (state) {
state.userRole = parseUserRoleFromToken(state.accessToken);
const hasValidToken = Boolean(state.accessToken && !isTokenExpired(state.accessToken));

if (!hasValidToken) {
state.accessToken = null;
state.userRole = null;
state.isAuthenticated = false;
state.refreshStatus = "idle";
} else {
state.userRole = parseUserRoleFromToken(state.accessToken);
state.isAuthenticated = true;
state.refreshStatus = "success";
}

state.isInitialized = true;
Comment on lines +91 to 104
Copy link
Copy Markdown

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

🏁 Script executed:

# First, let's find and read the actual file
find . -type f -name "useAuthStore.ts" | head -5

Repository: solid-connection/solid-connect-web

Length of output: 120


🏁 Script executed:

# Also check the file structure
git ls-files apps/web/src/lib/zustand/ | head -20

Repository: solid-connection/solid-connect-web

Length of output: 354


🏁 Script executed:

# Read the useAuthStore.ts file to see the actual implementation
cat -n ./apps/web/src/lib/zustand/useAuthStore.ts

Repository: solid-connection/solid-connect-web

Length of output: 3820


🏁 Script executed:

# Let's check if there are any other usages or tests of this store
grep -r "useAuthStore" apps/web/src --include="*.ts" --include="*.tsx" | head -20

Repository: solid-connection/solid-connect-web

Length of output: 1972


🏁 Script executed:

# Also check if there's documentation or examples about Zustand persist behavior
find . -type f \( -name "*.md" -o -name "*.txt" \) -path "*/zustand*" | head -10

Repository: solid-connection/solid-connect-web

Length of output: 60


🌐 Web query:

Zustand persist middleware onRehydrateStorage direct state mutation propagation subscribers

💡 Result:

In Zustand's persist middleware, the onRehydrateStorage callback is invoked before the persisted state is loaded from storage and applied to the store. It receives the current (initial) state as an argument and can return an optional post-rehydration callback that runs after the state is merged in or if an error occurs. State mutations (via set) within onRehydrateStorage or its returned callback use the standard set function, which does trigger subscribers because Zustand's core setState always notifies listeners/subscribers to state changes. However, for synchronous storages like localStorage: - Mutations in onRehydrateStorage can be overwritten by the final persisted state assignment. - This was a known issue (e.g., GitHub #1688), fixed in later versions where updates during rehydration are preserved properly. The rehydration itself (merging persisted state) happens via internal set calls after storage retrieval, so subscribers are notified of the hydration state changes. React components using useStore will re-render accordingly once hydrated. Use store.persist.hasHydrated, onHydrate, or onFinishHydration to detect/track hydration status reactively. Example usage: onRehydrateStorage: (state) => { // Pre-rehydration, mutations here may be overwritten (sync storage) return (newState, error) => { if (!error) { // Post-rehydration mutations propagate normally via set state.someAction; // Triggers subscribers } }; } Direct state mutation (e.g., store.setState({ ... })) during rehydration also notifies subscribers, as it's the same mechanism.

Citations:


🏁 Script executed:

# Check if there's a post-rehydration pattern used elsewhere or documentation
grep -r "onRehydrateStorage" apps/web --include="*.ts" --include="*.tsx" -A 5 -B 2

Repository: solid-connection/solid-connect-web

Length of output: 710


🌐 Web query:

Zustand persist middleware best practices token validation hydration pattern

💡 Result:

Zustand persist middleware best practices for token validation and hydration, particularly for authentication stores, involve several key patterns grounded in official documentation and community implementations. ## Store Setup Use persist middleware with partialize to store only serializable token data (avoid functions/actions). Store tokens as strings. Example auth store: import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import jwtDecode from 'jwt-decode'; interface AuthState { accessToken: string | null; refreshToken: string | null; user: any | null; isAuthenticated: boolean; setTokens: (access: string, refresh?: string, user?: any) => void; logout: => void; init: => void; // For validation } export const useAuthStore = create( persist( (set, get) => ({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false, setTokens: (accessToken, refreshToken, user) => { set({ accessToken, refreshToken, user, isAuthenticated: true }); }, logout: => { set({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false }); }, init: async => { const { accessToken, refreshToken } = get; if (accessToken) { try { // Validate JWT expiration const decoded = jwtDecode<{ exp: number }>(accessToken); if (decoded.exp * 1000 > Date.now) { // Valid, fetch user if needed set({ isAuthenticated: true }); } else if (refreshToken) { // Attempt refresh } else { get.logout; } } catch { get.logout; } } } }), { name: 'auth-storage', storage: createJSONStorage( => localStorage), partialize: (state) => ({ accessToken: state.accessToken, refreshToken: state.refreshToken }), // Custom merge for functions/serialization issues merge: (persistedState, currentState) => ({ ...currentState, ...persistedState }), } ) ); {value: "Example auth store code", citations: [1,5,6,7], confidence: "high"} ## Hydration Pattern Track hydration to prevent rendering mismatches (critical in SSR like Next.js). 1. Add hasHydrated flag: onRehydrateStorage: (state) => (state, error) => { if (!error && state) state.setHasHydrated(true); } In store: _hasHydrated: false, setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), Use: const hydrated = useAuthStore((s) => s._hasHydrated); if (!hydrated) return ; {value: "Hydration tracking with onRehydrateStorage", citations: [1,3,14], confidence: "high"} For SSR/Next.js: - skipHydration: true - Manual rehydrate in client component: 'use client'; useEffect( => { useAuthStore.persist.rehydrate.then( => useAuthStore.getState.init); }, []); {value: "SSR hydration handling", citations: [1,3,12,21], confidence: "high"} Alternative for hydration mismatches: useSyncExternalStore for selectors differing server/client. {value: "useSyncExternalStore for external stores", citations: [4], confidence: "medium"} ## Token Validation - Do not trust persisted tokens blindly. Always validate on hydration/init. - Decode JWT, check exp claim. - If expired, attempt refresh with refreshToken, else logout/clearStorage. - Call init after rehydrate. - Use onRehydrateStorage to conditionally clear invalid state (e.g., delete localStorage if no valid JWT). {value: "Token validation on init/hydration", citations: [6,16,19], confidence: "high"} ## Best Practices 1. partialize: Persist only tokens, not user data or functions. Fetch user on validation. 2. Actions separate: Store data only; validate in actions/getters. 3. SSR: Use skipHydration + manual rehydrate + hydration flag. 4. Security: Tokens in localStorage ok for SPA but consider httpOnly cookies for refreshToken. 5. Refresh logic: Intercept API calls, refresh if 401. 6. Selectors: Use shallow for performance. 7. Versioning: Use version for breaking changes. This pattern ensures tokens persist reliably, validate on load, and handle hydration without mismatches. {value: "Summary of best practices", citations: [1,6,7,9], confidence: "high"}

Citations:


hydration 콜백 구조를 수정하여 직접 mutation 대신 setState로 반영해주세요.

현재 코드는 onRehydrateStorage에서 state를 직접 변경하고 있어서 Zustand의 구독자 알림이 제대로 전파되지 않을 수 있습니다. 특히 localStorage 같은 동기 저장소에서는 이 변경사항이 최종 hydration 완료 전에 덮어씌워질 위험이 있습니다. 만료된 토큰을 정리하려면 다음처럼 콜백 구조를 바꾸어 setState를 통해 업데이트하는 것이 안전합니다:

✅ 개선된 패치
       onRehydrateStorage: () => (state, error) => {
-        // hydration 완료 후 isInitialized를 true로 설정
-        if (state) {
+        if (!error && state) {
           const hasValidToken = Boolean(state.accessToken && !isTokenExpired(state.accessToken));
 
-          if (!hasValidToken) {
-            state.accessToken = null;
-            state.userRole = null;
-            state.isAuthenticated = false;
-            state.refreshStatus = "idle";
-          } else {
-            state.userRole = parseUserRoleFromToken(state.accessToken);
-            state.isAuthenticated = true;
-            state.refreshStatus = "success";
-          }
-
-          state.isInitialized = true;
+          useAuthStore.setState(
+            hasValidToken
+              ? {
+                  userRole: parseUserRoleFromToken(state.accessToken),
+                  isAuthenticated: true,
+                  refreshStatus: "success",
+                  isInitialized: true,
+                }
+              : {
+                  accessToken: null,
+                  userRole: null,
+                  isAuthenticated: false,
+                  refreshStatus: "idle",
+                  isInitialized: true,
+                },
+          );
         }
       },

변경 요점:

  1. 콜백 함수에 error 파라미터 추가 → hydration 오류 상황 대응
  2. 콜백 내부에서 setState로 상태 업데이트 → 모든 구독자에게 변경 알림 전파
  3. 동기 저장소에서도 올바른 순서로 업데이트 처리 → 최종 hydration 상태 덮어씌워지지 않음
🤖 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 91 - 104, The
hydration callback is directly mutating the Zustand state (state.accessToken,
state.userRole, state.isAuthenticated, state.refreshStatus, state.isInitialized)
which can break subscriber notifications and be overwritten by sync storages;
change the onRehydrateStorage callback signature to (state, error) and perform
all updates via the store's setState API instead of assigning to state directly:
call setState with a new partial state that clears an expired token (use
isTokenExpired(state.accessToken)), sets userRole via parseUserRoleFromToken
when valid, toggles refreshStatus to "idle" or "success", and marks
isInitialized true so subscribers receive the change atomically.

}
},
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { isTokenExpired } from "@/utils/jwtUtils";

const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지

Expand All @@ -13,13 +14,14 @@ export function middleware(request: NextRequest) {

// HTTP-only 쿠키의 refreshToken 확인
const refreshToken = request.cookies.get("refreshToken")?.value;
const hasValidRefreshToken = Boolean(refreshToken && !isTokenExpired(refreshToken));

// 정확한 경로 매칭
const needLogin = loginNeedPages.some((path) => {
return url.pathname === path || url.pathname.startsWith(`${path}/`);
});

if (needLogin && !refreshToken) {
if (needLogin && !hasValidRefreshToken) {
url.pathname = "/login";
url.searchParams.delete("reason");
return NextResponse.redirect(url);
Expand Down
94 changes: 63 additions & 31 deletions apps/web/src/utils/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 { isTokenExpired } from "@/utils/jwtUtils";

// --- 글로벌 변수 ---
let reissuePromise: Promise<void> | null = null;
Expand Down Expand Up @@ -34,6 +35,34 @@ const redirectToLogin = (message: string) => {

export const convertToBearer = (token: string) => `Bearer ${token}`;

const tryReissueAccessToken = async (): Promise<string | null> => {
if (reissuePromise) {
await reissuePromise;
return useAuthStore.getState().accessToken;
}

const { setLoading, clearAccessToken, setInitialized, setRefreshStatus } = useAuthStore.getState();

reissuePromise = (async () => {
setRefreshStatus("refreshing");
setLoading(true);
try {
await postReissueToken();
setRefreshStatus("success");
} catch {
clearAccessToken();
setRefreshStatus("failed");
} finally {
setLoading(false);
setInitialized(true);
reissuePromise = null;
}
})();

await reissuePromise;
return useAuthStore.getState().accessToken;
};

// --- Axios 인스턴스 ---
// 인증이 필요 없는 공용 API 요청에 사용
export const publicAxiosInstance: AxiosInstance = axios.create({
Expand All @@ -52,40 +81,24 @@ export const axiosInstance: AxiosInstance = axios.create({
// 1. 요청 인터셉터 (Request Interceptor)
axiosInstance.interceptors.request.use(
async (config) => {
const { accessToken, setLoading, clearAccessToken, setInitialized, refreshStatus, setRefreshStatus } =
useAuthStore.getState();
const { accessToken, clearAccessToken, refreshStatus } = useAuthStore.getState();

// 만료된 access token은 즉시 제거하고 refresh 재발급 경로를 타게 한다.
if (accessToken && isTokenExpired(accessToken)) {
clearAccessToken();
Comment on lines +87 to +88
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid clearing auth state before refresh completes

Calling clearAccessToken() as soon as an access token is detected expired logs the client into a temporary unauthenticated state before refresh has even been attempted. In this codebase, UI/query flows are gated by isAuthenticated (for example in university and mentor widgets), so a normal silent refresh can now transiently disable authenticated behavior and reset auth-dependent UI even when refresh succeeds moments later. Keep the old auth state until reissue actually fails to avoid this regression.

Useful? React with 👍 / 👎.

}

const validAccessToken = useAuthStore.getState().accessToken;

// 토큰이 있으면 헤더에 추가하고 진행
if (accessToken) {
config.headers.Authorization = convertToBearer(accessToken);
if (validAccessToken) {
config.headers.Authorization = convertToBearer(validAccessToken);
return config;
}

if (refreshStatus !== "failed") {
try {
// 이미 reissue가 진행 중인지 확인
if (reissuePromise) {
await reissuePromise;
} else {
// 새로운 reissue 프로세스 시작 (HTTP-only 쿠키의 refreshToken 사용)
reissuePromise = (async () => {
setRefreshStatus("refreshing");
setLoading(true);
try {
await postReissueToken();
setRefreshStatus("success");
} catch {
clearAccessToken();
setRefreshStatus("failed");
} finally {
setLoading(false);
setInitialized(true);
reissuePromise = null;
}
})();

await reissuePromise;
}
await tryReissueAccessToken();

// reissue 완료 후 업데이트된 토큰으로 헤더 설정
const updatedAccessToken = useAuthStore.getState().accessToken;
Expand Down Expand Up @@ -119,12 +132,31 @@ axiosInstance.interceptors.request.use(
);

// 2. 응답 인터셉터 (Response Interceptor)
// 역할: 401 에러 시 로그인 페이지로 리다이렉트
// 역할: 401 에러 시 access 재발급 1회 재시도 후 실패하면 로그인 페이지로 리다이렉트
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
// 401 에러 시 로그인 페이지로 리다이렉트
if (error.response?.status === 401) {
async (error: AxiosError) => {
const status = error.response?.status;

if (status === 401) {
const originalRequest = error.config as (typeof error.config & { _retry?: boolean }) | undefined;

if (originalRequest && !originalRequest._retry && useAuthStore.getState().refreshStatus !== "failed") {
originalRequest._retry = true;

try {
const reissuedAccessToken = await tryReissueAccessToken();

if (reissuedAccessToken) {
originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization = convertToBearer(reissuedAccessToken);
return axiosInstance(originalRequest);
}
} catch {
// 재발급 실패 시 아래 로그인 리다이렉트로 처리
}
}

redirectToLogin("세션이 만료되었습니다. 다시 로그인해주세요.");
}

Expand Down
48 changes: 48 additions & 0 deletions docs/auth-refresh-edge-cases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Access/Refresh 토큰 엣지케이스 정리

이 문서는 웹 앱의 로그인 유지 로직에서 자주 발생하는 토큰 상태별 동작을 정리합니다.
특히 `멘토(/mentor*)`, `커뮤니티(/community*)` 경로에서 발생 빈도가 높은 케이스를 우선 다룹니다.

## 1. 현재 인증 판단 경계

- 서버 진입(Next middleware): `refreshToken` 쿠키 유효성으로 1차 진입 제어
- 클라이언트 API 요청(axios interceptor): `accessToken` 유효성 + 필요 시 `/auth/reissue` 재발급
- 멘토 진입 페이지: 렌더 전에 access 유효성 확인 후 필요 시 재발급
- 채팅 소켓 연결: access 유효성 확인 후 연결

## 2. 토큰 상태별 케이스 매트릭스

| 케이스 | 토큰 상태 | 주로 발생 화면 | 기대 동작 | 현재 처리 |
| --- | --- | --- | --- | --- |
| A | refresh 없음, access 없음 | `/mentor`, `/community`, `/my` 직접 진입 | 즉시 로그인 이동 | middleware에서 로그인 리다이렉트 |
| B | refresh 만료/손상, access 없음 | `/mentor`, `/community` 새로고침 | 즉시 로그인 이동 | middleware에서 만료 refresh 차단 |
| C | refresh 유효, access 없음 | 멘토 첫 진입, 커뮤니티 글쓰기 직전 | 백그라운드 재발급 후 계속 진행 | interceptor/멘토 클라에서 재발급 |
| D | refresh 유효, access 만료 | 멘토 목록/채팅, 커뮤니티 작성/수정 | 만료 access 폐기 -> 재발급 -> 요청 진행 | 만료 access 선제 정리 + 재발급 |
| E | refresh 유효, access 유효하지만 서버에서 401(폐기/불일치) | 멘토 API, 커뮤니티 mutation | 재발급 1회 후 원요청 재시도 | response interceptor에서 1회 retry |
| F | refresh 유효, access 없음 + 동시 다중 요청 | 멘토 페이지 초기 렌더 | 재발급 요청 1회만 수행 | `reissuePromise` 락으로 중복 방지 |
| G | refresh 재발급 실패 | 멘토/커뮤니티 보호 요청 | 무한 재시도 금지 + 로그인 이동 | `refreshStatus=failed`로 차단 후 리다이렉트 |
| H | 멘토 채팅 소켓 연결 시 access 만료 | `/mentor/chat/*` | 만료 토큰으로 연결 시도 금지 | 소켓 훅에서 만료 access 차단 |

## 3. 멘토/커뮤니티에서 자주 터지는 이유

1. 두 경로 모두 보호 페이지로 분류되어 진입 시점의 인증 상태 흔들림이 바로 노출됨
2. 멘토는 초기 렌더 시 인증 의존 API가 많아, access 만료 시 체감 문제가 빠르게 발생
3. 커뮤니티는 목록 조회는 public이지만 작성/수정/댓글은 인증 API라, 클릭 시점에 문제 노출

## 4. 이번 보완 포인트

- middleware에서 `refreshToken`의 단순 존재가 아니라 **만료 여부까지 검사**
- axios request interceptor에서 **만료된 access를 즉시 폐기**하고 재발급 경로로 전환
- axios response interceptor에서 401 발생 시 **재발급 1회 후 원요청 재시도**
- 멘토 클라이언트 렌더 분기에서 **만료 access를 유효 토큰으로 취급하지 않도록 보정**
- 소켓 연결 훅에서 **만료 access로 연결 시도 금지**

## 5. 수동 검증 체크리스트

1. refresh 없음 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동
2. refresh 만료 상태에서 `/mentor`, `/community` 진입 시 즉시 `/login`으로 이동
3. refresh 유효 + access 없음 상태에서 `/mentor` 진입 시 화면 유지 후 정상 렌더
4. refresh 유효 + access 만료 상태에서 `/mentor` 진입 시 로그인 튕김 없이 복구
5. refresh 유효 + access 만료 상태에서 커뮤니티 글 작성/댓글 시도 시 재발급 후 정상 요청
6. access가 서버에서 무효 처리된 상태(401)에서 요청 시 1회 재시도 후 실패 시 로그인 이동

Loading