diff --git a/package-lock.json b/package-lock.json index f3c7aa89..3f1db140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.65.1", + "@toss/utils": "^1.6.1", "@use-funnel/browser": "^0.0.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -68,6 +69,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@biomejs/biome": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", @@ -1821,6 +1831,38 @@ "react": "^18 || ^19" } }, + "node_modules/@toss/utility-types": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@toss/utility-types/-/utility-types-1.2.1.tgz", + "integrity": "sha512-1y8s1bvmuhuMX/d6qR9mmvcgFZIKYIQqJbAIshlGArXkjk/ec67gXc5uByEV1Y7in9ZhrGNRmjD8DTH0988vpQ==" + }, + "node_modules/@toss/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@toss/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-x6m8jLKWtAmCbxTLXbgTzJ5wZyRSUQPLpR/oLJP1ZK9ytXcRf03oA46W/+78kErUkEw/yQz2L+t2xFDHSeZ6IQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dependencies": { + "@babel/runtime": "^7.14.8", + "@toss/utility-types": "^1.2.1", + "date-fns": "^2.25.0" + } + }, + "node_modules/@toss/utils/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", diff --git a/package.json b/package.json index c2603efe..8fed7714 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.65.1", + "@toss/utils": "^1.6.1", "@use-funnel/browser": "^0.0.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/spurtyLoader/SpurtyLoader.tsx b/src/components/spurtyLoader/SpurtyLoader.tsx index 9be40361..d5672605 100644 --- a/src/components/spurtyLoader/SpurtyLoader.tsx +++ b/src/components/spurtyLoader/SpurtyLoader.tsx @@ -46,8 +46,14 @@ const SpurtyLoader = () => { priority /> -
- 로딩 중... +
+ 잠시만 기다려주세요
); diff --git a/src/lib/serverKy.ts b/src/lib/serverKy.ts index dd29e38a..cc6f219c 100644 --- a/src/lib/serverKy.ts +++ b/src/lib/serverKy.ts @@ -1,14 +1,56 @@ -import ky, { type KyResponse } from "ky"; +import { batchRequestsOf } from "@toss/utils"; // ① +import ky from "ky"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; const REFRESH_ENDPOINT = "/v1/auth/token/refresh"; const UNAUTHORIZED_CODE = 401; -let refreshPromise: Promise<{ - accessToken: string; - refreshToken: string; -}> | null = null; +async function refreshTokenOnce(): Promise { + const cookieStore = await cookies(); + const oldRefreshToken = cookieStore.get("refreshToken")?.value; + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}${REFRESH_ENDPOINT}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: oldRefreshToken }), + }, + ); + + if (!res.ok) { + const errText = await res.text(); + console.error("Refresh API 실패:", errText); + cookieStore.delete("accessToken"); + cookieStore.delete("refreshToken"); + throw new Error("refresh failed"); + } + + const { accessToken, refreshToken: newRefreshToken } = (await res.json()) as { + accessToken: string; + refreshToken: string; + }; + + cookieStore.set("accessToken", accessToken, { + httpOnly: true, + secure: true, + sameSite: "none", + path: "/", + maxAge: 60 * 60, + }); + cookieStore.set("refreshToken", newRefreshToken, { + httpOnly: true, + secure: true, + sameSite: "none", + path: "/", + maxAge: 60 * 60 * 24 * 7, + }); + + return accessToken; +} + +const batchedRefresh = batchRequestsOf(refreshTokenOnce); export const serverApi = ky.create({ prefixUrl: process.env.NEXT_PUBLIC_API_URL, @@ -23,89 +65,24 @@ export const serverApi = ky.create({ beforeRequest: [ async (request) => { const cookieStore = await cookies(); - const accessToken = cookieStore.get("accessToken")?.value; - if (accessToken) { - request.headers.set("Authorization", `Bearer ${accessToken}`); + const token = cookieStore.get("accessToken")?.value; + if (token) { + request.headers.set("Authorization", `Bearer ${token}`); } }, ], afterResponse: [ async (request, options, response) => { - if (response.status !== UNAUTHORIZED_CODE) { - return response; - } - - if (!refreshPromise) { - refreshPromise = (async () => { - const cookieStore = await cookies(); - const oldRefreshToken = cookieStore.get("refreshToken")?.value; - - const refreshRes = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}${REFRESH_ENDPOINT}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken: oldRefreshToken }), - }, - ); - - if (!refreshRes.ok) { - cookieStore.delete("accessToken"); - cookieStore.delete("refreshToken"); - throw new Error("Refresh failed"); - } - - const { accessToken, refreshToken: newRefreshToken } = - await refreshRes.json(); - - cookieStore.set("accessToken", accessToken, { - httpOnly: true, - secure: true, - sameSite: "none", - path: "/", - maxAge: 60 * 60, - }); - cookieStore.set("refreshToken", newRefreshToken, { - httpOnly: true, - secure: true, - sameSite: "none", - path: "/", - maxAge: 60 * 60 * 24 * 7, - }); - - return { accessToken, refreshToken: newRefreshToken }; - })(); - } - - try { - const { accessToken, refreshToken: newRefreshToken } = - await refreshPromise; - request.headers.set("Authorization", `Bearer ${accessToken}`); - - // ! 이 코드가 의미가 있을까..? - const cookieStore = await cookies(); - - cookieStore.set("accessToken", accessToken, { - httpOnly: true, - secure: true, - sameSite: "none", - path: "/", - maxAge: 60 * 60, - }); - cookieStore.set("refreshToken", newRefreshToken, { - httpOnly: true, - secure: true, - sameSite: "none", - path: "/", - maxAge: 60 * 60 * 24 * 7, - }); - - return serverApi(request, options); - } catch (err) { - return NextResponse.redirect(new URL("/login", request.url)); - } finally { - refreshPromise = null; + if (response.status === UNAUTHORIZED_CODE) { + try { + const newToken = await batchedRefresh(); + request.headers.set("Authorization", `Bearer ${newToken}`); + return serverApi(request, options); + } catch { + return NextResponse.redirect(new URL("/login", request.url)); + } } + return response; }, ], },