diff --git a/lib/axios.ts b/lib/axios.ts new file mode 100644 index 0000000..b89d044 --- /dev/null +++ b/lib/axios.ts @@ -0,0 +1,42 @@ +import axios from "axios"; + +// 1. 공통 설정 (URL, 헤더 등) +const axiosConfig = { + baseURL: process.env.NEXT_PUBLIC_API_URL, + 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" + ) { + originalRequest._retry = true; + + try { + // refreshToken으로 새 accessToken 발급 (withCredentials로 쿠키 자동 전송) + await api.post("/api/auth/login"); + + // 원래 요청 재시도 + return api(originalRequest); + } catch (reissueError) { + // 재발급 실패 → 로그인 페이지로 리다이렉트 + window.location.href = "/login"; + return Promise.reject(reissueError); + } + } + + return Promise.reject(error); + }, +); diff --git a/lib/server-api.ts b/lib/server-api.ts new file mode 100644 index 0000000..3d26b4a --- /dev/null +++ b/lib/server-api.ts @@ -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; +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"]; + 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("; "); + } + + 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"); + } + } + + return Promise.reject(error); + }, +); + +// 요청 옵션 타입 정의 +type RequestOptions = { + path: string; + params?: Record; // 쿼리 파라미터 + headers?: Record; // 추가 헤더 + body?: unknown; // POST, PUT 등에서 보낼 데이터 +}; + +// 3. 통합 요청 함수 +async function request( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + { path, params, headers, body }: RequestOptions, +): Promise { + // Axios 설정 객체 + const config: AxiosRequestConfig = { + url: path, + method, + headers, + params, // Axios가 객체를 쿼리스트링(?key=value)으로 자동 변환해줍니다. (buildQuery 불필요) + data: body, + }; + + try { + const response = await serverClient.request(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, + }); + + // 원본 에러를 그대로 던져서 상위에서 전체 컨텍스트 활용 가능 + // (401은 이미 인터셉터에서 처리됨) + throw error; + } + + // Axios 외의 알 수 없는 에러 + throw error; + } +} + +// 4. 사용하기 편하게 메서드별 export +export const serverApi = { + // GET 요청은 body가 없으므로 Omit으로 타입 제외 + get: (options: Omit) => request("GET", options), + + post: (options: RequestOptions) => request("POST", options), + put: (options: RequestOptions) => request("PUT", options), + delete: (options: RequestOptions) => request("DELETE", options), + patch: (options: RequestOptions) => request("PATCH", options), +}; diff --git a/package.json b/package.json index 16a75ec..ff07740 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "prepare": "husky" }, "dependencies": { + "@tanstack/react-query": "^5.90.20", + "@tanstack/react-query-devtools": "^5.91.2", + "axios": "^1.13.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8749a1..42c8a3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: ^5.90.20 + version: 5.90.20(react@19.2.3) + '@tanstack/react-query-devtools': + specifier: ^5.91.2 + version: 5.91.2(@tanstack/react-query@5.90.20(react@19.2.3))(react@19.2.3) + axios: + specifier: ^1.13.3 + version: 1.13.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -529,6 +538,23 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/query-devtools@5.92.0': + resolution: {integrity: sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==} + + '@tanstack/react-query-devtools@5.91.2': + resolution: {integrity: sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==} + peerDependencies: + '@tanstack/react-query': ^5.90.14 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.20': + resolution: {integrity: sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==} + peerDependencies: + react: ^18 || ^19 + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -781,6 +807,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -789,6 +818,9 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} + axios@1.13.3: + resolution: {integrity: sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -869,6 +901,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} @@ -929,6 +965,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1161,10 +1201,23 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1573,6 +1626,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -1793,6 +1854,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2554,6 +2618,21 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tanstack/query-core@5.90.20': {} + + '@tanstack/query-devtools@5.92.0': {} + + '@tanstack/react-query-devtools@5.91.2(@tanstack/react-query@5.90.20(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/query-devtools': 5.92.0 + '@tanstack/react-query': 5.90.20(react@19.2.3) + react: 19.2.3 + + '@tanstack/react-query@5.90.20(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.3 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -2827,12 +2906,22 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.11.1: {} + axios@1.13.3: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} babel-plugin-react-compiler@1.0.0: @@ -2915,6 +3004,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.2: {} concat-map@0.0.1: {} @@ -2971,6 +3064,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} doctrine@2.1.0: @@ -3348,10 +3443,20 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3747,6 +3852,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-function@5.0.1: {} minimatch@3.1.2: @@ -3911,6 +4022,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {}