Skip to content

[FEAT] 프록시 설정 & 리프레시 토큰 인터셉터 추가 - #222

Merged
tablemin03 merged 11 commits into
developfrom
feature/auth-proxy/#219
Mar 16, 2026
Merged

[FEAT] 프록시 설정 & 리프레시 토큰 인터셉터 추가#222
tablemin03 merged 11 commits into
developfrom
feature/auth-proxy/#219

Conversation

@tablemin03

@tablemin03 tablemin03 commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

PR 제목

[FEAT] 프록시 설정 & 리프레시 토큰 인터셉터 추가

PR을 한 이유

  • CORS 에러 방지를 위한 프록시 설정 -> 개발환경에서도 api 접근이 가능하도록
  • 리프레시 토큰 인터셉터 추가 & 로그아웃 후 로그인으로 리다이렉트 하도록 로직 개선

#️⃣연관된 이슈

closed #219

📝작업 내용

  • proxy 설정을 했습니다. env파일 그대로 냅두시고 사용하시면 됩니다.

  • accessToken이 없을 때, refreshToken을 이용해서 reissue를 하도록 했습니다.

만약 리프레시 토큰을 통해 액세스 토큰을 받았을 경우에
if(약관동의 안돼있을 경우) => 약관동의 화면으로 리다이렉트

리프레시 토큰이 없을 경우
로그아웃 진행 -> 로그아웃 실패시 세션을 제거하고 로그인 페이지로 리다이렉트

💬리뷰 요구사항(선택)

코드리뷰, 컨벤션, 사용자 플로우가 정확한지 확인해주시면 감사하겠습니다.

Summary by CodeRabbit

  • 새로운 기능

    • 만료된 세션 자동 재발급 및 원래 요청 재시도 처리
    • 세션 만료 시 사용자 대상 알림(토스트) 표시
  • 변경 사항

    • 회원가입 페이지 및 관련 라우트 제거(더 이상 /signup 접근 불가)
    • 환경(mode) 기반 API 엔드포인트 및 개발용 프록시 구성 지원 추가
    • 기본 요청 헤더(Content-Type: application/json) 및 인증 토큰 자동 첨부 처리 추가

@vercel

vercel Bot commented Mar 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
urisik-frontend Ready Ready Preview, Comment Mar 10, 2026 11:32am

@coderabbitai

coderabbitai Bot commented Mar 6, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

환경별 프록시 설정이 도입되고 axios 인스턴스에 기본 헤더·withCredentials 및 리프레시 토큰 응답 인터셉터가 추가되었습니다. 인증 관련 호출 형식 일부가 변경되었고, 회원가입 페이지와 관련 라우트가 삭제되었습니다.

Changes

Cohort / File(s) Summary
Axios 인스턴스 및 인터셉터
src/api/axios/axios.ts
환경 기반 BASE_URL 계산 및 기본 Content-Type: application/json 추가. 요청 인터셉터에서 로컬 access token을 Authorization에 주입. 응답 인터셉터에서 401 발생 시 postReissue로 토큰 재발급 시도 → 성공 시 토큰 갱신·원래 요청 재시도(또는 needAgreement 리다이렉트), 실패 시 세션 만료 처리(toast, postLogout, 토큰 삭제, /login 리다이렉트).
인증 API 호출 변경
src/api/auth.ts
postReissue 호출 페이로드 형식 변경(객체 리터럴에 withCredentials: true 전달). postLogout 호출 사용은 유지되나 호출 방식과 관련 주석/형식 소폭 변경.
회원가입 삭제
src/pages/auth/signup-page.tsx, src/routes/index.tsx
SignupPage 컴포넌트 내용 삭제 및 공개 라우트에서 /signup 경로 및 관련 import 제거.
Vite 프록시 설정 변경
vite.config.ts
loadEnv 도입 및 defineConfig(({ mode }) => {...}) 형태로 변경. /api 프록시 타깃을 env.VITE_API_BASE_URL로 동적 설정하도록 전환.

Sequence Diagram

