diff --git a/src/App.tsx b/src/App.tsx
index ca86023..2101c5e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,9 +2,13 @@ import "./App.css";
import { RouterProvider } from "react-router-dom";
+import { useTokenRefresh } from "@/hooks/auth/useTokenRefresh";
+
import { router } from "@/routes/Router";
function App() {
+ useTokenRefresh();
+
return (
<>
diff --git a/src/constants/auth.ts b/src/constants/auth.ts
deleted file mode 100644
index a42594d..0000000
--- a/src/constants/auth.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// 인증번호 입력 시간 상수화
-export const AUTH_TIMER_DURATION = 180;
diff --git a/src/hooks/auth/useEmailVerification.ts b/src/hooks/auth/useEmailVerification.ts
index ac39224..9355ee1 100644
--- a/src/hooks/auth/useEmailVerification.ts
+++ b/src/hooks/auth/useEmailVerification.ts
@@ -4,8 +4,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import type { z } from "zod";
-import { AUTH_TIMER_DURATION } from "@/constants/auth";
-
import { step01Schema } from "@/utils/validation";
import { useAuth } from "@/hooks/auth/useAuth";
@@ -43,14 +41,11 @@ export const useEmailVerification = ({
const watchedEmail = useWatch({ control, name: "email" });
const watchedCode = useWatch({ control, name: "code" });
- const { formattedTime, restart, stop, isExpired } = useTimer(
- AUTH_TIMER_DURATION,
- {
- onExpire: () => {
- toast.error("인증 시간이 만료되었습니다. 다시 시도해주세요.");
- },
+ const { formattedTime, restart, stop, isExpired } = useTimer(0, {
+ onExpire: () => {
+ toast.error("인증 시간이 만료되었습니다. 다시 시도해주세요.");
},
- );
+ });
const handleEditEmail = useCallback(() => {
setSendCode(false);
diff --git a/src/hooks/auth/useTokenRefresh.ts b/src/hooks/auth/useTokenRefresh.ts
new file mode 100644
index 0000000..e046b60
--- /dev/null
+++ b/src/hooks/auth/useTokenRefresh.ts
@@ -0,0 +1,25 @@
+import { useEffect } from "react";
+
+import { reissueToken } from "@/api/auth/auth";
+import useAuthStore from "@/store/useAuthStore";
+
+export const useTokenRefresh = () => {
+ const { setAccessToken, login, logout } = useAuthStore();
+
+ useEffect(() => {
+ const initAuth = async () => {
+ try {
+ const { data } = await reissueToken();
+ if (data.accessToken) {
+ // TODO: 재발급 성공 시 로그인 처리
+ login("user@example.com", data.accessToken);
+ }
+ } catch (error) {
+ console.log("토큰 재발급 실패:", error);
+ logout();
+ }
+ };
+
+ initAuth();
+ }, [login, logout, setAccessToken]);
+};
diff --git a/src/lib/axiosInstance.ts b/src/lib/axiosInstance.ts
index cde2d72..846cd2a 100644
--- a/src/lib/axiosInstance.ts
+++ b/src/lib/axiosInstance.ts
@@ -2,23 +2,26 @@ import axios, { type AxiosRequestConfig } from "axios";
import useAuthStore from "@/store/useAuthStore";
+const BASE_URL = import.meta.env.VITE_API_BASE_URL;
+
+if (!BASE_URL) {
+ throw new Error("API 서버 주소(VITE_API_BASE_URL)가 설정되지 않았습니다.");
+}
+
const axiosConfig: AxiosRequestConfig = {
- baseURL: import.meta.env.VITE_API_BASE_URL,
+ baseURL: BASE_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
};
-// 일반 API 요청용
export const axiosInstance = axios.create(axiosConfig);
-
-// 토큰 재발급 전용
export const authInstance = axios.create(axiosConfig);
axiosInstance.interceptors.request.use(
(config) => {
- const token = localStorage.getItem("accessToken");
+ const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -31,16 +34,28 @@ axiosInstance.interceptors.request.use(
);
let isRefreshing = false;
-let refreshSubscribers: ((token: string) => void)[] = [];
+interface IRefreshSubscriber {
+ resolve: (token: string) => void;
+ reject: (error: unknown) => void;
+}
+
+let refreshSubscribers: IRefreshSubscriber[] = [];
-// 대기 요청 처리
const onRefreshed = (accessToken: string) => {
- refreshSubscribers.forEach((callback) => callback(accessToken));
+ refreshSubscribers.forEach(({ resolve }) => resolve(accessToken));
refreshSubscribers = [];
};
-const addRefreshSubscriber = (callback: (token: string) => void) => {
- refreshSubscribers.push(callback);
+const onRefreshFailed = (error: unknown) => {
+ refreshSubscribers.forEach(({ reject }) => reject(error));
+ refreshSubscribers = [];
+};
+
+const addRefreshSubscriber = (
+ resolve: (token: string) => void,
+ reject: (error: unknown) => void,
+) => {
+ refreshSubscribers.push({ resolve, reject });
};
axiosInstance.interceptors.response.use(
@@ -50,28 +65,29 @@ axiosInstance.interceptors.response.use(
async (error) => {
const originalRequest = error.config;
- // 401 에러 감지
if (error.response?.status === 401) {
- // 재발급 진행 중: 대기열 등록
if (isRefreshing) {
- return new Promise((resolve) => {
- addRefreshSubscriber((accessToken: string) => {
- originalRequest.headers.Authorization = `Bearer ${accessToken}`;
- resolve(axiosInstance(originalRequest));
- });
+ return new Promise((resolve, reject) => {
+ addRefreshSubscriber(
+ (accessToken: string) => {
+ originalRequest.headers.Authorization = `Bearer ${accessToken}`;
+ resolve(axiosInstance(originalRequest));
+ },
+ (refreshError: unknown) => {
+ reject(refreshError);
+ },
+ );
});
}
- // 재발급 실패: 로그아웃
if (
originalRequest.url?.includes("/api/auth/reissue") ||
originalRequest._retry
) {
- localStorage.removeItem("accessToken");
+ useAuthStore.getState().logout();
return Promise.reject(error);
}
- // 첫 401: 재발급 시도
originalRequest._retry = true;
isRefreshing = true;
@@ -79,21 +95,18 @@ axiosInstance.interceptors.response.use(
const { data } = await authInstance.post("/api/auth/reissue");
const newAccessToken = data.data.accessToken;
- localStorage.setItem("accessToken", newAccessToken);
- // 대기 요청 일괄 처리
+ useAuthStore.getState().setAccessToken(newAccessToken);
onRefreshed(newAccessToken);
- // 현재 요청 재시도
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axiosInstance(originalRequest);
} catch (refreshError) {
- // 재발급 실패: 로그아웃
- console.error("Token reissue failed:", refreshError);
+ console.error("토큰 재발급 실패:", refreshError);
+ onRefreshFailed(refreshError);
useAuthStore.getState().logout();
return Promise.reject(refreshError);
} finally {
- // 상태 초기화
isRefreshing = false;
refreshSubscribers = [];
}
diff --git a/src/pages/auth/RedirectPage.tsx b/src/pages/auth/RedirectPage.tsx
index be011a8..2c6537a 100644
--- a/src/pages/auth/RedirectPage.tsx
+++ b/src/pages/auth/RedirectPage.tsx
@@ -19,16 +19,22 @@ export default function RedirectPage() {
if (parts.length === 2) return parts.pop()?.split(";").shift();
};
+ const deleteCookie = (name: string) => {
+ document.cookie =
+ name + "=; Max-Age=0; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;";
+ };
+
const accessToken = getCookie("access_token");
if (accessToken) {
- // TODO: Zustand 로그인 상태 업데이트 - 추후 내 정보 조회 API 연동 시 수정
+ // TODO: 로그인 상태 업데이트
login("social@user.com", accessToken);
+ deleteCookie("access_token");
+
toast.success("소셜 로그인되었습니다.");
navigate("/", { replace: true });
} else {
- console.error("No access token found in cookies.");
toast.error("소셜 로그인에 실패했습니다. 다시 시도해주세요.");
navigate("/login", { replace: true });
}
diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts
index bb19dff..e1f95d9 100644
--- a/src/store/useAuthStore.ts
+++ b/src/store/useAuthStore.ts
@@ -2,12 +2,14 @@ import { create } from "zustand";
interface IAuthState {
isLoggedIn: boolean;
+ accessToken: string | null;
email: string;
password: string;
socialId: number;
login: (email: string, accessToken: string) => void;
logout: () => void;
+ setAccessToken: (token: string) => void;
setEmail: (email: string) => void;
setPassword: (password: string) => void;
setSocialId: (socialId: number) => void;
@@ -16,22 +18,31 @@ interface IAuthState {
const useAuthStore = create((set) => ({
isLoggedIn: false,
+ accessToken: null,
email: "",
password: "",
socialId: -1,
+
login: (email, accessToken) => {
- localStorage.setItem("accessToken", accessToken);
- set({ isLoggedIn: true, email });
+ set({ isLoggedIn: true, email, accessToken });
},
logout: () => {
localStorage.removeItem("accessToken");
- set({ isLoggedIn: false, email: "", password: "", socialId: -1 });
+ localStorage.removeItem("refreshToken");
+ set({
+ isLoggedIn: false,
+ accessToken: null,
+ email: "",
+ password: "",
+ socialId: -1,
+ });
},
+ setAccessToken: (token) => set({ accessToken: token }),
setEmail: (email) => set({ email }),
setPassword: (password) => set({ password }),
setSocialId: (socialId) => set({ socialId }),
- resetAuth: () => set({ email: "", password: "" }),
+ resetAuth: () => set({ email: "", password: "", accessToken: null }),
}));
export default useAuthStore;
diff --git a/src/types/common/common.ts b/src/types/common/common.ts
index 8e3643a..1847dfe 100644
--- a/src/types/common/common.ts
+++ b/src/types/common/common.ts
@@ -11,7 +11,6 @@ export interface ICommonResponse {
data: T;
}
-// useCoreQuery 옵션 타입
export type TUseQueryCustomOptions<
TQueryFnData = unknown,
TData = TQueryFnData,
@@ -20,7 +19,6 @@ export type TUseQueryCustomOptions<
"queryKey" | "queryFn"
>;
-// useCoreMutation 옵션 타입
export type TUseMutationCustomOptions<
TData = unknown,
TVariables = unknown,