토큰 자동 갱신 기능 추가 및 에러 처리 개선

This commit is contained in:
kjs
2025-12-05 17:46:22 +09:00
parent 47552bc35c
commit cbe5cb4607
3 changed files with 199 additions and 20 deletions

View File

@@ -8,6 +8,7 @@ import path from "path";
import config from "./config/environment";
import { logger } from "./utils/logger";
import { errorHandler } from "./middleware/errorHandler";
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
// 라우터 임포트
import authRoutes from "./routes/authRoutes";
@@ -168,6 +169,10 @@ const limiter = rateLimit({
});
app.use("/api/", limiter);
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
app.use("/api/", refreshTokenIfNeeded);
// 헬스 체크 엔드포인트
app.get("/health", (req, res) => {
res.status(200).json({

View File

@@ -54,16 +54,17 @@ export const authenticateToken = (
next();
} catch (error) {
logger.error(
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
// 토큰 만료 에러인지 확인
const isTokenExpired = errorMessage.includes("만료");
res.status(401).json({
success: false,
error: {
code: "INVALID_TOKEN",
details:
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
details: errorMessage || "토큰 검증에 실패했습니다.",
},
});
}

View File

@@ -58,6 +58,18 @@ const TokenManager = {
return null;
},
setToken: (token: string): void => {
if (typeof window !== "undefined") {
localStorage.setItem("authToken", token);
}
},
removeToken: (): void => {
if (typeof window !== "undefined") {
localStorage.removeItem("authToken");
}
},
isTokenExpired: (token: string): boolean => {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
@@ -66,8 +78,147 @@ const TokenManager = {
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; // 30분
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 refreshPromise: Promise<string | null> | null = null;
// 토큰 갱신 함수
const refreshToken = async (): Promise<string | null> => {
// 이미 갱신 중이면 기존 Promise 반환
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const currentToken = TokenManager.getToken();
if (!currentToken) {
return null;
}
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);
console.log("[TokenManager] 토큰 갱신 성공");
return newToken;
}
return null;
} catch (error) {
console.error("[TokenManager] 토큰 갱신 실패:", error);
return null;
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
return refreshPromise;
};
// 자동 토큰 갱신 타이머
let tokenRefreshTimer: NodeJS.Timeout | null = null;
// 자동 토큰 갱신 시작
const startAutoRefresh = (): void => {
if (typeof window === "undefined") return;
// 기존 타이머 정리
if (tokenRefreshTimer) {
clearInterval(tokenRefreshTimer);
}
// 10분마다 토큰 상태 확인
tokenRefreshTimer = setInterval(async () => {
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
await refreshToken();
}
}, 10 * 60 * 1000); // 10분
// 페이지 로드 시 즉시 확인
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
refreshToken();
}
};
// 사용자 활동 감지 및 토큰 갱신
const setupActivityBasedRefresh = (): void => {
if (typeof window === "undefined") return;
let lastActivity = Date.now();
const activityThreshold = 5 * 60 * 1000; // 5분
const handleActivity = (): void => {
const now = Date.now();
// 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인
if (now - lastActivity > activityThreshold) {
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
refreshToken();
}
}
lastActivity = now;
};
// 사용자 활동 이벤트 감지
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
// 너무 잦은 호출 방지를 위해 throttle 적용
let throttleTimer: NodeJS.Timeout | null = null;
window.addEventListener(event, () => {
if (!throttleTimer) {
throttleTimer = setTimeout(() => {
handleActivity();
throttleTimer = null;
}, 1000); // 1초 throttle
}
}, { passive: true });
});
};
// 클라이언트 사이드에서 자동 갱신 시작
if (typeof window !== "undefined") {
startAutoRefresh();
setupActivityBasedRefresh();
}
// Axios 인스턴스 생성
export const apiClient = axios.create({
baseURL: API_BASE_URL,
@@ -138,9 +289,15 @@ apiClient.interceptors.request.use(
// 응답 인터셉터
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
// 백엔드에서 보내주는 새로운 토큰 처리
const newToken = response.headers["x-new-token"];
if (newToken) {
TokenManager.setToken(newToken);
console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료");
}
return response;
},
(error: AxiosError) => {
async (error: AxiosError) => {
const status = error.response?.status;
const url = error.config?.url;
@@ -153,7 +310,7 @@ apiClient.interceptors.response.use(
}
// 일반 409 에러는 간단한 로그만 출력
console.warn("⚠️ 데이터 중복:", {
console.warn("데이터 중복:", {
url: url,
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
});
@@ -161,7 +318,7 @@ apiClient.interceptors.response.use(
}
// 다른 에러들은 기존처럼 상세 로그 출력
console.error("API 응답 오류:", {
console.error("API 응답 오류:", {
status: status,
statusText: error.response?.statusText,
url: url,
@@ -170,24 +327,40 @@ apiClient.interceptors.response.use(
headers: error.config?.headers,
});
// 401 에러 시 상세 정보 출력
if (status === 401) {
console.error("🚨 401 Unauthorized 오류 상세 정보:", {
// 401 에러 처리
if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string } };
const errorCode = errorData?.error?.code;
console.warn("[Auth] 401 오류 발생:", {
url: url,
method: error.config?.method,
headers: error.config?.headers,
requestData: error.config?.data,
responseData: error.response?.data,
errorCode: errorCode,
token: TokenManager.getToken() ? "존재" : "없음",
});
}
// 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트
if (status === 401 && typeof window !== "undefined") {
localStorage.removeItem("authToken");
// 토큰 만료 에러인 경우 갱신 시도
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) {
console.log("[Auth] 토큰 만료, 갱신 시도...");
originalRequest._retry = true;
try {
const newToken = await refreshToken();
if (newToken && originalRequest) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return apiClient.request(originalRequest);
}
} catch (refreshError) {
console.error("[Auth] 토큰 갱신 실패:", refreshError);
}
}
// 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃
TokenManager.removeToken();
// 로그인 페이지가 아닌 경우에만 리다이렉트
if (window.location.pathname !== "/login") {
console.log("[Auth] 로그인 페이지로 리다이렉트");
window.location.href = "/login";
}
}