sequenceDiagram
    participant Client as 클라이언트
    participant Axios as axiosInstance
    participant API as API서버
    participant Auth as 인증엔드포인트

    Client->>Axios: 요청 (Authorization 헤더 포함)
    Axios->>API: 실제 HTTP 요청
    API-->>Axios: 401 Unauthorized
    Axios->>Auth: postReissue({ withCredentials: true })
    alt 갱신 성공
        Auth-->>Axios: 새 액세스 토큰
        Axios->>Axios: 로컬 토큰 갱신 및 Authorization 설정
        Axios->>API: 원래 요청 재시도
        API-->>Axios: 200 OK
        Axios-->>Client: 응답 전달
    else needAgreement 필요
        Auth-->>Axios: { needAgreement: true }
        Axios-->>Client: /agreement로 리다이렉트
    else 갱신 실패
        Axios->>Auth: postLogout({ withCredentials: true })
        Auth-->>Axios: 로그아웃 완료
        Axios-->>Client: 세션만료 토스트 + /login으로 리다이렉트
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 토큰이 꺼져도 괜찮아요
살짝 폴짝 재발급할게요
프록시 길로 개발자 달려가고
라우트는 가벼워졌네, 경쾌한 발걸음
당근 한 입에 다시 요청을 띄워요 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning src/pages/auth/signup-page.tsx와 src/routes/index.tsx의 삭제는 signup 라우트 제거로, 이슈 #219의 프록시 및 토큰 인터셉터 범위를 벗어나는 변경입니다. signup 페이지/라우트 삭제의 필요성을 명확히 설명하거나 관련 이슈를 추가 링크하시기 바랍니다. 현재로서는 요청된 기능 범위 외의 변경으로 보입니다.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 변경 내용의 핵심을 명확하게 설명하고 있으며, 프록시 설정과 리프레시 토큰 인터셉터 추가라는 주요 변경 사항을 정확히 반영합니다.
Description check ✅ Passed PR 설명이 제공된 템플릿의 모든 필수 섹션(PR 제목, 이유, 연관 이슈, 작업 내용, 리뷰 요구사항)을 포함하고 있으며, 각 섹션이 충분히 설명되어 있습니다.
Linked Issues check ✅ Passed PR의 코드 변경이 이슈 #219의 모든 주요 요구사항을 충족합니다: 프록시 설정 추가(vite.config.ts), 리프레시 토큰 인터셉터 구현(axios.ts), accessToken 재발급 및 리다이렉트 로직 추가.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/auth-proxy/#219

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/axios/axios.ts`:
- Around line 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().
- Around line 51-55: When reissueRes.result.needAgreement is true, stop the
interceptor from retrying the original request after assigning
window.location.href; change the flow in the interceptor (around reissueRes,
needAgreement, originalRequest, axiosInstance) to perform the redirect and then
immediately return a rejected Promise (e.g., Promise.reject(new
Error('redirect-to-agreement'))) or otherwise early-return so
axiosInstance(originalRequest) is not called; ensure the rejection provides
clear context for callers so the network retry is prevented.
- Around line 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.
- Line 48: postReissue and postLogout call raw axios without including cookies,
so refreshToken stored in an HttpOnly cookie isn't sent and reissue handling
fails; update the implementations of postReissue and postLogout (in
src/api/auth.ts) to either call the shared axiosInstance or pass {
withCredentials: true } as the third argument to axios.post so cookies are
included (refer to the postReissue and postLogout function names and the usage
site where reissueRes = await postReissue()).

In `@vite.config.ts`:
- Around line 6-20: postReissue and postLogout currently call raw axios.post and
bypass the axiosInstance which has withCredentials:true, causing cookie-based
refresh/logout to fail; update postReissue and postLogout in src/api/auth.ts to
either call the shared axiosInstance (e.g., axiosInstance.post) or pass {
withCredentials: true } in the axios.post options so cookies are sent
(alternatively read the refresh token from localStorage and include it in the
request body if you intentionally avoid cookies); ensure the changes reference
the postReissue and postLogout functions and the axiosInstance symbol so the
credential behavior is consistent with your response interceptor.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5248f06-0813-4e65-a4ca-f5142d5f72fd

📥 Commits

Reviewing files that changed from the base of the PR and between 1980ef9 and 43c12fd.

📒 Files selected for processing (5)
  • src/api/auth.ts
  • src/api/axios/axios.ts
  • src/pages/auth/signup-page.tsx
  • src/routes/index.tsx
  • vite.config.ts
💤 Files with no reviewable changes (3)
  • src/pages/auth/signup-page.tsx
  • src/api/auth.ts
  • src/routes/index.tsx

Comment thread src/api/axios/axios.ts
Comment on lines +47 to +55
try {
const reissueRes = await postReissue();

originalRequest.headers.Authorization = `Bearer ${reissueRes.result.accessToken}`;
if (reissueRes.result.needAgreement) {
// 약관동의 안 돼있을 경우 약관동의로 리다이렉트
window.location.href = "/agreement";
}
return axiosInstance(originalRequest);

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 src/api/axios/axios.ts
Comment thread src/api/axios/axios.ts
Comment thread src/api/axios/axios.ts
Comment on lines +56 to +68
} catch (refreshError) {
// 리프레시 토큰 만료
toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");

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

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().

Comment thread vite.config.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/api/axios/axios.ts (1)

21-22: 🧹 Nitpick | 🔵 Trivial

useLocalStorage 네이밍이 React 훅처럼 보이지만 실제 훅이 아님

Biome에서 조건부 훅 호출 경고가 발생하는 이유는 useLocalStorage가 React 훅 네이밍 컨벤션(use*)을 따르기 때문입니다. 실제로는 일반 유틸리티 함수이므로 혼란을 줄 수 있습니다.

향후 createLocalStorage 또는 localStorageUtils와 같은 이름으로 변경하면 린트 경고도 해결되고 의도가 명확해집니다.

Also applies to: 50-51

🤖 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 21 - 22, The helper named
useLocalStorage is misleading as it looks like a React hook; rename the utility
(e.g., createLocalStorage or localStorageUtils) and update all call sites:
replace useLocalStorage(LOCAL_STORAGE_KEY.accessToken) and destructuring of
getItem (and any setItem) with the new name, update the import/export where
useLocalStorage is defined, and adjust other occurrences (lines referenced
around 50-51) so accessToken = getItem() keeps working under the new utility
name.
♻️ Duplicate comments (1)
src/api/axios/axios.ts (1)

50-51: ⚠️ Potential issue | 🔴 Critical

useLocalStorage에 잘못된 키가 전달됨 - 치명적 버그

useLocalStorage의 인자로 LOCAL_STORAGE_KEY.accessToken(키) 대신 reissueRes.result.accessToken(토큰 값)을 전달하고 있습니다. 이로 인해:

  1. 토큰 값이 localStorage의 키로 사용되어 잘못된 위치에 저장됨
  2. 다음 요청 시 request interceptor(line 21)가 LOCAL_STORAGE_KEY.accessToken 키에서 토큰을 찾지 못함
  3. 매 요청마다 401 → reissue 반복 또는 인증 실패 발생
🐛 올바른 키 사용 수정
-        const { setItem } = useLocalStorage(reissueRes.result.accessToken);
+        const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
         setItem(reissueRes.result.accessToken);
🤖 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 50 - 51, The code calls useLocalStorage
with the token value instead of the storage key; change the call so
useLocalStorage receives LOCAL_STORAGE_KEY.accessToken (not
reissueRes.result.accessToken), then call setItem(reissueRes.result.accessToken)
to persist the token; update any nearby usage that reads from localStorage (the
request interceptor that expects LOCAL_STORAGE_KEY.accessToken) to rely on that
key so token retrieval and reissue flow work correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/axios/axios.ts`:
- Around line 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.

