From 5b5c21b07f30a61a50bf478b41e2effefad65ea3 Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:58:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EB=82=A8=EC=9D=80=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/match/_components/ProductCard.tsx | 3 +- .../match/_components/TournamentClient.tsx | 5 ++ .../tournament/[id]/match/_consts/image.ts | 9 +++ .../match/_hooks/usePreloadMatchImages.ts | 70 +++++++++++++++++++ .../[id]/match/_hooks/useTournament.ts | 6 ++ 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/tournament/[id]/match/_consts/image.ts create mode 100644 apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts diff --git a/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx b/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx index d8880b33b..3a93a3b50 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx @@ -3,6 +3,7 @@ import { Z_INDEX } from '@/consts/zIndex'; import formatPrice from '@/utils/formatPrice'; import type { ProductT } from '../../_common/_types/tournament'; +import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image'; type ProductCardProps = ProductT & { isPicked?: boolean; @@ -33,7 +34,7 @@ function ProductCard({ imageUrl, name, price, isPicked, isFinal = false, onClick diff --git a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx index 312675dfd..2e4f57ba3 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx @@ -1,6 +1,7 @@ 'use client'; import type { GetTournamentInProgressResponseT } from '../../_common/_types/tournamentResponse'; +import usePreloadMatchImages from '../_hooks/usePreloadMatchImages'; import useTournament from '../_hooks/useTournament'; import MatchSkeleton from './MatchSkeleton'; import RoundBadge from './RoundBadge'; @@ -17,6 +18,7 @@ type TournamentClientProps = { function TournamentClient({ tournamentId, tournamentName, inProgress }: TournamentClientProps) { const { currentMatch, + remainingItems, roundLabel, isFinalRound, transitionStage, @@ -26,6 +28,9 @@ function TournamentClient({ tournamentId, tournamentName, inProgress }: Tourname handleTransitionComplete, } = useTournament({ tournamentId, tournamentName, inProgress }); + // 다음 대진은 미리 알 수 없어도 후보는 알고 있다 — 라운드의 남은 아이템 이미지를 미리 받아둔다 + usePreloadMatchImages(remainingItems.map(item => item.imageUrl)); + const backgroundClassName = isFinalRound ? 'bg-gradient-to-b from-sky-blue-50 via-[#F5FCFF] to-white' : 'bg-bg-layer-basement'; diff --git a/apps/web/src/app/tournament/[id]/match/_consts/image.ts b/apps/web/src/app/tournament/[id]/match/_consts/image.ts new file mode 100644 index 000000000..fd2e409e2 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/match/_consts/image.ts @@ -0,0 +1,9 @@ +/** + * 매치 상품 카드 이미지의 `sizes`. + * + * next/image 는 이 값과 기기 DPR 로 srcset 후보 중 하나를 골라 + * `/_next/image?url=...&w=<선택된 폭>&q=75` 를 요청한다. + * 프리로드가 실제 렌더와 같은 URL 을 받아오려면(= 브라우저 캐시 키 일치) + * ProductCard 와 프리로드 훅이 반드시 같은 값을 써야 해서 상수로 뽑았다. + */ +export const PRODUCT_CARD_IMAGE_SIZES = '(max-width: 480px) 45vw, 200px'; diff --git a/apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts b/apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts new file mode 100644 index 000000000..fb012cdfd --- /dev/null +++ b/apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts @@ -0,0 +1,70 @@ +'use client'; + +import { getImageProps } from 'next/image'; +import { useEffect, useRef } from 'react'; + +import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image'; + +/** + * 다음 대진은 기록 응답(nextMatch)으로만 알 수 있어 미리 조회할 수 없다. + * 대신 그 라운드에 남은 아이템(remainingItems)은 이미 알고 있으므로, + * 라운드 진입 시점에 후보 이미지를 전부 브라우저 캐시에 올려둔다. + * → 어떤 조합이 나와도 스켈레톤이 걷힐 때 이미 받아둔 이미지가 그려진다. + * + * 캐시 키는 URL 문자열이고, 실제 렌더는 원본 URL 이 아니라 + * `/_next/image?url=...&w=...&q=75` 를 요청한다. 그래서 원본 URL 로 프리로드하면 + * 키가 어긋나 캐시 미스가 된다 — getImageProps 로 렌더와 동일한 srcSet/sizes 를 얻어 + * 브라우저가 렌더 때와 같은 후보를 고르게 한다. + */ +const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => { + /** 이미 요청한 URL — 라운드가 바뀌어도 중복 요청하지 않는다 */ + const preloadedUrlsRef = useRef>(new Set()); + /** 로드 완료 전 GC 로 요청이 취소되지 않도록 인스턴스를 붙잡아 둔다 */ + const pendingImagesRef = useRef>(new Set()); + + /** 배열 참조가 매 렌더 바뀌므로 내용 기준으로 의존성을 만든다 */ + const urlsKey = imageUrls.filter(Boolean).join('|'); + + useEffect(() => { + if (!urlsKey) return; + + const pendingImages = pendingImagesRef.current; + + urlsKey.split('|').forEach(imageUrl => { + if (preloadedUrlsRef.current.has(imageUrl)) return; + preloadedUrlsRef.current.add(imageUrl); + + const { props } = getImageProps({ + src: imageUrl, + alt: '', + fill: true, + sizes: PRODUCT_CARD_IMAGE_SIZES, + }); + + const image = new Image(); + /** 현재 매치 이미지와 대역폭을 다투지 않도록 낮은 우선순위로 */ + image.fetchPriority = 'low'; + /** srcset·sizes 를 src 보다 먼저 — 순서가 바뀌면 src 로 먼저 요청이 나간다 */ + if (props.sizes) image.sizes = props.sizes; + if (props.srcSet) image.srcset = props.srcSet; + + const release = () => pendingImages.delete(image); + image.onload = release; + image.onerror = release; + + pendingImages.add(image); + image.src = props.src; + }); + + return () => { + /** 언마운트 시 진행 중인 프리로드는 정리 — 남은 요청은 브라우저가 취소한다 */ + pendingImages.forEach(image => { + image.onload = null; + image.onerror = null; + }); + pendingImages.clear(); + }; + }, [urlsKey]); +}; + +export default usePreloadMatchImages; diff --git a/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts b/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts index f330ca084..62ed003da 100644 --- a/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts +++ b/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts @@ -77,6 +77,8 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const [currentMatch, setCurrentMatch] = useState( inProgress.currentMatch ); + // 해당 라운드에 남은 후보 아이템 — 다음 대진 이미지 프리로드용 (usePreloadMatchImages) + const [remainingItems, setRemainingItems] = useState(inProgress.remainingItems); // 라운드 내 진행한 매치 수 (라벨 표기용) — 라운드가 바뀌면 0 으로 초기화 const [matchIndex, setMatchIndex] = useState(0); const [transitionStage, setTransitionStage] = useState(null); @@ -112,6 +114,9 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const nextInProgress = next.inProgress; + // 바텀시트 분기보다 먼저 갱신 — 시트가 떠 있는 동안이 프리로드에 쓸 수 있는 시간이다 + setRemainingItems(nextInProgress.remainingItems); + // 라운드 전환 — 서버의 실제 다음 라운드 수 기준으로 바텀시트 판단 if (nextInProgress.currentRound !== currentRound) { const stage = getTransitionStage(nextInProgress.currentRound); @@ -228,6 +233,7 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { return { currentMatch, + remainingItems, roundLabel, isFinalRound, transitionStage, From 999b4d66400459e9e1b9fac7ad14e4385f988c3f Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:32:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=A7=A4=EC=B9=98=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8B=9C=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4?= =?UTF-8?q?=EC=9D=B4=20=EB=B2=88=EC=A9=8D=EC=9D=B4=EB=8A=94=20=EA=B9=9C?= =?UTF-8?q?=EB=B9=A1=EC=9E=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/match/_components/TournamentClient.tsx | 5 ++--- .../tournament/[id]/match/_hooks/useTournament.ts | 12 +----------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx index 2e4f57ba3..bd14fcc6a 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx @@ -23,7 +23,6 @@ function TournamentClient({ tournamentId, tournamentName, inProgress }: Tourname isFinalRound, transitionStage, selectionEpoch, - isRecordingMatch, handleSelect, handleTransitionComplete, } = useTournament({ tournamentId, tournamentName, inProgress }); @@ -49,8 +48,8 @@ function TournamentClient({ tournamentId, tournamentName, inProgress }: Tourname )}
- {/* 다음 매치는 서버 응답으로 오므로 기록 대기 동안 스켈레톤을 보여준다 */} - {isRecordingMatch || !currentMatch ? ( + {/* 기록 대기 중에는 이전 화면을 유지해 스켈레톤 깜빡임을 방지한다. */} + {!currentMatch ? ( ) : ( ; const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const router = useRouter(); const queryClient = useQueryClient(); - const { postRecordMatchMutation, isPostRecordMatchPending } = usePostRecordMatch({ + const { postRecordMatchMutation } = usePostRecordMatch({ tournamentId, onSuccess: data => { const completed = data.completed; @@ -85,9 +85,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { // 카드 선택 락 해제용 — 매치가 바뀌지 않는 기록 실패에서 VsSection 을 remount 시켜 // 재선택을 가능하게 한다 (락은 useCardSelectionAnimation 내부 상태) const [selectionEpoch, setSelectionEpoch] = useState(0); - // 결승 기록 후 결과 페이지로 이동하는 동안 true — 라우팅이 끝나기 전에 - // 방금 고른 결승 매치가 다시 그려지는 깜빡임을 막는다 - const [isNavigatingToResult, setIsNavigatingToResult] = useState(false); // 준결승/결승 바텀시트 표시 중 재조회 없이 적용할 다음 라운드 데이터 const pendingNextRoundRef = useRef(null); @@ -106,7 +103,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { queryClient.setQueryData(['tournament', tournamentId], next); if (next.status === TOURNAMENT_STATUS.COMPLETED) { - setIsNavigatingToResult(true); router.replace(ROUTES.TOURNAMENT_RESULT(tournamentId)); return; } @@ -172,7 +168,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { onSuccess: async data => { // 토너먼트 종료 — 캐시 정리(훅 onSuccess)까지 끝난 뒤 결과 페이지로 if (data.completed) { - setIsNavigatingToResult(true); router.replace(ROUTES.TOURNAMENT_RESULT(tournamentId)); return; } @@ -238,11 +233,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { isFinalRound, transitionStage, selectionEpoch, - /** - * 기록 요청 대기 중 — 다음 매치를 서버가 주므로 이 동안 스켈레톤을 노출한다. - * 결과 페이지로 이동하는 중에도 유지해 방금 고른 매치가 다시 보이지 않게 한다. - */ - isRecordingMatch: isPostRecordMatchPending || isNavigatingToResult, handleSelect, handleTransitionComplete, }; From ee72a680391fe355e3b7f0577a192bf04d7d388a Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:22:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=A1=9C=EB=94=A9=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=201=EB=9D=BC=EC=9A=B4?= =?UTF-8?q?=EB=93=9C=20=ED=9B=84=EB=B3=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=94=84=EB=A6=AC=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tournament/[id]/_common/_consts/image.ts | 2 ++ .../_hooks/usePreloadMatchImages.ts | 17 ++------------ .../src/app/tournament/[id]/loading/page.tsx | 23 +++++++++++++++++++ .../[id]/match/_components/ProductCard.tsx | 2 +- .../match/_components/TournamentClient.tsx | 2 +- .../tournament/[id]/match/_consts/image.ts | 9 -------- 6 files changed, 29 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/tournament/[id]/_common/_consts/image.ts rename apps/web/src/app/tournament/[id]/{match => _common}/_hooks/usePreloadMatchImages.ts (58%) delete mode 100644 apps/web/src/app/tournament/[id]/match/_consts/image.ts diff --git a/apps/web/src/app/tournament/[id]/_common/_consts/image.ts b/apps/web/src/app/tournament/[id]/_common/_consts/image.ts new file mode 100644 index 000000000..8fc145549 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/_common/_consts/image.ts @@ -0,0 +1,2 @@ +/** 프리로드와 ProductCard의 next/image 캐시 키를 일치시키는 공통 sizes 값 */ +export const PRODUCT_CARD_IMAGE_SIZES = '(max-width: 480px) 45vw, 200px'; diff --git a/apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts similarity index 58% rename from apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts rename to apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts index fb012cdfd..f21d4e88e 100644 --- a/apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts +++ b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts @@ -5,24 +5,11 @@ import { useEffect, useRef } from 'react'; import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image'; -/** - * 다음 대진은 기록 응답(nextMatch)으로만 알 수 있어 미리 조회할 수 없다. - * 대신 그 라운드에 남은 아이템(remainingItems)은 이미 알고 있으므로, - * 라운드 진입 시점에 후보 이미지를 전부 브라우저 캐시에 올려둔다. - * → 어떤 조합이 나와도 스켈레톤이 걷힐 때 이미 받아둔 이미지가 그려진다. - * - * 캐시 키는 URL 문자열이고, 실제 렌더는 원본 URL 이 아니라 - * `/_next/image?url=...&w=...&q=75` 를 요청한다. 그래서 원본 URL 로 프리로드하면 - * 키가 어긋나 캐시 미스가 된다 — getImageProps 로 렌더와 동일한 srcSet/sizes 를 얻어 - * 브라우저가 렌더 때와 같은 후보를 고르게 한다. - */ +/** 로딩 페이지에서는 1라운드 후보를, 매치 화면에서는 라운드별 `remainingItems`를 프리로드한다. */ const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => { - /** 이미 요청한 URL — 라운드가 바뀌어도 중복 요청하지 않는다 */ const preloadedUrlsRef = useRef>(new Set()); - /** 로드 완료 전 GC 로 요청이 취소되지 않도록 인스턴스를 붙잡아 둔다 */ const pendingImagesRef = useRef>(new Set()); - /** 배열 참조가 매 렌더 바뀌므로 내용 기준으로 의존성을 만든다 */ const urlsKey = imageUrls.filter(Boolean).join('|'); useEffect(() => { @@ -57,7 +44,7 @@ const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => { }); return () => { - /** 언마운트 시 진행 중인 프리로드는 정리 — 남은 요청은 브라우저가 취소한다 */ + /** 언마운트 시 보관 중인 이미지 참조와 이벤트 핸들러 정리 */ pendingImages.forEach(image => { image.onload = null; image.onerror = null; diff --git a/apps/web/src/app/tournament/[id]/loading/page.tsx b/apps/web/src/app/tournament/[id]/loading/page.tsx index 8fb4b766e..1a4647f12 100644 --- a/apps/web/src/app/tournament/[id]/loading/page.tsx +++ b/apps/web/src/app/tournament/[id]/loading/page.tsx @@ -1,20 +1,43 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import { useEffect } from 'react'; import { ROUTES } from '@/consts/route'; +import { TOURNAMENT_STATUS } from '@/consts/tournament'; +import usePreloadMatchImages from '../_common/_hooks/usePreloadMatchImages'; +import type { GetTournamentResponseT } from '../_common/_types/tournamentResponse'; import LoadingBar from './_components/LoadingBar'; import TournamentBracketAnimation from './_components/TournamentBracketAnimation'; const LOADING_DURATION_MS = 4000; +const getCandidateImageUrls = (tournament: GetTournamentResponseT | undefined) => { + if (!tournament) return []; + if (tournament.status === TOURNAMENT_STATUS.PENDING) + return tournament.pending.items.map(item => item.imageUrl); + if (tournament.status !== TOURNAMENT_STATUS.IN_PROGRESS) return []; + + return tournament.inProgress + ? tournament.inProgress.remainingItems.map(item => item.imageUrl) + : tournament.pending.items.map(item => item.imageUrl); +}; + function TournamentLoadingPage() { const router = useRouter(); const params = useParams(); + const queryClient = useQueryClient(); const tournamentId = Number(params.id); + const tournamentData = queryClient.getQueryData([ + 'tournament', + tournamentId, + ]); + + usePreloadMatchImages(getCandidateImageUrls(tournamentData)); + useEffect(() => { const timer = setTimeout(() => { router.replace(ROUTES.TOURNAMENT_MATCH(tournamentId)); diff --git a/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx b/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx index 3a93a3b50..a9fda6844 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx @@ -2,8 +2,8 @@ import BaseImage from '@/components/base-image'; import { Z_INDEX } from '@/consts/zIndex'; import formatPrice from '@/utils/formatPrice'; +import { PRODUCT_CARD_IMAGE_SIZES } from '../../_common/_consts/image'; import type { ProductT } from '../../_common/_types/tournament'; -import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image'; type ProductCardProps = ProductT & { isPicked?: boolean; diff --git a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx index bd14fcc6a..bf417663e 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx @@ -1,7 +1,7 @@ 'use client'; +import usePreloadMatchImages from '../../_common/_hooks/usePreloadMatchImages'; import type { GetTournamentInProgressResponseT } from '../../_common/_types/tournamentResponse'; -import usePreloadMatchImages from '../_hooks/usePreloadMatchImages'; import useTournament from '../_hooks/useTournament'; import MatchSkeleton from './MatchSkeleton'; import RoundBadge from './RoundBadge'; diff --git a/apps/web/src/app/tournament/[id]/match/_consts/image.ts b/apps/web/src/app/tournament/[id]/match/_consts/image.ts deleted file mode 100644 index fd2e409e2..000000000 --- a/apps/web/src/app/tournament/[id]/match/_consts/image.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * 매치 상품 카드 이미지의 `sizes`. - * - * next/image 는 이 값과 기기 DPR 로 srcset 후보 중 하나를 골라 - * `/_next/image?url=...&w=<선택된 폭>&q=75` 를 요청한다. - * 프리로드가 실제 렌더와 같은 URL 을 받아오려면(= 브라우저 캐시 키 일치) - * ProductCard 와 프리로드 훅이 반드시 같은 값을 써야 해서 상수로 뽑았다. - */ -export const PRODUCT_CARD_IMAGE_SIZES = '(max-width: 480px) 45vw, 200px'; From 08ee95df2a7ea9ffa5c57179db1a32fb83e06deb Mon Sep 17 00:00:00 2001 From: kanghaeun <145974230+kanghaeun@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:19:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=ED=94=84=EB=A6=AC=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20cleanup=20=EC=9D=B4=20=EC=A7=84=ED=96=89=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=81=8A=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=A5=BC=20=EB=A7=89=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_common/_hooks/usePreloadMatchImages.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts index f21d4e88e..47e461594 100644 --- a/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts +++ b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts @@ -31,26 +31,23 @@ const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => { const image = new Image(); /** 현재 매치 이미지와 대역폭을 다투지 않도록 낮은 우선순위로 */ image.fetchPriority = 'low'; - /** srcset·sizes 를 src 보다 먼저 — 순서가 바뀌면 src 로 먼저 요청이 나간다 */ + if (props.sizes) image.sizes = props.sizes; if (props.srcSet) image.srcset = props.srcSet; - const release = () => pendingImages.delete(image); - image.onload = release; - image.onerror = release; + image.onload = () => { + pendingImages.delete(image); + }; + /** 실패한 URL은 제거해 다음 진입 시 재시도한다. */ + image.onerror = () => { + pendingImages.delete(image); + + preloadedUrlsRef.current.delete(imageUrl); + }; pendingImages.add(image); image.src = props.src; }); - - return () => { - /** 언마운트 시 보관 중인 이미지 참조와 이벤트 핸들러 정리 */ - pendingImages.forEach(image => { - image.onload = null; - image.onerror = null; - }); - pendingImages.clear(); - }; }, [urlsKey]); };