From aba2cd76d21e3d36e6f50d96d13ff22b16c5578c Mon Sep 17 00:00:00 2001 From: Sienna Date: Thu, 7 Aug 2025 00:45:37 +0900 Subject: [PATCH 1/7] =?UTF-8?q?(#541)=20refactor:=20401=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20apiClient=20hook=EC=97=90=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 6 +- src/middleware.ts | 28 +++-- src/shared/constants/endpoints.ts | 2 +- src/shared/libs/formatErrorResponseHooks.ts | 116 ++++++++++++-------- 6 files changed, 95 insertions(+), 61 deletions(-) diff --git a/next.config.ts b/next.config.ts index f1f60742..b178573e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,7 +14,7 @@ const nextConfig: NextConfig = { reactStrictMode: true, // 빌드 환경에서 console.log 제거 compiler: { - removeConsole: process.env.NODE_ENV === 'production', + // removeConsole: process.env.NODE_ENV === 'production', }, images: { remotePatterns: [ diff --git a/package.json b/package.json index ccb7b0fd..58a993bf 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ }, "dependencies": { "@hookform/resolvers": "5.1.1", - "@storybook/nextjs": "9.1.0", "@tanstack/react-query": "5.81.5", "browser-image-compression": "2.0.2", "class-variance-authority": "0.7.1", @@ -42,6 +41,7 @@ "@storybook/addon-docs": "9.1.0", "@storybook/addon-onboarding": "9.1.0", "@storybook/addon-vitest": "9.1.0", + "@storybook/nextjs": "9.1.0", "@storybook/nextjs-vite": "^9.1.0", "@tailwindcss/postcss": "4", "@types/node": "20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f4c2e36..4ae26903 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@hookform/resolvers': specifier: 5.1.1 version: 5.1.1(react-hook-form@7.60.0(react@19.0.0)) - '@storybook/nextjs': - specifier: 9.1.0 - version: 9.1.0(esbuild@0.25.8)(next@15.3.5(@babel/core@7.28.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@9.1.0(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.0.6(@types/node@20.19.9)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)))(type-fest@2.19.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.0(esbuild@0.25.8)) '@tanstack/react-query': specifier: 5.81.5 version: 5.81.5(react@19.0.0) @@ -90,6 +87,9 @@ importers: '@storybook/addon-vitest': specifier: 9.1.0 version: 9.1.0(@vitest/browser@3.2.4)(@vitest/runner@3.2.4)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@9.1.0(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.0.6(@types/node@20.19.9)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)))(vitest@3.2.4) + '@storybook/nextjs': + specifier: 9.1.0 + version: 9.1.0(esbuild@0.25.8)(next@15.3.5(@babel/core@7.28.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(storybook@9.1.0(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.0.6(@types/node@20.19.9)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)))(type-fest@2.19.0)(typescript@5.8.3)(webpack-hot-middleware@2.26.1)(webpack@5.101.0(esbuild@0.25.8)) '@storybook/nextjs-vite': specifier: ^9.1.0 version: 9.1.0(@babel/core@7.28.0)(next@15.3.5(@babel/core@7.28.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(rollup@4.46.2)(storybook@9.1.0(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@7.0.6(@types/node@20.19.9)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)))(typescript@5.8.3)(vite@7.0.6(@types/node@20.19.9)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)) diff --git a/src/middleware.ts b/src/middleware.ts index 087b6dd4..59291e66 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -24,7 +24,7 @@ const protectedPageRoutes = [ROUTES.MYPAGE.ROOT]; * - `/api/`로 시작하지만 `/api/auth/`가 아닌 모든 요청을 가로채 백엔드 서버로 안전하게 전달합니다. * - 브라우저가 보낸 HttpOnly 쿠키에서 `accessToken`을 추출하여 `Authorization` 헤더에 담아 백엔드로 요청을 보냅니다. * - 백엔드에서 `401 Unauthorized` 에러를 받으면 `refreshToken`을 사용해 `accessToken`을 자동으로 갱신합니다. - * - 토큰 갱신마저 실패하면 로그인 페이지로 리다이렉션하여 세션 만료를 사용자에게 알립니다. + * - 토큰 갱신마저 실패하면 메인 페이지로 리다이렉션하며 세션 만료를 사용자에게 알립니다. * * ### 토큰 생명주기 (Token Lifecycle): * - **로그인 시**: `api/auth/signin` 또는 `api/auth/signup`을 통해 완전히 새로운 토큰 세트(Access/Refresh)가 발급됩니다. @@ -107,11 +107,19 @@ export async function middleware(request: NextRequest) { signal: AbortSignal.timeout(30000), }); - //! 여기서 분명히 갱신했는데 개발서버에서는 갱신이 되는데... 배포하면 안됨.. - if (response.status === 401 && request.cookies.get('refreshToken')?.value) { + const isProtectedRoute = protectedPageRoutes.some((route) => + pathname.startsWith(route), + ); + + //! 여기서 분명히 엑세스토큰 갱신했는데 개발서버에서는 갱신이 되는데... 배포하면 안됨.. + if ( + response.status === 401 && + request.cookies.get('refreshToken')?.value && + isProtectedRoute + ) { const refreshToken = request.cookies.get('refreshToken')!.value; - const refreshResponse = await fetch( - `${BACKEND_URL}${API_ENDPOINTS.AUTH.REFRESH_TOKEN}`, + const newTokenResponse = await fetch( + `${BACKEND_URL}${API_ENDPOINTS.AUTH.NEW_TOKEN}`, { method: 'POST', headers: { @@ -121,8 +129,8 @@ export async function middleware(request: NextRequest) { }, ); - if (refreshResponse.ok) { - const tokens = await refreshResponse.json(); + if (newTokenResponse.ok) { + const tokens = await newTokenResponse.json(); const newAccessToken = tokens.accessToken; console.log('[Middleware] 새로운 Access Token 발급 성공'); @@ -147,9 +155,9 @@ export async function middleware(request: NextRequest) { ); } else { console.log( - '[Middleware] Refresh Token 만료 또는 갱신 실패. 로그인 페이지로 리다이렉트합니다.', + '[Middleware] Refresh Token 만료 또는 갱신 실패. 메인 페이지로 리다이렉트합니다.', ); - const redirectUrl = new URL(ROUTES.SIGNIN, request.url); + const redirectUrl = new URL(ROUTES.ACTIVITIES.ROOT, request.url); redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); return NextResponse.redirect(redirectUrl); } @@ -172,5 +180,3 @@ export async function middleware(request: NextRequest) { export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], }; - -// 테스트 커밋 diff --git a/src/shared/constants/endpoints.ts b/src/shared/constants/endpoints.ts index 1be593f5..02f2aff8 100644 --- a/src/shared/constants/endpoints.ts +++ b/src/shared/constants/endpoints.ts @@ -30,7 +30,7 @@ export const API_ENDPOINTS = { AUTH: { SIGNUP: '/users', SIGNIN: '/auth/login', - REFRESH_TOKEN: '/auth/tokens', + NEW_TOKEN: '/auth/tokens', }, OAUTH: { diff --git a/src/shared/libs/formatErrorResponseHooks.ts b/src/shared/libs/formatErrorResponseHooks.ts index 75e357af..9875a93a 100644 --- a/src/shared/libs/formatErrorResponseHooks.ts +++ b/src/shared/libs/formatErrorResponseHooks.ts @@ -1,9 +1,77 @@ -import type { Hooks } from 'ky'; +import type { Hooks, Options } from 'ky'; import { HTTPError } from 'ky'; +import { ERROR_CODES, ROUTES } from '@/shared/constants/routes'; import { errorResponseSchema } from '@/shared/schemas/error'; import getErrorMessageByStatus from '@/shared/utils/errors/getErrorMessageByStatus'; +const beforeErrorHook = async (error: HTTPError) => { + const { response } = error; + let formattedMessage = null; + + try { + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const errorData = await response.json(); + const parsedError = errorResponseSchema.safeParse(errorData); + + if (parsedError.success) { + formattedMessage = parsedError.data.message; + } else { + formattedMessage = `[${response.status}] 서버 응답 형식이 올바르지 않습니다.`; + } + } else { + formattedMessage = `[${response.status}] 서버에서 예상치 않은 응답을 받았습니다.`; + } + } catch (err: unknown) { + const errorName = err instanceof Error ? err.name : 'UnknownError'; + console.error(`API Response Parsing Error [${errorName}]:`, err); + + if (errorName === 'AbortError' || errorName.includes('Timeout')) { + formattedMessage = + '요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.'; + } else { + const statusPart = response ? `[${response.status}] ` : ''; + const statusTextPart = response ? response.statusText || '' : ''; + formattedMessage = `${statusPart}${statusTextPart || '서버와 통신 중 오류가 발생했습니다.'}`; + } + } + + const commonErrorMessage = getErrorMessageByStatus(error); + if (commonErrorMessage) { + formattedMessage = commonErrorMessage; + } + + error.message = formattedMessage || error.message; + + return error; +}; + +const protectedPageRoutes = [ROUTES.MYPAGE.ROOT]; + +// afterResponse 훅을 별도로 정의합니다. +const afterResponseHook = async ( + request: Request, + options: Options, + response: Response, +) => { + const currentPathname = window.location.pathname; + + // 현재 페이지가 보호된 라우트 중 하나로 시작하는지 확인합니다. + const isCurrentlyOnProtectedPage = protectedPageRoutes.some((route) => + currentPathname.startsWith(route), + ); + + // 401 에러가 발생했고, 현재 페이지가 보호된 페이지인 경우에만 메인페이지로 리다이렉션합니다. + if (response.status === 401 && isCurrentlyOnProtectedPage) { + console.error('클라이언트 측에서 401 에러 감지. 세션이 만료되었습니다.'); + + const redirectUrl = new URL(ROUTES.ACTIVITIES.ROOT, window.location.origin); + redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); + window.location.href = redirectUrl.toString(); + } +}; + /** * @description API 에러 응답을 가로채 사용자 친화적인 메시지로 포맷팅하고 전역적으로 토스트를 표시하는 공용 `ky` 훅입니다. * 이 훅은 `ky` 요청 실패 시 호출되어 다음과 같은 로직으로 에러를 처리합니다. @@ -23,47 +91,7 @@ import getErrorMessageByStatus from '@/shared/utils/errors/getErrorMessageByStat * @returns {Promise} - 처리되고 메시지가 포맷팅된 HTTP 에러 객체를 반환합니다. */ export const formatErrorResponseHooks: Hooks = { - beforeError: [ - async (error: HTTPError) => { - const { response } = error; - let formattedMessage = null; - - try { - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - const errorData = await response.json(); - const parsedError = errorResponseSchema.safeParse(errorData); - - if (parsedError.success) { - formattedMessage = parsedError.data.message; - } else { - formattedMessage = `[${response.status}] 서버 응답 형식이 올바르지 않습니다.`; - } - } else { - formattedMessage = `[${response.status}] 서버에서 예상치 않은 응답을 받았습니다.`; - } - } catch (err: unknown) { - const errorName = err instanceof Error ? err.name : 'UnknownError'; - console.error(`API Response Parsing Error [${errorName}]:`, err); - - if (errorName === 'AbortError' || errorName.includes('Timeout')) { - formattedMessage = - '요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.'; - } else { - const statusPart = response ? `[${response.status}] ` : ''; - const statusTextPart = response ? response.statusText || '' : ''; - formattedMessage = `${statusPart}${statusTextPart || '서버와 통신 중 오류가 발생했습니다.'}`; - } - } - - const commonErrorMessage = getErrorMessageByStatus(error); - if (commonErrorMessage) { - formattedMessage = commonErrorMessage; - } - - error.message = formattedMessage || error.message; - - return error; - }, - ], + // 별도로 정의된 훅들을 배열에 담아 할당합니다. + beforeError: [beforeErrorHook], + afterResponse: [afterResponseHook], }; From 648da98172afe449a4e19c6ffc4d6776e4d4467e Mon Sep 17 00:00:00 2001 From: Sienna Date: Thu, 7 Aug 2025 01:34:58 +0900 Subject: [PATCH 2/7] =?UTF-8?q?(#541)=20feat:=20test=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=A7=8C=EB=A3=8C=EA=B8=B0=EA=B0=84=2020=EC=B4=88?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domain/Auth/utils/setAuthCookies.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/Auth/utils/setAuthCookies.ts b/src/domain/Auth/utils/setAuthCookies.ts index 9492b198..c94fa140 100644 --- a/src/domain/Auth/utils/setAuthCookies.ts +++ b/src/domain/Auth/utils/setAuthCookies.ts @@ -45,7 +45,8 @@ export default function setAuthCookies( secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - maxAge: 60 * 60 * 24 * 7, + // maxAge: 60 * 60 * 24 * 7, + maxAge: 10, }); return response; From 680df128c085c0b38c0dd8c73be45d88d6be5a79 Mon Sep 17 00:00:00 2001 From: Sienna Date: Thu, 7 Aug 2025 03:04:55 +0900 Subject: [PATCH 3/7] =?UTF-8?q?(#541)=20refactor:=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=8B=9C=20=ED=97=A4=EB=8D=94=EC=97=90=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/hooks/useProfileImageMutation.ts | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/src/domain/User/hooks/useProfileImageMutation.ts b/src/domain/User/hooks/useProfileImageMutation.ts index 106f1d74..cc8c2c73 100644 --- a/src/domain/User/hooks/useProfileImageMutation.ts +++ b/src/domain/User/hooks/useProfileImageMutation.ts @@ -1,8 +1,6 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { Dispatch, SetStateAction } from 'react'; -import { useRoamReadyStore } from '@/shared/store'; - import { uploadProfileImage, UploadProfileImageResponse, @@ -35,45 +33,16 @@ interface MutationContext { */ export const useProfileImageMutation = ({ setProfileImageUrl, - initialImageUrl, onImageChange, }: MutationHookProp) => { - const queryClient = useQueryClient(); - const { user, setUser } = useRoamReadyStore(); - return useMutation({ mutationFn: uploadProfileImage, - // 낙관적 업데이트: 업로드 시작 전 즉시 미리보기 표시 - onMutate: async (imageFile: File): Promise => { - // 롤백을 위해 이전 이미지 URL 저장 - const previousImageUrl = initialImageUrl; - - // 즉시 미리보기를 위한 임시 URL 생성 - const previewUrl = URL.createObjectURL(imageFile); - - // UI를 즉시 업데이트하여 사용자에게 반응성 제공 - setProfileImageUrl(previewUrl); - onImageChange?.(previewUrl); - - // 컨텍스트로 이전 상태와 임시 URL 반환 - return { previousImageUrl, previewUrl }; - }, - // 업로드 성공 시 실행: 실제 서버 URL로 교체 onSuccess: (data, _variables, context?: MutationContext) => { - // 기존 user 객체의 다른 정보는 유지한 채, profileImageUrl만 업데이트합니다. - if (user) { - const updatedUser = { ...user, profileImageUrl: data.profileImageUrl }; - setUser(updatedUser); - } - - // Tanstack Query 캐시 무효화는 그대로 유지합니다. - queryClient.invalidateQueries({ queryKey: ['user', 'me'] }); - - // API로부터 받은 실제 이미지 URL로 상태 업데이트 + // 서버에서 받은 실제 이미지 URL로 상태를 업데이트합니다. setProfileImageUrl(data.profileImageUrl); - // 상위 컴포넌트에 최종 이미지 변경을 알림 + // 상위 컴포넌트에 최종 이미지 변경을 알립니다. onImageChange?.(data.profileImageUrl); // 미리보기 URL 메모리 해제 From 30655a1c14846148d7c724ba6b14a244b8646ca4 Mon Sep 17 00:00:00 2001 From: Sienna Date: Thu, 7 Aug 2025 03:06:14 +0900 Subject: [PATCH 4/7] =?UTF-8?q?(#541)=20fix:=20url=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A5=BC=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80=ED=95=B4=20=EC=9C=A0=EC=A0=80=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/_components/ClientProvider.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/_components/ClientProvider.tsx b/src/app/_components/ClientProvider.tsx index 9b80046c..014085ee 100644 --- a/src/app/_components/ClientProvider.tsx +++ b/src/app/_components/ClientProvider.tsx @@ -1,11 +1,14 @@ 'use client'; import { QueryClientProvider } from '@tanstack/react-query'; -import { ReactNode } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { ReactNode, useEffect } from 'react'; import AuthInitializer from '@/app/_components/AuthInitializer'; import ToastContainer from '@/shared/components/ui/toast/ToastContainer'; +import { ERROR_CODES } from '@/shared/constants/routes'; import { queryClient } from '@/shared/libs/queryClient'; +import { useRoamReadyStore } from '@/shared/store'; /** * @component ClientProvider @@ -15,7 +18,10 @@ import { queryClient } from '@/shared/libs/queryClient'; * * 1. **인증 상태 초기화**: 앱이 시작될 때 ``를 통해 실제 서버의 인증 상태와 클라이언트 UI 상태를 동기화하여, "UI는 로그인 상태인데 실제로는 토큰이 만료된" 상태 불일치 문제를 해결합니다. * 2. **서버 상태 관리**: Tanstack Query의 `QueryClientProvider`를 설정하여 애플리케이션 전반에서 데이터 페칭 및 캐싱을 관리합니다. - * 3. **전역 알림**: 전역 토스트 알림을 위한 ``를 렌더링합니다. + * 3. **세션 만료 시 클라이언트 상태 초기화** + * - URL에 `?error=SESSION_EXPIRED` 쿼리 파라미터가 존재하면, 클라이언트 전역 상태(`Zustand`)에서 사용자 정보를 초기화합니다. + * - 이는 미들웨어 또는 API 응답에서 세션 만료로 리디렉션된 경우를 처리하기 위한 UX 방어로직입니다. + * 4. **전역 알림**: 전역 토스트 알림을 위한 ``를 렌더링합니다. * * 이 컴포넌트는 Next.js의 'use client' 지시어가 적용되어 클라이언트 사이드에서만 동작하며, 애플리케이션의 루트 레이아웃에서 사용되어야 합니다. * @@ -24,6 +30,15 @@ import { queryClient } from '@/shared/libs/queryClient'; * @returns {JSX.Element} 각종 프로바이더로 래핑된 자식 요소입니다. */ export default function ClientProvider({ children }: { children: ReactNode }) { + const clearUser = useRoamReadyStore((state) => state.clearUser); + const searchParams = useSearchParams(); + + useEffect(() => { + if (searchParams?.get('error') === ERROR_CODES.SESSION_EXPIRED) { + clearUser(); + } + }, [searchParams, clearUser]); + return ( From 7049cb868b93c1696c94ec6155ece31091dd0d45 Mon Sep 17 00:00:00 2001 From: Sienna Date: Thu, 7 Aug 2025 03:07:35 +0900 Subject: [PATCH 5/7] =?UTF-8?q?(#541)=20fix:=20401=EB=B0=9C=EC=83=9D=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=84=EC=97=AD=EC=83=81=ED=83=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94,=20=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=A6=AC=EB=94=94=EB=A0=89=EC=85=98(befor?= =?UTF-8?q?eErrorHook)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/test/nothing/page.dev.tsx | 36 ++++++++------ src/domain/Auth/utils/setAuthCookies.ts | 8 +-- src/middleware.ts | 33 ++++++------- src/shared/libs/formatErrorResponseHooks.ts | 55 ++++++++++----------- 4 files changed, 66 insertions(+), 66 deletions(-) diff --git a/src/app/test/nothing/page.dev.tsx b/src/app/test/nothing/page.dev.tsx index 66b0e91c..dd59f154 100644 --- a/src/app/test/nothing/page.dev.tsx +++ b/src/app/test/nothing/page.dev.tsx @@ -1,25 +1,29 @@ 'use client'; +import { Suspense } from 'react'; + import Nothing from '@/shared/components/ui/nothing'; export default function NothingTestPage() { return ( -
-
- - type: reservation -

- 버튼 클릭시 /activities 페이지로 이동합니다. -

-
-
- - type: activity -
-
- - type: review + +
+
+ + type: reservation +

+ 버튼 클릭시 /activities 페이지로 이동합니다. +

+
+
+ + type: activity +
+
+ + type: review +
-
+ ); } diff --git a/src/domain/Auth/utils/setAuthCookies.ts b/src/domain/Auth/utils/setAuthCookies.ts index c94fa140..416e131d 100644 --- a/src/domain/Auth/utils/setAuthCookies.ts +++ b/src/domain/Auth/utils/setAuthCookies.ts @@ -34,8 +34,8 @@ export default function setAuthCookies( secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - // maxAge: 60 * 60, //! 테스트를 위해 15초로 - maxAge: 15, + // maxAge: 60 * 60, //! 테스트를 위해 15초로 + maxAge: 10, }); response.cookies.set({ @@ -45,8 +45,8 @@ export default function setAuthCookies( secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - // maxAge: 60 * 60 * 24 * 7, - maxAge: 10, + maxAge: 60 * 60 * 24 * 7, + // maxAge: 10, }); return response; diff --git a/src/middleware.ts b/src/middleware.ts index 59291e66..882a8196 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import setAuthCookies from '@/domain/Auth/utils/setAuthCookies'; -import { ERROR_CODES, ROUTES } from '@/shared/constants/routes'; +import { ROUTES } from '@/shared/constants/routes'; import { BRIDGE_API } from './shared/constants/bridgeEndpoints'; import { API_ENDPOINTS } from './shared/constants/endpoints'; @@ -24,7 +24,6 @@ const protectedPageRoutes = [ROUTES.MYPAGE.ROOT]; * - `/api/`로 시작하지만 `/api/auth/`가 아닌 모든 요청을 가로채 백엔드 서버로 안전하게 전달합니다. * - 브라우저가 보낸 HttpOnly 쿠키에서 `accessToken`을 추출하여 `Authorization` 헤더에 담아 백엔드로 요청을 보냅니다. * - 백엔드에서 `401 Unauthorized` 에러를 받으면 `refreshToken`을 사용해 `accessToken`을 자동으로 갱신합니다. - * - 토큰 갱신마저 실패하면 메인 페이지로 리다이렉션하며 세션 만료를 사용자에게 알립니다. * * ### 토큰 생명주기 (Token Lifecycle): * - **로그인 시**: `api/auth/signin` 또는 `api/auth/signup`을 통해 완전히 새로운 토큰 세트(Access/Refresh)가 발급됩니다. @@ -107,17 +106,20 @@ export async function middleware(request: NextRequest) { signal: AbortSignal.timeout(30000), }); - const isProtectedRoute = protectedPageRoutes.some((route) => - pathname.startsWith(route), - ); - - //! 여기서 분명히 엑세스토큰 갱신했는데 개발서버에서는 갱신이 되는데... 배포하면 안됨.. - if ( - response.status === 401 && - request.cookies.get('refreshToken')?.value && - isProtectedRoute - ) { + // if (response.status === 401) { + // console.log('--- 미들웨어 디버깅 ---'); + // console.log('응답 상태:', response.status); + // console.log( + // 'refreshToken 쿠키 존재 여부:', + // !!request.cookies.get('refreshToken')?.value, + // ); + // console.log('보호된 라우트 여부:', isProtectedRoute); + // console.log('-----------------------'); + // } + + if (response.status === 401 && request.cookies.get('refreshToken')?.value) { const refreshToken = request.cookies.get('refreshToken')!.value; + console.log(refreshToken); const newTokenResponse = await fetch( `${BACKEND_URL}${API_ENDPOINTS.AUTH.NEW_TOKEN}`, { @@ -154,12 +156,7 @@ export async function middleware(request: NextRequest) { tokens, ); } else { - console.log( - '[Middleware] Refresh Token 만료 또는 갱신 실패. 메인 페이지로 리다이렉트합니다.', - ); - const redirectUrl = new URL(ROUTES.ACTIVITIES.ROOT, request.url); - redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); - return NextResponse.redirect(redirectUrl); + console.log('[Middleware] Refresh Token 만료 또는 갱신 실패'); } } diff --git a/src/shared/libs/formatErrorResponseHooks.ts b/src/shared/libs/formatErrorResponseHooks.ts index 9875a93a..0afe86e8 100644 --- a/src/shared/libs/formatErrorResponseHooks.ts +++ b/src/shared/libs/formatErrorResponseHooks.ts @@ -1,14 +1,36 @@ -import type { Hooks, Options } from 'ky'; +import type { Hooks } from 'ky'; import { HTTPError } from 'ky'; import { ERROR_CODES, ROUTES } from '@/shared/constants/routes'; import { errorResponseSchema } from '@/shared/schemas/error'; +import { useRoamReadyStore } from '@/shared/store'; import getErrorMessageByStatus from '@/shared/utils/errors/getErrorMessageByStatus'; const beforeErrorHook = async (error: HTTPError) => { const { response } = error; + const protectedPageRoutes = [ROUTES.MYPAGE.ROOT]; let formattedMessage = null; + if (response.status === 401) { + console.error('클라이언트 측에서 401 에러 감지. 세션이 만료되었습니다.'); + const { clearUser } = useRoamReadyStore.getState(); + clearUser(); + } + + const currentPathname = window.location.pathname; + const isCurrentlyOnProtectedPage = protectedPageRoutes.some((route) => + currentPathname.startsWith(route), + ); + + if (response.status === 401 && isCurrentlyOnProtectedPage) { + console.error( + '클라이언트 측에서 401 에러 감지. 보호된 페이지에서 세션이 만료되었습니다.', + ); + const redirectUrl = new URL(ROUTES.ACTIVITIES.ROOT, window.location.origin); + redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); + window.location.href = redirectUrl.toString(); + } + try { const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { @@ -47,31 +69,6 @@ const beforeErrorHook = async (error: HTTPError) => { return error; }; -const protectedPageRoutes = [ROUTES.MYPAGE.ROOT]; - -// afterResponse 훅을 별도로 정의합니다. -const afterResponseHook = async ( - request: Request, - options: Options, - response: Response, -) => { - const currentPathname = window.location.pathname; - - // 현재 페이지가 보호된 라우트 중 하나로 시작하는지 확인합니다. - const isCurrentlyOnProtectedPage = protectedPageRoutes.some((route) => - currentPathname.startsWith(route), - ); - - // 401 에러가 발생했고, 현재 페이지가 보호된 페이지인 경우에만 메인페이지로 리다이렉션합니다. - if (response.status === 401 && isCurrentlyOnProtectedPage) { - console.error('클라이언트 측에서 401 에러 감지. 세션이 만료되었습니다.'); - - const redirectUrl = new URL(ROUTES.ACTIVITIES.ROOT, window.location.origin); - redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); - window.location.href = redirectUrl.toString(); - } -}; - /** * @description API 에러 응답을 가로채 사용자 친화적인 메시지로 포맷팅하고 전역적으로 토스트를 표시하는 공용 `ky` 훅입니다. * 이 훅은 `ky` 요청 실패 시 호출되어 다음과 같은 로직으로 에러를 처리합니다. @@ -87,11 +84,13 @@ const afterResponseHook = async ( * - `AbortError` 또는 `Timeout`과 같은 네트워크 오류 발생 시: '요청 시간이 초과되었습니다.'라는 사용자 친화적인 메시지를 설정합니다. * - 그 외의 파싱 오류 또는 네트워크 오류의 경우: 응답 상태를 포함한 일반적인 통신 오류 메시지를 설정합니다. * + * 3. **인증 에러(`401`) 전역 처리**: + * - API 요청이 `401 Unauthorized`를 반환하면, 클라이언트의 전역 사용자 상태(`useRoamReadyStore`)를 즉시 초기화하여 헤더 UI가 로그아웃 상태로 변경되도록 합니다. + * - 또한, 현재 페이지가 `mypage`와 같은 보호된 라우트일 경우, 사용자를 메인 페이지로 리디렉션하여 세션 만료를 알립니다. + * * @param {HTTPError} error - `ky` 라이브러리에서 발생한 HTTP 에러 객체입니다. * @returns {Promise} - 처리되고 메시지가 포맷팅된 HTTP 에러 객체를 반환합니다. */ export const formatErrorResponseHooks: Hooks = { - // 별도로 정의된 훅들을 배열에 담아 할당합니다. beforeError: [beforeErrorHook], - afterResponse: [afterResponseHook], }; From 628d8ea91962880490737241d7cc97693b878c76 Mon Sep 17 00:00:00 2001 From: Sienna Date: Thu, 7 Aug 2025 03:34:04 +0900 Subject: [PATCH 6/7] =?UTF-8?q?(#541)=20fix:=20suspense=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=90=EC=8B=B8=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1d68487d..5d4a6371 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,7 @@ import './global.css'; import type { Metadata } from 'next'; import localFont from 'next/font/local'; -import { ReactNode } from 'react'; +import { ReactNode, Suspense } from 'react'; import ClientProvider from './_components/ClientProvider'; @@ -30,7 +30,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
- {children} + + {children} + ); From 875e5314c3acfc6a19ff112ecd62f45b06095917 Mon Sep 17 00:00:00 2001 From: Sienna Date: Thu, 7 Aug 2025 03:57:00 +0900 Subject: [PATCH 7/7] =?UTF-8?q?(#541)=20fix:=20useProfileImageMutation=20?= =?UTF-8?q?=EC=9B=90=EC=83=81=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../User/hooks/useProfileImageMutation.ts | 37 +++++++++++++++++-- src/shared/libs/formatErrorResponseHooks.ts | 28 ++++++++------ 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/domain/User/hooks/useProfileImageMutation.ts b/src/domain/User/hooks/useProfileImageMutation.ts index cc8c2c73..106f1d74 100644 --- a/src/domain/User/hooks/useProfileImageMutation.ts +++ b/src/domain/User/hooks/useProfileImageMutation.ts @@ -1,6 +1,8 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Dispatch, SetStateAction } from 'react'; +import { useRoamReadyStore } from '@/shared/store'; + import { uploadProfileImage, UploadProfileImageResponse, @@ -33,16 +35,45 @@ interface MutationContext { */ export const useProfileImageMutation = ({ setProfileImageUrl, + initialImageUrl, onImageChange, }: MutationHookProp) => { + const queryClient = useQueryClient(); + const { user, setUser } = useRoamReadyStore(); + return useMutation({ mutationFn: uploadProfileImage, + // 낙관적 업데이트: 업로드 시작 전 즉시 미리보기 표시 + onMutate: async (imageFile: File): Promise => { + // 롤백을 위해 이전 이미지 URL 저장 + const previousImageUrl = initialImageUrl; + + // 즉시 미리보기를 위한 임시 URL 생성 + const previewUrl = URL.createObjectURL(imageFile); + + // UI를 즉시 업데이트하여 사용자에게 반응성 제공 + setProfileImageUrl(previewUrl); + onImageChange?.(previewUrl); + + // 컨텍스트로 이전 상태와 임시 URL 반환 + return { previousImageUrl, previewUrl }; + }, + // 업로드 성공 시 실행: 실제 서버 URL로 교체 onSuccess: (data, _variables, context?: MutationContext) => { - // 서버에서 받은 실제 이미지 URL로 상태를 업데이트합니다. + // 기존 user 객체의 다른 정보는 유지한 채, profileImageUrl만 업데이트합니다. + if (user) { + const updatedUser = { ...user, profileImageUrl: data.profileImageUrl }; + setUser(updatedUser); + } + + // Tanstack Query 캐시 무효화는 그대로 유지합니다. + queryClient.invalidateQueries({ queryKey: ['user', 'me'] }); + + // API로부터 받은 실제 이미지 URL로 상태 업데이트 setProfileImageUrl(data.profileImageUrl); - // 상위 컴포넌트에 최종 이미지 변경을 알립니다. + // 상위 컴포넌트에 최종 이미지 변경을 알림 onImageChange?.(data.profileImageUrl); // 미리보기 URL 메모리 해제 diff --git a/src/shared/libs/formatErrorResponseHooks.ts b/src/shared/libs/formatErrorResponseHooks.ts index 0afe86e8..ead00af6 100644 --- a/src/shared/libs/formatErrorResponseHooks.ts +++ b/src/shared/libs/formatErrorResponseHooks.ts @@ -2,6 +2,7 @@ import type { Hooks } from 'ky'; import { HTTPError } from 'ky'; import { ERROR_CODES, ROUTES } from '@/shared/constants/routes'; +import { queryClient } from '@/shared/libs/queryClient'; import { errorResponseSchema } from '@/shared/schemas/error'; import { useRoamReadyStore } from '@/shared/store'; import getErrorMessageByStatus from '@/shared/utils/errors/getErrorMessageByStatus'; @@ -15,20 +16,25 @@ const beforeErrorHook = async (error: HTTPError) => { console.error('클라이언트 측에서 401 에러 감지. 세션이 만료되었습니다.'); const { clearUser } = useRoamReadyStore.getState(); clearUser(); - } - const currentPathname = window.location.pathname; - const isCurrentlyOnProtectedPage = protectedPageRoutes.some((route) => - currentPathname.startsWith(route), - ); + queryClient.invalidateQueries({ queryKey: ['user', 'me'] }); - if (response.status === 401 && isCurrentlyOnProtectedPage) { - console.error( - '클라이언트 측에서 401 에러 감지. 보호된 페이지에서 세션이 만료되었습니다.', + const currentPathname = window.location.pathname; + const isCurrentlyOnProtectedPage = protectedPageRoutes.some((route) => + currentPathname.startsWith(route), ); - const redirectUrl = new URL(ROUTES.ACTIVITIES.ROOT, window.location.origin); - redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); - window.location.href = redirectUrl.toString(); + + if (response.status === 401 && isCurrentlyOnProtectedPage) { + console.error( + '클라이언트 측에서 401 에러 감지. 보호된 페이지에서 세션이 만료되었습니다.', + ); + const redirectUrl = new URL( + ROUTES.ACTIVITIES.ROOT, + window.location.origin, + ); + redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); + window.location.href = redirectUrl.toString(); + } } try {