---

Outside diff comments:
In `@src/api/axios/axios.ts`:
- Around line 21-22: The helper named useLocalStorage is misleading as it looks
like a React hook; rename the utility (e.g., createLocalStorage or
localStorageUtils) and update all call sites: replace
useLocalStorage(LOCAL_STORAGE_KEY.accessToken) and destructuring of getItem (and
any setItem) with the new name, update the import/export where useLocalStorage
is defined, and adjust other occurrences (lines referenced around 50-51) so
accessToken = getItem() keeps working under the new utility name.

---

Duplicate comments:
In `@src/api/axios/axios.ts`:
- Around line 50-51: The code calls useLocalStorage with the token value instead
of the storage key; change the call so useLocalStorage receives
LOCAL_STORAGE_KEY.accessToken (not reissueRes.result.accessToken), then call
setItem(reissueRes.result.accessToken) to persist the token; update any nearby
usage that reads from localStorage (the request interceptor that expects
LOCAL_STORAGE_KEY.accessToken) to rely on that key so token retrieval and
reissue flow work correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c35377ab-3f97-413e-88ef-ba4a36ad1041

📥 Commits

Reviewing files that changed from the base of the PR and between 43c12fd and b55bb42.

📒 Files selected for processing (2)
  • src/api/auth.ts
  • src/api/axios/axios.ts

