Skip to content
Merged
6 changes: 3 additions & 3 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import type { LogoutResponse, ReissueResponse } from "../types/auth";
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;
};

export const postLogout = async (): Promise<LogoutResponse> => {
const { data } = await axios.post<LogoutResponse>(
`${import.meta.env.VITE_API_BASE_URL}/api/auth/logout`,
{},
{ withCredentials: true },
);
return data;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
53 changes: 51 additions & 2 deletions src/api/axios/axios.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
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,
withCredentials: true,
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 +33,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(reissueRes.result.accessToken);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로컬스토리지의 accessToken 값을 갱신해줘야 해서 reissueRes.result.accessToken 말고 LOCAL_STORAGE_KEY.accessToken 이 들어가야 할 것 같습니다!!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로컬스토리지의 권한없는 accessToken 값을 postReissue에서 받아온 새로운 accessToken으로 갱신시키려고reissueRes.result.accessToken으로 넣어준건데 혹시 내가 잘못 이해하고 있는건가?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

useLocalStorage() 괄호 안에는 localStorage에 들어갈 key값, setItem()괄호 안에는 실제 저장할 value값이 들어가는 방식이야
현재 권한 없는 기존 accessTokenLOCAL_STORAGE_KEY.accessToken 키에 저장되어 있으니 이 키 값의 setItem()을 불러와서 밑에 줄에서 setItem(reissueRes.result.accessToken);value값을 교체해서 갱신!

지금 상태로 하면 key값도 실제 토큰, value값도 실제 토큰이 돼
그래서 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.
});