From a04d50368c4537f638490ae5a9f3deacc518dec2 Mon Sep 17 00:00:00 2001 From: ahk0413 Date: Tue, 14 Oct 2025 11:54:18 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[fix]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20auth/me=20=EC=9E=90=EB=8F=99=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=A1=9C=20401=20=EC=97=90=EB=9F=AC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/components/ClientInitHook.tsx | 12 ++++-- src/domains/shared/store/auth.ts | 37 ++++++++++--------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/domains/login/components/ClientInitHook.tsx b/src/domains/login/components/ClientInitHook.tsx index 034d284..1e780cd 100644 --- a/src/domains/login/components/ClientInitHook.tsx +++ b/src/domains/login/components/ClientInitHook.tsx @@ -6,14 +6,20 @@ import { useEffect } from 'react'; import { useAuthStore } from '@/domains/shared/store/auth'; function ClientInitHook() { - const checkAuth = useAuthStore((state) => state.checkAuth); + const { checkAuth, isLoggedIn, isAuthChecked } = useAuthStore(); useIdleLogout(); + useFetchInterceptor(); useEffect(() => { - checkAuth(); - }, [checkAuth]); + // 로그인 상태가 아니거나 이미 체크 완료면 호출하지 않음 + if (isLoggedIn && !isAuthChecked) { + checkAuth(); // 쿠키 기반 인증 상태 확인 + } + }, [checkAuth, isLoggedIn, isAuthChecked]); + return null; } + export default ClientInitHook; diff --git a/src/domains/shared/store/auth.ts b/src/domains/shared/store/auth.ts index eebb854..1cd6e92 100644 --- a/src/domains/shared/store/auth.ts +++ b/src/domains/shared/store/auth.ts @@ -13,6 +13,7 @@ export interface User { interface AuthState { user: User | null; isLoggedIn: boolean; + isAuthChecked: boolean; setUser: (user: User) => void; logout: () => Promise; loginWithProvider: (provider: User['provider']) => void; @@ -21,9 +22,12 @@ interface AuthState { checkAuth: () => Promise; } +const hasAccessToken = typeof document !== 'undefined' && document.cookie.includes('accessToken'); + export const useAuthStore = create()((set) => ({ user: null, - isLoggedIn: false, + isLoggedIn: hasAccessToken, + isAuthChecked: false, loginWithProvider: (provider) => { window.location.href = `${getApi}/oauth2/authorization/${provider}`; @@ -41,11 +45,12 @@ export const useAuthStore = create()((set) => ({ method: 'POST', credentials: 'include', }); - set({ user: null, isLoggedIn: false }); + + // 상태 초기화 + set({ user: null, isLoggedIn: false, isAuthChecked: true }); } catch (err) { console.error('로그아웃 실패', err); - } finally { - set({ user: null, isLoggedIn: false }); + set({ user: null, isLoggedIn: false, isAuthChecked: true }); } }, @@ -75,25 +80,21 @@ export const useAuthStore = create()((set) => ({ } }, - // 시작 시 로그인 상태 확인 checkAuth: async () => { + const state = useAuthStore.getState(); + if (!state.isLoggedIn || state.isAuthChecked) return null; + try { - const res = await fetch(`${getApi}/user/auth/me`, { - method: 'GET', - credentials: 'include', - }); - if (!res.ok) throw new Error('인증 실패'); + const res = await fetch(`${getApi}/user/auth/me`, { method: 'GET', credentials: 'include' }); + if (!res.ok) return set({ user: null, isLoggedIn: false }); const data = await res.json(); const userInfo = data?.data?.user; - if (userInfo) { - set({ user: userInfo, isLoggedIn: true }); - return userInfo; - } - return null; - } catch { - set({ user: null, isLoggedIn: false }); - return null; + if (userInfo) set({ user: userInfo, isLoggedIn: true }); + + return userInfo || null; + } finally { + set({ isAuthChecked: true }); } }, })); From e872a3db760203868b25899fd67090dfc7e5b1a8 Mon Sep 17 00:00:00 2001 From: ahk0413 Date: Tue, 14 Oct 2025 15:05:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[fix]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=8B=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login/components/ClientInitHook.tsx | 17 +++-- src/domains/shared/store/auth.ts | 73 +++++++++++++------ src/middleware.ts | 27 +++++++ src/shared/hook/useFetchInterceptor.tsx | 13 ++++ 4 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 src/middleware.ts diff --git a/src/domains/login/components/ClientInitHook.tsx b/src/domains/login/components/ClientInitHook.tsx index 1e780cd..3f86154 100644 --- a/src/domains/login/components/ClientInitHook.tsx +++ b/src/domains/login/components/ClientInitHook.tsx @@ -2,22 +2,27 @@ import { useFetchInterceptor } from '@/shared/hook/useFetchInterceptor'; import { useIdleLogout } from '../hook/useIdleLogout'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useAuthStore } from '@/domains/shared/store/auth'; function ClientInitHook() { - const { checkAuth, isLoggedIn, isAuthChecked } = useAuthStore(); + const { isAuthChecked } = useAuthStore(); + const checkAuthRef = useRef(useAuthStore.getState().checkAuth); useIdleLogout(); useFetchInterceptor(); useEffect(() => { - // 로그인 상태가 아니거나 이미 체크 완료면 호출하지 않음 - if (isLoggedIn && !isAuthChecked) { - checkAuth(); // 쿠키 기반 인증 상태 확인 + // ref를 최신 함수로 업데이트 + checkAuthRef.current = useAuthStore.getState().checkAuth; + }); + + useEffect(() => { + if (!isAuthChecked) { + checkAuthRef.current(); } - }, [checkAuth, isLoggedIn, isAuthChecked]); + }, [isAuthChecked]); return null; } diff --git a/src/domains/shared/store/auth.ts b/src/domains/shared/store/auth.ts index 1cd6e92..6ddfade 100644 --- a/src/domains/shared/store/auth.ts +++ b/src/domains/shared/store/auth.ts @@ -1,3 +1,4 @@ +// auth.ts import { getApi } from '@/app/api/config/appConfig'; import { create } from 'zustand'; @@ -14,19 +15,17 @@ interface AuthState { user: User | null; isLoggedIn: boolean; isAuthChecked: boolean; + setUser: (user: User) => void; logout: () => Promise; loginWithProvider: (provider: User['provider']) => void; - updateUser: () => Promise; checkAuth: () => Promise; } -const hasAccessToken = typeof document !== 'undefined' && document.cookie.includes('accessToken'); - -export const useAuthStore = create()((set) => ({ +export const useAuthStore = create()((set, get) => ({ user: null, - isLoggedIn: hasAccessToken, + isLoggedIn: false, isAuthChecked: false, loginWithProvider: (provider) => { @@ -35,10 +34,13 @@ export const useAuthStore = create()((set) => ({ setUser: (user) => { const updatedUser = { ...user, abv_degree: user.abv_degree ?? 5.0 }; - set({ user: updatedUser, isLoggedIn: true }); + set({ + user: updatedUser, + isLoggedIn: true, + isAuthChecked: true, + }); }, - // 로그아웃 logout: async () => { try { await fetch(`${getApi}/user/auth/logout`, { @@ -46,15 +48,16 @@ export const useAuthStore = create()((set) => ({ credentials: 'include', }); - // 상태 초기화 set({ user: null, isLoggedIn: false, isAuthChecked: true }); + + // 로그아웃 후 로그인 페이지로 리다이렉트 + window.location.href = '/login'; } catch (err) { console.error('로그아웃 실패', err); set({ user: null, isLoggedIn: false, isAuthChecked: true }); } }, - // idle + refresh 시 호출 updateUser: async () => { try { const res = await fetch(`${getApi}/user/auth/refresh`, { @@ -63,15 +66,22 @@ export const useAuthStore = create()((set) => ({ headers: { 'Content-Type': 'application/json' }, }); - if (!res.ok) throw new Error('토큰 갱신 실패'); + // 200 응답 기대 + if (!res.ok) { + set({ user: null, isLoggedIn: false }); + return null; + } const data = await res.json(); const userInfo = data?.data?.user; if (userInfo) { - set({ user: userInfo, isLoggedIn: true }); - return userInfo; + const updatedUser = { ...userInfo, abv_degree: userInfo.abv_degree ?? 5.0 }; + set({ user: updatedUser, isLoggedIn: true }); + return updatedUser; } + + set({ user: null, isLoggedIn: false }); return null; } catch (err) { console.error('updateUser 실패', err); @@ -81,20 +91,41 @@ export const useAuthStore = create()((set) => ({ }, checkAuth: async () => { - const state = useAuthStore.getState(); - if (!state.isLoggedIn || state.isAuthChecked) return null; + const { isAuthChecked } = get(); + + // 이미 체크했으면 현재 user 반환 + if (isAuthChecked) { + return get().user; + } try { - const res = await fetch(`${getApi}/user/auth/me`, { method: 'GET', credentials: 'include' }); - if (!res.ok) return set({ user: null, isLoggedIn: false }); + const res = await fetch(`${getApi}/user/auth/me`, { + method: 'GET', + credentials: 'include', + }); + + // 항상 200 응답 기대 + if (!res.ok) { + set({ user: null, isLoggedIn: false, isAuthChecked: true }); + return null; + } const data = await res.json(); - const userInfo = data?.data?.user; - if (userInfo) set({ user: userInfo, isLoggedIn: true }); + const userInfo = data?.data?.user || null; + + if (userInfo) { + const updatedUser = { ...userInfo, abv_degree: userInfo.abv_degree ?? 5.0 }; + set({ user: updatedUser, isLoggedIn: true, isAuthChecked: true }); + return updatedUser; + } - return userInfo || null; - } finally { - set({ isAuthChecked: true }); + // user가 null이어도 정상 응답 + set({ user: null, isLoggedIn: false, isAuthChecked: true }); + return null; + } catch (err) { + console.error('checkAuth 실패', err); + set({ user: null, isLoggedIn: false, isAuthChecked: true }); + return null; } }, })); diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..1056249 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,27 @@ +// middleware.ts +import { NextResponse, NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const authCookie = request.cookies.get('accessToken')?.value; + const { pathname } = request.nextUrl; + + const PUBLIC_PATHS = ['/', '/login', '/community', '/recipe', '/recommend']; + + const isPublicPath = PUBLIC_PATHS.some((path) => pathname.startsWith(path)); + + // 로그인한 사용자가 로그인 페이지 접근 → 홈으로 + if (authCookie && pathname === '/login') { + return NextResponse.redirect(new URL('/', request.url)); + } + + // 공개 경로가 아니면 로그인 필요 + if (!authCookie && !isPublicPath) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'], +}; diff --git a/src/shared/hook/useFetchInterceptor.tsx b/src/shared/hook/useFetchInterceptor.tsx index 3d8df3a..b4b2ffa 100644 --- a/src/shared/hook/useFetchInterceptor.tsx +++ b/src/shared/hook/useFetchInterceptor.tsx @@ -16,6 +16,15 @@ export const useFetchInterceptor = () => { const response = await originalFetch(input, { ...init, credentials: 'include' }); if (response.status === 401) { + // URL 문자열 추출 + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + // refresh API 자체가 401이면 무한루프 방지 + if (url.includes('/user/auth/refresh')) { + return response; + } + try { const refreshRes = await originalFetch(`${getApi}/user/auth/refresh`, { method: 'POST', @@ -23,14 +32,18 @@ export const useFetchInterceptor = () => { }); if (refreshRes.ok) { + // 토큰 갱신 성공 시 원래 요청 재시도 return originalFetch(input, { ...init, credentials: 'include' }); } else { + // refresh 실패 → 로그인 페이지로 toastInfo('로그인 인증 만료로 다시 로그인해주세요.'); router.push('/login'); + return response; } } catch { toastInfo('로그인 인증 만료로 다시 로그인해주세요.'); router.push('/login'); + return response; } } return response; From bc0ba6ee87698a74e110c042c97aec4a0d31c9c8 Mon Sep 17 00:00:00 2001 From: ahk0413 Date: Tue, 14 Oct 2025 15:37:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=20setTimeout=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/domains/login/hook/useLoginRedirect.ts | 6 +++--- src/domains/shared/store/auth.ts | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/domains/login/hook/useLoginRedirect.ts b/src/domains/login/hook/useLoginRedirect.ts index 32b46a8..70d1d74 100644 --- a/src/domains/login/hook/useLoginRedirect.ts +++ b/src/domains/login/hook/useLoginRedirect.ts @@ -33,7 +33,7 @@ export const useLoginRedirect = () => { if (user && preLoginPath === '/login') { router.replace('/'); - removeCookie('preLoginPath'); + setTimeout(() => removeCookie('preLoginPath'), 500); return; } @@ -42,14 +42,14 @@ export const useLoginRedirect = () => { } else if (pathname.startsWith('/login/user/success')) { toastSuccess(`${user.nickname}님 \n 로그인 성공 🎉`); router.replace(preLoginPath); - removeCookie('preLoginPath'); + setTimeout(() => removeCookie('preLoginPath'), 500); } }, [pathname, user, loading, router, toastSuccess]); const handleCloseWelcomeModal = () => { setWelcomeModalOpen(false); const preLoginPath = getCookie('preLoginPath') || '/'; - removeCookie('preLoginPath'); + setTimeout(() => removeCookie('preLoginPath'), 500); router.replace(preLoginPath); }; diff --git a/src/domains/shared/store/auth.ts b/src/domains/shared/store/auth.ts index 6ddfade..9ff0325 100644 --- a/src/domains/shared/store/auth.ts +++ b/src/domains/shared/store/auth.ts @@ -49,9 +49,6 @@ export const useAuthStore = create()((set, get) => ({ }); set({ user: null, isLoggedIn: false, isAuthChecked: true }); - - // 로그아웃 후 로그인 페이지로 리다이렉트 - window.location.href = '/login'; } catch (err) { console.error('로그아웃 실패', err); set({ user: null, isLoggedIn: false, isAuthChecked: true });