Comment thread src/api/axios/axios.ts
Comment on lines +44 to +58
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
const reissueRes = await postReissue();

const { setItem } = useLocalStorage(reissueRes.result.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);

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.

@shooukie shooukie left a comment

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.

토큰 재발급 이후 약관 동의 여부까지 체크하는 로직 너무 좋습니다! 컨벤션도 잘 지켜진 것 같아요 고생하셨습니다!!

Comment thread src/api/axios/axios.ts Outdated
try {
const reissueRes = await postReissue();

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이 들어가야 할 것 같아

Comment thread src/api/axios/axios.ts Outdated

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.

이부분은 pr전 기존 코드이긴 하지만, 이번에 파일 수정하는 김에 const로 바꿔도 괜찮을 것 같아요! 여기서 선언한 accessToken이 재할당되지 않는 것 같아서요!!

@shooukie

shooukie commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai 내가 헷갈려서 그러는데 axiosInstance안에서 postReissue로 재발급을 하는데 이때 401에러가 나면 무한루프가 일어날 수 있어? postReissue 안에서도 axiosInstance를 사용해서 무한루프가 나는지 아니면 괜찮은지 알려줘!!

@coderabbitai

coderabbitai Bot commented Mar 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cat src/api/axios/axios.ts
echo "---"
cat src/api/auth.ts

Length of output: 2695


@shooukie 네, 무한루프가 발생할 수 있습니다. 코드를 직접 확인했습니다.


🔍 문제 분석

현재 코드에서 _retry 플래그의 역할을 먼저 확인해야 합니다:

if (error.response?.status === 401 && !originalRequest._retry) {
  originalRequest._retry = true; // 여기서 _retry를 세팅
  // ...
  await postReissue(); // 이 안에서 axiosInstance를 다시 사용!
}

_retry 플래그는 최초에 실패한 요청(originalRequest) 에만 세팅됩니다. 즉, /api/auth/reissue 요청은 별개의 새로운 config 객체이기 때문에 _retry가 없습니다.

무한루프 시나리오는 다음과 같습니다:

1. 어떤 API 요청 → 401 발생
2. 인터셉터: originalRequest._retry = true → postReissue() 호출
3. postReissue() → axiosInstance.post("/api/auth/reissue") → 401 발생
4. 인터셉터: 이 reissue 요청의 config에는 _retry가 없음 → 또 postReissue() 호출
5. 무한루프 ♾️

postLogout()axiosInstance를 사용하므로 logout이 401을 반환해도 동일한 문제가 생깁니다.


✅ 해결 방법

방법 1 (권장): 인터셉터 내부에서 reissue/logout URL을 건너뛰기

if (
  error.response?.status === 401 &&
  !originalRequest._retry &&
  !originalRequest.url?.includes("/api/auth/reissue") && // 추가
  !originalRequest.url?.includes("/api/auth/logout")    // 추가
) {

방법 2: reissue/logout에 별도의 순수 axios 인스턴스 사용

// auth.ts
import axios from "axios"; // axiosInstance 대신 순수 axios 사용

const plainAxios = axios.create({
  baseURL: ...,
  withCredentials: true,
});

export const postReissue = async (): Promise<ReissueResponse> => {
  const { data } = await plainAxios.post<ReissueResponse>("/api/auth/reissue");
  return data;
};

방법 3: 모듈 레벨의 isRefreshing 플래그

let isRefreshing = false;

// 인터셉터 내부
if (error.response?.status === 401 && !originalRequest._retry && !isRefreshing) {
  originalRequest._retry = true;
  isRefreshing = true;
  try {
    await postReissue();
  } finally {
    isRefreshing = false;
  }
}

요약하면, 현재 코드는 _retry 플래그만으로는 무한루프를 완전히 막을 수 없습니다. 방법 1이 가장 간단하고 명확한 수정안입니다! 🐇

@shooukie

shooukie commented Mar 8, 2026

Copy link
Copy Markdown
Contributor

코드레빗이 추천한 방법1도 괜찮은 것 같고 아니면 인스턴스를 두 개로 나누어도 괜찮을 것 같아요!
기존의 axiosInstance는 withCredentials: true 를 빼고 쿠키 인증이 필요한 요청(postReissue) 전용 인스턴스에만 withCredentials: true 를 추가하는 방식으로요! 두 개로 나누면 필요할 때만 쿠키를 전달할 수 있어서 더 안전할 것 같습니다!

@tablemin03

tablemin03 commented Mar 9, 2026

Copy link
Copy Markdown
Contributor Author

코드레빗이 추천한 방법1도 괜찮은 것 같고 아니면 인스턴스를 두 개로 나누어도 괜찮을 것 같아요! 기존의 axiosInstance는 withCredentials: true 를 빼고 쿠키 인증이 필요한 요청(postReissue) 전용 인스턴스에만 withCredentials: true 를 추가하는 방식으로요! 두 개로 나누면 필요할 때만 쿠키를 전달할 수 있어서 더 안전할 것 같습니다!

사용할 일이 별로 없을 것 같아서 인스턴스를 두 개로 나누지는 않았고, 그냥 기존 코드대로 기존 axios를 사용하는 방법으로 다시 고쳤어

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/api/axios/axios.ts (3)

44-46: ⚠️ Potential issue | 🟠 Major

동시 401 응답 시 중복 토큰 재발급 요청 발생 가능

_retry 플래그가 요청별로 설정되므로 여러 요청이 동시에 401을 받으면 postReissue()가 중복 호출됩니다. 이전 리뷰에서 제안된 isRefreshing 플래그와 큐잉 패턴 적용을 권장합니다.

🤖 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 - 46, The current axios interceptor
sets originalRequest._retry per-request which allows multiple simultaneous 401
responses to each trigger postReissue(); introduce a shared isRefreshing boolean
and a request queue (e.g., array of pending promise resolvers) so that when a
401 is received and isRefreshing is true you enqueue the request and wait for
the refreshed token, and when isRefreshing is false you set isRefreshing = true,
call postReissue(), resolve pending queued requests with the new token, then set
isRefreshing = false; update the interceptor logic that references
originalRequest._retry and postReissue() to use the shared isRefreshing + queue
pattern to ensure only one postReissue() runs at a time and other requests wait
for its result.

50-51: ⚠️ Potential issue | 🔴 Critical

useLocalStorage에 토큰 키 대신 토큰 값이 전달됨 — 토큰 저장 실패

useLocalStorage 훅은 localStorage의 키 문자열을 인자로 받습니다. 현재 코드는 토큰 값(reissueRes.result.accessToken)을 키로 전달하고 있어, 실제 accessToken 키에 토큰이 저장되지 않습니다.

결과적으로 다음 요청 시 request 인터셉터(Line 21)가 빈 토큰을 읽어 매 요청마다 401 → reissue가 반복됩니다.

🐛 수정 제안
-        const { setItem } = useLocalStorage(reissueRes.result.accessToken);
+        const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
         setItem(reissueRes.result.accessToken);
🤖 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 50 - 51, 현재 useLocalStorage 훅에 실제 토큰
값(reissueRes.result.accessToken)을 키로 넘기고 있어 로컬에 "accessToken" 키로 저장되지 않습니다; 수정은
useLocalStorage 호출에서 반드시 키 문자열 ("accessToken" 또는 프로젝트에서 사용하는 키명)을 첫 인자로 전달하고, 이후
setItem(reissueRes.result.accessToken)로 토큰 값을 저장하도록 변경하세요 (참조: useLocalStorage,
setItem, reissueRes.result.accessToken).

59-71: ⚠️ Potential issue | 🟡 Minor

사용되지 않는 에러 변수로 인한 ESLint 오류

refreshErrorlogoutError가 선언되었지만 사용되지 않아 ESLint 오류가 발생합니다. 언더스코어 접두사를 사용하거나 변수를 생략하세요.

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

         try {
           await postLogout();
-        } catch (logoutError) {
+        } catch {
           toast.error("로그아웃 API 호출 실패");
         } finally {
🤖 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 59 - 71, The catch blocks declare unused
error variables causing ESLint errors; update the catch clauses in the axios
token refresh flow to either omit the error identifier (use "catch { ... }") or
rename them with an underscore prefix (e.g., _refreshError, _logoutError) so
ESLint ignores them, keeping the existing logic that calls postLogout(), removes
LOCAL_STORAGE_KEY.accessToken, shows toast messages, and redirects to "/login".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/auth.ts`:
- Around line 14-19: postLogout is passing withCredentials in the request body
(same bug as postReissue); move withCredentials out of the payload and pass it
as the axios request config (third argument) for the POST call in postLogout so
the cookie credentials are sent correctly while the request body remains the
intended payload.
- Around line 7-9: The axios.post calls are passing { withCredentials: true } as
the request body instead of the config, so cookies (refreshToken) aren't sent;
locate any axios.post usage and change the call signature to axios.post(url,
data, { withCredentials: true }) (or axios.post(url, null, { withCredentials:
true }) if no body) so withCredentials is in the config argument and the
refreshToken cookie is included; update all occurrences referencing axios.post
and the withCredentials object accordingly.

---

Duplicate comments:
In `@src/api/axios/axios.ts`:
- Around line 44-46: The current axios interceptor sets originalRequest._retry
per-request which allows multiple simultaneous 401 responses to each trigger
postReissue(); introduce a shared isRefreshing boolean and a request queue
(e.g., array of pending promise resolvers) so that when a 401 is received and
isRefreshing is true you enqueue the request and wait for the refreshed token,
and when isRefreshing is false you set isRefreshing = true, call postReissue(),
resolve pending queued requests with the new token, then set isRefreshing =
false; update the interceptor logic that references originalRequest._retry and
postReissue() to use the shared isRefreshing + queue pattern to ensure only one
postReissue() runs at a time and other requests wait for its result.
- Around line 50-51: 현재 useLocalStorage 훅에 실제 토큰
값(reissueRes.result.accessToken)을 키로 넘기고 있어 로컬에 "accessToken" 키로 저장되지 않습니다; 수정은
useLocalStorage 호출에서 반드시 키 문자열 ("accessToken" 또는 프로젝트에서 사용하는 키명)을 첫 인자로 전달하고, 이후
setItem(reissueRes.result.accessToken)로 토큰 값을 저장하도록 변경하세요 (참조: useLocalStorage,
setItem, reissueRes.result.accessToken).
- Around line 59-71: The catch blocks declare unused error variables causing
ESLint errors; update the catch clauses in the axios token refresh flow to
either omit the error identifier (use "catch { ... }") or rename them with an
underscore prefix (e.g., _refreshError, _logoutError) so ESLint ignores them,
keeping the existing logic that calls postLogout(), removes
LOCAL_STORAGE_KEY.accessToken, shows toast messages, and redirects to "/login".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b6bf991d-899c-48e2-bbe6-76a7306e9254

📥 Commits

Reviewing files that changed from the base of the PR and between b55bb42 and c1942f2.

📒 Files selected for processing (2)
  • src/api/auth.ts
  • src/api/axios/axios.ts

Comment thread src/api/auth.ts
Comment thread src/api/auth.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (3)
src/api/axios/axios.ts (3)

58-64: ⚠️ Potential issue | 🟡 Minor

사용하지 않는 catch 변수 때문에 린트가 깨집니다.

refreshError, logoutError를 읽지 않아서 현재 ESLint 에러가 납니다. catch {}로 바꾸거나 _refreshError처럼 의도를 명시하세요.

🤖 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 58 - 64, The catch blocks in the token
refresh flow use unused variables refreshError and logoutError causing ESLint
errors; update the catch signatures in the axios token-refresh logic (the
try/catch that calls postLogout) to either use anonymous catches (catch {}) or
rename to intentionally unused identifiers (catch (_refreshError) and catch
(_logoutError)) so the linter stops reporting unused variables while preserving
the existing error-handling behavior.

49-50: ⚠️ Potential issue | 🔴 Critical

재발급 토큰을 잘못된 localStorage 키에 저장하고 있습니다.

Line 49는 useLocalStorage(reissueRes.result.accessToken)로 실제 토큰 값을 key로 사용합니다. 이 상태면 요청 인터셉터가 읽는 LOCAL_STORAGE_KEY.accessToken은 갱신되지 않아, 다음 요청들이 계속 이전 토큰으로 나갑니다.

수정 예시
-        const { setItem } = useLocalStorage(reissueRes.result.accessToken);
+        const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
         setItem(reissueRes.result.accessToken);
🤖 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 49 - 50, The code is storing the new
token using the token value as the localStorage key; change the useLocalStorage
call to use the correct key (LOCAL_STORAGE_KEY.accessToken) and then call
setItem(reissueRes.result.accessToken) to store the token value; specifically
update the call site that currently does
useLocalStorage(reissueRes.result.accessToken) so it becomes
useLocalStorage(LOCAL_STORAGE_KEY.accessToken) and then invoke the returned
setItem with reissueRes.result.accessToken so the request interceptor reads the
updated token.

43-57: ⚠️ Potential issue | 🟠 Major

동시 401 응답에서 재발급이 중복 호출됩니다.

_retry는 요청별 플래그라서 여러 요청이 동시에 401을 받으면 각 요청이 모두 postReissue()를 호출합니다. refresh token 회전 정책이 있거나 서버가 재발급을 직렬화하지 않으면 일부 요청이 불필요하게 로그아웃 경로로 떨어질 수 있으니, 모듈 레벨 reissuePromise/queue로 단일 재발급만 수행해야 합니다.

🤖 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 43 - 57, The 401 handler currently calls
postReissue() per-request because _retry is per-request; introduce a
module-level reissuePromise (e.g., let reissuePromise: Promise<any> | null =
null) and use it to serialize refreshes: when encountering
error.response?.status === 401 and !originalRequest._retry, set
originalRequest._retry = true, then if reissuePromise is null assign
reissuePromise = postReissue(); await reissuePromise for all concurrent
requests, clear reissuePromise after resolution or rejection, then use the
resolved reissueRes to call
useLocalStorage(reissueRes.result.accessToken).setItem(...), update
originalRequest.headers.Authorization, handle needAgreement by redirecting and
rejecting, and finally return axiosInstance(originalRequest); ensure
reissuePromise is cleared on errors so future attempts can retry.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/api/axios/axios.ts`:
- Around line 58-64: The catch blocks in the token refresh flow use unused
variables refreshError and logoutError causing ESLint errors; update the catch
signatures in the axios token-refresh logic (the try/catch that calls
postLogout) to either use anonymous catches (catch {}) or rename to
intentionally unused identifiers (catch (_refreshError) and catch
(_logoutError)) so the linter stops reporting unused variables while preserving
the existing error-handling behavior.
- Around line 49-50: The code is storing the new token using the token value as
the localStorage key; change the useLocalStorage call to use the correct key
(LOCAL_STORAGE_KEY.accessToken) and then call
setItem(reissueRes.result.accessToken) to store the token value; specifically
update the call site that currently does
useLocalStorage(reissueRes.result.accessToken) so it becomes
useLocalStorage(LOCAL_STORAGE_KEY.accessToken) and then invoke the returned
setItem with reissueRes.result.accessToken so the request interceptor reads the
updated token.
- Around line 43-57: The 401 handler currently calls postReissue() per-request
because _retry is per-request; introduce a module-level reissuePromise (e.g.,
let reissuePromise: Promise<any> | null = null) and use it to serialize
refreshes: when encountering error.response?.status === 401 and
!originalRequest._retry, set originalRequest._retry = true, then if
reissuePromise is null assign reissuePromise = postReissue(); await
reissuePromise for all concurrent requests, clear reissuePromise after
resolution or rejection, then use the resolved reissueRes to call
useLocalStorage(reissueRes.result.accessToken).setItem(...), update
originalRequest.headers.Authorization, handle needAgreement by redirecting and
rejecting, and finally return axiosInstance(originalRequest); ensure
reissuePromise is cleared on errors so future attempts can retry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4adbd25b-d6f9-4947-8518-bc7d6c597cf2

📥 Commits

Reviewing files that changed from the base of the PR and between c7312f5 and 887750a.

📒 Files selected for processing (1)
  • src/api/axios/axios.ts

@tablemin03
tablemin03 merged commit 0f48d68 into develop Mar 16, 2026
4 checks passed
@tablemin03
tablemin03 deleted the feature/auth-proxy/#219 branch March 16, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] 프록시 설정 & 리프레시 토큰 인터셉터 추가

2 participants