Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions src/app/_components/ClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,7 +18,10 @@ import { queryClient } from '@/shared/libs/queryClient';
*
* 1. **인증 상태 초기화**: 앱이 시작될 때 `<AuthInitializer />`를 통해 실제 서버의 인증 상태와 클라이언트 UI 상태를 동기화하여, "UI는 로그인 상태인데 실제로는 토큰이 만료된" 상태 불일치 문제를 해결합니다.
* 2. **서버 상태 관리**: Tanstack Query의 `QueryClientProvider`를 설정하여 애플리케이션 전반에서 데이터 페칭 및 캐싱을 관리합니다.
* 3. **전역 알림**: 전역 토스트 알림을 위한 `<ToastContainer />`를 렌더링합니다.
* 3. **세션 만료 시 클라이언트 상태 초기화**
* - URL에 `?error=SESSION_EXPIRED` 쿼리 파라미터가 존재하면, 클라이언트 전역 상태(`Zustand`)에서 사용자 정보를 초기화합니다.
* - 이는 미들웨어 또는 API 응답에서 세션 만료로 리디렉션된 경우를 처리하기 위한 UX 방어로직입니다.
* 4. **전역 알림**: 전역 토스트 알림을 위한 `<ToastContainer />`를 렌더링합니다.
*
* 이 컴포넌트는 Next.js의 'use client' 지시어가 적용되어 클라이언트 사이드에서만 동작하며, 애플리케이션의 루트 레이아웃에서 사용되어야 합니다.
*
Expand All @@ -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]);
Comment on lines +33 to +40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

세션 만료 처리 로직을 검증해 주세요.

구현 로직은 올바르게 작성되었지만, 다음 사항들을 확인해 주세요:

  1. ERROR_CODES.SESSION_EXPIRED 상수값이 실제 미들웨어나 API 응답에서 사용하는 값과 일치하는지
  2. clearUser() 함수가 모든 필요한 사용자 상태를 완전히 초기화하는지
  3. 다른 에러 코드들도 비슷한 방식으로 처리가 필요한지

다음 스크립트를 실행하여 ERROR_CODES 상수와 clearUser 함수의 구현을 확인해 주세요:


🏁 Script executed:

#!/bin/bash
# ERROR_CODES 상수 정의 확인
echo "=== ERROR_CODES 상수 정의 ==="
rg -A 10 "ERROR_CODES" --type ts

echo "=== clearUser 함수 구현 확인 ==="
ast-grep --pattern 'clearUser: $_'

echo "=== 세션 만료 관련 다른 사용처 확인 ==="
rg "SESSION_EXPIRED" --type ts -A 3 -B 3

Length of output: 16835


다음 스크립트를 실행하여 clearUser 함수의 정의 위치와 구현 내용을 확인해 주세요.


🏁 Script executed:

#!/bin/bash
# clearUser 함수 정의가 선언된 파일 찾기
echo "=== clearUser 함수 정의 위치 ==="
rg -n "clearUser" -g "src/shared/store/**/*.{ts,tsx}"

# clearUser 함수 구현 전체 확인
echo "=== clearUser 함수 구현 확인 (문맥 포함) ==="
rg -n -B2 -A5 "clearUser" -g "src/shared/store/**/*.{ts,tsx}"

Length of output: 257


다음과 같이 전 범위에서 clearUser 정의를 찾는 스크립트를 실행해주세요.

위 결과를 통해 clearUser가 선언된 파일과 구현 내용을 확인한 뒤 다시 공유해주세요.


🏁 Script executed:

#!/bin/bash
# 전 범위에서 clearUser 호출/정의 위치 확인
echo "=== clearUser 모든 출현 위치 ==="
rg -n "clearUser" -A3 -B3

Length of output: 6961


세션 만료 처리 로직 검증 및 추가 캐시 초기화 제안

  1. ERROR_CODES.SESSION_EXPIRED

    • 정의(src/shared/constants/routes.ts)된 값 'session_expired'이 formatErrorResponseHooks, 테스트 페이지 등에서 일관되게 사용되고 있어 매칭에 문제 없습니다.
  2. clearUser() 동작 확인

    • src/shared/slices/userSlice.ts 에서 user: null만 설정하도록 구현되어 있습니다.
    • 세션 만료 시, 사용자 정보 외에도 TanStack Query 캐시가 남아 있을 수 있으므로 queryClient.removeQueries(['user', 'me']) 또는 queryClient.clear() 등을 통해 추가 초기화를 고려해주세요.
  3. 기타 에러 코드 검토

    • 401 외에, 403(권한 없음) 등 다른 HTTP 상태에서도 UX 방어 로직(리다이렉트·상태 초기화)이 필요한지 함께 검토 바랍니다.

