diff --git a/src/domains/login/components/ClientInitHook.tsx b/src/domains/login/components/ClientInitHook.tsx index 034d284..3f86154 100644 --- a/src/domains/login/components/ClientInitHook.tsx +++ b/src/domains/login/components/ClientInitHook.tsx @@ -2,18 +2,29 @@ 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 = useAuthStore((state) => state.checkAuth); + const { isAuthChecked } = useAuthStore(); + const checkAuthRef = useRef(useAuthStore.getState().checkAuth); useIdleLogout(); + useFetchInterceptor(); useEffect(() => { - checkAuth(); - }, [checkAuth]); + // ref를 최신 함수로 업데이트 + checkAuthRef.current = useAuthStore.getState().checkAuth; + }); + + useEffect(() => { + if (!isAuthChecked) { + checkAuthRef.current(); + } + }, [isAuthChecked]); + return null; } + export default ClientInitHook; 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 eebb854..9ff0325 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'; @@ -13,17 +14,19 @@ export interface User { interface AuthState { user: User | null; isLoggedIn: boolean; + isAuthChecked: boolean; + setUser: (user: User) => void; logout: () => Promise; loginWithProvider: (provider: User['provider']) => void; - updateUser: () => Promise; checkAuth: () => Promise; } -export const useAuthStore = create()((set) => ({ +export const useAuthStore = create()((set, get) => ({ user: null, isLoggedIn: false, + isAuthChecked: false, loginWithProvider: (provider) => { window.location.href = `${getApi}/oauth2/authorization/${provider}`; @@ -31,25 +34,27 @@ 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`, { 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 }); } }, - // idle + refresh 시 호출 updateUser: async () => { try { const res = await fetch(`${getApi}/user/auth/refresh`, { @@ -58,15 +63,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); @@ -75,24 +87,41 @@ export const useAuthStore = create()((set) => ({ } }, - // 시작 시 로그인 상태 확인 checkAuth: async () => { + 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) throw new Error('인증 실패'); + + // 항상 200 응답 기대 + if (!res.ok) { + set({ user: null, isLoggedIn: false, isAuthChecked: true }); + return null; + } const data = await res.json(); - const userInfo = data?.data?.user; + const userInfo = data?.data?.user || null; + if (userInfo) { - set({ user: userInfo, isLoggedIn: true }); - return userInfo; + const updatedUser = { ...userInfo, abv_degree: userInfo.abv_degree ?? 5.0 }; + set({ user: updatedUser, isLoggedIn: true, isAuthChecked: true }); + return updatedUser; } + + // user가 null이어도 정상 응답 + set({ user: null, isLoggedIn: false, isAuthChecked: true }); return null; - } catch { - set({ user: null, isLoggedIn: false }); + } 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;