/** * 인증 이벤트 로거 * - 토큰 갱신/삭제/리다이렉트 발생 시 원인을 기록 * - localStorage에 저장하여 브라우저에서 확인 가능 * - 콘솔에서 window.__AUTH_LOG.show() 로 조회 */ const STORAGE_KEY = "auth_debug_log"; const MAX_ENTRIES = 200; export type AuthEventType = | "TOKEN_SET" | "TOKEN_REMOVED" | "TOKEN_EXPIRED_DETECTED" | "TOKEN_REFRESH_START" | "TOKEN_REFRESH_SUCCESS" | "TOKEN_REFRESH_FAIL" | "REDIRECT_TO_LOGIN" | "API_401_RECEIVED" | "API_401_RETRY" | "AUTH_CHECK_START" | "AUTH_CHECK_SUCCESS" | "AUTH_CHECK_FAIL" | "AUTH_GUARD_BLOCK" | "AUTH_GUARD_PASS" | "MENU_LOAD_FAIL" | "VISIBILITY_CHANGE" | "MIDDLEWARE_REDIRECT"; interface AuthLogEntry { timestamp: string; event: AuthEventType; detail: string; tokenStatus: string; url: string; stack?: string; } function getTokenSummary(): string { if (typeof window === "undefined") return "SSR"; const token = localStorage.getItem("authToken"); if (!token) return "없음"; try { const payload = JSON.parse(atob(token.split(".")[1])); const exp = payload.exp * 1000; const now = Date.now(); const remainMs = exp - now; if (remainMs <= 0) { return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`; } const remainMin = Math.round(remainMs / 60000); const remainHour = Math.floor(remainMin / 60); const min = remainMin % 60; return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`; } catch { return "파싱실패"; } } function getCallStack(): string { try { const stack = new Error().stack || ""; const lines = stack.split("\n").slice(3, 7); return lines.map((l) => l.trim()).join(" <- "); } catch { return ""; } } function writeLog(event: AuthEventType, detail: string) { if (typeof window === "undefined") return; const entry: AuthLogEntry = { timestamp: new Date().toISOString(), event, detail, tokenStatus: getTokenSummary(), url: window.location.pathname + window.location.search, stack: getCallStack(), }; // 콘솔 출력 (그룹) const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event); const logFn = isError ? console.warn : console.debug; logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`); // localStorage에 저장 try { const stored = localStorage.getItem(STORAGE_KEY); const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : []; logs.push(entry); // 최대 개수 초과 시 오래된 것 제거 while (logs.length > MAX_ENTRIES) { logs.shift(); } localStorage.setItem(STORAGE_KEY, JSON.stringify(logs)); } catch { // localStorage 공간 부족 등의 경우 무시 } } /** * 저장된 로그 조회 */ function getLogs(): AuthLogEntry[] { if (typeof window === "undefined") return []; try { const stored = localStorage.getItem(STORAGE_KEY); return stored ? JSON.parse(stored) : []; } catch { return []; } } /** * 로그 초기화 */ function clearLogs() { if (typeof window === "undefined") return; localStorage.removeItem(STORAGE_KEY); } /** * 로그를 테이블 형태로 콘솔에 출력 */ function showLogs(filter?: AuthEventType | "ERROR") { const logs = getLogs(); if (logs.length === 0) { console.log("[AuthLog] 저장된 로그가 없습니다."); return; } let filtered = logs; if (filter === "ERROR") { filtered = logs.filter((l) => ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event) ); } else if (filter) { filtered = logs.filter((l) => l.event === filter); } console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`); console.log("─".repeat(120)); filtered.forEach((entry, i) => { const time = entry.timestamp.replace("T", " ").split(".")[0]; console.log( `${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n` ); }); } /** * 마지막 리다이렉트 원인 조회 */ function getLastRedirectReason(): AuthLogEntry | null { const logs = getLogs(); for (let i = logs.length - 1; i >= 0; i--) { if (logs[i].event === "REDIRECT_TO_LOGIN") { return logs[i]; } } return null; } /** * 로그를 텍스트 파일로 다운로드 */ function downloadLogs() { if (typeof window === "undefined") return; const logs = getLogs(); if (logs.length === 0) { console.log("[AuthLog] 저장된 로그가 없습니다."); return; } const text = logs .map((entry, i) => { const time = entry.timestamp.replace("T", " ").split(".")[0]; return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`; }) .join("\n\n"); const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`; a.click(); URL.revokeObjectURL(url); console.log("[AuthLog] 로그 파일 다운로드 완료"); } // 전역 접근 가능하게 등록 if (typeof window !== "undefined") { (window as any).__AUTH_LOG = { show: showLogs, errors: () => showLogs("ERROR"), clear: clearLogs, download: downloadLogs, lastRedirect: getLastRedirectReason, raw: getLogs, }; } export const AuthLogger = { log: writeLog, getLogs, clearLogs, showLogs, downloadLogs, getLastRedirectReason, }; export default AuthLogger;