– 검사 대상

  • src/app/_components/ClientProvider.tsx (useEffect 내 clearUser() 호출부)
  • 필요 시 queryClient 초기화 로직 추가 위치
🤖 Prompt for AI Agents
In src/app/_components/ClientProvider.tsx around lines 33 to 40, the current
session expiration handling calls clearUser() but does not clear TanStack Query
cache, which may leave stale data. To fix this, import and use the queryClient
instance to call queryClient.removeQueries(['user', 'me']) or
queryClient.clear() inside the useEffect when the session expired error is
detected, ensuring all relevant cached data is cleared. Also, review if similar
handling is needed for other error codes like 403 and implement corresponding
state resets or redirects as appropriate.


return (
<QueryClientProvider client={queryClient}>
<AuthInitializer />
Expand Down
6 changes: 4 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -30,7 +30,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<html lang='ko'>
<body className={pretendard.className}>
<div id='portal-root'></div>
<ClientProvider>{children}</ClientProvider>
<Suspense>
<ClientProvider>{children}</ClientProvider>
</Suspense>
</body>
</html>
);
Expand Down
36 changes: 20 additions & 16 deletions src/app/test/nothing/page.dev.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
'use client';

import { Suspense } from 'react';

import Nothing from '@/shared/components/ui/nothing';

export default function NothingTestPage() {
return (
<div className='flex h-screen w-full content-start items-center justify-center gap-100'>
<div className='flex flex-col items-center gap-20'>
<Nothing type='reservation' />
<span className='font-size-16 text-gray-700'>type: reservation</span>
<p className='font-size-13'>
버튼 클릭시 /activities 페이지로 이동합니다.
</p>
</div>
<div className='flex flex-col items-center gap-20'>
<Nothing type='activity' />
<span className='font-size-16 text-gray-700'>type: activity</span>
</div>
<div className='flex flex-col items-center gap-20'>
<Nothing type='review' />
<span className='font-size-16 text-gray-700'>type: review</span>
<Suspense fallback={null}>
<div className='flex h-screen w-full content-start items-center justify-center gap-100'>
<div className='flex flex-col items-center gap-20'>
<Nothing type='reservation' />
<span className='font-size-16 text-gray-700'>type: reservation</span>
<p className='font-size-13'>
버튼 클릭시 /activities 페이지로 이동합니다.
</p>
</div>
<div className='flex flex-col items-center gap-20'>
<Nothing type='activity' />
<span className='font-size-16 text-gray-700'>type: activity</span>
</div>
<div className='flex flex-col items-center gap-20'>
<Nothing type='review' />
<span className='font-size-16 text-gray-700'>type: review</span>
</div>
</div>
</div>
</Suspense>
);
}
7 changes: 5 additions & 2 deletions src/domain/Auth/utils/setAuthCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export default function setAuthCookies<T>(
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60,
// maxAge: 15,

// maxAge: 60 * 60, //! 테스트를 위해 15초로
maxAge: 10,

});

response.cookies.set({
Expand All @@ -46,6 +48,7 @@ export default function setAuthCookies<T>(
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
// maxAge: 10,
});

return response;
Expand Down
33 changes: 18 additions & 15 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)가 발급됩니다.
Expand Down Expand Up @@ -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: {
Expand All @@ -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 발급 성공');

Expand All @@ -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 만료 또는 갱신 실패');
}
}

Expand All @@ -172,5 +177,3 @@ export async function middleware(request: NextRequest) {
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

// 테스트 커밋
2 changes: 1 addition & 1 deletion src/shared/constants/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const API_ENDPOINTS = {
AUTH: {
SIGNUP: '/users',
SIGNIN: '/auth/login',
REFRESH_TOKEN: '/auth/tokens',
NEW_TOKEN: '/auth/tokens',
},

OAUTH: {
Expand Down
119 changes: 76 additions & 43 deletions src/shared/libs/formatErrorResponseHooks.ts
Original file line number Diff line number Diff line change
@@ -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` 요청 실패 시 호출되어 다음과 같은 로직으로 에러를 처리합니다.
Expand All @@ -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<HTTPError>} - 처리되고 메시지가 포맷팅된 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],
};
Loading