From db188bfdd01692435f18f01fca45785b3216b8fa Mon Sep 17 00:00:00 2001 From: Taewoo Park Date: Tue, 18 Nov 2025 15:02:08 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20accessToken=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20zustand=EB=A1=9C=20=EC=9E=AC=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/ui/LoginForm/LoginForm.tsx | 1 + .../auth/ui/SignUpForm/SignUpForm.tsx | 1 + src/shared/api/fetcher.ts | 51 ++++++++++++++----- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/features/auth/ui/LoginForm/LoginForm.tsx b/src/features/auth/ui/LoginForm/LoginForm.tsx index 31772976..b2810fb6 100644 --- a/src/features/auth/ui/LoginForm/LoginForm.tsx +++ b/src/features/auth/ui/LoginForm/LoginForm.tsx @@ -39,6 +39,7 @@ export const LoginForm = ({ }>("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), + noAuth: true, }); setAccessToken(res.accessToken); diff --git a/src/features/auth/ui/SignUpForm/SignUpForm.tsx b/src/features/auth/ui/SignUpForm/SignUpForm.tsx index b49bfab5..3a349e3c 100644 --- a/src/features/auth/ui/SignUpForm/SignUpForm.tsx +++ b/src/features/auth/ui/SignUpForm/SignUpForm.tsx @@ -135,6 +135,7 @@ export const SignUpForm = ({ onSuccess, onError }: SignUpFormProps) => { const res = await apiFetch("/api/users/", { method: "POST", body: JSON.stringify(body), + noAuth: true, }); console.log("회원가입 성공:", res); diff --git a/src/shared/api/fetcher.ts b/src/shared/api/fetcher.ts index ca387a0b..f77ec620 100644 --- a/src/shared/api/fetcher.ts +++ b/src/shared/api/fetcher.ts @@ -1,19 +1,26 @@ +import { useAuthStore } from "@/features/auth/model/auth.store"; +import { refreshAccessToken } from "./refresh"; import { useModalStore } from "@/shared/model/modal.store"; const BASE_URL = process.env.NEXT_PUBLIC_API_URL; export async function apiFetch( endpoint: string, - options: RequestInit, + options: RequestInit & { noAuth?: boolean }, ): Promise { - const { headers, ...restOptions } = options; + const { headers, noAuth, ...restOptions } = options; + const { accessToken, setAccessToken, logout } = useAuthStore.getState(); const { openModal, closeModal } = useModalStore.getState(); const defaultHeaders: HeadersInit = { "Content-Type": "application/json", }; - const res = await fetch(`${BASE_URL}${endpoint}`, { + if (!noAuth && typeof window !== "undefined") { + defaultHeaders["Authorization"] = `Bearer ${accessToken}`; + } + + let res = await fetch(`${BASE_URL}${endpoint}`, { headers: { ...defaultHeaders, ...headers }, cache: "no-store", credentials: "include", @@ -21,15 +28,35 @@ export async function apiFetch( }); //AccessToken 만료 처리 - if (res.status === 401) { - openModal("normal", { - message: "세션이 만료되었습니다. 다시 로그인 해주세요.", - buttonText: "확인", - onClick: () => { - closeModal(); - location.replace("/login"); - }, - }); + if (res.status === 401 && !noAuth) { + const newToken = await refreshAccessToken(); + + if (newToken) { + setAccessToken(newToken); + defaultHeaders["Authorization"] = `Bearer ${newToken}`; + + //동일한 경로에 요청 재시도 + res = await fetch(`${BASE_URL}${endpoint}`, { + headers: { ...defaultHeaders, ...headers }, + cache: "no-store", + credentials: "include", + ...restOptions, + }); + } else { + logout(); + openModal("normal", { + message: "세션이 만료되었습니다. 다시 로그인 해주세요.", + buttonText: "확인", + onClick: () => { + closeModal(); + if (typeof window !== "undefined") { + location.replace("/login"); + } + }, + }); + + throw new Error("세션이 만료되었습니다. 다시 로그인 해주세요."); + } } if (!res.ok) { From 3e4828b448b501df4e4ce5c6cc6635b9e2419f73 Mon Sep 17 00:00:00 2001 From: Taewoo Park Date: Tue, 18 Nov 2025 15:28:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20refresh=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=86=8C=EC=BC=93=20refresh?= =?UTF-8?q?=20=ED=9B=84=20=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/chat/model/socket.ts | 16 ++++++++++++++- src/shared/api/fetcher.ts | 33 ++++++++---------------------- src/shared/api/refresh.ts | 34 +++++++++++++++++++++++++------ 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/entities/chat/model/socket.ts b/src/entities/chat/model/socket.ts index 48148638..702de261 100644 --- a/src/entities/chat/model/socket.ts +++ b/src/entities/chat/model/socket.ts @@ -1,5 +1,7 @@ +import { error } from "console"; import { MessageProps } from "./types"; import { useAuthStore } from "@/features/auth/model/auth.store"; +import { refreshAccessToken } from "@/shared/api/refresh"; export interface ChatSocketEvents { onOpen?: () => void; @@ -64,7 +66,13 @@ export class ChatSocket { } }; - this.socket.onclose = (event) => { + this.socket.onclose = async (event) => { + if (event.code === 4001) { + console.warn("[Socket] Token expired (4001)"); + + const newToken = await refreshAccessToken(); + this.reconnect(); + } console.warn("[Socket] Closed:", event.code); this.events.onClose?.(event.code, event.reason); this.socket = null; @@ -78,6 +86,12 @@ export class ChatSocket { }); } + private reconnect() { + console.log("[Socket] Reconnecting after refresh…"); + this.socket = null; + this.connect(); + } + sendMessage(type: "text" | "image", content: string) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { console.warn("[Socket] Not connected"); diff --git a/src/shared/api/fetcher.ts b/src/shared/api/fetcher.ts index f77ec620..47632c48 100644 --- a/src/shared/api/fetcher.ts +++ b/src/shared/api/fetcher.ts @@ -31,32 +31,17 @@ export async function apiFetch( if (res.status === 401 && !noAuth) { const newToken = await refreshAccessToken(); - if (newToken) { - setAccessToken(newToken); - defaultHeaders["Authorization"] = `Bearer ${newToken}`; + if (!newToken) throw new Error("세션 만료"); - //동일한 경로에 요청 재시도 - res = await fetch(`${BASE_URL}${endpoint}`, { - headers: { ...defaultHeaders, ...headers }, - cache: "no-store", - credentials: "include", - ...restOptions, - }); - } else { - logout(); - openModal("normal", { - message: "세션이 만료되었습니다. 다시 로그인 해주세요.", - buttonText: "확인", - onClick: () => { - closeModal(); - if (typeof window !== "undefined") { - location.replace("/login"); - } - }, - }); + defaultHeaders["Authorization"] = `Bearer ${newToken}`; - throw new Error("세션이 만료되었습니다. 다시 로그인 해주세요."); - } + //동일한 경로에 요청 재시도 + res = await fetch(`${BASE_URL}${endpoint}`, { + headers: { ...defaultHeaders, ...headers }, + cache: "no-store", + credentials: "include", + ...restOptions, + }); } if (!res.ok) { diff --git a/src/shared/api/refresh.ts b/src/shared/api/refresh.ts index 804caa5b..4e3187b7 100644 --- a/src/shared/api/refresh.ts +++ b/src/shared/api/refresh.ts @@ -1,26 +1,48 @@ import { useAuthStore } from "@/features/auth/model/auth.store"; +import { useModalStore } from "@/shared/model/modal.store"; const BASE_URL = process.env.NEXT_PUBLIC_API_URL; export async function refreshAccessToken(): Promise { + const { setAccessToken, logout } = useAuthStore.getState(); + const { openModal, closeModal } = useModalStore.getState(); + try { const res = await fetch(`${BASE_URL}/auth/refresh`, { method: "POST", - credentials: "include", // 쿠키 전송 + credentials: "include", }); if (!res.ok) { + logout(); + openModal("normal", { + message: "세션이 만료되었습니다. 다시 로그인 해주세요.", + buttonText: "확인", + onClick: () => { + closeModal(); + location.replace("/login"); + }, + }); return null; } const data = await res.json(); - const newAccessToken = data.accessToken as string; + const newToken = data.accessToken as string; + + setAccessToken(newToken); - // zustand 업데이트 - useAuthStore.getState().setAccessToken(newAccessToken); + return newToken; + } catch (err) { + logout(); + openModal("normal", { + message: "세션이 만료되었습니다. 다시 로그인 해주세요.", + buttonText: "확인", + onClick: () => { + closeModal(); + location.replace("/login"); + }, + }); - return newAccessToken; - } catch { return null; } }