From 8007e83f247607205b06820909d0a2ac5a57f3b8 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 16:50:01 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9B=84=20=EC=9B=90=EB=9E=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B3=B5=EA=B7=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkAuth: 인증 실패 시 redirectTo 쿼리파라미터로 원래 경로 전달 - LoginPage: redirectTo 파라미터를 읽어 kakaoLogin에 전달 - kakaoLogin: state에 완전한 URL(origin + pathname) 담아 전송 (백엔드가 로그인 후 해당 URL로 직접 redirect) --- src/entities/auth/lib/checkAuth.ts | 9 ++++----- src/entities/auth/lib/kakaoLogin.ts | 12 ++++++++---- src/pages/login/LoginPage.tsx | 14 ++++++-------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/entities/auth/lib/checkAuth.ts b/src/entities/auth/lib/checkAuth.ts index 3f873918..0014eba7 100644 --- a/src/entities/auth/lib/checkAuth.ts +++ b/src/entities/auth/lib/checkAuth.ts @@ -1,4 +1,5 @@ import { redirect } from 'react-router'; +import type { LoaderFunctionArgs } from 'react-router'; import { ROUTE } from '@/shared/config/route'; import { queryClient } from '@/shared/api/queryClient'; import { getAuth } from '../api/auth'; @@ -6,7 +7,7 @@ import { getAuth } from '../api/auth'; /** * 페이지에 접근하기 전에 실행되는 함수 * */ -const checkAuth = async () => { +const checkAuth = async ({ request }: LoaderFunctionArgs) => { try { const user = await queryClient.ensureQueryData({ queryKey: ['auth', 'user'], @@ -21,10 +22,8 @@ const checkAuth = async () => { return user; } catch { - // NOTE - 로그인 성공 후 이전 페이지로 돌아가기 위한 로직 - // const redirectTo = new URL(request.url).pathname; - // return redirect(`${ROUTE.login}?redirectTo=${redirectTo}`); - return redirect(ROUTE.login); + const redirectTo = new URL(request.url).pathname; + return redirect(`${ROUTE.login}?redirectTo=${redirectTo}`); } }; diff --git a/src/entities/auth/lib/kakaoLogin.ts b/src/entities/auth/lib/kakaoLogin.ts index f9e3ab4e..fa85fe2f 100644 --- a/src/entities/auth/lib/kakaoLogin.ts +++ b/src/entities/auth/lib/kakaoLogin.ts @@ -1,16 +1,20 @@ const KAKAO_CLIENT_ID = import.meta.env.VITE_KAKAO_CLIENT_ID; const KAKAO_REDIRECT_URI = import.meta.env.VITE_KAKAO_REDIRECT_URI; -function kakaoLogin(url?: string) { +/** + * 카카오 소셜 로그인 페이지로 이동한다. + * @param redirectPathAfterLogin - 로그인 완료 후 돌아올 경로 (pathname). 미전달 시 origin(루트)으로 이동. + * state 파라미터에 완전한 URL을 담아 전달하며, 백엔드가 로그인 완료 후 해당 URL로 redirect한다. + */ +function kakaoLogin(redirectPathAfterLogin?: string) { if (!KAKAO_CLIENT_ID || !KAKAO_REDIRECT_URI) { throw new Error('카카오 OAuth에 필요한 환경 변수가 설정되지 않았습니다.'); } - const defaultRedirectUrl = window.location.origin; - const redirectUrl = url || defaultRedirectUrl; + const stateUrl = `${window.location.origin}${redirectPathAfterLogin ?? ''}`; window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${KAKAO_REDIRECT_URI}&response_type=code&state=${encodeURIComponent( - redirectUrl + stateUrl )}`; } diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 00908c90..06ff9364 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -1,7 +1,6 @@ import LogoImg from '@/shared/assets/pngs/LogoImg.png'; import Text from '@/shared/ui/Text'; -import { useNavigate } from 'react-router'; -import { ROUTE } from '@/shared/config/route'; +import { useSearchParams } from 'react-router'; import { useEffect, useState } from 'react'; import theme from '@/shared/styles/theme'; import Button from '@/shared/ui/Button'; @@ -14,17 +13,16 @@ import * as S from './LoginPage.styles'; function LoginPage() { const { refetch: getGuestToken } = useGetGuestToken(); - const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [isEntrance, setIsEntrance] = useState(true); const handleLoginButtonClick = (loginType: 'KAKAO' | 'GUEST') => { - const token = localStorage.getItem('accessToken'); if (loginType === 'KAKAO') { - kakaoLogin(); - } else if (!token) { - getGuestToken(); + const redirectPathAfterLogin = + searchParams.get('redirectTo') ?? undefined; + kakaoLogin(redirectPathAfterLogin); } else { - navigate(ROUTE.home); + getGuestToken(); } }; From a0fb892778a6d2fccdbbb8ec29fdf2c838628828 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 20:26:52 +0900 Subject: [PATCH 02/27] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20API=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20mock=20=EC=98=B5=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/auth/api/auth.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/entities/auth/api/auth.ts b/src/entities/auth/api/auth.ts index adb2a80c..e0db2fc8 100644 --- a/src/entities/auth/api/auth.ts +++ b/src/entities/auth/api/auth.ts @@ -17,9 +17,7 @@ export const getGuestToken = async (): Promise => { // ========== export const getAuth = async () => { - const response = await axiosInstance.get('/user/auth/check', { - useMock: true, - }); + const response = await axiosInstance.get('/auth/check'); return response.data; }; From 903e2ea30d48f9835b36795ff287076815224d88 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 20:27:52 +0900 Subject: [PATCH 03/27] =?UTF-8?q?refactor:=20auth=20loader=EB=A5=BC=20feat?= =?UTF-8?q?ures=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkAuth를 entities/auth/lib에서 features/auth/lib로 이동하고 import 경로를 수정합니다. --- src/app/Router.tsx | 2 +- src/{entities => features}/auth/lib/checkAuth.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{entities => features}/auth/lib/checkAuth.ts (93%) diff --git a/src/app/Router.tsx b/src/app/Router.tsx index 23d3a7cd..ee19f41e 100644 --- a/src/app/Router.tsx +++ b/src/app/Router.tsx @@ -3,7 +3,7 @@ import { createBrowserRouter, Outlet, RouterProvider } from 'react-router'; import { ROUTE } from '@/shared/config/route'; import RouteErrorBoundary from '@/app/RouteErrorBoundary'; import RouteErrorElement from '@/app/RouteErrorElement'; -import checkAuth from '@/entities/auth/lib/checkAuth'; +import checkAuth from '@/features/auth/lib/checkAuth'; import groupTokenUrlLoader from '@/entities/auth/lib/groupTokenUrlLoader'; import createExpensePageGuardLoader from '@/pages/CreateExpensePage/lib/createExpensePageGuardLoader'; diff --git a/src/entities/auth/lib/checkAuth.ts b/src/features/auth/lib/checkAuth.ts similarity index 93% rename from src/entities/auth/lib/checkAuth.ts rename to src/features/auth/lib/checkAuth.ts index 0014eba7..9ee33aa1 100644 --- a/src/entities/auth/lib/checkAuth.ts +++ b/src/features/auth/lib/checkAuth.ts @@ -2,7 +2,7 @@ import { redirect } from 'react-router'; import type { LoaderFunctionArgs } from 'react-router'; import { ROUTE } from '@/shared/config/route'; import { queryClient } from '@/shared/api/queryClient'; -import { getAuth } from '../api/auth'; +import { getAuth } from '@/entities/auth/api/auth'; /** * 페이지에 접근하기 전에 실행되는 함수 From d55c5950fc22c0ef0c9f0aa814d8dfc5fb6380cf Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 20:28:41 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=83=81=ED=83=9C=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?loader=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이미 인증된 경우 redirectTo 또는 홈으로 redirect하는 checkAlreadyAuthLoader를 추가하고 /login 라우트에 연결합니다. --- src/app/Router.tsx | 2 ++ .../auth/lib/checkAlreadyAuthLoader.ts | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 src/features/auth/lib/checkAlreadyAuthLoader.ts diff --git a/src/app/Router.tsx b/src/app/Router.tsx index ee19f41e..21b8970b 100644 --- a/src/app/Router.tsx +++ b/src/app/Router.tsx @@ -4,6 +4,7 @@ import { ROUTE } from '@/shared/config/route'; import RouteErrorBoundary from '@/app/RouteErrorBoundary'; import RouteErrorElement from '@/app/RouteErrorElement'; import checkAuth from '@/features/auth/lib/checkAuth'; +import checkAlreadyAuthLoader from '@/features/auth/lib/checkAlreadyAuthLoader'; import groupTokenUrlLoader from '@/entities/auth/lib/groupTokenUrlLoader'; import createExpensePageGuardLoader from '@/pages/CreateExpensePage/lib/createExpensePageGuardLoader'; @@ -80,6 +81,7 @@ function AppRouter() { { path: ROUTE.login, element: , + loader: checkAlreadyAuthLoader, }, { id: 'protected', diff --git a/src/features/auth/lib/checkAlreadyAuthLoader.ts b/src/features/auth/lib/checkAlreadyAuthLoader.ts new file mode 100644 index 00000000..df3d87e7 --- /dev/null +++ b/src/features/auth/lib/checkAlreadyAuthLoader.ts @@ -0,0 +1,31 @@ +import { redirect } from 'react-router'; +import type { LoaderFunctionArgs } from 'react-router'; +import { ROUTE } from '@/shared/config/route'; +import { queryClient } from '@/shared/api/queryClient'; +import { getAuth } from '@/entities/auth/api/auth'; + +/** + * 로그인 페이지 진입 전에 실행되는 loader + * 이미 인증된 상태라면 redirectTo 또는 홈으로 redirect한다. + */ +const checkAlreadyAuthLoader = async ({ request }: LoaderFunctionArgs) => { + try { + const user = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + if (user?.authenticated) { + const redirectTo = new URL(request.url).searchParams.get('redirectTo'); + return redirect(redirectTo ?? ROUTE.home); + } + } catch { + // 인증 실패 시 로그인 페이지 렌더링 + } + + return null; +}; + +export default checkAlreadyAuthLoader; From 22750dd18a43dd6874a22fe9f1cb4f07679a54af Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 20:33:33 +0900 Subject: [PATCH 05/27] =?UTF-8?q?fix:=20axios=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EC=85=89=ED=84=B0=EC=97=90=EC=84=9C=20401=20=EC=8B=9C=20redire?= =?UTF-8?q?ctTo=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 401 응답 시 현재 경로를 redirectTo 쿼리 파라미터에 담아 로그인 페이지로 이동하도록 수정합니다. 또한 localStorage 기반 Authorization 헤더를 제거합니다 (쿠키 기반 인증으로 전환). ref: https://github.com/moddo-kr/moddo-frontend/pull/28#discussion_r3031737654 --- src/shared/api/axios.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/shared/api/axios.ts b/src/shared/api/axios.ts index 9069f71c..b57c8535 100644 --- a/src/shared/api/axios.ts +++ b/src/shared/api/axios.ts @@ -10,7 +10,6 @@ const axiosInstance = axios.create({ withCredentials: true, headers: { 'Content-Type': 'application/json', - Authorization: `${localStorage.getItem('accessToken')}`, }, }); @@ -18,11 +17,6 @@ const axiosInstance = axios.create({ axiosInstance.interceptors.request.use( (config) => { const newConfig = { ...config }; // config 객체를 복사하여 수정 - /** 최신값이 있다면 바꿔주기 */ - const accessToken = localStorage.getItem('accessToken'); - if (accessToken) { - newConfig.headers.Authorization = accessToken; - } /** 개발 환경에서 useMock 설정이 true인 경우에는 X-Mock-Request 헤더를 추가해서 모킹한 API를 사용할 수 있게 하는 interceptor */ if (import.meta.env.MODE === 'development' && newConfig.useMock) { newConfig.baseURL = '/api/v1'; @@ -46,9 +40,7 @@ axiosInstance.interceptors.request.use( ); /** - * accessToken 만료 시 재발급받도록 로그인 페이지로 리다이렉션 - * @Todo accessToken, refreshToken 저장 방식 수정 후 로직 추가 - * refreshToken 여부 확인 후 재발급 or 로그인 페이지 리다이렉션 로직 추가 + * 401 응답 시 현재 경로를 redirectTo에 담아 로그인 페이지로 이동 */ axiosInstance.interceptors.response.use( function (response) { @@ -56,9 +48,8 @@ axiosInstance.interceptors.response.use( }, async function (error) { if (error.response && error.response.status === 401) { - alert('세션이 만료되었습니다. 재로그인해주세요'); - window.location.href = ROUTE.login; - localStorage.removeItem('accessToken'); + const redirectTo = encodeURIComponent(window.location.pathname); + window.location.href = `${ROUTE.login}?redirectTo=${redirectTo}`; } return Promise.reject(error); } From d991b7685907bdb9185b227b8f4a893dc74703e5 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 20:38:44 +0900 Subject: [PATCH 06/27] =?UTF-8?q?refactor:=20Supabase=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20baseUR?= =?UTF-8?q?L=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/api/axios.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/shared/api/axios.ts b/src/shared/api/axios.ts index b57c8535..2d480410 100644 --- a/src/shared/api/axios.ts +++ b/src/shared/api/axios.ts @@ -5,7 +5,7 @@ const axiosInstance = axios.create({ // 환경변수에서 서버 URL을 가져오고, 기본값으로 빈 문자열을 사용하도록 설정 // 의도적으로 상대경로를 사용해야 하는 경우(예: 스토리북)를 위해서 빈 문자열도 사용할 수 있도록 함 baseURL: import.meta.env.VITE_SERVER_URL - ? `${import.meta.env.VITE_SERVER_URL}/functions/v1` + ? `${import.meta.env.VITE_SERVER_URL}/api/v1` : '', withCredentials: true, headers: { @@ -25,13 +25,6 @@ axiosInstance.interceptors.request.use( 'X-Mock-Request': 'true', }); } - // SUPABASE용 apikey 헤더 추가 (필요 시) - else if (newConfig.url?.split('?')[0].endsWith('user/guest/token')) { - newConfig.headers = AxiosHeaders.from({ - ...newConfig.headers, - apikey: import.meta.env.VITE_SUPABASE_PUBLIC_KEY, - }); - } return newConfig; }, (error) => { @@ -40,7 +33,9 @@ axiosInstance.interceptors.request.use( ); /** - * 401 응답 시 현재 경로를 redirectTo에 담아 로그인 페이지로 이동 + * accessToken 만료 시 재발급받도록 로그인 페이지로 리다이렉션 + * @Todo accessToken, refreshToken 저장 방식 수정 후 로직 추가 + * refreshToken 여부 확인 후 재발급 or 로그인 페이지 리다이렉션 로직 추가 */ axiosInstance.interceptors.response.use( function (response) { @@ -48,8 +43,9 @@ axiosInstance.interceptors.response.use( }, async function (error) { if (error.response && error.response.status === 401) { - const redirectTo = encodeURIComponent(window.location.pathname); - window.location.href = `${ROUTE.login}?redirectTo=${redirectTo}`; + alert('세션이 만료되었습니다. 재로그인해주세요'); + window.location.href = ROUTE.login; + localStorage.removeItem('accessToken'); } return Promise.reject(error); } From 908d7cc5dadcd82dc4965550e3200277d395b766 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 21:06:05 +0900 Subject: [PATCH 07/27] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=84=20cookie=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8A=94=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?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 localstorage에 저장하는 로직 제거 --- src/entities/auth/api/useGetGuestToken.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/entities/auth/api/useGetGuestToken.ts b/src/entities/auth/api/useGetGuestToken.ts index 81c4a4d8..6ee5bd71 100644 --- a/src/entities/auth/api/useGetGuestToken.ts +++ b/src/entities/auth/api/useGetGuestToken.ts @@ -9,12 +9,8 @@ export const useGetGuestToken = () => { queryKey: ['guestToken'], queryFn: async () => { const response = await getGuestToken(); - if (response?.accessToken) { - localStorage.setItem('accessToken', `Bearer ${response?.accessToken}`); - navigate(ROUTE.selectGroup); - return response; - } - throw new Error('Access Token not found'); + navigate(ROUTE.selectGroup); + return response; }, enabled: false, // refetch가 호출될 때만 실행되도록 설정 }); From 0332532fdd176d4ae3497be789865334299bd60b Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 21:17:39 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20401=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=ED=9B=84=20=EC=9B=90=EB=9E=98=20=EC=9A=94=EC=B2=AD=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/api/axios.ts | 31 ++++++++++++++++++++----------- src/shared/types/axios.d.ts | 1 + 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/shared/api/axios.ts b/src/shared/api/axios.ts index 2d480410..3df93dfe 100644 --- a/src/shared/api/axios.ts +++ b/src/shared/api/axios.ts @@ -33,20 +33,29 @@ axiosInstance.interceptors.request.use( ); /** - * accessToken 만료 시 재발급받도록 로그인 페이지로 리다이렉션 - * @Todo accessToken, refreshToken 저장 방식 수정 후 로직 추가 - * refreshToken 여부 확인 후 재발급 or 로그인 페이지 리다이렉션 로직 추가 + * 401 응답 시 refreshToken으로 재발급 시도 + * 재발급 성공 시 원래 요청 재시도, 실패 시 로그인 페이지로 redirect */ axiosInstance.interceptors.response.use( - function (response) { - return response; - }, - async function (error) { - if (error.response && error.response.status === 401) { - alert('세션이 만료되었습니다. 재로그인해주세요'); - window.location.href = ROUTE.login; - localStorage.removeItem('accessToken'); + (response) => response, + async (error) => { + const originalRequest = error.config; + + // isRetry: 재발급 요청 자체가 401을 반환할 경우 무한 루프 방지 + if (error.response?.status === 401 && !originalRequest.isRetry) { + originalRequest.isRetry = true; + + try { + await axiosInstance.put('/user/reissue/token'); + return await axiosInstance(originalRequest); // 원래 요청 재시도 + } catch { + // 재발급 실패 시 로그인 페이지로 redirect + // 로그인 페이지로 redirect할 때, 현재 페이지 경로를 쿼리 파라미터로 전달해서 로그인 후 원래 페이지로 돌아올 수 있도록 함 + const redirectTo = encodeURIComponent(window.location.pathname); + window.location.href = `${ROUTE.login}?redirectTo=${redirectTo}`; + } } + return Promise.reject(error); } ); diff --git a/src/shared/types/axios.d.ts b/src/shared/types/axios.d.ts index 4760d354..bc8cffd9 100644 --- a/src/shared/types/axios.d.ts +++ b/src/shared/types/axios.d.ts @@ -3,5 +3,6 @@ import 'axios'; declare module 'axios' { interface AxiosRequestConfig { useMock?: boolean; + isRetry?: boolean; } } From c4a67f28577879c24504c60242f1daedb024787b Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 21:23:00 +0900 Subject: [PATCH 09/27] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20mock=20=EC=98=B5=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/api/authApi.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/auth/api/authApi.ts b/src/features/auth/api/authApi.ts index c7bf8b26..e1fc5297 100644 --- a/src/features/auth/api/authApi.ts +++ b/src/features/auth/api/authApi.ts @@ -1,7 +1,6 @@ import axiosInstance from '@/shared/api/axios'; -export const logout = () => - axiosInstance.post('/user/logout', null, { useMock: true }); +export const logout = () => axiosInstance.post('/logout'); export const unregister = () => axiosInstance.delete('/users/me', { From 0fe504c115701fa65641daed8c537a0a30fd1c74 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 21:23:34 +0900 Subject: [PATCH 10/27] =?UTF-8?q?fix:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20API=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20mock=20=EC=98=B5=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/api/authApi.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/features/auth/api/authApi.ts b/src/features/auth/api/authApi.ts index e1fc5297..62ea9abd 100644 --- a/src/features/auth/api/authApi.ts +++ b/src/features/auth/api/authApi.ts @@ -2,7 +2,4 @@ import axiosInstance from '@/shared/api/axios'; export const logout = () => axiosInstance.post('/logout'); -export const unregister = () => - axiosInstance.delete('/users/me', { - useMock: true, - }); +export const unregister = () => axiosInstance.delete('/unlink'); From 62301fe41326857ac80707baef0bab2e74edcf09 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 21:30:31 +0900 Subject: [PATCH 11/27] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20User=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20API=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/auth/api/auth.ts | 5 +---- src/entities/auth/model/user.type.ts | 3 +-- src/features/user-profile/ui/MyProfile/index.tsx | 8 ++++---- src/mocks/handlers/auth.ts | 1 - 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/entities/auth/api/auth.ts b/src/entities/auth/api/auth.ts index e0db2fc8..2f04c7ab 100644 --- a/src/entities/auth/api/auth.ts +++ b/src/entities/auth/api/auth.ts @@ -22,9 +22,6 @@ export const getAuth = async () => { }; export const getUserInfo = async () => { - const response = await axiosInstance.get('/user/info', { - useMock: true, - }); - + const response = await axiosInstance.get('/user'); return response.data; }; diff --git a/src/entities/auth/model/user.type.ts b/src/entities/auth/model/user.type.ts index 940fb3f3..fde46ec6 100644 --- a/src/entities/auth/model/user.type.ts +++ b/src/entities/auth/model/user.type.ts @@ -1,6 +1,5 @@ export interface User { - id: number; email: string; name: string; - profileImageUrl?: string; + profile?: string; } diff --git a/src/features/user-profile/ui/MyProfile/index.tsx b/src/features/user-profile/ui/MyProfile/index.tsx index b87e0122..ff0187b1 100644 --- a/src/features/user-profile/ui/MyProfile/index.tsx +++ b/src/features/user-profile/ui/MyProfile/index.tsx @@ -9,22 +9,22 @@ import Button from '@/shared/ui/Button'; import * as S from './index.styles'; function MyProfile() { - const { data: profile } = useGetUserInfo(); + const { data: user } = useGetUserInfo(); const navigate = useNavigate(); const theme = useTheme(); return ( - + - {profile.name} + {user.name} {/* TODO: 디자인 시스템 정비 후 다시 디자인 확인이 필요합니다 (Opacity를 계속 쓰는지?) */} - {profile.email} + {user.email} {/* TODO: 현 피그마 디자인은 Chip이 Button으로 쓰이고 있는 상황이라 우선 button 컴포넌트 기준으로 구현했습니다. 디자인시스템 정리 후 다시 확인이 필요합니다! */} diff --git a/src/mocks/handlers/auth.ts b/src/mocks/handlers/auth.ts index f4fd8ab9..2078210b 100644 --- a/src/mocks/handlers/auth.ts +++ b/src/mocks/handlers/auth.ts @@ -29,7 +29,6 @@ const authHandlers = [ if (!isMocked || isMocked !== 'true') return passthrough(); const mockUserInfo: User = { - id: 1, name: '김모또', email: 'moddo@kakao.com', }; From d4eb76cf58972a0e0a6620758a053029e618dc6f Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 21:40:05 +0900 Subject: [PATCH 12/27] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=EC=97=90=EC=84=9C=20?= =?UTF-8?q?TanStack=20Query=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐싱이 불필요한 단순 액션이므로 useQuery 대신 직접 API 호출 방식으로 변경합니다. --- src/entities/auth/api/useGetGuestToken.ts | 17 ----------------- src/pages/login/LoginPage.tsx | 12 +++++++----- 2 files changed, 7 insertions(+), 22 deletions(-) delete mode 100644 src/entities/auth/api/useGetGuestToken.ts diff --git a/src/entities/auth/api/useGetGuestToken.ts b/src/entities/auth/api/useGetGuestToken.ts deleted file mode 100644 index 6ee5bd71..00000000 --- a/src/entities/auth/api/useGetGuestToken.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useNavigate } from 'react-router'; -import { useQuery } from '@tanstack/react-query'; -import { ROUTE } from '@/shared/config/route'; -import { getGuestToken } from './auth'; - -export const useGetGuestToken = () => { - const navigate = useNavigate(); - return useQuery({ - queryKey: ['guestToken'], - queryFn: async () => { - const response = await getGuestToken(); - navigate(ROUTE.selectGroup); - return response; - }, - enabled: false, // refetch가 호출될 때만 실행되도록 설정 - }); -}; diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 06ff9364..7a2b8d14 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -1,28 +1,30 @@ import LogoImg from '@/shared/assets/pngs/LogoImg.png'; import Text from '@/shared/ui/Text'; -import { useSearchParams } from 'react-router'; +import { useNavigate, useSearchParams } from 'react-router'; import { useEffect, useState } from 'react'; import theme from '@/shared/styles/theme'; import Button from '@/shared/ui/Button'; import { Kakao } from '@/shared/assets/svgs/icon'; import Flex from '@/shared/ui/Flex'; -import { useGetGuestToken } from '@/entities/auth/api/useGetGuestToken'; +import { getGuestToken } from '@/entities/auth/api/auth'; +import { ROUTE } from '@/shared/config/route'; import kakaoLogin from '@/entities/auth/lib/kakaoLogin'; import LoginEntranceView from './LoginEntranceView'; import * as S from './LoginPage.styles'; function LoginPage() { - const { refetch: getGuestToken } = useGetGuestToken(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [isEntrance, setIsEntrance] = useState(true); - const handleLoginButtonClick = (loginType: 'KAKAO' | 'GUEST') => { + const handleLoginButtonClick = async (loginType: 'KAKAO' | 'GUEST') => { if (loginType === 'KAKAO') { const redirectPathAfterLogin = searchParams.get('redirectTo') ?? undefined; kakaoLogin(redirectPathAfterLogin); } else { - getGuestToken(); + await getGuestToken(); + navigate(ROUTE.selectGroup); } }; From 33a7fcaa92885d0e708efe8197b0362a1abb9d57 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 22:20:27 +0900 Subject: [PATCH 13/27] =?UTF-8?q?fix:=20redirectTo=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20Open=20Redirect=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3068041154 ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3068041162 --- src/entities/auth/lib/kakaoLogin.ts | 9 ++++++++- src/features/auth/lib/checkAlreadyAuthLoader.ts | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/entities/auth/lib/kakaoLogin.ts b/src/entities/auth/lib/kakaoLogin.ts index fa85fe2f..53ea7a0a 100644 --- a/src/entities/auth/lib/kakaoLogin.ts +++ b/src/entities/auth/lib/kakaoLogin.ts @@ -11,7 +11,14 @@ function kakaoLogin(redirectPathAfterLogin?: string) { throw new Error('카카오 OAuth에 필요한 환경 변수가 설정되지 않았습니다.'); } - const stateUrl = `${window.location.origin}${redirectPathAfterLogin ?? ''}`; + // Open Redirect 방어: '/'로 시작하고 '//'로 시작하지 않는 same-origin 경로만 허용 + // '//'로 시작하는 경우 프로토콜 상대 URL로 외부 도메인으로 redirect될 수 있음 + const safePath = + redirectPathAfterLogin?.startsWith('/') && + !redirectPathAfterLogin.startsWith('//') + ? redirectPathAfterLogin + : '/'; + const stateUrl = `${window.location.origin}${safePath}`; window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${KAKAO_REDIRECT_URI}&response_type=code&state=${encodeURIComponent( stateUrl diff --git a/src/features/auth/lib/checkAlreadyAuthLoader.ts b/src/features/auth/lib/checkAlreadyAuthLoader.ts index df3d87e7..68201fc9 100644 --- a/src/features/auth/lib/checkAlreadyAuthLoader.ts +++ b/src/features/auth/lib/checkAlreadyAuthLoader.ts @@ -19,7 +19,13 @@ const checkAlreadyAuthLoader = async ({ request }: LoaderFunctionArgs) => { if (user?.authenticated) { const redirectTo = new URL(request.url).searchParams.get('redirectTo'); - return redirect(redirectTo ?? ROUTE.home); + // Open Redirect 방어: '/'로 시작하고 '//'로 시작하지 않는 same-origin 경로만 허용 + // '//'로 시작하는 경우 프로토콜 상대 URL로 외부 도메인으로 redirect될 수 있음 + const safeRedirectTo = + redirectTo?.startsWith('/') && !redirectTo.startsWith('//') + ? redirectTo + : ROUTE.home; + return redirect(safeRedirectTo); } } catch { // 인증 실패 시 로그인 페이지 렌더링 From 8309bd33edc269b2c8d787f327a66344fc6ed00b Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 22:29:02 +0900 Subject: [PATCH 14/27] =?UTF-8?q?fix:=20checkAuth=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20redirectT?= =?UTF-8?q?o=EC=97=90=20search,=20hash=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인증 실패 시에만 로그인으로 redirect하고, 5xx/네트워크 오류는 RouteErrorElement에서 처리되도록 불필요한 try/catch를 제거합니다. 또한 redirectTo에 pathname만 포함하던 것을 search, hash까지 포함하도록 수정합니다. ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3068041164 --- src/features/auth/lib/checkAuth.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/features/auth/lib/checkAuth.ts b/src/features/auth/lib/checkAuth.ts index 9ee33aa1..733aa83d 100644 --- a/src/features/auth/lib/checkAuth.ts +++ b/src/features/auth/lib/checkAuth.ts @@ -8,23 +8,23 @@ import { getAuth } from '@/entities/auth/api/auth'; * 페이지에 접근하기 전에 실행되는 함수 * */ const checkAuth = async ({ request }: LoaderFunctionArgs) => { - try { - const user = await queryClient.ensureQueryData({ - queryKey: ['auth', 'user'], - queryFn: getAuth, - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - }); + const user = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }); - if (!user || !user.authenticated) { - throw new Error('Unauthorized'); - } - - return user; - } catch { - const redirectTo = new URL(request.url).pathname; - return redirect(`${ROUTE.login}?redirectTo=${redirectTo}`); + if (!user || !user.authenticated) { + const { pathname, search, hash } = new URL(request.url); + const redirectTo = `${pathname}${search}${hash}`; + return redirect( + `${ROUTE.login}?redirectTo=${encodeURIComponent(redirectTo)}` + ); } + + // 5xx, 네트워크 오류 등 인증과 무관한 에러는 상위로 throw → RouteErrorElement에서 처리 (error를 여기서 캐치하지 않습니다.) + return user; }; export default checkAuth; From f2622871a549bed6136c64a1b50f4af009ee46f9 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 22:32:23 +0900 Subject: [PATCH 15/27] =?UTF-8?q?fix:=20=EC=B4=88=EA=B8=B0=20=EB=9E=9C?= =?UTF-8?q?=EB=8D=94=20=EC=83=81=ED=99=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?null=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3068041167 --- src/features/user-profile/ui/MyProfile/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/features/user-profile/ui/MyProfile/index.tsx b/src/features/user-profile/ui/MyProfile/index.tsx index ff0187b1..024e7afd 100644 --- a/src/features/user-profile/ui/MyProfile/index.tsx +++ b/src/features/user-profile/ui/MyProfile/index.tsx @@ -13,6 +13,12 @@ function MyProfile() { const navigate = useNavigate(); const theme = useTheme(); + // suspense로 감싸져 있긴 초기에 없는 경우의 에러를 방지하기 위해 null guard를 추가했습니다. + // ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3068041167 + if (!user) { + return null; + } + return ( From 384fdc33290db40616c24c8b8deea81e81c0f85c Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sat, 11 Apr 2026 22:38:19 +0900 Subject: [PATCH 16/27] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=8B=9C=20=EB=AC=B4=ED=95=9C=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?refreshClient=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재발급 요청을 axiosInstance 대신 interceptor가 없는 refreshClient로 호출합니다. 재발급 API가 401을 반환해도 interceptor를 타지 않으며, isRetry 플래그는 재시도한 원래 요청이 다시 401을 반환하는 루프를 방지합니다. ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3068041170 --- src/shared/api/axios.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/shared/api/axios.ts b/src/shared/api/axios.ts index 3df93dfe..19d92072 100644 --- a/src/shared/api/axios.ts +++ b/src/shared/api/axios.ts @@ -1,12 +1,23 @@ import axios, { AxiosHeaders } from 'axios'; import { ROUTE } from '@/shared/config/route'; +const BASE_URL = import.meta.env.VITE_SERVER_URL + ? `${import.meta.env.VITE_SERVER_URL}/api/v1` + : ''; + const axiosInstance = axios.create({ // 환경변수에서 서버 URL을 가져오고, 기본값으로 빈 문자열을 사용하도록 설정 // 의도적으로 상대경로를 사용해야 하는 경우(예: 스토리북)를 위해서 빈 문자열도 사용할 수 있도록 함 - baseURL: import.meta.env.VITE_SERVER_URL - ? `${import.meta.env.VITE_SERVER_URL}/api/v1` - : '', + baseURL: BASE_URL, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 토큰 재발급 전용 클라이언트 - response interceptor 없이 사용해 재발급 시 무한 루프 방지 +const refreshClient = axios.create({ + baseURL: BASE_URL, withCredentials: true, headers: { 'Content-Type': 'application/json', @@ -41,12 +52,13 @@ axiosInstance.interceptors.response.use( async (error) => { const originalRequest = error.config; - // isRetry: 재발급 요청 자체가 401을 반환할 경우 무한 루프 방지 + // isRetry: 재시도한 원래 요청이 다시 401을 반환할 경우 무한 루프 방지 if (error.response?.status === 401 && !originalRequest.isRetry) { originalRequest.isRetry = true; try { - await axiosInstance.put('/user/reissue/token'); + // refreshClient 사용: response interceptor가 없어 재발급 요청 자체가 인터셉터를 타지 않음 + await refreshClient.put('/user/reissue/token'); return await axiosInstance(originalRequest); // 원래 요청 재시도 } catch { // 재발급 실패 시 로그인 페이지로 redirect From 4daba49014ab49f8387933e17def671456798b97 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Wed, 15 Apr 2026 00:05:48 +0900 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=9B=84=20auth=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EA=B1=B0=EB=A1=9C=20checkAuth=20=EC=9E=AC?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게스트 토큰 발급 후 ['auth', 'user'] 캐시를 removeQueries로 제거 - ensureQueryData가 캐시를 재사용하지 않고 반드시 재요청하도록 함 --- src/pages/login/LoginPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 7a2b8d14..a3c65d76 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -9,6 +9,7 @@ import Flex from '@/shared/ui/Flex'; import { getGuestToken } from '@/entities/auth/api/auth'; import { ROUTE } from '@/shared/config/route'; import kakaoLogin from '@/entities/auth/lib/kakaoLogin'; +import { queryClient } from '@/shared/api/queryClient'; import LoginEntranceView from './LoginEntranceView'; import * as S from './LoginPage.styles'; @@ -24,6 +25,7 @@ function LoginPage() { kakaoLogin(redirectPathAfterLogin); } else { await getGuestToken(); + queryClient.removeQueries({ queryKey: ['auth', 'user'] }); navigate(ROUTE.selectGroup); } }; From 792bf3b3c98993a444c744e48582b3379d1f7706 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Wed, 15 Apr 2026 00:06:00 +0900 Subject: [PATCH 18/27] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20cross-origin=20=EC=BF=A0=ED=82=A4=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vite proxy 추가: /api/v1 요청을 VITE_SERVER_URL로 포워딩 - cookieDomainRewrite로 쿠키 domain을 localhost로 재작성 - 개발 환경에서 Vite proxy 경유하도록 수정 - Storybook MSW 핸들러 URL을 /api/v1 prefix 포함하도록 수정 --- .../ui/ExpenseTimeHeader/index.stories.tsx | 2 +- src/shared/api/axios.ts | 13 +++++-------- vite.config.ts | 12 +++++++++++- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx index 911e3cfc..3fe8f180 100644 --- a/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx +++ b/src/pages/expenseDetail/ui/ExpenseTimeHeader/index.stories.tsx @@ -22,7 +22,7 @@ const meta: Meta = { chromatic: { disableSnapshot: false }, msw: { handlers: [ - http.get('/group/header', () => { + http.get('/api/v1/group/header', () => { return HttpResponse.json({ groupName: '모또 정기모임', totalAmount: 150000, diff --git a/src/shared/api/axios.ts b/src/shared/api/axios.ts index a6e3378b..8f29a6ec 100644 --- a/src/shared/api/axios.ts +++ b/src/shared/api/axios.ts @@ -1,13 +1,13 @@ import axios, { AxiosHeaders } from 'axios'; import { ROUTE } from '@/shared/config/route'; -const BASE_URL = import.meta.env.VITE_SERVER_URL - ? `${import.meta.env.VITE_SERVER_URL}/api/v1` - : ''; +// 개발 환경에서는 Vite proxy(/api/v1)를 경유해 cross-origin 쿠키 차단을 우회 (참고: vite.config.ts의 server.proxy 설정) +// 프로덕션에서는 VITE_SERVER_URL로 직접 요청 +const BASE_URL = import.meta.env.DEV + ? '/api/v1' + : `${import.meta.env.VITE_SERVER_URL ?? ''}/api/v1`; const axiosInstance = axios.create({ - // 환경변수에서 서버 URL을 가져오고, 기본값으로 빈 문자열을 사용하도록 설정 - // 의도적으로 상대경로를 사용해야 하는 경우(예: 스토리북)를 위해서 빈 문자열도 사용할 수 있도록 함 baseURL: BASE_URL, withCredentials: true, headers: { @@ -18,9 +18,6 @@ const axiosInstance = axios.create({ // 토큰 재발급 전용 클라이언트 - response interceptor 없이 사용해 재발급 시 무한 루프 방지 const refreshClient = axios.create({ baseURL: BASE_URL, - baseURL: import.meta.env.VITE_SERVER_URL - ? `${import.meta.env.VITE_SERVER_URL}/api/v1` - : '', withCredentials: true, headers: { 'Content-Type': 'application/json', diff --git a/vite.config.ts b/vite.config.ts index dde48579..85eed763 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,11 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import svgr from 'vite-plugin-svgr'; import { VitePWA } from 'vite-plugin-pwa'; // https://vite.dev/config/ export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); // Storybook 빌드인지 확인 const isStorybook = process.env.STORYBOOK === 'true'; @@ -102,6 +103,15 @@ export default defineConfig(({ mode }) => { server: { host: '0.0.0.0', port: 3000, + proxy: { + '/api/v1': { + target: env.VITE_SERVER_URL, + changeOrigin: true, + cookieDomainRewrite: { + [new URL(env.VITE_SERVER_URL).hostname]: 'localhost', + }, + }, + }, }, }; }); From 3868a423cea9f4f2cf8ad2aa6c1dc5ef0010aabc Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Wed, 15 Apr 2026 00:14:54 +0900 Subject: [PATCH 19/27] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20401?= =?UTF-8?q?=EB=A7=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20red?= =?UTF-8?q?irect,=20=EB=82=98=EB=A8=B8=EC=A7=80=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EC=A0=84=ED=8C=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isAxiosError + status 401 체크로 실제 인증 실패만 구분 - 그 외 에러는 reject해서 error boundary에서 처리하도록 함 ref: https://github.com/moddo-kr/moddo-frontend/pull/30/#discussion_r3079682425 --- src/shared/api/axios.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/shared/api/axios.ts b/src/shared/api/axios.ts index 8f29a6ec..19b8a586 100644 --- a/src/shared/api/axios.ts +++ b/src/shared/api/axios.ts @@ -1,4 +1,4 @@ -import axios, { AxiosHeaders } from 'axios'; +import axios, { AxiosHeaders, isAxiosError } from 'axios'; import { ROUTE } from '@/shared/config/route'; // 개발 환경에서는 Vite proxy(/api/v1)를 경유해 cross-origin 쿠키 차단을 우회 (참고: vite.config.ts의 server.proxy 설정) @@ -60,11 +60,17 @@ axiosInstance.interceptors.response.use( // refreshClient 사용: response interceptor가 없어 재발급 요청 자체가 인터셉터를 타지 않음 await refreshClient.put('/user/reissue/token'); return await axiosInstance(originalRequest); // 원래 요청 재시도 - } catch { - // 재발급 실패 시 로그인 페이지로 redirect + } catch (refreshError: unknown) { + // 실제 인증 실패(401)일 때만 로그인으로 redirect // 로그인 페이지로 redirect할 때, 현재 페이지 경로를 쿼리 파라미터로 전달해서 로그인 후 원래 페이지로 돌아올 수 있도록 함 - const redirectTo = encodeURIComponent(window.location.pathname); - window.location.href = `${ROUTE.login}?redirectTo=${redirectTo}`; + if ( + isAxiosError(refreshError) && + refreshError?.response?.status === 401 + ) { + const redirectTo = encodeURIComponent(window.location.pathname); + window.location.href = `${ROUTE.login}?redirectTo=${redirectTo}`; + } + return Promise.reject(refreshError); } } From 68cddedf96b794f0d74b722e7227ff7d8508e5c6 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Wed, 15 Apr 2026 00:15:17 +0900 Subject: [PATCH 20/27] =?UTF-8?q?fix:=20VITE=5FSERVER=5FURL=20=EB=AF=B8?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=8B=9C=20Storybook=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxy 설정을 VITE_SERVER_URL이 있을 때만 적용하도록 변경 --- vite.config.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 85eed763..6c865933 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -103,15 +103,17 @@ export default defineConfig(({ mode }) => { server: { host: '0.0.0.0', port: 3000, - proxy: { - '/api/v1': { - target: env.VITE_SERVER_URL, - changeOrigin: true, - cookieDomainRewrite: { - [new URL(env.VITE_SERVER_URL).hostname]: 'localhost', + ...(env.VITE_SERVER_URL && { + proxy: { + '/api/v1': { + target: env.VITE_SERVER_URL, + changeOrigin: true, + cookieDomainRewrite: { + [new URL(env.VITE_SERVER_URL).hostname]: 'localhost', + }, }, }, - }, + }), }, }; }); From 61fa24a719dbcb54a3760b44aaafadc4a047ac60 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Wed, 15 Apr 2026 20:28:15 +0900 Subject: [PATCH 21/27] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3085980414 --- src/pages/login/LoginPage.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index a3c65d76..86853d44 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -10,6 +10,7 @@ import { getGuestToken } from '@/entities/auth/api/auth'; import { ROUTE } from '@/shared/config/route'; import kakaoLogin from '@/entities/auth/lib/kakaoLogin'; import { queryClient } from '@/shared/api/queryClient'; +import { showToast } from '@/shared/ui/Toast'; import LoginEntranceView from './LoginEntranceView'; import * as S from './LoginPage.styles'; @@ -24,9 +25,16 @@ function LoginPage() { searchParams.get('redirectTo') ?? undefined; kakaoLogin(redirectPathAfterLogin); } else { - await getGuestToken(); - queryClient.removeQueries({ queryKey: ['auth', 'user'] }); - navigate(ROUTE.selectGroup); + try { + await getGuestToken(); + queryClient.removeQueries({ queryKey: ['auth', 'user'] }); + navigate(ROUTE.selectGroup); + } catch { + showToast({ + type: 'error', + content: '비회원 로그인에 실패했습니다. 다시 시도해주세요.', + }); + } } }; From 767f19671cf49559f072ed1bec3543be973a4828 Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Wed, 15 Apr 2026 20:31:11 +0900 Subject: [PATCH 22/27] =?UTF-8?q?fix:=20proxy=20cookieDomainRewrite=20?= =?UTF-8?q?=EC=99=80=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EA=B3=BC=20URL=20=ED=8C=8C=EC=8B=B1=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VITE_SERVER_URL 파싱을 상단에서 try/catch로 처리해서 잘못된 URL 형식에도 안전하게 동작하도록 함 - cookieDomainRewrite를 { '*': 'localhost' }로 변경해 .moddo.kr 등 모든 도메인 변형을 모두 커버하도록 함 ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3085980448 --- vite.config.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 6c865933..00c06bee 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,13 @@ import { VitePWA } from 'vite-plugin-pwa'; // https://vite.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); + const parsedServerUrl = (() => { + try { + return env.VITE_SERVER_URL ? new URL(env.VITE_SERVER_URL) : null; + } catch { + return null; + } + })(); // Storybook 빌드인지 확인 const isStorybook = process.env.STORYBOOK === 'true'; @@ -103,14 +110,12 @@ export default defineConfig(({ mode }) => { server: { host: '0.0.0.0', port: 3000, - ...(env.VITE_SERVER_URL && { + ...(parsedServerUrl && { proxy: { '/api/v1': { - target: env.VITE_SERVER_URL, + target: parsedServerUrl.origin, changeOrigin: true, - cookieDomainRewrite: { - [new URL(env.VITE_SERVER_URL).hostname]: 'localhost', - }, + cookieDomainRewrite: { '*': 'localhost' }, }, }, }), From 6169b94349d5dca7bcc51bd678c35db641d1602b Mon Sep 17 00:00:00 2001 From: yoouyeon Date: Sun, 19 Apr 2026 15:32:21 +0900 Subject: [PATCH 23/27] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=97=B0=ED=83=80=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3087182215 --- src/pages/login/LoginPage.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 86853d44..8d7c5b30 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -18,6 +18,7 @@ function LoginPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [isEntrance, setIsEntrance] = useState(true); + const [isGuestLoginPending, setIsGuestLoginPending] = useState(false); const handleLoginButtonClick = async (loginType: 'KAKAO' | 'GUEST') => { if (loginType === 'KAKAO') { @@ -25,6 +26,8 @@ function LoginPage() { searchParams.get('redirectTo') ?? undefined; kakaoLogin(redirectPathAfterLogin); } else { + if (isGuestLoginPending) return; + setIsGuestLoginPending(true); try { await getGuestToken(); queryClient.removeQueries({ queryKey: ['auth', 'user'] }); @@ -34,6 +37,8 @@ function LoginPage() { type: 'error', content: '비회원 로그인에 실패했습니다. 다시 시도해주세요.', }); + } finally { + setIsGuestLoginPending(false); } } }; @@ -79,6 +84,7 @@ function LoginPage() {