-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/axios interceptor #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
65c625e
7332729
f3d8e71
d5ff430
627c954
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import axios from "axios"; | ||
|
|
||
| // 1. 공통 설정 (URL, 헤더 등) | ||
| const axiosConfig = { | ||
| baseURL: process.env.NEXT_PUBLIC_API_URL, | ||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| headers: { "Content-Type": "application/json" }, | ||
| withCredentials: true, | ||
| }; | ||
|
|
||
| // 2. 클라이언트용(CSR) 인스턴스 생성 및 export | ||
| export const api = axios.create(axiosConfig); | ||
|
|
||
| // 3. 응답 인터셉터: 401 에러 시 토큰 자동 재발급 | ||
| api.interceptors.response.use( | ||
| (response) => response, | ||
| async (error) => { | ||
| const originalRequest = error.config; | ||
|
|
||
| // 401 에러 && 재발급 시도 전 && 재발급 API가 아닐 때 | ||
| if ( | ||
| error.response?.status === 401 && | ||
| !originalRequest._retry && | ||
| originalRequest.url !== "/api/auth/login" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| ) { | ||
| originalRequest._retry = true; | ||
|
|
||
| try { | ||
| // refreshToken으로 새 accessToken 발급 (withCredentials로 쿠키 자동 전송) | ||
| await api.post("/api/auth/login"); | ||
|
|
||
| // 원래 요청 재시도 | ||
| return api(originalRequest); | ||
| } catch (reissueError) { | ||
| // 재발급 실패 → 로그인 페이지로 리다이렉트 | ||
| window.location.href = "/login"; | ||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return Promise.reject(reissueError); | ||
| } | ||
| } | ||
|
|
||
| return Promise.reject(error); | ||
| }, | ||
| ); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,130 @@ | ||||||||||||||||||||||||||
| import axios, { type AxiosRequestConfig, isAxiosError } from "axios"; | ||||||||||||||||||||||||||
| import { cookies } from "next/headers"; | ||||||||||||||||||||||||||
| import { redirect } from "next/navigation"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const API_URL = process.env.NEXT_PUBLIC_API_URL; | ||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| if (!API_URL) { | ||||||||||||||||||||||||||
| throw new Error("NEXT_PUBLIC_API_URL is not defined"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 1. 서버 전용 Axios 인스턴스 생성 | ||||||||||||||||||||||||||
| const serverClient = axios.create({ | ||||||||||||||||||||||||||
| baseURL: API_URL, | ||||||||||||||||||||||||||
| timeout: 10000, | ||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||
| "Content-Type": "application/json", | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 2. [핵심] 요청 인터셉터: 인증에 필요한 쿠키만 선별하여 전달 | ||||||||||||||||||||||||||
| serverClient.interceptors.request.use( | ||||||||||||||||||||||||||
| async (config) => { | ||||||||||||||||||||||||||
| const cookieStore = await cookies(); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 백엔드 API 인증에 필요한 쿠키만 선별 | ||||||||||||||||||||||||||
| const authCookieNames = ["accessToken", "refreshToken", "sessionId"]; | ||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| const authCookies: string[] = []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| authCookieNames.forEach((name) => { | ||||||||||||||||||||||||||
| const cookie = cookieStore.get(name); | ||||||||||||||||||||||||||
| if (cookie) { | ||||||||||||||||||||||||||
| authCookies.push(`${name}=${cookie.value}`); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 인증 쿠키가 있을 때만 헤더에 추가 | ||||||||||||||||||||||||||
| if (authCookies.length > 0) { | ||||||||||||||||||||||||||
| config.headers.Cookie = authCookies.join("; "); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+22
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
이 방식은 API 인증과 무관한 제3자 서비스(예: 분석 도구)의 쿠키까지 백엔드 서버로 전송하여 불필요한 데이터를 노출할 수 있으며, 헤더 크기 제한을 초과할 위험도 있습니다. 필요한 세션 또는 인증 토큰 쿠키(예:
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 수정완료 |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return config; | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| (error) => Promise.reject(error), | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 3. [핵심] 응답 인터셉터: 401 에러 시 토큰 재발급 시도 | ||||||||||||||||||||||||||
| serverClient.interceptors.response.use( | ||||||||||||||||||||||||||
| (response) => response, | ||||||||||||||||||||||||||
| async (error) => { | ||||||||||||||||||||||||||
| const originalRequest = error.config; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 401 에러 && 재발급 시도 전 && 재발급 API가 아닐 때 | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| error.response?.status === 401 && | ||||||||||||||||||||||||||
| !originalRequest._retry && | ||||||||||||||||||||||||||
| originalRequest.url !== "/api/auth/login" | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| originalRequest._retry = true; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||
| // refreshToken으로 새 accessToken 발급 (쿠키 자동 전송됨) | ||||||||||||||||||||||||||
| await serverClient.post("/api/auth/login"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 원래 요청 재시도 | ||||||||||||||||||||||||||
| return serverClient(originalRequest); | ||||||||||||||||||||||||||
| } catch (reissueError) { | ||||||||||||||||||||||||||
| // 재발급 실패 → 로그인 페이지로 | ||||||||||||||||||||||||||
| redirect("/login"); | ||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return Promise.reject(error); | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 요청 옵션 타입 정의 | ||||||||||||||||||||||||||
| type RequestOptions = { | ||||||||||||||||||||||||||
| path: string; | ||||||||||||||||||||||||||
| params?: Record<string, string | number | boolean>; // 쿼리 파라미터 | ||||||||||||||||||||||||||
| headers?: Record<string, string>; // 추가 헤더 | ||||||||||||||||||||||||||
| body?: unknown; // POST, PUT 등에서 보낼 데이터 | ||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 3. 통합 요청 함수 | ||||||||||||||||||||||||||
| async function request<T>( | ||||||||||||||||||||||||||
| method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", | ||||||||||||||||||||||||||
| { path, params, headers, body }: RequestOptions, | ||||||||||||||||||||||||||
| ): Promise<T> { | ||||||||||||||||||||||||||
| // Axios 설정 객체 | ||||||||||||||||||||||||||
| const config: AxiosRequestConfig = { | ||||||||||||||||||||||||||
| url: path, | ||||||||||||||||||||||||||
| method, | ||||||||||||||||||||||||||
| headers, | ||||||||||||||||||||||||||
| params, // Axios가 객체를 쿼리스트링(?key=value)으로 자동 변환해줍니다. (buildQuery 불필요) | ||||||||||||||||||||||||||
| data: body, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||
| const response = await serverClient.request<T>(config); | ||||||||||||||||||||||||||
| return response.data; | ||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||
| if (isAxiosError(error)) { | ||||||||||||||||||||||||||
| const status = error.response?.status; | ||||||||||||||||||||||||||
| const errorMessage = error.response?.data?.message || "API Error"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| console.error(`[Server API Error] ${method} ${path}`, { | ||||||||||||||||||||||||||
| status, | ||||||||||||||||||||||||||
| message: errorMessage, | ||||||||||||||||||||||||||
| data: error.response?.data, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
Comment on lines
+105
to
+109
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 원본 에러를 그대로 던져서 상위에서 전체 컨텍스트 활용 가능 | ||||||||||||||||||||||||||
| // (401은 이미 인터셉터에서 처리됨) | ||||||||||||||||||||||||||
| throw error; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Axios 외의 알 수 없는 에러 | ||||||||||||||||||||||||||
| throw error; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 4. 사용하기 편하게 메서드별 export | ||||||||||||||||||||||||||
| export const serverApi = { | ||||||||||||||||||||||||||
| // GET 요청은 body가 없으므로 Omit으로 타입 제외 | ||||||||||||||||||||||||||
| get: <T>(options: Omit<RequestOptions, "body">) => request<T>("GET", options), | ||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| post: <T>(options: RequestOptions) => request<T>("POST", options), | ||||||||||||||||||||||||||
| put: <T>(options: RequestOptions) => request<T>("PUT", options), | ||||||||||||||||||||||||||
| delete: <T>(options: RequestOptions) => request<T>("DELETE", options), | ||||||||||||||||||||||||||
| patch: <T>(options: RequestOptions) => request<T>("PATCH", options), | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
dasosann marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
File Walkthrough
axios.ts (+42/-0)
클라이언트용 Axios 인스턴스 및 토큰 재발급 인터셉터lib/axios.ts
_retry)로 무한 루프 방지server-api.ts (+130/-0)
서버 환경 API 클라이언트 및 쿠키 기반 인증lib/server-api.ts
request()함수와 HTTP 메서드별 래퍼 함수(get,post,put,delete,patch) 제공package.json (+3/-0)
API 관리 및 개발 도구 의존성 추가package.json
@tanstack/react-query^5.90.20 추가@tanstack/react-query-devtools^5.91.2 추가axios^1.13.3 추가pnpm-lock.yaml (+113/-0)
의존성 잠금 파일 업데이트pnpm-lock.yaml
react-query-devtools)
mime-db)