diff --git a/apps/web/e2e/helpers/fakeJwt.ts b/apps/web/e2e/helpers/fakeJwt.ts index 6112a04c..2565f5a6 100644 --- a/apps/web/e2e/helpers/fakeJwt.ts +++ b/apps/web/e2e/helpers/fakeJwt.ts @@ -8,6 +8,11 @@ const toBase64Url = (value: object) => Buffer.from(JSON.stringify(value)).toStri export const createFakeJwt = (expiresInSeconds = 60 * 60) => [ toBase64Url({ alg: 'HS256', typ: 'JWT' }), - toBase64Url({ sub: 'e2e-guest', exp: Math.floor(Date.now() / 1000) + expiresInSeconds }), + toBase64Url({ + sub: 'e2e-user', + /** role 클레임 기반 gating(getRoleFromToken) 통과용 — SSR 목의 MOCK_MEMBER_ME(회원 고정)와 정합 */ + role: 'MEMBER', + exp: Math.floor(Date.now() / 1000) + expiresInSeconds, + }), 'e2e-fake-signature', ].join('.'); diff --git a/apps/web/src/apis/getMe.ts b/apps/web/src/apis/getMe.ts index b9dfea37..fd92ff56 100644 --- a/apps/web/src/apis/getMe.ts +++ b/apps/web/src/apis/getMe.ts @@ -1,5 +1,6 @@ -import { environmentManager } from '@tanstack/react-query'; +import { environmentManager, queryOptions } from '@tanstack/react-query'; +import { QUERY_KEYS } from '@/consts/queryKeys'; import type { ApiResponseT } from '@/types/api'; import type { UserT } from '@/types/user'; @@ -16,3 +17,9 @@ export const getMe = async () => { const { data } = await clientApi.get>('/api/v1/users/me'); return data.data; }; + +export const getMeQueryOptions = queryOptions({ + queryKey: QUERY_KEYS.USER.ME, + queryFn: getMe, + staleTime: 5 * 60 * 1000, +}); diff --git a/apps/web/src/app/archive/wish/layout.tsx b/apps/web/src/app/archive/wish/layout.tsx index aafe317d..94e522ad 100644 --- a/apps/web/src/app/archive/wish/layout.tsx +++ b/apps/web/src/app/archive/wish/layout.tsx @@ -1,15 +1,10 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { getMe } from '@/apis/getMe'; import WishLoginRequired from '@/components/common/wish-login-required'; import { QUERY_ACTION } from '@/consts/queryAction'; -import type { ApiErrorResponseT } from '@/types/api'; -import type { UserT } from '@/types/user'; +import { getRoleFromToken } from '@/utils/auth'; import { getLoginPath } from '@/utils/loginRedirect'; -import { getQueryClient } from '@/utils/queryClient'; type WishArchiveLayoutProps = { children: React.ReactNode; @@ -18,28 +13,18 @@ type WishArchiveLayoutProps = { async function WishArchiveLayout({ children }: WishArchiveLayoutProps) { const headerStore = await headers(); const redirectPath = headerStore.get('x-redirect-path'); - const queryClient = getQueryClient(); - /** MEMBER 권한 조회 - 멤버 권한 없으면 로그인 페이지로 리다이렉트 */ - let user: UserT; - try { - user = await queryClient.fetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }); - } catch (error) { - if (!isAxiosError(error)) throw error; + /** MEMBER 권한 판별 */ + const accessToken = (await cookies()).get('access_token')?.value; + const role = getRoleFromToken(accessToken); - if (error.response?.status === 401 || error.response?.status === 404) - redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED)); - - throw error; - } + /** 토큰이 유효하지 않은 경우 세션 만료 처리 */ + if (role === null) redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED)); /** 위시 페이지는 멤버가 아니면 로그인 유도 화면을 렌더 */ - if (user.identityType !== 'MEMBER') return ; + if (role !== 'MEMBER') return ; - return {children}; + return children; } export default WishArchiveLayout; diff --git a/apps/web/src/app/auth/callback/[provider]/_hooks/usePostSocialLogin.ts b/apps/web/src/app/auth/callback/[provider]/_hooks/usePostSocialLogin.ts index 42cd3274..d1b5e424 100644 --- a/apps/web/src/app/auth/callback/[provider]/_hooks/usePostSocialLogin.ts +++ b/apps/web/src/app/auth/callback/[provider]/_hooks/usePostSocialLogin.ts @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { ANALYTICS_EVENT } from '@/consts/analytics'; import { QUERY_ACTION } from '@/consts/queryAction'; +import { QUERY_KEYS } from '@/consts/queryKeys'; import type { ApiErrorResponseT } from '@/types/api'; import { logAnalyticsEvent } from '@/utils/analytics'; import { isServerOrNetworkError, isWithdrawnAccountError } from '@/utils/apiError'; @@ -30,7 +31,7 @@ export const usePostSocialLogin = (provider: SocialProviderT) => { }) => postSocialLogin(provider, { code, redirectUri, state }), onSuccess: (_, variables) => { logAnalyticsEvent(ANALYTICS_EVENT.SIGN_UP_COMPLETE, { provider }); - queryClient.invalidateQueries({ queryKey: ['me'] }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.ME }); window.location.replace(getLoginRedirectPath(variables.redirect)); }, onError: (error, variables) => { diff --git a/apps/web/src/app/home/page.tsx b/apps/web/src/app/home/page.tsx index 6e0f93b6..c8027f75 100644 --- a/apps/web/src/app/home/page.tsx +++ b/apps/web/src/app/home/page.tsx @@ -1,14 +1,7 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; - -import { getMe } from '@/apis/getMe'; -import { getTournamentList } from '@/apis/getTournamentList'; import PiKiLogo from '@/assets/images/piki-logo-text.svg'; import { Header, HeaderIcon } from '@/components/header'; import Spacing from '@/components/spacing'; -import { QUERY_KEYS } from '@/consts/queryKeys'; import { getIsGuest } from '@/utils/getIsGuest'; -import { getQueryClient } from '@/utils/queryClient'; -import { serverPrefetch } from '@/utils/serverPrefetch'; import AddWishHomeDialog from './_components/AddWishHomeDialog'; import CreateTournamentDialog from './_components/CreateTournamentDialog'; @@ -18,53 +11,32 @@ import HomeOnboarding from './_components/home-onboarding'; import TournamentList from './_components/tournament-list'; async function HomePage() { - const queryClient = getQueryClient(); - - await serverPrefetch(() => - queryClient.fetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }) - ); - - queryClient.prefetchQuery({ - queryKey: QUERY_KEYS.TOURNAMENT.LIST.BY_PARAMS({ limit: 3 }), - queryFn: () => getTournamentList({ limit: 3 }), - }); - const isGuest = await getIsGuest(); return ( - -
- {/* 상단 헤더 */} -
} right={} /> - - - - {/* 메인 컨텐츠 */} -
- {/** - * 배너 + 그리드 영역. - * Figma 스펙: flex-direction: column; gap: 12px (배너↔그리드 사이). - * 배너 자체는 padding: 16.4px 61.5px 12.23px 0 을 가짐. - */} -
- {isGuest && } -
- - - -
-
- - {/* 최근 생성한 토너먼트 */} - -
- - -
-
+
+ {/* 상단 헤더 */} +
} right={} /> + + + + {/* 메인 컨텐츠 */} +
+
+ {isGuest && } +
+ + + +
+
+ + {/* 최근 생성한 토너먼트 */} + +
+ + +
); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 277dca07..f2a24de8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,13 +1,17 @@ import { GoogleAnalytics } from '@next/third-parties/google'; +import { isTokenUnexpired } from '@piki/core'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import type { Metadata, Viewport } from 'next'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import React from 'react'; +import { getMeQueryOptions } from '@/apis/getMe'; import BottomTabBar from '@/components/bottom-tab-bar'; import AppUpdateDialog from '@/components/common/app-update-dialog'; import { APP_UPDATE_PROMPT } from '@/consts/appUpdate'; import { SCROLL_CONTAINER_ID } from '@/consts/layout'; import { getAppVersion, isAppVersionSupported } from '@/utils/appVersion'; +import { getQueryClient } from '@/utils/queryClient'; import { isWebview as _isWebView } from '@/utils/webBridge'; import Providers from '../components/Providers'; @@ -38,6 +42,12 @@ async function RootLayout({ const userAgent = headerStore.get('user-agent') ?? ''; const isWebview = _isWebView(userAgent); + const queryClient = getQueryClient(); + const accessToken = (await cookies()).get('access_token')?.value; + if (isTokenUnexpired(accessToken ?? null)) { + queryClient.prefetchQuery(getMeQueryOptions); + } + const shouldUpdateApp = isWebview && !isAppVersionSupported(getAppVersion(userAgent), APP_UPDATE_PROMPT.targetVersion); @@ -77,7 +87,7 @@ async function RootLayout({ id={SCROLL_CONTAINER_ID} className="mx-auto hide-scrollbar h-full max-w-120 overflow-y-auto [scrollbar-gutter:stable]" > - {children} + {children} {/* NOTE: 전환 애니메이션이 끊기지 않게 하기 위해 탭바를 레이아웃에 렌더 */} diff --git a/apps/web/src/app/login/_hooks/usePostGuestLogin.ts b/apps/web/src/app/login/_hooks/usePostGuestLogin.ts index 40609fde..ecef114c 100644 --- a/apps/web/src/app/login/_hooks/usePostGuestLogin.ts +++ b/apps/web/src/app/login/_hooks/usePostGuestLogin.ts @@ -2,6 +2,7 @@ import { WEBBRIDGE_MESSAGE_TYPE } from '@piki/core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; +import { QUERY_KEYS } from '@/consts/queryKeys'; import { setCookie } from '@/utils/cookie'; import { getPostLoginRedirectPath } from '@/utils/loginRedirect'; import { WebBridge, isWebview } from '@/utils/webBridge'; @@ -15,7 +16,7 @@ export const usePostGuestLogin = () => { const { mutate: postGuestLoginMutation, isPending: isPostGuestLoginPending } = useMutation({ mutationFn: postGuestLogin, onSuccess: data => { - queryClient.invalidateQueries({ queryKey: ['me'] }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.ME }); if (isWebview() && data.accessToken && data.refreshToken) { setCookie('access_token', data.accessToken, { minutes: 15 }); diff --git a/apps/web/src/app/mypage/edit/_hooks/usePatchMe.ts b/apps/web/src/app/mypage/edit/_hooks/usePatchMe.ts index b455907b..bec8eddd 100644 --- a/apps/web/src/app/mypage/edit/_hooks/usePatchMe.ts +++ b/apps/web/src/app/mypage/edit/_hooks/usePatchMe.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { QUERY_KEYS } from '@/consts/queryKeys'; import { isGlobalNetError } from '@/utils/apiError'; import { getApiErrorMessage } from '@/utils/getApiErrorMessage'; @@ -18,7 +19,7 @@ export const usePatchMe = () => { return patchMe(formData); }, onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['me'] }); + await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER.ME }); }, onError: error => { if (isGlobalNetError(error)) return; diff --git a/apps/web/src/app/mypage/edit/page.tsx b/apps/web/src/app/mypage/edit/page.tsx index 5f196d6a..428e04a5 100644 --- a/apps/web/src/app/mypage/edit/page.tsx +++ b/apps/web/src/app/mypage/edit/page.tsx @@ -1,19 +1,9 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; - -import { getMe } from '@/apis/getMe'; import { Header, HeaderIcon } from '@/components/header'; import Spacing from '@/components/spacing'; -import { getQueryClient } from '@/utils/queryClient'; import EditForm from './_components/EditForm'; function MypageEditPage() { - const queryClient = getQueryClient(); - queryClient.prefetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }); - return (
- - - +
); } diff --git a/apps/web/src/app/mypage/page.tsx b/apps/web/src/app/mypage/page.tsx index 0ea99778..f0c541b5 100644 --- a/apps/web/src/app/mypage/page.tsx +++ b/apps/web/src/app/mypage/page.tsx @@ -1,10 +1,6 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; - -import { getMe } from '@/apis/getMe'; import { Header, HeaderIcon } from '@/components/header'; import Spacing from '@/components/spacing'; import { getIsGuest } from '@/utils/getIsGuest'; -import { getQueryClient } from '@/utils/queryClient'; import AccountInfoSection from './_components/AccountInfoSection'; import AppVersionFooter from './_components/AppVersionFooter'; @@ -12,12 +8,6 @@ import MypageGuestBanner from './_components/MypageGuestBanner'; import ProfileSection from './_components/ProfileSection'; async function MypagePage() { - const queryClient = getQueryClient(); - queryClient.prefetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }); - const isGuest = await getIsGuest(); return ( @@ -33,9 +23,7 @@ async function MypagePage() { )} - - - + diff --git a/apps/web/src/app/mypage/withdraw/layout.tsx b/apps/web/src/app/mypage/withdraw/layout.tsx index c541a9ba..1a8d1cf4 100644 --- a/apps/web/src/app/mypage/withdraw/layout.tsx +++ b/apps/web/src/app/mypage/withdraw/layout.tsx @@ -1,38 +1,26 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { getMe } from '@/apis/getMe'; import { QUERY_ACTION } from '@/consts/queryAction'; import { ROUTES } from '@/consts/route'; -import type { ApiErrorResponseT } from '@/types/api'; +import { getRoleFromToken } from '@/utils/auth'; import { getLoginPath } from '@/utils/loginRedirect'; -import { getQueryClient } from '@/utils/queryClient'; async function MyPageMemberOnlyLayout({ children }: { children: React.ReactNode }) { const headerStore = await headers(); const redirectPath = headerStore.get('x-redirect-path'); - const queryClient = getQueryClient(); - /** 유저 정보 조회 */ - try { - const userData = await queryClient.fetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }); + /** MEMBER 권한 판별 */ + const accessToken = (await cookies()).get('access_token')?.value; + const role = getRoleFromToken(accessToken); - if (userData.identityType !== 'MEMBER') redirect(ROUTES.LOGIN); - } catch (error) { - if (!isAxiosError(error)) throw error; + /** 토큰이 유효하지 않은 경우 세션 만료 처리 */ + if (role === null) redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED)); - if (error.response?.status === 401 || error.response?.status === 404) - redirect(getLoginPath(redirectPath, QUERY_ACTION.VALUE.SESSION_EXPIRED)); + /** 멤버가 아닌 경우 마이페이지로 리다이렉트 */ + if (role !== 'MEMBER') redirect(ROUTES.MYPAGE); - throw error; - } - - return {children}; + return children; } export default MyPageMemberOnlyLayout; diff --git a/apps/web/src/app/notification/_components/NotificationContent.tsx b/apps/web/src/app/notification/_components/NotificationContent.tsx index be0d60f4..2a204cf5 100644 --- a/apps/web/src/app/notification/_components/NotificationContent.tsx +++ b/apps/web/src/app/notification/_components/NotificationContent.tsx @@ -5,7 +5,6 @@ import { useState } from 'react'; import { CheckCircledIconOutline } from '@/assets/icons'; import Button from '@/components/button'; -import { Header, HeaderIcon } from '@/components/header'; import { cn } from '@/utils/cn'; import { formatTimeKo } from '@/utils/formatDate'; import { isWebview } from '@/utils/webBridge'; @@ -57,17 +56,11 @@ function NotificationContent() { }; return ( -
-
} - center="알림 히스토리" - centerClassName="heading-1-bold" - /> - -
{renderContent()}
+ <> + {renderContent()} -
+ ); function renderContent() { diff --git a/apps/web/src/app/notification/_hooks/useGetNotifications.ts b/apps/web/src/app/notification/_hooks/useGetNotifications.ts index 0f2f9264..6a2d45c8 100644 --- a/apps/web/src/app/notification/_hooks/useGetNotifications.ts +++ b/apps/web/src/app/notification/_hooks/useGetNotifications.ts @@ -2,6 +2,7 @@ import { WEBBRIDGE_MESSAGE_TYPE } from '@piki/core'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; +import { QUERY_KEYS } from '@/consts/queryKeys'; import { WebBridge, isWebview } from '@/utils/webBridge'; import { getNotifications } from '../_apis/getNotifications'; @@ -17,7 +18,7 @@ export const useGetNotifications = () => { isFetchNextPageError, refetch, } = useInfiniteQuery({ - queryKey: ['notifications'], + queryKey: QUERY_KEYS.NOTIFICATION.LIST, queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }), initialPageParam: null as string | null, getNextPageParam: lastPage => (lastPage.hasNext ? (lastPage.nextCursor ?? null) : null), diff --git a/apps/web/src/app/notification/_hooks/usePostNotificationsRead.ts b/apps/web/src/app/notification/_hooks/usePostNotificationsRead.ts index 9b7107e3..86f95ab9 100644 --- a/apps/web/src/app/notification/_hooks/usePostNotificationsRead.ts +++ b/apps/web/src/app/notification/_hooks/usePostNotificationsRead.ts @@ -1,7 +1,8 @@ import { WEBBRIDGE_MESSAGE_TYPE } from '@piki/core'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { isWebview, WebBridge } from '@/utils/webBridge'; +import { QUERY_KEYS } from '@/consts/queryKeys'; +import { WebBridge, isWebview } from '@/utils/webBridge'; import { postNotificationsRead } from '../_apis/postNotificationsRead'; @@ -12,7 +13,7 @@ export const usePostNotificationsRead = () => { useMutation({ mutationFn: postNotificationsRead, onSuccess: data => { - queryClient.invalidateQueries({ queryKey: ['notifications'] }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.NOTIFICATION.LIST }); if (isWebview() && data) { WebBridge.postMessage({ type: WEBBRIDGE_MESSAGE_TYPE.WEB_REQ_SET_BADGE, diff --git a/apps/web/src/app/notification/page.tsx b/apps/web/src/app/notification/page.tsx index ac6732fa..ce319a09 100644 --- a/apps/web/src/app/notification/page.tsx +++ b/apps/web/src/app/notification/page.tsx @@ -1,23 +1,20 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { Header, HeaderIcon } from '@/components/header'; -import { getQueryClient } from '@/utils/queryClient'; - -import { getNotifications } from './_apis/getNotifications'; import NotificationContent from './_components/NotificationContent'; function Notification() { - const queryClient = getQueryClient(); - - queryClient.prefetchInfiniteQuery({ - queryKey: ['notifications'], - queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam as string | null }), - initialPageParam: null, - }); - return ( - - - +
+
} + center="알림 히스토리" + centerClassName="heading-1-bold" + /> + +
+ +
+
); } diff --git a/apps/web/src/app/tournament/[id]/create/page.tsx b/apps/web/src/app/tournament/[id]/create/page.tsx index 874f177c..ceac2e4b 100644 --- a/apps/web/src/app/tournament/[id]/create/page.tsx +++ b/apps/web/src/app/tournament/[id]/create/page.tsx @@ -1,8 +1,3 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; - -import { getMe } from '@/apis/getMe'; -import { getQueryClient } from '@/utils/queryClient'; - import TournamentCreateClient from './_components/TournamentCreateClient'; type TournamentCreatePageProps = { @@ -12,18 +7,8 @@ type TournamentCreatePageProps = { async function TournamentCreatePage({ params }: TournamentCreatePageProps) { const { id } = await params; const tournamentId = Number(id); - const queryClient = getQueryClient(); - - queryClient.prefetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }); - return ( - - - - ); + return ; } export default TournamentCreatePage; diff --git a/apps/web/src/app/tournament/join/[id]/page.tsx b/apps/web/src/app/tournament/join/[id]/page.tsx index a605fd03..e34ed79a 100644 --- a/apps/web/src/app/tournament/join/[id]/page.tsx +++ b/apps/web/src/app/tournament/join/[id]/page.tsx @@ -1,15 +1,12 @@ import { ERROR_CODE } from '@piki/core'; -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; import { getInvitePreviewByCode } from '@/apis/getInvitePreviewByCode'; -import { getMe } from '@/apis/getMe'; import { ROUTES } from '@/consts/route'; import { getApiErrorCode, getApiErrorStatus, isServerOrNetworkError } from '@/utils/apiError'; import { parseIdParam } from '@/utils/parseIdParam'; -import { getQueryClient } from '@/utils/queryClient'; import JoinErrorScreen from './_components/JoinErrorScreen'; import JoinPreviewClient from './_components/JoinPreviewClient'; @@ -80,17 +77,7 @@ async function TournamentJoinPage({ params, searchParams }: TournamentJoinPagePr /** 이미 참여한 유저인 경우 - 바로 토너먼트 준비 화면으로 진입 */ if (preview.joined) redirect(ROUTES.TOURNAMENT_CREATE(tournamentId)); - const queryClient = getQueryClient(); - queryClient.prefetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }); - - return ( - - - - ); + return ; } export default TournamentJoinPage; diff --git a/apps/web/src/components/notification-sse-provider/index.tsx b/apps/web/src/components/notification-sse-provider/index.tsx index a18cc22f..af1d3c49 100644 --- a/apps/web/src/components/notification-sse-provider/index.tsx +++ b/apps/web/src/components/notification-sse-provider/index.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { usePathname } from 'next/navigation'; -import { getMe } from '@/apis/getMe'; +import { getMeQueryOptions } from '@/apis/getMe'; import { useNotificationSSE } from '@/hooks/useNotificationSSE'; import { getRouteType } from '@/utils/getRouteType'; @@ -13,8 +13,7 @@ function NotificationSSEProvider() { const enabled = !!routeType && routeType !== 'PUBLIC'; const { data: meData } = useQuery({ - queryKey: ['me'], - queryFn: getMe, + ...getMeQueryOptions, enabled, retry: false, }); diff --git a/apps/web/src/consts/queryKeys.ts b/apps/web/src/consts/queryKeys.ts index 4ff12a28..c1b84204 100644 --- a/apps/web/src/consts/queryKeys.ts +++ b/apps/web/src/consts/queryKeys.ts @@ -1,6 +1,16 @@ import type { GetTournamentListRequestT } from '@/types/tournament'; export const QUERY_KEYS = { + /** 유저 */ + USER: { + /** 내 정보 */ + ME: ['me'] as const, + }, + /** 알림 */ + NOTIFICATION: { + /** 알림 히스토리 목록 */ + LIST: ['notifications'] as const, + }, /** 토너먼트 */ TOURNAMENT: { /** 토너먼트 리스트 */ diff --git a/apps/web/src/hooks/useGetMe.ts b/apps/web/src/hooks/useGetMe.ts index c5f5d7cd..6c0fc99f 100644 --- a/apps/web/src/hooks/useGetMe.ts +++ b/apps/web/src/hooks/useGetMe.ts @@ -2,15 +2,10 @@ import * as Sentry from '@sentry/nextjs'; import { useSuspenseQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; -import { getMe } from '@/apis/getMe'; +import { getMeQueryOptions } from '@/apis/getMe'; export const useGetMe = () => { - const { data: userData } = useSuspenseQuery({ - queryKey: ['me'], - queryFn: getMe, - // 유저 정보는 거의 변하지 않으므로 길게 유지 — 재방문/탭 전환 시 즉시 렌더 범위 확대 - staleTime: 5 * 60 * 1000, - }); + const { data: userData } = useSuspenseQuery(getMeQueryOptions); /** 에러가 어떤 유저에게 발생했는지 식별 (PII 정책상 id만, 이메일/닉네임 제외) */ useEffect(() => { diff --git a/apps/web/src/hooks/useNotificationSSE.ts b/apps/web/src/hooks/useNotificationSSE.ts index 7756cb8f..b769c81d 100644 --- a/apps/web/src/hooks/useNotificationSSE.ts +++ b/apps/web/src/hooks/useNotificationSSE.ts @@ -8,6 +8,7 @@ import { toast } from 'sonner'; import { ENDPOINTS } from '@/consts/api'; import { QUERY_ACTION } from '@/consts/queryAction'; +import { QUERY_KEYS } from '@/consts/queryKeys'; import { ROUTES } from '@/consts/route'; import { CLIENT_TYPE } from '@/consts/webBridge'; import type { NotificationSsePayloadT, SilentSyncSsePayloadT } from '@/types/notification'; @@ -99,8 +100,8 @@ export const useNotificationSSE = (enabled: boolean) => { retryDelayRef.current = 1_000; authFailCountRef.current = 0; if (hasConnectedRef.current) { - // 재연결 성공 — 끊긴 동안 SSE 이벤트로 놓쳤을 수 있는 도메인만 재조회 - void queryClient.invalidateQueries({ queryKey: ['notifications'] }); + // 재연결 성공 — 끊긴 동안 SSE 이벤트로 놓쳤을 수 있는 도메인만 재조회 + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.NOTIFICATION.LIST }); void queryClient.invalidateQueries({ queryKey: ['tournament'] }); void queryClient.invalidateQueries({ queryKey: ['wishlists'] }); } @@ -141,7 +142,10 @@ export const useNotificationSSE = (enabled: boolean) => { queryClient.invalidateQueries({ queryKey: ['tournament', payload.tournamentId] }); break; case 'UNREAD_COUNT_CHANGED': - void queryClient.refetchQueries({ queryKey: ['notifications'], type: 'all' }); + void queryClient.refetchQueries({ + queryKey: QUERY_KEYS.NOTIFICATION.LIST, + type: 'all', + }); if (isWebview()) { WebBridge.postMessage({ type: WEBBRIDGE_MESSAGE_TYPE.WEB_REQ_SET_BADGE, @@ -160,7 +164,10 @@ export const useNotificationSSE = (enabled: boolean) => { try { const payload = JSON.parse(event.data) as NotificationSsePayloadT; // 배지 갱신은 silent-sync(UNREAD_COUNT_CHANGED) 가 payload 의 count 로 처리한다 — 별도 조회 금지 - void queryClient.refetchQueries({ queryKey: ['notifications'], type: 'all' }); + void queryClient.refetchQueries({ + queryKey: QUERY_KEYS.NOTIFICATION.LIST, + type: 'all', + }); const message = buildToastMessage(payload); const deepLink = resolveDeepLink(payload); const action = deepLink