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;
},
],
},