Files
vexplor/frontend/lib/api/client.ts
DDD1542 d43f0821ed refactor: Update authentication handling in authRoutes and useAuth hook
- Replaced the middleware `checkAuthStatus` with the `AuthController.checkAuthStatus` method in the authentication routes for improved clarity and structure.
- Simplified token validation logic in the `useAuth` hook by removing unnecessary checks for expired tokens, allowing the API client to handle token refresh automatically.
- Enhanced logging for authentication checks to provide clearer insights into the authentication flow and potential issues.
- Adjusted the handling of user authentication status to ensure consistency and reliability in user state management.

This refactor streamlines the authentication process and improves the overall maintainability of the authentication logic.
2026-03-05 11:51:05 +09:00

529 lines
16 KiB
TypeScript

import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
import { AuthLogger } from "@/lib/authLogger";
const authLog = (event: string, detail: string) => {
if (typeof window === "undefined") return;
try {
AuthLogger.log(event as any, detail);
} catch {
// 로거 실패해도 앱 동작에 영향 없음
}
};
// API URL 동적 설정 - 환경변수 우선 사용
const getApiBaseUrl = (): string => {
if (process.env.NEXT_PUBLIC_API_URL) {
return process.env.NEXT_PUBLIC_API_URL;
}
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
const currentPort = window.location.port;
if (currentHost === "v1.vexplor.com") {
return "https://api.vexplor.com/api";
}
if (
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
(currentPort === "9771" || currentPort === "3000")
) {
return "http://localhost:8080/api";
}
}
return "http://localhost:8080/api";
};
export const API_BASE_URL = getApiBaseUrl();
// 이미지 URL을 완전한 URL로 변환하는 함수
export const getFullImageUrl = (imagePath: string): string => {
if (!imagePath) return "";
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
return imagePath;
}
if (imagePath.startsWith("/uploads")) {
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
if (currentHost === "v1.vexplor.com") {
return `https://api.vexplor.com${imagePath}`;
}
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
return `http://localhost:8080${imagePath}`;
}
}
const baseUrl = API_BASE_URL.replace(/\/api$/, "");
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
return `${baseUrl}${imagePath}`;
}
return imagePath;
}
return imagePath;
};
// ============================================
// JWT 토큰 관리 유틸리티
// ============================================
const TokenManager = {
getToken: (): string | null => {
if (typeof window !== "undefined") {
return localStorage.getItem("authToken");
}
return null;
},
setToken: (token: string): void => {
if (typeof window !== "undefined") {
localStorage.setItem("authToken", token);
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
}
},
removeToken: (): void => {
if (typeof window !== "undefined") {
localStorage.removeItem("authToken");
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
}
},
isTokenExpired: (token: string): boolean => {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.exp * 1000 < Date.now();
} catch {
return true;
}
},
// 만료 30분 전부터 갱신 대상
isTokenExpiringSoon: (token: string): boolean => {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const expiryTime = payload.exp * 1000;
const currentTime = Date.now();
const thirtyMinutes = 30 * 60 * 1000;
return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime;
} catch {
return false;
}
},
getTimeUntilExpiry: (token: string): number => {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.exp * 1000 - Date.now();
} catch {
return 0;
}
},
};
// ============================================
// 토큰 갱신 로직 (중복 요청 방지)
// ============================================
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
let failedRefreshSubscribers: Array<(error: Error) => void> = [];
// 갱신 대기 중인 요청들에게 새 토큰 전달
const onTokenRefreshed = (newToken: string) => {
refreshSubscribers.forEach((callback) => callback(newToken));
refreshSubscribers = [];
failedRefreshSubscribers = [];
};
// 갱신 실패 시 대기 중인 요청들에게 에러 전달
const onRefreshFailed = (error: Error) => {
failedRefreshSubscribers.forEach((callback) => callback(error));
refreshSubscribers = [];
failedRefreshSubscribers = [];
};
// 갱신 완료 대기 Promise 등록
const waitForTokenRefresh = (): Promise<string> => {
return new Promise((resolve, reject) => {
refreshSubscribers.push(resolve);
failedRefreshSubscribers.push(reject);
});
};
const refreshToken = async (): Promise<string | null> => {
try {
const currentToken = TokenManager.getToken();
if (!currentToken) {
authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음");
return null;
}
authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}`);
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${currentToken}`,
},
},
);
if (response.data?.success && response.data?.data?.token) {
const newToken = response.data.data.token;
TokenManager.setToken(newToken);
authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료");
return newToken;
}
authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`);
return null;
} catch (err: any) {
authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`);
return null;
}
};
// ============================================
// 자동 토큰 갱신 (백그라운드)
// ============================================
let tokenRefreshTimer: ReturnType<typeof setInterval> | null = null;
const startAutoRefresh = (): void => {
if (typeof window === "undefined") return;
if (tokenRefreshTimer) {
clearInterval(tokenRefreshTimer);
}
// 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축)
tokenRefreshTimer = setInterval(
async () => {
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
await refreshToken();
}
},
5 * 60 * 1000,
);
// 페이지 로드 시 즉시 확인
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
refreshToken();
}
};
// 페이지 포커스 복귀 시 토큰 갱신 체크 (백그라운드 탭 throttle 대응)
const setupVisibilityRefresh = (): void => {
if (typeof window === "undefined") return;
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
const token = TokenManager.getToken();
if (!token) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음");
return;
}
if (TokenManager.isTokenExpired(token)) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도");
refreshToken().then((newToken) => {
if (!newToken) {
authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트");
redirectToLogin();
}
});
} else if (TokenManager.isTokenExpiringSoon(token)) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도");
refreshToken();
}
}
});
};
// 사용자 활동 감지 기반 갱신
const setupActivityBasedRefresh = (): void => {
if (typeof window === "undefined") return;
let lastActivityCheck = Date.now();
const activityThreshold = 5 * 60 * 1000; // 5분
const handleActivity = (): void => {
const now = Date.now();
if (now - lastActivityCheck > activityThreshold) {
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
refreshToken();
}
lastActivityCheck = now;
}
};
["click", "keydown"].forEach((event) => {
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
window.addEventListener(
event,
() => {
if (!throttleTimer) {
throttleTimer = setTimeout(() => {
handleActivity();
throttleTimer = null;
}, 2000);
}
},
{ passive: true },
);
});
};
// 로그인 페이지 리다이렉트 (중복 방지)
let isRedirecting = false;
const redirectToLogin = (): void => {
if (typeof window === "undefined") return;
if (isRedirecting) return;
if (window.location.pathname === "/login") return;
authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`);
isRedirecting = true;
TokenManager.removeToken();
window.location.href = "/login";
};
// 클라이언트 사이드에서 자동 갱신 시작
if (typeof window !== "undefined") {
startAutoRefresh();
setupVisibilityRefresh();
setupActivityBasedRefresh();
}
// ============================================
// Axios 인스턴스 생성
// ============================================
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
"Content-Type": "application/json",
},
withCredentials: true,
});
// ============================================
// 요청 인터셉터
// ============================================
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const token = TokenManager.getToken();
if (token) {
if (!TokenManager.isTokenExpired(token)) {
config.headers.Authorization = `Bearer ${token}`;
} else {
authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`);
const newToken = await refreshToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
} else {
// 갱신 실패 시 인증 없는 요청을 보내면 TOKEN_MISSING 401 → 즉시 redirectToLogin 연쇄 장애
// 요청 자체를 차단하여 호출부의 try/catch에서 처리하도록 함
authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 요청 차단 (${config.url})`);
return Promise.reject(new Error("TOKEN_REFRESH_FAILED"));
}
}
}
// FormData 요청 시 Content-Type 자동 처리
if (config.data instanceof FormData) {
delete config.headers["Content-Type"];
}
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청)
if (config.method?.toUpperCase() === "GET") {
let currentLang = "KR";
if (typeof window !== "undefined") {
if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) {
currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG;
} else {
const storedLocale = localStorage.getItem("userLocale");
if (storedLocale) {
currentLang = storedLocale;
}
}
}
if (config.params) {
config.params.userLang = currentLang;
} else {
config.params = { userLang: currentLang };
}
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
// ============================================
// 응답 인터셉터
// ============================================
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
// 백엔드에서 보내주는 새로운 토큰 처리
const newToken = response.headers["x-new-token"];
if (newToken) {
TokenManager.setToken(newToken);
}
return response;
},
async (error: AxiosError) => {
const status = error.response?.status;
const url = error.config?.url;
// 409 에러 (중복 데이터) - 조용하게 처리
if (status === 409) {
if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) {
return Promise.reject(error);
}
return Promise.reject(error);
}
// 채번 규칙 미리보기 API 실패는 조용하게 처리
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
return Promise.reject(error);
}
// 401 에러 처리 (핵심 개선)
if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string; details?: string } };
const errorCode = errorData?.error?.code;
const errorDetails = errorData?.error?.details;
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`);
// 이미 재시도한 요청이면 로그인으로
if (originalRequest?._retry) {
authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
// 토큰 만료 에러 → 갱신 후 재시도
if (errorCode === "TOKEN_EXPIRED" && originalRequest) {
if (!isRefreshing) {
isRefreshing = true;
originalRequest._retry = true;
try {
authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`);
const newToken = await refreshToken();
if (newToken) {
isRefreshing = false;
onTokenRefreshed(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient.request(originalRequest);
} else {
isRefreshing = false;
onRefreshFailed(new Error("토큰 갱신 실패"));
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
} catch (refreshError) {
isRefreshing = false;
onRefreshFailed(refreshError as Error);
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
} else {
try {
const newToken = await waitForTokenRefresh();
originalRequest._retry = true;
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient.request(originalRequest);
} catch {
return Promise.reject(error);
}
}
}
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
redirectToLogin();
}
return Promise.reject(error);
},
);
// ============================================
// 공통 타입 및 헬퍼
// ============================================
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
message?: string;
errorCode?: string;
}
export interface UserInfo {
userId: string;
userName: string;
deptName?: string;
companyCode?: string;
userType?: string;
userTypeName?: string;
email?: string;
photo?: string;
locale?: string;
isAdmin?: boolean;
}
export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
try {
const response = await apiClient.get("/auth/me");
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
};
export const apiCall = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE",
url: string,
data?: unknown,
): Promise<ApiResponse<T>> => {
try {
const response = await apiClient.request({
method,
url,
data,
});
return response.data;
} catch (error: unknown) {
const axiosError = error as AxiosError;
return {
success: false,
message:
(axiosError.response?.data as { message?: string })?.message ||
axiosError.message ||
"알 수 없는 오류가 발생했습니다.",
errorCode: (axiosError.response?.data as { errorCode?: string })?.errorCode,
};
}
};