Skip to content
Merged
4 changes: 3 additions & 1 deletion src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ export const postReissue = async (): Promise<ReissueResponse> => {
const { data } = await axios.post<ReissueResponse>(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/reissue`,
{},
{ withCredentials: true },
{
withCredentials: true,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
return data;
};
Expand Down
52 changes: 50 additions & 2 deletions src/api/axios/axios.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import axios from "axios";
import { useLocalStorage } from "../../hooks/use-local-storage";
import { LOCAL_STORAGE_KEY } from "../../constants/key";
import { postLogout, postReissue } from "../auth";
import toast from "react-hot-toast";

const isDev = import.meta.env.DEV;
const BASE_URL = isDev ? "" : import.meta.env.VITE_API_BASE_URL;

export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
baseURL: BASE_URL,
headers: {
"Content-Type": "application/json",
},
});

// 요청 인터셉터: 모든 요청 전에 accessToken을 Authorization 헤더에 추가
axiosInstance.interceptors.request.use(
(config) => {
const { getItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
let accessToken = getItem();
const accessToken = getItem();

// accessToken이 존재하면 Authorization 헤더에 Bearer 토큰 형식으로 추가한다
if (accessToken) {
Expand All @@ -24,3 +32,43 @@ axiosInstance.interceptors.request.use(
// 요청 인터셉터가 실패하면, 에러 뿜음
(error) => Promise.reject(error),
);

// 응답 인터셉터
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;

// 401에러 & 재시도 X
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
const reissueRes = await postReissue();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
setItem(reissueRes.result.accessToken);
originalRequest.headers.Authorization = `Bearer ${reissueRes.result.accessToken}`;
if (reissueRes.result.needAgreement) {
// 약관동의 안 돼있을 경우 약관동의로 리다이렉트
window.location.href = "/agreement";
return Promise.reject(new Error("Agreement required"));
}
return axiosInstance(originalRequest);
Comment on lines +46 to +57

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 | 🔴 Critical

재발급된 accessToken이 localStorage에 저장되지 않음 - 중요 버그

토큰 재발급 성공 시 새 accessTokenoriginalRequest.headers에만 설정하고 localStorage에 저장하지 않습니다. 이로 인해:

  1. 해당 요청은 성공하지만, 다음 요청 시 request interceptor(line 21)가 여전히 이전/빈 토큰을 읽음
  2. 매 요청마다 401 → reissue 반복 또는 무한 루프 발생 가능
🐛 토큰 저장 로직 추가 제안
       try {
         const reissueRes = await postReissue();
+        const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
+        setItem(reissueRes.result.accessToken);

         originalRequest.headers.Authorization = `Bearer ${reissueRes.result.accessToken}`;
         if (reissueRes.result.needAgreement) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const reissueRes = await postReissue();
originalRequest.headers.Authorization = `Bearer ${reissueRes.result.accessToken}`;
if (reissueRes.result.needAgreement) {
// 약관동의 안 돼있을 경우 약관동의로 리다이렉트
window.location.href = "/agreement";
}
return axiosInstance(originalRequest);
try {
const reissueRes = await postReissue();
localStorage.setItem(LOCAL_STORAGE_KEY.accessToken, reissueRes.result.accessToken);
originalRequest.headers.Authorization = `Bearer ${reissueRes.result.accessToken}`;
if (reissueRes.result.needAgreement) {
// 약관동의 안 돼있을 경우 약관동의로 리다이렉트
window.location.href = "/agreement";
}
return axiosInstance(originalRequest);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/axios/axios.ts` around lines 47 - 55, When handling a successful
token reissue in the postReissue flow, you're only updating
originalRequest.headers.Authorization and not persisting the new accessToken,
which causes subsequent requests (and the request interceptor that reads
localStorage) to continue using the old token; modify the post-reissue branch
(where postReissue() is called and originalRequest is updated) to save
reissueRes.result.accessToken into the same storage the request interceptor
reads (e.g., localStorage.setItem with the existing access token key) and also
update any in-memory or axios default header (e.g.,
axiosInstance.defaults.headers.Authorization) so future requests use the new
token and avoid repeated 401→reissue loops.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +43 to +57

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

동시 401 응답 시 레이스 컨디션 발생 가능

_retry 플래그가 요청별로 설정되므로 여러 요청이 동시에 401을 받으면 postReissue()가 중복 호출됩니다. 이로 인해:

  1. 서버에 불필요한 토큰 재발급 요청 다수 발생
  2. 토큰 저장 시점에 따라 일부 요청이 이전 토큰으로 재시도될 수 있음
🔒 재발급 요청 큐잉 패턴 제안
+let isRefreshing = false;
+let failedQueue: Array<{
+  resolve: (token: string) => void;
+  reject: (error: unknown) => void;
+}> = [];
+
+const processQueue = (error: unknown, token: string | null = null) => {
+  failedQueue.forEach((prom) => {
+    if (error) {
+      prom.reject(error);
+    } else {
+      prom.resolve(token!);
+    }
+  });
+  failedQueue = [];
+};
+
 axiosInstance.interceptors.response.use(
   (response) => response,
   async (error) => {
     const originalRequest = error.config;

     if (error.response?.status === 401 && !originalRequest._retry) {
+      if (isRefreshing) {
+        return new Promise((resolve, reject) => {
+          failedQueue.push({ resolve, reject });
+        }).then((token) => {
+          originalRequest.headers.Authorization = `Bearer ${token}`;
+          return axiosInstance(originalRequest);
+        });
+      }
+
       originalRequest._retry = true;
+      isRefreshing = true;

       try {
         const reissueRes = await postReissue();
-        // ... token storage and retry
+        const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
+        setItem(reissueRes.result.accessToken);
+        processQueue(null, reissueRes.result.accessToken);
+        // ... rest of success handling
       } catch (refreshError) {
+        processQueue(refreshError, null);
         // ... error handling
+      } finally {
+        isRefreshing = false;
       }
     }
🧰 Tools
🪛 Biome (2.4.4)

[error] 50-50: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/axios/axios.ts` around lines 44 - 58, Multiple concurrent 401
responses can trigger duplicate postReissue() calls because _retry is
per-request; fix by introducing a shared reissuePromise (module-level) used in
the axios response interceptor so only the first 401 caller invokes
postReissue() and other handlers await that promise, then update local storage
via useLocalStorage(reissueRes.result.accessToken), set Authorization on
originalRequest, handle needAgreement redirect, and finally return
axiosInstance(originalRequest); modify the logic around originalRequest._retry,
postReissue, axiosInstance, and useLocalStorage in the interceptor to use this
queueing/shared-promise pattern.

} catch (refreshError) {
// 리프레시 토큰 만료
toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");

try {
await postLogout();
} catch (logoutError) {
toast.error("로그아웃 API 호출 실패");
} finally {
localStorage.removeItem(LOCAL_STORAGE_KEY.accessToken);
window.location.href = "/login";
}
}
Comment on lines +58 to +70

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 | 🟡 Minor

사용되지 않는 에러 변수 수정 필요

refreshErrorlogoutError가 정의되었지만 사용되지 않아 ESLint 오류가 발생합니다.

🔧 언더스코어 접두사 사용 또는 생략
-      } catch (refreshError) {
+      } catch (_refreshError) {
         // 리프레시 토큰 만료
         toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");

         try {
           await postLogout();
-        } catch (logoutError) {
+        } catch {
           toast.error("로그아웃 API 호출 실패");
         } finally {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (refreshError) {
// 리프레시 토큰 만료
toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");
try {
await postLogout();
} catch (logoutError) {
toast.error("로그아웃 API 호출 실패");
} finally {
localStorage.removeItem(LOCAL_STORAGE_KEY.accessToken);
window.location.href = "/login";
}
}
} catch (_refreshError) {
// 리프레시 토큰 만료
toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");
try {
await postLogout();
} catch {
toast.error("로그아웃 API 호출 실패");
} finally {
localStorage.removeItem(LOCAL_STORAGE_KEY.accessToken);
window.location.href = "/login";
}
}
🧰 Tools
🪛 ESLint

[error] 56-56: 'refreshError' is defined but never used.

(@typescript-eslint/no-unused-vars)


[error] 62-62: 'logoutError' is defined but never used.

(@typescript-eslint/no-unused-vars)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/axios/axios.ts` around lines 56 - 68, The catch blocks in
src/api/axios/axios.ts declare unused error variables refreshError and
logoutError causing ESLint errors; update the catches to either omit the
identifier (use plain `catch { ... }`) or rename to a prefixed unused variable
like `_refreshError` and `_logoutError` so the linter recognizes them as
intentionally unused, making the changes in the block handling the refresh flow
(the catch after the token refresh attempt) and the nested catch around
postLogout().

}
return Promise.reject(error);
},
);
5 changes: 0 additions & 5 deletions src/pages/auth/signup-page.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createBrowserRouter } from "react-router-dom";
import MobileLayout from "../layouts/mobile-layout";
import ProtectedRoute from "./protected-route";
import SignupPage from "../pages/auth/signup-page";
import LoginPage from "../pages/auth/login-page";
import HomePage from "../pages/home/home-page";
import MyPage from "../pages/mypage/my-page";
Expand Down Expand Up @@ -47,10 +46,6 @@ export const router = createBrowserRouter([
path: "login/callback",
element: <LoginRedirectPage />,
},
{
path: "signup",
element: <SignupPage />,
},
{
path: "agreement",
element: <TermsAgreementPage />,
Expand Down
19 changes: 16 additions & 3 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");

return {
plugins: [react(), tailwindcss()],
server: {
proxy: {
// '/api'로 시작하는 모든 요청을 프록시가 가로챔
"/api": {
target: env.VITE_API_BASE_URL,
changeOrigin: true,
},
},
},
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});