From 65c625e252f24ac76cb696fc1799a367280916f6 Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 26 Jan 2026 15:20:02 +0900 Subject: [PATCH 1/5] feat: add axios instance and server API for handling requests --- lib/axios.ts | 11 +++++++ lib/server-api.ts | 16 ++++++++++ package.json | 1 + pnpm-lock.yaml | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 lib/axios.ts create mode 100644 lib/server-api.ts diff --git a/lib/axios.ts b/lib/axios.ts new file mode 100644 index 0000000..f9520cf --- /dev/null +++ b/lib/axios.ts @@ -0,0 +1,11 @@ +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); diff --git a/lib/server-api.ts b/lib/server-api.ts new file mode 100644 index 0000000..1247204 --- /dev/null +++ b/lib/server-api.ts @@ -0,0 +1,16 @@ +import { cookies } from "next/headers"; +import { api } from "./axios"; +import type { AxiosRequestConfig } from "axios"; + +export async function serverApi(url: string, options: AxiosRequestConfig = {}) { + const cookieStore = await cookies(); + + // 기존 api 인스턴스를 쓰되, 쿠키 헤더만 쓱 끼워넣기 + return api.get(url, { + ...options, + headers: { + ...options.headers, // 기존 헤더 유지 + Cookie: cookieStore.toString(), // 쿠키 추가 + }, + }); +} diff --git a/package.json b/package.json index 16a75ec..3818e2f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prepare": "husky" }, "dependencies": { + "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..19f2a6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + axios: + specifier: ^1.13.3 + version: 1.13.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -781,6 +784,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 +795,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 +878,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 +942,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 +1178,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 +1603,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 +1831,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'} @@ -2827,12 +2868,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 +2966,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 +3026,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 +3405,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 +3814,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 +3984,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: {} From 733272993c2b7c398bb143e498f4a7f7cd19f952 Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 26 Jan 2026 15:22:55 +0900 Subject: [PATCH 2/5] feat: add @tanstack/react-query and @tanstack/react-query-devtools dependencies --- package.json | 2 ++ pnpm-lock.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/package.json b/package.json index 3818e2f..ff07740 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19f2a6b..42c8a3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ 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 @@ -532,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==} @@ -2595,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 From f3d8e718d11222fa7722c26a5095df5945024eb5 Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 26 Jan 2026 15:30:17 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20axios=EC=9D=98=20server=20api=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/server-api.ts | 44 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/lib/server-api.ts b/lib/server-api.ts index 1247204..d572e5a 100644 --- a/lib/server-api.ts +++ b/lib/server-api.ts @@ -1,16 +1,46 @@ import { cookies } from "next/headers"; import { api } from "./axios"; -import type { AxiosRequestConfig } from "axios"; +import type { AxiosRequestConfig, AxiosResponse } from "axios"; -export async function serverApi(url: string, options: AxiosRequestConfig = {}) { +// 서버 환경에서 사용할 API 클라이언트입니다. +// next/headers의 cookies()를 사용하여 요청에 자동으로 쿠키를 포함시킵니다. +async function request( + config: AxiosRequestConfig, +): Promise> { const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); - // 기존 api 인스턴스를 쓰되, 쿠키 헤더만 쓱 끼워넣기 - return api.get(url, { - ...options, + return api({ + ...config, headers: { - ...options.headers, // 기존 헤더 유지 - Cookie: cookieStore.toString(), // 쿠키 추가 + ...config.headers, + ...(cookieHeader && { Cookie: cookieHeader }), }, }); } + +export const serverApi = { + get: (url: string, config?: AxiosRequestConfig) => + request({ ...config, method: "GET", url }), + + post: ( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ) => request({ ...config, method: "POST", url, data }), + + put: ( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ) => request({ ...config, method: "PUT", url, data }), + + delete: (url: string, config?: AxiosRequestConfig) => + request({ ...config, method: "DELETE", url }), + + patch: ( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ) => request({ ...config, method: "PATCH", url, data }), +}; From d5ff4306392e9e2f7b0875d403c801c20f70a94a Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 26 Jan 2026 16:14:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20ssr=EC=9A=A9=20api=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/server-api.ts | 126 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/lib/server-api.ts b/lib/server-api.ts index d572e5a..a2fc4e6 100644 --- a/lib/server-api.ts +++ b/lib/server-api.ts @@ -1,46 +1,94 @@ +import axios, { type AxiosRequestConfig, isAxiosError } from "axios"; import { cookies } from "next/headers"; -import { api } from "./axios"; -import type { AxiosRequestConfig, AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; -// 서버 환경에서 사용할 API 클라이언트입니다. -// next/headers의 cookies()를 사용하여 요청에 자동으로 쿠키를 포함시킵니다. +// 환경 변수 확인 (없으면 빌드 타임/런타임에 경고) +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) => { + // next/headers의 cookies()는 서버 컴포넌트에서만 동작합니다. + const cookieStore = await cookies(); + const allCookies = cookieStore.toString(); // "key=value; key2=value2" 형태 + + if (allCookies) { + config.headers.Cookie = allCookies; + } + + return config; + }, + (error) => Promise.reject(error), +); + +// 요청 옵션 타입 정의 +type RequestOptions = { + path: string; + params?: Record; // 쿼리 파라미터 + headers?: Record; // 추가 헤더 + body?: unknown; // POST, PUT 등에서 보낼 데이터 +}; + +// 3. 통합 요청 함수 async function request( - config: AxiosRequestConfig, -): Promise> { - const cookieStore = await cookies(); - const cookieHeader = cookieStore.toString(); - - return api({ - ...config, - headers: { - ...config.headers, - ...(cookieHeader && { Cookie: cookieHeader }), - }, - }); + 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"; + + // 401 인증 에러 발생 시 → 로그인 페이지로 튕겨내기 + if (status === 401) { + redirect("/login"); + } + + console.error(`[Server API Error] ${method} ${path}`, { + status, + message: errorMessage, + }); + + // 에러를 던져서 상위 컴포넌트나 Error Boundary가 처리하게 함 + throw new Error(errorMessage); + } + + // Axios 외의 알 수 없는 에러 + throw error; + } } +// 4. 사용하기 편하게 메서드별 export export const serverApi = { - get: (url: string, config?: AxiosRequestConfig) => - request({ ...config, method: "GET", url }), - - post: ( - url: string, - data?: unknown, - config?: AxiosRequestConfig, - ) => request({ ...config, method: "POST", url, data }), - - put: ( - url: string, - data?: unknown, - config?: AxiosRequestConfig, - ) => request({ ...config, method: "PUT", url, data }), - - delete: (url: string, config?: AxiosRequestConfig) => - request({ ...config, method: "DELETE", url }), - - patch: ( - url: string, - data?: unknown, - config?: AxiosRequestConfig, - ) => request({ ...config, method: "PATCH", url, data }), + // 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), }; From 627c95469f481c5fae4fb19ae80877c4d6bfe5c3 Mon Sep 17 00:00:00 2001 From: dasosann Date: Mon, 26 Jan 2026 17:12:11 +0900 Subject: [PATCH 5/5] feat: implement response interceptor for automatic token renewal on 401 errors --- lib/axios.ts | 31 ++++++++++++++++++++++++ lib/server-api.ts | 62 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/lib/axios.ts b/lib/axios.ts index f9520cf..b89d044 100644 --- a/lib/axios.ts +++ b/lib/axios.ts @@ -9,3 +9,34 @@ const axiosConfig = { // 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 index a2fc4e6..3d26b4a 100644 --- a/lib/server-api.ts +++ b/lib/server-api.ts @@ -2,7 +2,6 @@ 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"); @@ -17,15 +16,25 @@ const serverClient = axios.create({ }, }); -// 2. [핵심] 요청 인터셉터: 브라우저의 쿠키를 통째로 헤더에 이식 +// 2. [핵심] 요청 인터셉터: 인증에 필요한 쿠키만 선별하여 전달 serverClient.interceptors.request.use( async (config) => { - // next/headers의 cookies()는 서버 컴포넌트에서만 동작합니다. const cookieStore = await cookies(); - const allCookies = cookieStore.toString(); // "key=value; key2=value2" 형태 - if (allCookies) { - config.headers.Cookie = allCookies; + // 백엔드 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; @@ -33,6 +42,36 @@ serverClient.interceptors.request.use( (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; @@ -63,18 +102,15 @@ async function request( const status = error.response?.status; const errorMessage = error.response?.data?.message || "API Error"; - // 401 인증 에러 발생 시 → 로그인 페이지로 튕겨내기 - if (status === 401) { - redirect("/login"); - } - console.error(`[Server API Error] ${method} ${path}`, { status, message: errorMessage, + data: error.response?.data, }); - // 에러를 던져서 상위 컴포넌트나 Error Boundary가 처리하게 함 - throw new Error(errorMessage); + // 원본 에러를 그대로 던져서 상위에서 전체 컨텍스트 활용 가능 + // (401은 이미 인터셉터에서 처리됨) + throw error; } // Axios 외의 알 수 없는 에러