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/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 ( 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} + ); 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 9366976d..943f5834 100644 --- a/src/domain/Auth/utils/setAuthCookies.ts +++ b/src/domain/Auth/utils/setAuthCookies.ts @@ -34,8 +34,10 @@ export default function setAuthCookies( secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - maxAge: 60 * 60, - // maxAge: 15, + + // maxAge: 60 * 60, //! 테스트를 위해 15초로 + maxAge: 10, + }); response.cookies.set({ @@ -46,6 +48,7 @@ export default function setAuthCookies( sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7, + // maxAge: 10, }); return response; diff --git a/src/middleware.ts b/src/middleware.ts index 087b6dd4..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,11 +106,22 @@ export async function middleware(request: NextRequest) { signal: AbortSignal.timeout(30000), }); - //! 여기서 분명히 갱신했는데 개발서버에서는 갱신이 되는데... 배포하면 안됨.. + // 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; - const refreshResponse = await fetch( - `${BACKEND_URL}${API_ENDPOINTS.AUTH.REFRESH_TOKEN}`, + console.log(refreshToken); + const newTokenResponse = await fetch( + `${BACKEND_URL}${API_ENDPOINTS.AUTH.NEW_TOKEN}`, { method: 'POST', headers: { @@ -121,8 +131,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 발급 성공'); @@ -146,12 +156,7 @@ export async function middleware(request: NextRequest) { tokens, ); } else { - console.log( - '[Middleware] Refresh Token 만료 또는 갱신 실패. 로그인 페이지로 리다이렉트합니다.', - ); - const redirectUrl = new URL(ROUTES.SIGNIN, request.url); - redirectUrl.searchParams.set('error', ERROR_CODES.SESSION_EXPIRED); - return NextResponse.redirect(redirectUrl); + console.log('[Middleware] Refresh Token 만료 또는 갱신 실패'); } } @@ -172,5 +177,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..ead00af6 100644 --- a/src/shared/libs/formatErrorResponseHooks.ts +++ b/src/shared/libs/formatErrorResponseHooks.ts @@ -1,9 +1,80 @@ 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'; +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(); + + queryClient.invalidateQueries({ queryKey: ['user', 'me'] }); + + 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')) { + 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; +}; + /** * @description API 에러 응답을 가로채 사용자 친화적인 메시지로 포맷팅하고 전역적으로 토스트를 표시하는 공용 `ky` 훅입니다. * 이 훅은 `ky` 요청 실패 시 호출되어 다음과 같은 로직으로 에러를 처리합니다. @@ -19,51 +90,13 @@ import getErrorMessageByStatus from '@/shared/utils/errors/getErrorMessageByStat * - `AbortError` 또는 `Timeout`과 같은 네트워크 오류 발생 시: '요청 시간이 초과되었습니다.'라는 사용자 친화적인 메시지를 설정합니다. * - 그 외의 파싱 오류 또는 네트워크 오류의 경우: 응답 상태를 포함한 일반적인 통신 오류 메시지를 설정합니다. * + * 3. **인증 에러(`401`) 전역 처리**: + * - API 요청이 `401 Unauthorized`를 반환하면, 클라이언트의 전역 사용자 상태(`useRoamReadyStore`)를 즉시 초기화하여 헤더 UI가 로그아웃 상태로 변경되도록 합니다. + * - 또한, 현재 페이지가 `mypage`와 같은 보호된 라우트일 경우, 사용자를 메인 페이지로 리디렉션하여 세션 만료를 알립니다. + * * @param {HTTPError} error - `ky` 라이브러리에서 발생한 HTTP 에러 객체입니다. * @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], };