From 240891a7cc3f9f82d3469e3249df0606e82e38d1 Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 15:03:44 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=ED=81=B4=EB=9D=BC=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=B6=94=EB=A1=A0=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layout/RootLayout.tsx | 32 +-- .../generate/hooks/useFurnitureHotspots.ts | 2 +- src/pages/generate/hooks/useOnnxModel.ts | 21 +- .../generate/pages/result/ResultPage.tsx | 2 + src/pages/generate/pages/start/StartPage.tsx | 11 - .../GeneratedImagesSection.tsx | 66 +---- .../mypage/hooks/useDetectionPrefetch.ts | 238 +----------------- 7 files changed, 39 insertions(+), 333 deletions(-) diff --git a/src/layout/RootLayout.tsx b/src/layout/RootLayout.tsx index ac06d0866..8fb1b4bb8 100644 --- a/src/layout/RootLayout.tsx +++ b/src/layout/RootLayout.tsx @@ -1,24 +1,9 @@ -import { useEffect } from 'react'; - -import { Outlet, useLocation } from 'react-router-dom'; - -import { ROUTES } from '@/routes/paths'; +import { Outlet } from 'react-router-dom'; import { useScrollToTop } from '@/shared/hooks/useScrollToTop'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; - -const GENERATE_WARMUP_PATHS = [ - ROUTES.GENERATE, - ROUTES.GENERATE_RESULT, - ROUTES.GENERATE_START, - ROUTES.IMAGE_SETUP, -]; - function RootLayout() { // 라우트/쿼리/해시/키 변화와 초기 마운트 시 스크롤 최상단으로 이동 useScrollToTop(); - useGenerateWarmup(); return (
@@ -26,19 +11,4 @@ function RootLayout() { ); } -function useGenerateWarmup() { - const location = useLocation(); - - useEffect(() => { - const pathname = location.pathname; - const shouldWarmup = GENERATE_WARMUP_PATHS.some( - (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) - ); - - if (!shouldWarmup) return; - - preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); - }, [location.pathname]); -} - export default RootLayout; diff --git a/src/pages/generate/hooks/useFurnitureHotspots.ts b/src/pages/generate/hooks/useFurnitureHotspots.ts index c9b97d22a..bcfdfe757 100644 --- a/src/pages/generate/hooks/useFurnitureHotspots.ts +++ b/src/pages/generate/hooks/useFurnitureHotspots.ts @@ -130,7 +130,7 @@ export function useFurnitureHotspots( runInference, isLoading, error: modelError, - } = useONNXModel(OBJ365_MODEL_PATH); + } = useONNXModel(OBJ365_MODEL_PATH, { enabled }); const prefetchedDetections = options?.prefetchedDetections ?? null; const onInferenceComplete = options?.onInferenceComplete; const inferenceCompleteRef = diff --git a/src/pages/generate/hooks/useOnnxModel.ts b/src/pages/generate/hooks/useOnnxModel.ts index 6e5b9c9d0..49cc6d6af 100644 --- a/src/pages/generate/hooks/useOnnxModel.ts +++ b/src/pages/generate/hooks/useOnnxModel.ts @@ -218,7 +218,15 @@ export const preloadONNXModel = async ( * - 640×640 렌더링 텐서를 입력으로 받아 감지 결과를 반환 * - 추론 결과는 후속 파이프라인(`useFurnitureHotspots`)에서 원본 좌표로 보정 */ -export function useONNXModel(modelPath: string) { +interface UseONNXModelOptions { + enabled?: boolean; +} + +export function useONNXModel( + modelPath: string, + options?: UseONNXModelOptions +) { + const enabled = options?.enabled ?? true; const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -226,6 +234,15 @@ export function useONNXModel(modelPath: string) { const ortRef = useRef(null); // onnxruntime-web 모듈 보관 useEffect(() => { + if (!enabled) { + setSession(null); + ortRef.current = null; + setIsLoading(false); + setError(null); + setProgress(0); + return; + } + if (typeof window === 'undefined') { setIsLoading(false); setError('브라우저 환경이 아닙니다'); @@ -260,7 +277,7 @@ export function useONNXModel(modelPath: string) { return () => { isMounted = false; }; - }, [modelPath]); + }, [enabled, modelPath]); const runInference = useCallback( async (imageElement: HTMLImageElement): Promise => { diff --git a/src/pages/generate/pages/result/ResultPage.tsx b/src/pages/generate/pages/result/ResultPage.tsx index 25e2529f4..07cd27ec9 100644 --- a/src/pages/generate/pages/result/ResultPage.tsx +++ b/src/pages/generate/pages/result/ResultPage.tsx @@ -215,6 +215,7 @@ const ResultPage = () => { { )} diff --git a/src/pages/generate/pages/start/StartPage.tsx b/src/pages/generate/pages/start/StartPage.tsx index f78b2cf9b..fbfce951e 100644 --- a/src/pages/generate/pages/start/StartPage.tsx +++ b/src/pages/generate/pages/start/StartPage.tsx @@ -1,5 +1,3 @@ -import { useEffect } from 'react'; - import { useNavigate } from 'react-router-dom'; import { useABTest } from '@/pages/generate/hooks/useABTest'; @@ -10,8 +8,6 @@ import TitleNavBar from '@/shared/components/navBar/TitleNavBar'; import { useUserStore } from '@/store/useUserStore'; import SignupImage from '@assets/icons/loginAfter.png'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; import * as styles from './StartPage.css.ts'; @@ -21,13 +17,6 @@ const StartPage = () => { const navigate = useNavigate(); const { variant } = useABTest(); - useEffect(() => { - // 이미지 생성 플로우 진입 시 모델 선로딩 - preloadONNXModel(OBJ365_MODEL_PATH).catch(() => { - // console.warn('[StartPage] preload model failed'); - }); - }, []); - const handleGoToImageSetup = () => { // 이미지 생성 시작 페이지 CTA 버튼 클릭 시 GA 이벤트 전송 logGenerateStartClickBtnCTA(variant); diff --git a/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx b/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx index 63ee12251..1045d6027 100644 --- a/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx +++ b/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx @@ -1,9 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import CardCuration from '@/pages/mypage/components/card/cardCuration/CardCuration'; -import { useDetectionPrefetch } from '@/pages/mypage/hooks/useDetectionPrefetch'; import { useMyPageImagesQuery } from '@/pages/mypage/hooks/useMypage'; import type { MyPageImageHistory, @@ -23,15 +22,13 @@ interface GeneratedImagesSectionProps { /** * 마이페이지 생성 이미지 목록 섹션 - * - 감지 데이터 프리패치와 네비게이션 상태 구성을 함께 처리 + * - 결과 페이지 네비게이션 상태 구성을 함께 처리 */ const GeneratedImagesSection = ({ userProfile, }: GeneratedImagesSectionProps) => { const navigate = useNavigate(); const { data: imagesData, isLoading, isError } = useMyPageImagesQuery(); - const { prefetchDetection } = useDetectionPrefetch(); - const prefetchedImageIdsRef = useRef>(new Set()); const [loadedImages, setLoadedImages] = useState>( () => { if (typeof window === 'undefined') return {}; @@ -43,51 +40,9 @@ const GeneratedImagesSection = ({ } } ); - const primaryImageId = imagesData?.histories[0]?.imageId ?? null; - - useEffect(() => { - if (!imagesData?.histories) return; - imagesData.histories.forEach((history, index) => { - if (prefetchedImageIdsRef.current.has(history.imageId)) return; - prefetchedImageIdsRef.current.add(history.imageId); - prefetchDetection(history.imageId, history.generatedImageUrl, { - priority: index === 0 ? 'immediate' : 'background', - }); - }); - }, [imagesData, prefetchDetection]); /** - * 감지 프리패치를 브라우저 idle 시간에 스케줄링 - * - 우선순위(immediate) 요청은 즉시 수행 - */ - const scheduleDetectionPrefetch = useCallback( - (imageId: number, imageUrl: string, options?: { immediate?: boolean }) => { - if (!imageId || !imageUrl) return; - const runTask = () => { - prefetchDetection(imageId, imageUrl, { - priority: options?.immediate ? 'immediate' : 'background', - }); - }; - if (options?.immediate || typeof window === 'undefined') { - runTask(); - return; - } - const idleCallback = ( - window as Window & { - requestIdleCallback?: (callback: IdleRequestCallback) => number; - } - ).requestIdleCallback; - if (idleCallback) { - idleCallback(() => runTask()); - return; - } - window.setTimeout(runTask, 0); - }, - [prefetchDetection] - ); - - /** - * 결과 페이지로 이동하며 필요한 감지 데이터를 선행 프리패치 + * 결과 페이지로 이동 */ const handleViewResult = (history: MyPageImageHistory) => { const { houseId } = history; @@ -103,17 +58,13 @@ const GeneratedImagesSection = ({ navigate(`${ROUTES.GENERATE_RESULT}?${params.toString()}`, { state: navigationState, }); - // 네비게이션 직후 우선순위 감지 프리페치 실행 - scheduleDetectionPrefetch(history.imageId, history.generatedImageUrl, { - immediate: true, - }); }; /** - * 이미지 로드 완료 시 로컬 캐시를 갱신하고 프리패치 스케줄 + * 이미지 로드 완료 시 로컬 캐시 갱신 */ const handleImageLoad = useCallback( - (imageId: number, imageUrl?: string) => { + (imageId: number) => { setLoadedImages((prev) => { if (prev[imageId]) return prev; const next = { ...prev, [imageId]: true }; @@ -122,13 +73,8 @@ const GeneratedImagesSection = ({ } return next; }); - if (imageUrl) { - scheduleDetectionPrefetch(imageId, imageUrl, { - immediate: primaryImageId === imageId, - }); - } }, - [primaryImageId, scheduleDetectionPrefetch] + [] ); // 로딩 중 diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.ts b/src/pages/mypage/hooks/useDetectionPrefetch.ts index 1cd2b662f..c4421322c 100644 --- a/src/pages/mypage/hooks/useDetectionPrefetch.ts +++ b/src/pages/mypage/hooks/useDetectionPrefetch.ts @@ -1,45 +1,4 @@ -import { useCallback, useRef } from 'react'; - -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { buildHotspotsPipeline } from '@pages/generate/hooks/furnitureHotspotPipeline'; -import { loadCorsImage } from '@pages/generate/hooks/useFurnitureHotspots'; -import { useONNXModel } from '@pages/generate/hooks/useOnnxModel'; -import { useDetectionCacheStore } from '@pages/generate/stores/useDetectionCacheStore'; -import { - filterAllowedDetectedObjects, - mapHotspotsToDetectedObjects, -} from '@pages/generate/utils/detectedObjectMapper'; - -import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; -import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; -import type { ProcessedDetections } from '@pages/generate/types/detection'; - -const PREFETCH_DELAY_MS = 120; - -/** - * 외부 이미지 요소 로더 - * - crossOrigin 허용을 기본으로 시도 - * - 실패 시 에러를 상위로 전달 - */ -const loadImageElement = (url: string) => - new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.decoding = 'async'; - img.onload = () => resolve(img); - img.onerror = (event) => - reject( - event instanceof ErrorEvent - ? event.error - : new Error('이미지 로드 실패') - ); - img.src = url; - }); - -const sleep = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); +import { useCallback } from 'react'; type PrefetchPriority = 'immediate' | 'background'; @@ -47,198 +6,21 @@ export interface DetectionPrefetchOptions { priority?: PrefetchPriority; } -type PrefetchTask = { - imageId: number; - imageUrl: string; -}; - -const MAX_CONCURRENCY = 1; - /** - * 감지(inference) 결과를 사전 계산해 캐시에 적재하는 훅 - * - 즉시(immediate) 요청과 백그라운드 큐를 분리해 성능 균형 유지 + * 감지 프리패치 훅 + * - 클라이언트 객체 추론 비활성화로 no-op 유지 */ export const useDetectionPrefetch = () => { - const { runInference, isLoading, error } = useONNXModel(OBJ365_MODEL_PATH); - const setEntry = useDetectionCacheStore((state) => state.setEntry); - const pendingRef = useRef>(new Set()); - const queueRef = useRef([]); - const drainingRef = useRef(false); - const activeCountRef = useRef(0); // 동시에 실행 중인 작업 수 - const waitersRef = useRef<(() => void)[]>([]); // 세마포어 대기열 - - // 세마포어 슬롯 확보 - const acquireSlot = useCallback(async () => { - if (activeCountRef.current < MAX_CONCURRENCY) { - activeCountRef.current += 1; - return; - } - await new Promise((resolve) => { - waitersRef.current.push(resolve); - }); - activeCountRef.current += 1; - }, []); - - // 세마포어 슬롯 반환 - const releaseSlot = useCallback(() => { - activeCountRef.current = Math.max(0, activeCountRef.current - 1); - const next = waitersRef.current.shift(); - if (next) { - next(); - } - }, []); - - // 공통 실행 래퍼: 동시 실행 상한을 2개로 제한 - const runWithSemaphore = useCallback( - async (task: () => Promise) => { - await acquireSlot(); - try { - await task(); - } finally { - releaseSlot(); - } - }, - [acquireSlot, releaseSlot] - ); - - const storeDetections = useCallback( - ( - imageId: number, - imageUrl: string, - payload: ProcessedDetections, - extra?: { - hotspots?: FurnitureHotspot[]; - detectedObjects?: FurnitureCategoryCode[]; - } - ) => { - setEntry(imageId, { - imageUrl, - processedDetections: payload, - hotspots: extra?.hotspots ?? [], - detectedObjects: extra?.detectedObjects, - }); - }, - [setEntry] - ); - - const processAndStore = useCallback( + const prefetchDetection = useCallback( ( - imageId: number, - imageUrl: string, - targetImage: HTMLImageElement, - processed: ProcessedDetections + _imageId: number, + _imageUrl: string, + _options?: DetectionPrefetchOptions ) => { - const pipeline = buildHotspotsPipeline(targetImage, processed); - const rawDetectedObjects = mapHotspotsToDetectedObjects( - pipeline.hotspots - ); - const detectedObjects = filterAllowedDetectedObjects(rawDetectedObjects, { - stage: 'prefetch-detection', - imageId, - hotspotCount: pipeline.hotspots.length, - }); - - storeDetections(imageId, imageUrl, processed, { - hotspots: pipeline.hotspots, - detectedObjects, - }); - }, - [storeDetections] - ); - - const executePrefetch = useCallback( - async (imageId: number, imageUrl: string) => { - if (!imageId || !imageUrl) return; - if (pendingRef.current.has(imageId)) return; - const cached = useDetectionCacheStore.getState().images[imageId]; - if (cached) return; - if (isLoading || error) return; - - pendingRef.current.add(imageId); - try { - let targetImage: HTMLImageElement | null = null; - try { - targetImage = await loadImageElement(imageUrl); - } catch { - targetImage = await loadCorsImage(imageUrl); - } - if (!targetImage) return; - - try { - const result = await runInference(targetImage); - processAndStore(imageId, imageUrl, targetImage, result); - return; - } catch (inferenceError) { - if ( - inferenceError instanceof DOMException && - inferenceError.name === 'SecurityError' - ) { - const corsImage = await loadCorsImage(imageUrl); - if (!corsImage) return; - const corsResult = await runInference(corsImage); - processAndStore(imageId, imageUrl, corsImage, corsResult); - return; - } - console.warn('감지 프리페치 실패', inferenceError); - } - } catch (unexpectedError) { - console.warn('감지 프리페치 예외', unexpectedError); - } finally { - pendingRef.current.delete(imageId); - } - }, - [error, isLoading, processAndStore, runInference] - ); - - // 백그라운드 큐를 순차로 소모해 모델 호출 폭주 방지 - const drainQueue = useCallback(async () => { - if (drainingRef.current) return; - drainingRef.current = true; - try { - const jobs: Promise[] = []; - while (queueRef.current.length > 0) { - const task = queueRef.current.shift(); - if (!task) continue; - jobs.push( - runWithSemaphore(async () => { - await executePrefetch(task.imageId, task.imageUrl); - await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 - }) - ); - } - await Promise.all(jobs); - } finally { - drainingRef.current = false; - } - }, [executePrefetch, runWithSemaphore]); - - const scheduleBackgroundPrefetch = useCallback( - (imageId: number, imageUrl: string) => { - if (!imageId || !imageUrl) return; - if (queueRef.current.some((task) => task.imageId === imageId)) return; - queueRef.current.push({ imageId, imageUrl }); - void drainQueue(); - }, - [drainQueue] - ); - - const prefetchDetection = useCallback( - (imageId: number, imageUrl: string, options?: DetectionPrefetchOptions) => { - const priority = options?.priority ?? 'background'; - if (priority === 'immediate') { - void runWithSemaphore(() => executePrefetch(imageId, imageUrl)); - return; - } - scheduleBackgroundPrefetch(imageId, imageUrl); + // no-op by design }, - [executePrefetch, runWithSemaphore, scheduleBackgroundPrefetch] + [] ); - /** - * 감지 프리패치 트리거 - * - 반환 객체를 통해 외부에서 우선순위를 선택적으로 제어 - */ - return { - prefetchDetection, - }; + return { prefetchDetection }; }; From e7fe9a4acc4d9d4107d4080506587ceb43d231ec Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 15:06:03 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EA=B0=90=EC=A7=80=EA=B0=9D=EC=B2=B4=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/generate/apis/furniture.ts | 9 +++++++-- src/pages/generate/hooks/useFurnitureCuration.ts | 2 +- .../pages/result/curationSheet/CurationSheet.tsx | 8 -------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/pages/generate/apis/furniture.ts b/src/pages/generate/apis/furniture.ts index 0c11905e7..856004264 100644 --- a/src/pages/generate/apis/furniture.ts +++ b/src/pages/generate/apis/furniture.ts @@ -13,12 +13,17 @@ import type { // 생성 이미지 카테고리 조회 API 호출 export const getGeneratedImageCategories = async ( imageId: number, - detectedObjects: FurnitureCategoryCode[] + detectedObjects?: FurnitureCategoryCode[] ) => { + const query = + detectedObjects && detectedObjects.length > 0 + ? { detectedObjects } + : undefined; + return request({ method: HTTPMethod.GET, url: API_ENDPOINT.GENERATE.CURATION_CATEGORIES(imageId), - query: { detectedObjects }, + ...(query ? { query } : {}), }); }; diff --git a/src/pages/generate/hooks/useFurnitureCuration.ts b/src/pages/generate/hooks/useFurnitureCuration.ts index fd5d2a8a6..b50116e87 100644 --- a/src/pages/generate/hooks/useFurnitureCuration.ts +++ b/src/pages/generate/hooks/useFurnitureCuration.ts @@ -136,7 +136,7 @@ export const useGeneratedCategoriesQuery = ( queryKey: categoriesQueryKey, queryFn: () => getGeneratedImageCategories(imageId!, normalizedDetectedObjects), - enabled: Boolean(imageId) && normalizedDetectedObjects.length > 0, + enabled: Boolean(imageId), staleTime: 15 * 60 * 1000, gcTime: 30 * 60 * 1000, ...(initialCategoriesResponse diff --git a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx index 5af0a4c46..7b517b987 100644 --- a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx +++ b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx @@ -52,11 +52,6 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { const imageState = useActiveImageCurationState(); const selectedCategoryId = imageState?.selectedCategoryId ?? null; const selectCategory = useCurationStore((state) => state.selectCategory); - const detectedObjects = useMemo( - () => imageState?.detectedObjects ?? [], - [imageState?.detectedObjects] - ); - const hasDetectionCodes = detectedObjects.length > 0; const navigate = useNavigate(); const { variant } = useABTest(); @@ -255,9 +250,6 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { '상단 가구 필터에서 원하는 가구를 선택해 주세요' ); } - if (!hasDetectionCodes) { - return renderStatus('가구를 분석 중이에요', '잠시만 기다려 주세요'); - } if (categoriesQuery.isLoading) { return renderStatus( '감지된 가구를 분석 중이에요', From 2cb1c879214d17d0d2e02b9fad8439e2a51a5b8b Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 15:26:43 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=B6=94=EB=A1=A0=20=EA=B2=BD=EB=A1=9C=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=EC=9C=84=EC=B9=98=EB=A1=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layout/RootLayout.tsx | 35 ++- .../constants/curationDetectionMode.ts | 19 ++ .../generate/hooks/useFurnitureCuration.ts | 10 +- .../generate/pages/result/ResultPage.tsx | 5 +- .../result/curationSheet/CurationSheet.tsx | 9 + src/pages/generate/pages/start/StartPage.tsx | 13 + .../GeneratedImagesSection.tsx | 66 ++++- .../mypage/hooks/useDetectionPrefetch.ts | 242 +++++++++++++++++- src/vite-env.d.ts | 1 + 9 files changed, 380 insertions(+), 20 deletions(-) create mode 100644 src/pages/generate/constants/curationDetectionMode.ts diff --git a/src/layout/RootLayout.tsx b/src/layout/RootLayout.tsx index 8fb1b4bb8..88402c55d 100644 --- a/src/layout/RootLayout.tsx +++ b/src/layout/RootLayout.tsx @@ -1,9 +1,25 @@ -import { Outlet } from 'react-router-dom'; +import { useEffect } from 'react'; + +import { Outlet, useLocation } from 'react-router-dom'; + +import { ROUTES } from '@/routes/paths'; import { useScrollToTop } from '@/shared/hooks/useScrollToTop'; +import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; +import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; + +const GENERATE_WARMUP_PATHS = [ + ROUTES.GENERATE, + ROUTES.GENERATE_RESULT, + ROUTES.GENERATE_START, + ROUTES.IMAGE_SETUP, +]; + function RootLayout() { // 라우트/쿼리/해시/키 변화와 초기 마운트 시 스크롤 최상단으로 이동 useScrollToTop(); + useGenerateWarmup(); return (
@@ -11,4 +27,21 @@ function RootLayout() { ); } +function useGenerateWarmup() { + const location = useLocation(); + + useEffect(() => { + if (!IS_CLIENT_DETECTION_MODE) return; + + const pathname = location.pathname; + const shouldWarmup = GENERATE_WARMUP_PATHS.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ); + + if (!shouldWarmup) return; + + preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); + }, [location.pathname]); +} + export default RootLayout; diff --git a/src/pages/generate/constants/curationDetectionMode.ts b/src/pages/generate/constants/curationDetectionMode.ts new file mode 100644 index 000000000..7f3678517 --- /dev/null +++ b/src/pages/generate/constants/curationDetectionMode.ts @@ -0,0 +1,19 @@ +export type CurationDetectionMode = 'server' | 'client'; + +const DEFAULT_DETECTION_MODE: CurationDetectionMode = 'server'; + +const normalizeMode = ( + rawMode: string | undefined +): CurationDetectionMode => { + if (rawMode?.trim().toLowerCase() === 'client') { + return 'client'; + } + return DEFAULT_DETECTION_MODE; +}; + +export const CURATION_DETECTION_MODE = normalizeMode( + import.meta.env.VITE_CURATION_DETECTION_MODE +); + +export const IS_CLIENT_DETECTION_MODE = + CURATION_DETECTION_MODE === 'client'; diff --git a/src/pages/generate/hooks/useFurnitureCuration.ts b/src/pages/generate/hooks/useFurnitureCuration.ts index b50116e87..e66389ff8 100644 --- a/src/pages/generate/hooks/useFurnitureCuration.ts +++ b/src/pages/generate/hooks/useFurnitureCuration.ts @@ -10,6 +10,7 @@ import { getGeneratedImageCategories, getGeneratedImageProducts, } from '@pages/generate/apis/furniture'; +import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; import { useCurationCacheStore } from '@pages/generate/stores/useCurationCacheStore'; import { useCurationStore, @@ -135,8 +136,13 @@ export const useGeneratedCategoriesQuery = ( // queryKey에 이미지/감지값 전체를 직접 포함해 의존성 유지 queryKey: categoriesQueryKey, queryFn: () => - getGeneratedImageCategories(imageId!, normalizedDetectedObjects), - enabled: Boolean(imageId), + getGeneratedImageCategories( + imageId!, + IS_CLIENT_DETECTION_MODE ? normalizedDetectedObjects : undefined + ), + enabled: + Boolean(imageId) && + (!IS_CLIENT_DETECTION_MODE || normalizedDetectedObjects.length > 0), staleTime: 15 * 60 * 1000, gcTime: 30 * 60 * 1000, ...(initialCategoriesResponse diff --git a/src/pages/generate/pages/result/ResultPage.tsx b/src/pages/generate/pages/result/ResultPage.tsx index 07cd27ec9..7addfebfc 100644 --- a/src/pages/generate/pages/result/ResultPage.tsx +++ b/src/pages/generate/pages/result/ResultPage.tsx @@ -11,6 +11,7 @@ import type { import { createImageDetailPlaceholder } from '@/pages/mypage/utils/resultNavigation'; import Loading from '@components/loading/Loading'; +import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; import { useABTest } from '@pages/generate/hooks/useABTest'; import { useGetResultDataQuery } from '@pages/generate/hooks/useGenerate'; import { useCurationStore } from '@pages/generate/stores/useCurationStore'; @@ -215,7 +216,7 @@ const ResultPage = () => { { )} diff --git a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx index 7b517b987..300d009f8 100644 --- a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx +++ b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx @@ -20,6 +20,7 @@ import { QUERY_KEY } from '@/shared/constants/queryKey'; import { useSavedItemsStore } from '@/store/useSavedItemsStore'; import { getGeneratedImageProducts } from '@pages/generate/apis/furniture'; +import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; import CardProductItem from './CardProductItem'; import * as styles from './CurationSheet.css'; @@ -52,6 +53,11 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { const imageState = useActiveImageCurationState(); const selectedCategoryId = imageState?.selectedCategoryId ?? null; const selectCategory = useCurationStore((state) => state.selectCategory); + const detectedObjects = useMemo( + () => imageState?.detectedObjects ?? [], + [imageState?.detectedObjects] + ); + const hasDetectionCodes = detectedObjects.length > 0; const navigate = useNavigate(); const { variant } = useABTest(); @@ -250,6 +256,9 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { '상단 가구 필터에서 원하는 가구를 선택해 주세요' ); } + if (IS_CLIENT_DETECTION_MODE && !hasDetectionCodes) { + return renderStatus('가구를 분석 중이에요', '잠시만 기다려 주세요'); + } if (categoriesQuery.isLoading) { return renderStatus( '감지된 가구를 분석 중이에요', diff --git a/src/pages/generate/pages/start/StartPage.tsx b/src/pages/generate/pages/start/StartPage.tsx index fbfce951e..0b639bdf1 100644 --- a/src/pages/generate/pages/start/StartPage.tsx +++ b/src/pages/generate/pages/start/StartPage.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; + import { useNavigate } from 'react-router-dom'; import { useABTest } from '@/pages/generate/hooks/useABTest'; @@ -8,6 +10,9 @@ import TitleNavBar from '@/shared/components/navBar/TitleNavBar'; import { useUserStore } from '@/store/useUserStore'; import SignupImage from '@assets/icons/loginAfter.png'; +import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; +import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; import * as styles from './StartPage.css.ts'; @@ -17,6 +22,14 @@ const StartPage = () => { const navigate = useNavigate(); const { variant } = useABTest(); + useEffect(() => { + if (!IS_CLIENT_DETECTION_MODE) return; + // 이미지 생성 플로우 진입 시 모델 선로딩 + preloadONNXModel(OBJ365_MODEL_PATH).catch(() => { + // console.warn('[StartPage] preload model failed'); + }); + }, []); + const handleGoToImageSetup = () => { // 이미지 생성 시작 페이지 CTA 버튼 클릭 시 GA 이벤트 전송 logGenerateStartClickBtnCTA(variant); diff --git a/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx b/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx index 1045d6027..63ee12251 100644 --- a/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx +++ b/src/pages/mypage/components/section/generatedImages/GeneratedImagesSection.tsx @@ -1,8 +1,9 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import CardCuration from '@/pages/mypage/components/card/cardCuration/CardCuration'; +import { useDetectionPrefetch } from '@/pages/mypage/hooks/useDetectionPrefetch'; import { useMyPageImagesQuery } from '@/pages/mypage/hooks/useMypage'; import type { MyPageImageHistory, @@ -22,13 +23,15 @@ interface GeneratedImagesSectionProps { /** * 마이페이지 생성 이미지 목록 섹션 - * - 결과 페이지 네비게이션 상태 구성을 함께 처리 + * - 감지 데이터 프리패치와 네비게이션 상태 구성을 함께 처리 */ const GeneratedImagesSection = ({ userProfile, }: GeneratedImagesSectionProps) => { const navigate = useNavigate(); const { data: imagesData, isLoading, isError } = useMyPageImagesQuery(); + const { prefetchDetection } = useDetectionPrefetch(); + const prefetchedImageIdsRef = useRef>(new Set()); const [loadedImages, setLoadedImages] = useState>( () => { if (typeof window === 'undefined') return {}; @@ -40,9 +43,51 @@ const GeneratedImagesSection = ({ } } ); + const primaryImageId = imagesData?.histories[0]?.imageId ?? null; + + useEffect(() => { + if (!imagesData?.histories) return; + imagesData.histories.forEach((history, index) => { + if (prefetchedImageIdsRef.current.has(history.imageId)) return; + prefetchedImageIdsRef.current.add(history.imageId); + prefetchDetection(history.imageId, history.generatedImageUrl, { + priority: index === 0 ? 'immediate' : 'background', + }); + }); + }, [imagesData, prefetchDetection]); /** - * 결과 페이지로 이동 + * 감지 프리패치를 브라우저 idle 시간에 스케줄링 + * - 우선순위(immediate) 요청은 즉시 수행 + */ + const scheduleDetectionPrefetch = useCallback( + (imageId: number, imageUrl: string, options?: { immediate?: boolean }) => { + if (!imageId || !imageUrl) return; + const runTask = () => { + prefetchDetection(imageId, imageUrl, { + priority: options?.immediate ? 'immediate' : 'background', + }); + }; + if (options?.immediate || typeof window === 'undefined') { + runTask(); + return; + } + const idleCallback = ( + window as Window & { + requestIdleCallback?: (callback: IdleRequestCallback) => number; + } + ).requestIdleCallback; + if (idleCallback) { + idleCallback(() => runTask()); + return; + } + window.setTimeout(runTask, 0); + }, + [prefetchDetection] + ); + + /** + * 결과 페이지로 이동하며 필요한 감지 데이터를 선행 프리패치 */ const handleViewResult = (history: MyPageImageHistory) => { const { houseId } = history; @@ -58,13 +103,17 @@ const GeneratedImagesSection = ({ navigate(`${ROUTES.GENERATE_RESULT}?${params.toString()}`, { state: navigationState, }); + // 네비게이션 직후 우선순위 감지 프리페치 실행 + scheduleDetectionPrefetch(history.imageId, history.generatedImageUrl, { + immediate: true, + }); }; /** - * 이미지 로드 완료 시 로컬 캐시 갱신 + * 이미지 로드 완료 시 로컬 캐시를 갱신하고 프리패치 스케줄 */ const handleImageLoad = useCallback( - (imageId: number) => { + (imageId: number, imageUrl?: string) => { setLoadedImages((prev) => { if (prev[imageId]) return prev; const next = { ...prev, [imageId]: true }; @@ -73,8 +122,13 @@ const GeneratedImagesSection = ({ } return next; }); + if (imageUrl) { + scheduleDetectionPrefetch(imageId, imageUrl, { + immediate: primaryImageId === imageId, + }); + } }, - [] + [primaryImageId, scheduleDetectionPrefetch] ); // 로딩 중 diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.ts b/src/pages/mypage/hooks/useDetectionPrefetch.ts index c4421322c..d5d0f93f3 100644 --- a/src/pages/mypage/hooks/useDetectionPrefetch.ts +++ b/src/pages/mypage/hooks/useDetectionPrefetch.ts @@ -1,4 +1,46 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; + +import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; +import { buildHotspotsPipeline } from '@pages/generate/hooks/furnitureHotspotPipeline'; +import { loadCorsImage } from '@pages/generate/hooks/useFurnitureHotspots'; +import { useONNXModel } from '@pages/generate/hooks/useOnnxModel'; +import { useDetectionCacheStore } from '@pages/generate/stores/useDetectionCacheStore'; +import { + filterAllowedDetectedObjects, + mapHotspotsToDetectedObjects, +} from '@pages/generate/utils/detectedObjectMapper'; + +import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; +import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; +import type { ProcessedDetections } from '@pages/generate/types/detection'; + +const PREFETCH_DELAY_MS = 120; + +/** + * 외부 이미지 요소 로더 + * - crossOrigin 허용을 기본으로 시도 + * - 실패 시 에러를 상위로 전달 + */ +const loadImageElement = (url: string) => + new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + img.onload = () => resolve(img); + img.onerror = (event) => + reject( + event instanceof ErrorEvent + ? event.error + : new Error('이미지 로드 실패') + ); + img.src = url; + }); + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); type PrefetchPriority = 'immediate' | 'background'; @@ -6,21 +48,203 @@ export interface DetectionPrefetchOptions { priority?: PrefetchPriority; } +type PrefetchTask = { + imageId: number; + imageUrl: string; +}; + +const MAX_CONCURRENCY = 1; + /** - * 감지 프리패치 훅 - * - 클라이언트 객체 추론 비활성화로 no-op 유지 + * 감지(inference) 결과를 사전 계산해 캐시에 적재하는 훅 + * - 즉시(immediate) 요청과 백그라운드 큐를 분리해 성능 균형 유지 */ export const useDetectionPrefetch = () => { - const prefetchDetection = useCallback( + const enabled = IS_CLIENT_DETECTION_MODE; + const { runInference, isLoading, error } = useONNXModel(OBJ365_MODEL_PATH, { + enabled, + }); + const setEntry = useDetectionCacheStore((state) => state.setEntry); + const pendingRef = useRef>(new Set()); + const queueRef = useRef([]); + const drainingRef = useRef(false); + const activeCountRef = useRef(0); // 동시에 실행 중인 작업 수 + const waitersRef = useRef<(() => void)[]>([]); // 세마포어 대기열 + + // 세마포어 슬롯 확보 + const acquireSlot = useCallback(async () => { + if (activeCountRef.current < MAX_CONCURRENCY) { + activeCountRef.current += 1; + return; + } + await new Promise((resolve) => { + waitersRef.current.push(resolve); + }); + activeCountRef.current += 1; + }, []); + + // 세마포어 슬롯 반환 + const releaseSlot = useCallback(() => { + activeCountRef.current = Math.max(0, activeCountRef.current - 1); + const next = waitersRef.current.shift(); + if (next) { + next(); + } + }, []); + + // 공통 실행 래퍼: 동시 실행 상한 적용 + const runWithSemaphore = useCallback( + async (task: () => Promise) => { + await acquireSlot(); + try { + await task(); + } finally { + releaseSlot(); + } + }, + [acquireSlot, releaseSlot] + ); + + const storeDetections = useCallback( + ( + imageId: number, + imageUrl: string, + payload: ProcessedDetections, + extra?: { + hotspots?: FurnitureHotspot[]; + detectedObjects?: FurnitureCategoryCode[]; + } + ) => { + setEntry(imageId, { + imageUrl, + processedDetections: payload, + hotspots: extra?.hotspots ?? [], + detectedObjects: extra?.detectedObjects, + }); + }, + [setEntry] + ); + + const processAndStore = useCallback( ( - _imageId: number, - _imageUrl: string, - _options?: DetectionPrefetchOptions + imageId: number, + imageUrl: string, + targetImage: HTMLImageElement, + processed: ProcessedDetections ) => { - // no-op by design + const pipeline = buildHotspotsPipeline(targetImage, processed); + const rawDetectedObjects = mapHotspotsToDetectedObjects( + pipeline.hotspots + ); + const detectedObjects = filterAllowedDetectedObjects(rawDetectedObjects, { + stage: 'prefetch-detection', + imageId, + hotspotCount: pipeline.hotspots.length, + }); + + storeDetections(imageId, imageUrl, processed, { + hotspots: pipeline.hotspots, + detectedObjects, + }); + }, + [storeDetections] + ); + + const executePrefetch = useCallback( + async (imageId: number, imageUrl: string) => { + if (!enabled) return; + if (!imageId || !imageUrl) return; + if (pendingRef.current.has(imageId)) return; + const cached = useDetectionCacheStore.getState().images[imageId]; + if (cached) return; + if (isLoading || error) return; + + pendingRef.current.add(imageId); + try { + let targetImage: HTMLImageElement | null = null; + try { + targetImage = await loadImageElement(imageUrl); + } catch { + targetImage = await loadCorsImage(imageUrl); + } + if (!targetImage) return; + + try { + const result = await runInference(targetImage); + processAndStore(imageId, imageUrl, targetImage, result); + return; + } catch (inferenceError) { + if ( + inferenceError instanceof DOMException && + inferenceError.name === 'SecurityError' + ) { + const corsImage = await loadCorsImage(imageUrl); + if (!corsImage) return; + const corsResult = await runInference(corsImage); + processAndStore(imageId, imageUrl, corsImage, corsResult); + return; + } + console.warn('감지 프리페치 실패', inferenceError); + } + } catch (unexpectedError) { + console.warn('감지 프리페치 예외', unexpectedError); + } finally { + pendingRef.current.delete(imageId); + } + }, + [enabled, error, isLoading, processAndStore, runInference] + ); + + // 백그라운드 큐를 순차로 소모해 모델 호출 폭주 방지 + const drainQueue = useCallback(async () => { + if (!enabled) return; + if (drainingRef.current) return; + drainingRef.current = true; + try { + const jobs: Promise[] = []; + while (queueRef.current.length > 0) { + const task = queueRef.current.shift(); + if (!task) continue; + jobs.push( + runWithSemaphore(async () => { + await executePrefetch(task.imageId, task.imageUrl); + await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 + }) + ); + } + await Promise.all(jobs); + } finally { + drainingRef.current = false; + } + }, [enabled, executePrefetch, runWithSemaphore]); + + const scheduleBackgroundPrefetch = useCallback( + (imageId: number, imageUrl: string) => { + if (!enabled) return; + if (!imageId || !imageUrl) return; + if (queueRef.current.some((task) => task.imageId === imageId)) return; + queueRef.current.push({ imageId, imageUrl }); + void drainQueue(); + }, + [drainQueue, enabled] + ); + + const prefetchDetection = useCallback( + (imageId: number, imageUrl: string, options?: DetectionPrefetchOptions) => { + if (!enabled) return; + const priority = options?.priority ?? 'background'; + if (priority === 'immediate') { + void runWithSemaphore(() => executePrefetch(imageId, imageUrl)); + return; + } + scheduleBackgroundPrefetch(imageId, imageUrl); }, - [] + [enabled, executePrefetch, runWithSemaphore, scheduleBackgroundPrefetch] ); + /** + * 감지 프리패치 트리거 + * - 반환 객체를 통해 외부에서 우선순위를 선택적으로 제어 + */ return { prefetchDetection }; }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 523e3e90c..1aaa23827 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_SENTRY_ENVIRONMENT?: string; readonly VITE_SENTRY_RELEASE?: string; readonly VITE_CURATION_OUTBOUND_UTM_QUERY?: string; + readonly VITE_CURATION_DETECTION_MODE?: 'server' | 'client'; } declare const __APP_VERSION__: string; From 58b848402e22241a7d7f7bbe923e79fe10d2abbe Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 15:33:38 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=ED=9B=85=20=EB=B6=84=EB=A6=AC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layout/RootLayout.tsx | 34 +-- .../constants/curationDetectionMode.ts | 31 ++- .../generate/hooks/useFurnitureCuration.ts | 11 +- src/pages/generate/hooks/useGenerateWarmup.ts | 38 +++ .../hooks/useStartPageModelPreload.ts | 18 ++ .../generate/pages/result/ResultPage.tsx | 6 +- .../result/curationSheet/CurationSheet.tsx | 10 +- src/pages/generate/pages/start/StartPage.tsx | 14 +- .../hooks/useDetectionPrefetch.client.ts | 236 ++++++++++++++++ .../hooks/useDetectionPrefetch.server.ts | 20 ++ .../mypage/hooks/useDetectionPrefetch.ts | 254 +----------------- 11 files changed, 351 insertions(+), 321 deletions(-) create mode 100644 src/pages/generate/hooks/useGenerateWarmup.ts create mode 100644 src/pages/generate/hooks/useStartPageModelPreload.ts create mode 100644 src/pages/mypage/hooks/useDetectionPrefetch.client.ts create mode 100644 src/pages/mypage/hooks/useDetectionPrefetch.server.ts diff --git a/src/layout/RootLayout.tsx b/src/layout/RootLayout.tsx index 88402c55d..42dfba10e 100644 --- a/src/layout/RootLayout.tsx +++ b/src/layout/RootLayout.tsx @@ -1,20 +1,7 @@ -import { useEffect } from 'react'; - -import { Outlet, useLocation } from 'react-router-dom'; - -import { ROUTES } from '@/routes/paths'; +import { Outlet } from 'react-router-dom'; import { useScrollToTop } from '@/shared/hooks/useScrollToTop'; -import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; - -const GENERATE_WARMUP_PATHS = [ - ROUTES.GENERATE, - ROUTES.GENERATE_RESULT, - ROUTES.GENERATE_START, - ROUTES.IMAGE_SETUP, -]; +import { useGenerateWarmup } from '@pages/generate/hooks/useGenerateWarmup'; function RootLayout() { // 라우트/쿼리/해시/키 변화와 초기 마운트 시 스크롤 최상단으로 이동 @@ -27,21 +14,4 @@ function RootLayout() { ); } -function useGenerateWarmup() { - const location = useLocation(); - - useEffect(() => { - if (!IS_CLIENT_DETECTION_MODE) return; - - const pathname = location.pathname; - const shouldWarmup = GENERATE_WARMUP_PATHS.some( - (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) - ); - - if (!shouldWarmup) return; - - preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); - }, [location.pathname]); -} - export default RootLayout; diff --git a/src/pages/generate/constants/curationDetectionMode.ts b/src/pages/generate/constants/curationDetectionMode.ts index 7f3678517..937d74578 100644 --- a/src/pages/generate/constants/curationDetectionMode.ts +++ b/src/pages/generate/constants/curationDetectionMode.ts @@ -1,19 +1,22 @@ export type CurationDetectionMode = 'server' | 'client'; -const DEFAULT_DETECTION_MODE: CurationDetectionMode = 'server'; +export const CURATION_DETECTION_MODE: CurationDetectionMode = + import.meta.env.VITE_CURATION_DETECTION_MODE === 'client' + ? 'client' + : 'server'; -const normalizeMode = ( - rawMode: string | undefined -): CurationDetectionMode => { - if (rawMode?.trim().toLowerCase() === 'client') { - return 'client'; - } - return DEFAULT_DETECTION_MODE; -}; +export const IS_CLIENT_DETECTION_ENABLED = + CURATION_DETECTION_MODE === 'client'; -export const CURATION_DETECTION_MODE = normalizeMode( - import.meta.env.VITE_CURATION_DETECTION_MODE -); +export const getCategoryQueryDetectedObjects = (detectedObjects: T[]) => + IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined; -export const IS_CLIENT_DETECTION_MODE = - CURATION_DETECTION_MODE === 'client'; +export const isCategoryQueryEnabled = ( + imageId: number | null, + detectedObjectsCount: number +) => + Boolean(imageId) && + (!IS_CLIENT_DETECTION_ENABLED || detectedObjectsCount > 0); + +export const shouldShowDetectionPending = (detectedObjectsCount: number) => + IS_CLIENT_DETECTION_ENABLED && detectedObjectsCount === 0; diff --git a/src/pages/generate/hooks/useFurnitureCuration.ts b/src/pages/generate/hooks/useFurnitureCuration.ts index e66389ff8..17347df03 100644 --- a/src/pages/generate/hooks/useFurnitureCuration.ts +++ b/src/pages/generate/hooks/useFurnitureCuration.ts @@ -10,7 +10,10 @@ import { getGeneratedImageCategories, getGeneratedImageProducts, } from '@pages/generate/apis/furniture'; -import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; +import { + getCategoryQueryDetectedObjects, + isCategoryQueryEnabled, +} from '@pages/generate/constants/curationDetectionMode'; import { useCurationCacheStore } from '@pages/generate/stores/useCurationCacheStore'; import { useCurationStore, @@ -138,11 +141,9 @@ export const useGeneratedCategoriesQuery = ( queryFn: () => getGeneratedImageCategories( imageId!, - IS_CLIENT_DETECTION_MODE ? normalizedDetectedObjects : undefined + getCategoryQueryDetectedObjects(normalizedDetectedObjects) ), - enabled: - Boolean(imageId) && - (!IS_CLIENT_DETECTION_MODE || normalizedDetectedObjects.length > 0), + enabled: isCategoryQueryEnabled(imageId, normalizedDetectedObjects.length), staleTime: 15 * 60 * 1000, gcTime: 30 * 60 * 1000, ...(initialCategoriesResponse diff --git a/src/pages/generate/hooks/useGenerateWarmup.ts b/src/pages/generate/hooks/useGenerateWarmup.ts new file mode 100644 index 000000000..5d4b86307 --- /dev/null +++ b/src/pages/generate/hooks/useGenerateWarmup.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { ROUTES } from '@/routes/paths'; + +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; + +import { preloadONNXModel } from './useOnnxModel'; + +const GENERATE_WARMUP_PATHS = [ + ROUTES.GENERATE, + ROUTES.GENERATE_RESULT, + ROUTES.GENERATE_START, + ROUTES.IMAGE_SETUP, +]; + +const useGenerateWarmupClient = () => { + const location = useLocation(); + + useEffect(() => { + const pathname = location.pathname; + const shouldWarmup = GENERATE_WARMUP_PATHS.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ); + + if (!shouldWarmup) return; + + preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); + }, [location.pathname]); +}; + +const useGenerateWarmupServer = () => {}; + +export const useGenerateWarmup = IS_CLIENT_DETECTION_ENABLED + ? useGenerateWarmupClient + : useGenerateWarmupServer; diff --git a/src/pages/generate/hooks/useStartPageModelPreload.ts b/src/pages/generate/hooks/useStartPageModelPreload.ts new file mode 100644 index 000000000..a806d3bb8 --- /dev/null +++ b/src/pages/generate/hooks/useStartPageModelPreload.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; + +import { preloadONNXModel } from './useOnnxModel'; + +const useStartPageModelPreloadClient = () => { + useEffect(() => { + preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); + }, []); +}; + +const useStartPageModelPreloadServer = () => {}; + +export const useStartPageModelPreload = IS_CLIENT_DETECTION_ENABLED + ? useStartPageModelPreloadClient + : useStartPageModelPreloadServer; diff --git a/src/pages/generate/pages/result/ResultPage.tsx b/src/pages/generate/pages/result/ResultPage.tsx index 7addfebfc..c15bea0cc 100644 --- a/src/pages/generate/pages/result/ResultPage.tsx +++ b/src/pages/generate/pages/result/ResultPage.tsx @@ -11,7 +11,7 @@ import type { import { createImageDetailPlaceholder } from '@/pages/mypage/utils/resultNavigation'; import Loading from '@components/loading/Loading'; -import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; import { useABTest } from '@pages/generate/hooks/useABTest'; import { useGetResultDataQuery } from '@pages/generate/hooks/useGenerate'; import { useCurationStore } from '@pages/generate/stores/useCurationStore'; @@ -216,7 +216,7 @@ const ResultPage = () => { { )} diff --git a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx index 300d009f8..584320963 100644 --- a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx +++ b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx @@ -20,7 +20,7 @@ import { QUERY_KEY } from '@/shared/constants/queryKey'; import { useSavedItemsStore } from '@/store/useSavedItemsStore'; import { getGeneratedImageProducts } from '@pages/generate/apis/furniture'; -import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; +import { shouldShowDetectionPending } from '@pages/generate/constants/curationDetectionMode'; import CardProductItem from './CardProductItem'; import * as styles from './CurationSheet.css'; @@ -53,11 +53,7 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { const imageState = useActiveImageCurationState(); const selectedCategoryId = imageState?.selectedCategoryId ?? null; const selectCategory = useCurationStore((state) => state.selectCategory); - const detectedObjects = useMemo( - () => imageState?.detectedObjects ?? [], - [imageState?.detectedObjects] - ); - const hasDetectionCodes = detectedObjects.length > 0; + const detectedObjectsCount = imageState?.detectedObjects.length ?? 0; const navigate = useNavigate(); const { variant } = useABTest(); @@ -256,7 +252,7 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { '상단 가구 필터에서 원하는 가구를 선택해 주세요' ); } - if (IS_CLIENT_DETECTION_MODE && !hasDetectionCodes) { + if (shouldShowDetectionPending(detectedObjectsCount)) { return renderStatus('가구를 분석 중이에요', '잠시만 기다려 주세요'); } if (categoriesQuery.isLoading) { diff --git a/src/pages/generate/pages/start/StartPage.tsx b/src/pages/generate/pages/start/StartPage.tsx index 0b639bdf1..a94486633 100644 --- a/src/pages/generate/pages/start/StartPage.tsx +++ b/src/pages/generate/pages/start/StartPage.tsx @@ -1,5 +1,3 @@ -import { useEffect } from 'react'; - import { useNavigate } from 'react-router-dom'; import { useABTest } from '@/pages/generate/hooks/useABTest'; @@ -10,9 +8,7 @@ import TitleNavBar from '@/shared/components/navBar/TitleNavBar'; import { useUserStore } from '@/store/useUserStore'; import SignupImage from '@assets/icons/loginAfter.png'; -import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; +import { useStartPageModelPreload } from '@pages/generate/hooks/useStartPageModelPreload'; import * as styles from './StartPage.css.ts'; @@ -22,13 +18,7 @@ const StartPage = () => { const navigate = useNavigate(); const { variant } = useABTest(); - useEffect(() => { - if (!IS_CLIENT_DETECTION_MODE) return; - // 이미지 생성 플로우 진입 시 모델 선로딩 - preloadONNXModel(OBJ365_MODEL_PATH).catch(() => { - // console.warn('[StartPage] preload model failed'); - }); - }, []); + useStartPageModelPreload(); const handleGoToImageSetup = () => { // 이미지 생성 시작 페이지 CTA 버튼 클릭 시 GA 이벤트 전송 diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.client.ts b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts new file mode 100644 index 000000000..8d9c8ce0f --- /dev/null +++ b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts @@ -0,0 +1,236 @@ +import { useCallback, useRef } from 'react'; + +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; +import { buildHotspotsPipeline } from '@pages/generate/hooks/furnitureHotspotPipeline'; +import { loadCorsImage } from '@pages/generate/hooks/useFurnitureHotspots'; +import { useONNXModel } from '@pages/generate/hooks/useOnnxModel'; +import { useDetectionCacheStore } from '@pages/generate/stores/useDetectionCacheStore'; +import { + filterAllowedDetectedObjects, + mapHotspotsToDetectedObjects, +} from '@pages/generate/utils/detectedObjectMapper'; + +import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; +import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; +import type { ProcessedDetections } from '@pages/generate/types/detection'; + +const PREFETCH_DELAY_MS = 120; +const MAX_CONCURRENCY = 1; + +type PrefetchPriority = 'immediate' | 'background'; +type PrefetchTask = { + imageId: number; + imageUrl: string; +}; + +interface DetectionPrefetchOptions { + priority?: PrefetchPriority; +} + +/** + * 외부 이미지 요소 로더 + * - crossOrigin 허용을 기본으로 시도 + * - 실패 시 에러를 상위로 전달 + */ +const loadImageElement = (url: string) => + new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + img.onload = () => resolve(img); + img.onerror = (event) => + reject( + event instanceof ErrorEvent + ? event.error + : new Error('이미지 로드 실패') + ); + img.src = url; + }); + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * 감지(inference) 결과를 사전 계산해 캐시에 적재하는 훅 + * - 즉시(immediate) 요청과 백그라운드 큐를 분리해 성능 균형 유지 + */ +export const useDetectionPrefetchClient = () => { + const { runInference, isLoading, error } = useONNXModel(OBJ365_MODEL_PATH); + const setEntry = useDetectionCacheStore((state) => state.setEntry); + const pendingRef = useRef>(new Set()); + const queueRef = useRef([]); + const drainingRef = useRef(false); + const activeCountRef = useRef(0); // 동시에 실행 중인 작업 수 + const waitersRef = useRef<(() => void)[]>([]); // 세마포어 대기열 + + // 세마포어 슬롯 확보 + const acquireSlot = useCallback(async () => { + if (activeCountRef.current < MAX_CONCURRENCY) { + activeCountRef.current += 1; + return; + } + await new Promise((resolve) => { + waitersRef.current.push(resolve); + }); + activeCountRef.current += 1; + }, []); + + // 세마포어 슬롯 반환 + const releaseSlot = useCallback(() => { + activeCountRef.current = Math.max(0, activeCountRef.current - 1); + const next = waitersRef.current.shift(); + if (next) { + next(); + } + }, []); + + // 공통 실행 래퍼: 동시 실행 상한 적용 + const runWithSemaphore = useCallback( + async (task: () => Promise) => { + await acquireSlot(); + try { + await task(); + } finally { + releaseSlot(); + } + }, + [acquireSlot, releaseSlot] + ); + + const storeDetections = useCallback( + ( + imageId: number, + imageUrl: string, + payload: ProcessedDetections, + extra?: { + hotspots?: FurnitureHotspot[]; + detectedObjects?: FurnitureCategoryCode[]; + } + ) => { + setEntry(imageId, { + imageUrl, + processedDetections: payload, + hotspots: extra?.hotspots ?? [], + detectedObjects: extra?.detectedObjects, + }); + }, + [setEntry] + ); + + const processAndStore = useCallback( + ( + imageId: number, + imageUrl: string, + targetImage: HTMLImageElement, + processed: ProcessedDetections + ) => { + const pipeline = buildHotspotsPipeline(targetImage, processed); + const rawDetectedObjects = mapHotspotsToDetectedObjects( + pipeline.hotspots + ); + const detectedObjects = filterAllowedDetectedObjects(rawDetectedObjects, { + stage: 'prefetch-detection', + imageId, + hotspotCount: pipeline.hotspots.length, + }); + + storeDetections(imageId, imageUrl, processed, { + hotspots: pipeline.hotspots, + detectedObjects, + }); + }, + [storeDetections] + ); + + const executePrefetch = useCallback( + async (imageId: number, imageUrl: string) => { + if (!imageId || !imageUrl) return; + if (pendingRef.current.has(imageId)) return; + const cached = useDetectionCacheStore.getState().images[imageId]; + if (cached) return; + if (isLoading || error) return; + + pendingRef.current.add(imageId); + try { + let targetImage: HTMLImageElement | null = null; + try { + targetImage = await loadImageElement(imageUrl); + } catch { + targetImage = await loadCorsImage(imageUrl); + } + if (!targetImage) return; + + try { + const result = await runInference(targetImage); + processAndStore(imageId, imageUrl, targetImage, result); + return; + } catch (inferenceError) { + if ( + inferenceError instanceof DOMException && + inferenceError.name === 'SecurityError' + ) { + const corsImage = await loadCorsImage(imageUrl); + if (!corsImage) return; + const corsResult = await runInference(corsImage); + processAndStore(imageId, imageUrl, corsImage, corsResult); + return; + } + console.warn('감지 프리페치 실패', inferenceError); + } + } catch (unexpectedError) { + console.warn('감지 프리페치 예외', unexpectedError); + } finally { + pendingRef.current.delete(imageId); + } + }, + [error, isLoading, processAndStore, runInference] + ); + + // 백그라운드 큐를 순차로 소모해 모델 호출 폭주 방지 + const drainQueue = useCallback(async () => { + if (drainingRef.current) return; + drainingRef.current = true; + try { + const jobs: Promise[] = []; + while (queueRef.current.length > 0) { + const task = queueRef.current.shift(); + if (!task) continue; + jobs.push( + runWithSemaphore(async () => { + await executePrefetch(task.imageId, task.imageUrl); + await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 + }) + ); + } + await Promise.all(jobs); + } finally { + drainingRef.current = false; + } + }, [executePrefetch, runWithSemaphore]); + + const scheduleBackgroundPrefetch = useCallback( + (imageId: number, imageUrl: string) => { + if (!imageId || !imageUrl) return; + if (queueRef.current.some((task) => task.imageId === imageId)) return; + queueRef.current.push({ imageId, imageUrl }); + void drainQueue(); + }, + [drainQueue] + ); + + const prefetchDetection = useCallback( + (imageId: number, imageUrl: string, options?: DetectionPrefetchOptions) => { + const priority = options?.priority ?? 'background'; + if (priority === 'immediate') { + void runWithSemaphore(() => executePrefetch(imageId, imageUrl)); + return; + } + scheduleBackgroundPrefetch(imageId, imageUrl); + }, + [executePrefetch, runWithSemaphore, scheduleBackgroundPrefetch] + ); + + return { prefetchDetection }; +}; diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.server.ts b/src/pages/mypage/hooks/useDetectionPrefetch.server.ts new file mode 100644 index 000000000..8b1dffcf5 --- /dev/null +++ b/src/pages/mypage/hooks/useDetectionPrefetch.server.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; + +type PrefetchPriority = 'immediate' | 'background'; + +interface DetectionPrefetchOptions { + priority?: PrefetchPriority; +} + +export const useDetectionPrefetchServer = () => { + const prefetchDetection = useCallback( + ( + _imageId: number, + _imageUrl: string, + _options?: DetectionPrefetchOptions + ) => {}, + [] + ); + + return { prefetchDetection }; +}; diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.ts b/src/pages/mypage/hooks/useDetectionPrefetch.ts index d5d0f93f3..61eacbb30 100644 --- a/src/pages/mypage/hooks/useDetectionPrefetch.ts +++ b/src/pages/mypage/hooks/useDetectionPrefetch.ts @@ -1,250 +1,8 @@ -import { useCallback, useRef } from 'react'; +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; -import { IS_CLIENT_DETECTION_MODE } from '@pages/generate/constants/curationDetectionMode'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { buildHotspotsPipeline } from '@pages/generate/hooks/furnitureHotspotPipeline'; -import { loadCorsImage } from '@pages/generate/hooks/useFurnitureHotspots'; -import { useONNXModel } from '@pages/generate/hooks/useOnnxModel'; -import { useDetectionCacheStore } from '@pages/generate/stores/useDetectionCacheStore'; -import { - filterAllowedDetectedObjects, - mapHotspotsToDetectedObjects, -} from '@pages/generate/utils/detectedObjectMapper'; +import { useDetectionPrefetchClient } from './useDetectionPrefetch.client'; +import { useDetectionPrefetchServer } from './useDetectionPrefetch.server'; -import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; -import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; -import type { ProcessedDetections } from '@pages/generate/types/detection'; - -const PREFETCH_DELAY_MS = 120; - -/** - * 외부 이미지 요소 로더 - * - crossOrigin 허용을 기본으로 시도 - * - 실패 시 에러를 상위로 전달 - */ -const loadImageElement = (url: string) => - new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.decoding = 'async'; - img.onload = () => resolve(img); - img.onerror = (event) => - reject( - event instanceof ErrorEvent - ? event.error - : new Error('이미지 로드 실패') - ); - img.src = url; - }); - -const sleep = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -type PrefetchPriority = 'immediate' | 'background'; - -export interface DetectionPrefetchOptions { - priority?: PrefetchPriority; -} - -type PrefetchTask = { - imageId: number; - imageUrl: string; -}; - -const MAX_CONCURRENCY = 1; - -/** - * 감지(inference) 결과를 사전 계산해 캐시에 적재하는 훅 - * - 즉시(immediate) 요청과 백그라운드 큐를 분리해 성능 균형 유지 - */ -export const useDetectionPrefetch = () => { - const enabled = IS_CLIENT_DETECTION_MODE; - const { runInference, isLoading, error } = useONNXModel(OBJ365_MODEL_PATH, { - enabled, - }); - const setEntry = useDetectionCacheStore((state) => state.setEntry); - const pendingRef = useRef>(new Set()); - const queueRef = useRef([]); - const drainingRef = useRef(false); - const activeCountRef = useRef(0); // 동시에 실행 중인 작업 수 - const waitersRef = useRef<(() => void)[]>([]); // 세마포어 대기열 - - // 세마포어 슬롯 확보 - const acquireSlot = useCallback(async () => { - if (activeCountRef.current < MAX_CONCURRENCY) { - activeCountRef.current += 1; - return; - } - await new Promise((resolve) => { - waitersRef.current.push(resolve); - }); - activeCountRef.current += 1; - }, []); - - // 세마포어 슬롯 반환 - const releaseSlot = useCallback(() => { - activeCountRef.current = Math.max(0, activeCountRef.current - 1); - const next = waitersRef.current.shift(); - if (next) { - next(); - } - }, []); - - // 공통 실행 래퍼: 동시 실행 상한 적용 - const runWithSemaphore = useCallback( - async (task: () => Promise) => { - await acquireSlot(); - try { - await task(); - } finally { - releaseSlot(); - } - }, - [acquireSlot, releaseSlot] - ); - - const storeDetections = useCallback( - ( - imageId: number, - imageUrl: string, - payload: ProcessedDetections, - extra?: { - hotspots?: FurnitureHotspot[]; - detectedObjects?: FurnitureCategoryCode[]; - } - ) => { - setEntry(imageId, { - imageUrl, - processedDetections: payload, - hotspots: extra?.hotspots ?? [], - detectedObjects: extra?.detectedObjects, - }); - }, - [setEntry] - ); - - const processAndStore = useCallback( - ( - imageId: number, - imageUrl: string, - targetImage: HTMLImageElement, - processed: ProcessedDetections - ) => { - const pipeline = buildHotspotsPipeline(targetImage, processed); - const rawDetectedObjects = mapHotspotsToDetectedObjects( - pipeline.hotspots - ); - const detectedObjects = filterAllowedDetectedObjects(rawDetectedObjects, { - stage: 'prefetch-detection', - imageId, - hotspotCount: pipeline.hotspots.length, - }); - - storeDetections(imageId, imageUrl, processed, { - hotspots: pipeline.hotspots, - detectedObjects, - }); - }, - [storeDetections] - ); - - const executePrefetch = useCallback( - async (imageId: number, imageUrl: string) => { - if (!enabled) return; - if (!imageId || !imageUrl) return; - if (pendingRef.current.has(imageId)) return; - const cached = useDetectionCacheStore.getState().images[imageId]; - if (cached) return; - if (isLoading || error) return; - - pendingRef.current.add(imageId); - try { - let targetImage: HTMLImageElement | null = null; - try { - targetImage = await loadImageElement(imageUrl); - } catch { - targetImage = await loadCorsImage(imageUrl); - } - if (!targetImage) return; - - try { - const result = await runInference(targetImage); - processAndStore(imageId, imageUrl, targetImage, result); - return; - } catch (inferenceError) { - if ( - inferenceError instanceof DOMException && - inferenceError.name === 'SecurityError' - ) { - const corsImage = await loadCorsImage(imageUrl); - if (!corsImage) return; - const corsResult = await runInference(corsImage); - processAndStore(imageId, imageUrl, corsImage, corsResult); - return; - } - console.warn('감지 프리페치 실패', inferenceError); - } - } catch (unexpectedError) { - console.warn('감지 프리페치 예외', unexpectedError); - } finally { - pendingRef.current.delete(imageId); - } - }, - [enabled, error, isLoading, processAndStore, runInference] - ); - - // 백그라운드 큐를 순차로 소모해 모델 호출 폭주 방지 - const drainQueue = useCallback(async () => { - if (!enabled) return; - if (drainingRef.current) return; - drainingRef.current = true; - try { - const jobs: Promise[] = []; - while (queueRef.current.length > 0) { - const task = queueRef.current.shift(); - if (!task) continue; - jobs.push( - runWithSemaphore(async () => { - await executePrefetch(task.imageId, task.imageUrl); - await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 - }) - ); - } - await Promise.all(jobs); - } finally { - drainingRef.current = false; - } - }, [enabled, executePrefetch, runWithSemaphore]); - - const scheduleBackgroundPrefetch = useCallback( - (imageId: number, imageUrl: string) => { - if (!enabled) return; - if (!imageId || !imageUrl) return; - if (queueRef.current.some((task) => task.imageId === imageId)) return; - queueRef.current.push({ imageId, imageUrl }); - void drainQueue(); - }, - [drainQueue, enabled] - ); - - const prefetchDetection = useCallback( - (imageId: number, imageUrl: string, options?: DetectionPrefetchOptions) => { - if (!enabled) return; - const priority = options?.priority ?? 'background'; - if (priority === 'immediate') { - void runWithSemaphore(() => executePrefetch(imageId, imageUrl)); - return; - } - scheduleBackgroundPrefetch(imageId, imageUrl); - }, - [enabled, executePrefetch, runWithSemaphore, scheduleBackgroundPrefetch] - ); - - /** - * 감지 프리패치 트리거 - * - 반환 객체를 통해 외부에서 우선순위를 선택적으로 제어 - */ - return { prefetchDetection }; -}; +export const useDetectionPrefetch = IS_CLIENT_DETECTION_ENABLED + ? useDetectionPrefetchClient + : useDetectionPrefetchServer; From 085a45f6fd185f84eb672ef40b357118c5c6defb Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 15:55:46 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=ED=81=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=BA=90=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/curationDetectionMode.ts | 2 +- .../generate/hooks/useFurnitureCuration.ts | 22 ++++++++++++++----- .../generate/stores/useCurationCacheStore.ts | 9 +++++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/pages/generate/constants/curationDetectionMode.ts b/src/pages/generate/constants/curationDetectionMode.ts index 937d74578..75e084974 100644 --- a/src/pages/generate/constants/curationDetectionMode.ts +++ b/src/pages/generate/constants/curationDetectionMode.ts @@ -15,7 +15,7 @@ export const isCategoryQueryEnabled = ( imageId: number | null, detectedObjectsCount: number ) => - Boolean(imageId) && + imageId !== null && (!IS_CLIENT_DETECTION_ENABLED || detectedObjectsCount > 0); export const shouldShowDetectionPending = (detectedObjectsCount: number) => diff --git a/src/pages/generate/hooks/useFurnitureCuration.ts b/src/pages/generate/hooks/useFurnitureCuration.ts index 17347df03..66e5723fe 100644 --- a/src/pages/generate/hooks/useFurnitureCuration.ts +++ b/src/pages/generate/hooks/useFurnitureCuration.ts @@ -111,7 +111,9 @@ export const useGeneratedCategoriesQuery = ( ); const canUseGroupInitialData = groupId !== null && + imageId !== null && groupCategoriesEntry !== null && + groupCategoriesEntry.imageId === imageId && groupCategoriesEntry.detectionSignature === detectionSignature; const categoriesQueryKey: CategoriesQueryKey = [ @@ -153,11 +155,13 @@ export const useGeneratedCategoriesQuery = ( useEffect(() => { if (groupId === null) return; + if (imageId === null) return; if (!query.data) return; const existing = useCurationCacheStore.getState().groups[groupId]?.categories ?? null; if ( existing && + existing.imageId === imageId && existing.detectionSignature === detectionSignature && existing.response === query.data ) { @@ -165,12 +169,14 @@ export const useGeneratedCategoriesQuery = ( } saveGroupCategories({ groupId, + imageId, response: query.data, detectedObjects: normalizedDetectedObjects, detectionSignature, }); }, [ groupId, + imageId, query.data, detectionSignature, normalizedDetectedObjects, @@ -181,7 +187,7 @@ export const useGeneratedCategoriesQuery = ( // 카테고리 자동 선택 제거 // - 기본값은 선택 해제 상태 유지 // - 현재 선택이 더 이상 유효하지 않다면 null 로 초기화 - if (!imageId) return; + if (imageId === null) return; if (!query.data) { if (selectedCategoryId !== null) selectCategory(imageId, null); return; @@ -233,7 +239,10 @@ export const useGeneratedProductsQuery = ( ]; const initialProductsResponse = - groupId !== null && productCacheEntry + groupId !== null && + imageId !== null && + productCacheEntry && + productCacheEntry.imageId === imageId ? productCacheEntry.response : undefined; @@ -246,7 +255,7 @@ export const useGeneratedProductsQuery = ( // queryKey에 그룹/이미지/카테고리 식별자를 직접 배치 queryKey: productsQueryKey, queryFn: () => getGeneratedImageProducts(imageId!, categoryId!), - enabled: Boolean(imageId) && categoryId !== null, + enabled: imageId !== null && categoryId !== null, staleTime: 5 * 60 * 1000, gcTime: 30 * 60 * 1000, ...(initialProductsResponse @@ -255,19 +264,20 @@ export const useGeneratedProductsQuery = ( }); useEffect(() => { - if (groupId === null || categoryId === null) return; + if (groupId === null || imageId === null || categoryId === null) return; if (!query.data) return; const groupCache = useCurationCacheStore.getState().groups[groupId]; const existing = groupCache?.products[categoryId] ?? null; - if (existing?.response === query.data) { + if (existing?.imageId === imageId && existing.response === query.data) { return; } saveGroupProducts({ groupId, + imageId, categoryId, response: query.data, }); - }, [groupId, categoryId, query.data, saveGroupProducts]); + }, [groupId, imageId, categoryId, query.data, saveGroupProducts]); return query; }; diff --git a/src/pages/generate/stores/useCurationCacheStore.ts b/src/pages/generate/stores/useCurationCacheStore.ts index 62e00d3d4..ce76e77d0 100644 --- a/src/pages/generate/stores/useCurationCacheStore.ts +++ b/src/pages/generate/stores/useCurationCacheStore.ts @@ -8,6 +8,7 @@ import type { // 카테고리 응답과 감지 객체 집합을 묶어 저장 type CategoryCacheEntry = { + imageId: number; response: FurnitureCategoriesResponse; detectedObjects: FurnitureCategoryCode[]; detectionSignature: string; @@ -16,6 +17,7 @@ type CategoryCacheEntry = { // 카테고리별 추천 상품 응답 저장 type ProductCacheEntry = { + imageId: number; response: FurnitureProductsInfoResponse; updatedAt: number; }; @@ -34,12 +36,14 @@ interface CurationCacheStore { groups: Record; saveCategories: (params: { groupId: number; + imageId: number; response: FurnitureCategoriesResponse; detectedObjects: FurnitureCategoryCode[]; detectionSignature: string; }) => void; saveProducts: (params: { groupId: number; + imageId: number; categoryId: number; response: FurnitureProductsInfoResponse; }) => void; @@ -53,6 +57,7 @@ export const useCurationCacheStore = create()((set) => ({ groups: {}, saveCategories: ({ groupId, + imageId, response, detectedObjects, detectionSignature, @@ -66,6 +71,7 @@ export const useCurationCacheStore = create()((set) => ({ [groupId]: { ...prevGroup, categories: { + imageId, response, detectedObjects, detectionSignature, @@ -76,7 +82,7 @@ export const useCurationCacheStore = create()((set) => ({ }; }); }, - saveProducts: ({ groupId, categoryId, response }) => { + saveProducts: ({ groupId, imageId, categoryId, response }) => { if (!groupId || !categoryId) return; set((state) => { const prevGroup = state.groups[groupId] ?? createDefaultGroup(); @@ -88,6 +94,7 @@ export const useCurationCacheStore = create()((set) => ({ products: { ...prevGroup.products, [categoryId]: { + imageId, response, updatedAt: Date.now(), }, From fb32434cb93fe54bcf830d772d65aee3e43447f5 Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 15:55:50 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EA=B0=90=EC=A7=80=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=ED=8E=98=EC=B9=98=20=ED=81=90=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=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 --- .../mypage/hooks/detectionPrefetch.types.ts | 10 ++ .../hooks/useDetectionPrefetch.client.ts | 143 ++++++++++++++---- .../hooks/useDetectionPrefetch.server.ts | 6 +- 3 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 src/pages/mypage/hooks/detectionPrefetch.types.ts diff --git a/src/pages/mypage/hooks/detectionPrefetch.types.ts b/src/pages/mypage/hooks/detectionPrefetch.types.ts new file mode 100644 index 000000000..3fe389655 --- /dev/null +++ b/src/pages/mypage/hooks/detectionPrefetch.types.ts @@ -0,0 +1,10 @@ +export type PrefetchPriority = 'immediate' | 'background'; + +export interface DetectionPrefetchOptions { + priority?: PrefetchPriority; +} + +export interface PrefetchTask { + imageId: number; + imageUrl: string; +} diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.client.ts b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts index 8d9c8ce0f..a513325b5 100644 --- a/src/pages/mypage/hooks/useDetectionPrefetch.client.ts +++ b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; import { buildHotspotsPipeline } from '@pages/generate/hooks/furnitureHotspotPipeline'; @@ -14,39 +14,74 @@ import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureC import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; import type { ProcessedDetections } from '@pages/generate/types/detection'; +import type { + DetectionPrefetchOptions, + PrefetchTask, +} from './detectionPrefetch.types'; + const PREFETCH_DELAY_MS = 120; const MAX_CONCURRENCY = 1; - -type PrefetchPriority = 'immediate' | 'background'; -type PrefetchTask = { - imageId: number; - imageUrl: string; -}; - -interface DetectionPrefetchOptions { - priority?: PrefetchPriority; -} +const IMAGE_LOAD_TIMEOUT_MS = 12_000; /** * 외부 이미지 요소 로더 * - crossOrigin 허용을 기본으로 시도 * - 실패 시 에러를 상위로 전달 */ -const loadImageElement = (url: string) => +const loadImageElement = (url: string, signal?: AbortSignal) => new Promise((resolve, reject) => { const img = new Image(); + let settled = false; img.crossOrigin = 'anonymous'; img.decoding = 'async'; - img.onload = () => resolve(img); - img.onerror = (event) => - reject( + const cleanup = () => { + clearTimeout(timeoutId); + img.onload = null; + img.onerror = null; + signal?.removeEventListener('abort', handleAbort); + }; + const finalizeReject = (reason: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(reason); + }; + const handleAbort = () => { + img.src = ''; + finalizeReject(new DOMException('이미지 로드 취소', 'AbortError')); + }; + const timeoutId = window.setTimeout(() => { + img.src = ''; + finalizeReject( + new Error(`이미지 로드 타임아웃(${IMAGE_LOAD_TIMEOUT_MS}ms)`) + ); + }, IMAGE_LOAD_TIMEOUT_MS); + + if (signal?.aborted) { + handleAbort(); + return; + } + + signal?.addEventListener('abort', handleAbort, { once: true }); + img.onload = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(img); + }; + img.onerror = (event) => { + finalizeReject( event instanceof ErrorEvent ? event.error : new Error('이미지 로드 실패') ); + }; img.src = url; }); +const isAbortError = (value: unknown) => + value instanceof DOMException && value.name === 'AbortError'; + const sleep = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); @@ -59,11 +94,16 @@ const sleep = (ms: number) => export const useDetectionPrefetchClient = () => { const { runInference, isLoading, error } = useONNXModel(OBJ365_MODEL_PATH); const setEntry = useDetectionCacheStore((state) => state.setEntry); + const modelStateRef = useRef({ isLoading, error }); + const isMountedRef = useRef(true); const pendingRef = useRef>(new Set()); const queueRef = useRef([]); const drainingRef = useRef(false); const activeCountRef = useRef(0); // 동시에 실행 중인 작업 수 const waitersRef = useRef<(() => void)[]>([]); // 세마포어 대기열 + const inflightControllersRef = useRef>( + new Map() + ); // 세마포어 슬롯 확보 const acquireSlot = useCallback(async () => { @@ -150,42 +190,59 @@ export const useDetectionPrefetchClient = () => { if (pendingRef.current.has(imageId)) return; const cached = useDetectionCacheStore.getState().images[imageId]; if (cached) return; - if (isLoading || error) return; + if (modelStateRef.current.isLoading || modelStateRef.current.error) return; + if (!isMountedRef.current) return; + const controller = new AbortController(); + inflightControllersRef.current.set(imageId, controller); pendingRef.current.add(imageId); try { let targetImage: HTMLImageElement | null = null; try { - targetImage = await loadImageElement(imageUrl); + targetImage = await loadImageElement(imageUrl, controller.signal); } catch { + if (controller.signal.aborted || !isMountedRef.current) return; targetImage = await loadCorsImage(imageUrl); } - if (!targetImage) return; + if (!targetImage || controller.signal.aborted || !isMountedRef.current) { + return; + } try { const result = await runInference(targetImage); + if (controller.signal.aborted || !isMountedRef.current) return; processAndStore(imageId, imageUrl, targetImage, result); return; } catch (inferenceError) { + if (isAbortError(inferenceError)) return; if ( inferenceError instanceof DOMException && inferenceError.name === 'SecurityError' ) { const corsImage = await loadCorsImage(imageUrl); - if (!corsImage) return; + if ( + !corsImage || + controller.signal.aborted || + !isMountedRef.current + ) { + return; + } const corsResult = await runInference(corsImage); + if (controller.signal.aborted || !isMountedRef.current) return; processAndStore(imageId, imageUrl, corsImage, corsResult); return; } console.warn('감지 프리페치 실패', inferenceError); } } catch (unexpectedError) { + if (isAbortError(unexpectedError)) return; console.warn('감지 프리페치 예외', unexpectedError); } finally { + inflightControllersRef.current.delete(imageId); pendingRef.current.delete(imageId); } }, - [error, isLoading, processAndStore, runInference] + [processAndStore, runInference] ); // 백그라운드 큐를 순차로 소모해 모델 호출 폭주 방지 @@ -193,20 +250,27 @@ export const useDetectionPrefetchClient = () => { if (drainingRef.current) return; drainingRef.current = true; try { - const jobs: Promise[] = []; while (queueRef.current.length > 0) { + if (modelStateRef.current.isLoading || modelStateRef.current.error) { + break; + } const task = queueRef.current.shift(); if (!task) continue; - jobs.push( - runWithSemaphore(async () => { - await executePrefetch(task.imageId, task.imageUrl); - await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 - }) - ); + await runWithSemaphore(async () => { + await executePrefetch(task.imageId, task.imageUrl); + await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 + }); } - await Promise.all(jobs); } finally { drainingRef.current = false; + if ( + isMountedRef.current && + queueRef.current.length > 0 && + !modelStateRef.current.isLoading && + !modelStateRef.current.error + ) { + void drainQueue(); + } } }, [executePrefetch, runWithSemaphore]); @@ -224,6 +288,10 @@ export const useDetectionPrefetchClient = () => { (imageId: number, imageUrl: string, options?: DetectionPrefetchOptions) => { const priority = options?.priority ?? 'background'; if (priority === 'immediate') { + if (modelStateRef.current.isLoading || modelStateRef.current.error) { + scheduleBackgroundPrefetch(imageId, imageUrl); + return; + } void runWithSemaphore(() => executePrefetch(imageId, imageUrl)); return; } @@ -232,5 +300,24 @@ export const useDetectionPrefetchClient = () => { [executePrefetch, runWithSemaphore, scheduleBackgroundPrefetch] ); + useEffect(() => { + modelStateRef.current = { isLoading, error }; + if (!isLoading && !error && queueRef.current.length > 0) { + void drainQueue(); + } + }, [isLoading, error, drainQueue]); + + useEffect(() => { + return () => { + isMountedRef.current = false; + queueRef.current = []; + pendingRef.current.clear(); + activeCountRef.current = 0; + inflightControllersRef.current.forEach((controller) => controller.abort()); + inflightControllersRef.current.clear(); + waitersRef.current.splice(0).forEach((resolve) => resolve()); + }; + }, []); + return { prefetchDetection }; }; diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.server.ts b/src/pages/mypage/hooks/useDetectionPrefetch.server.ts index 8b1dffcf5..54da42d25 100644 --- a/src/pages/mypage/hooks/useDetectionPrefetch.server.ts +++ b/src/pages/mypage/hooks/useDetectionPrefetch.server.ts @@ -1,10 +1,6 @@ import { useCallback } from 'react'; -type PrefetchPriority = 'immediate' | 'background'; - -interface DetectionPrefetchOptions { - priority?: PrefetchPriority; -} +import type { DetectionPrefetchOptions } from './detectionPrefetch.types'; export const useDetectionPrefetchServer = () => { const prefetchDetection = useCallback( From b65fe73e1cba64e7ae98aa7341f5726dc1b2d21e Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 15:55:54 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20=EC=9B=8C=EB=B0=8D=EC=97=85?= =?UTF-8?q?=20=ED=9B=85=20=EB=B6=84=EA=B8=B0=EC=99=80=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layout/RootLayout.tsx | 1 + src/pages/generate/hooks/useGenerateWarmup.ts | 6 +++--- src/pages/generate/hooks/useOnnxModel.ts | 5 +---- src/pages/generate/hooks/useStartPageModelPreload.ts | 4 +++- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/layout/RootLayout.tsx b/src/layout/RootLayout.tsx index 42dfba10e..d7846e1a7 100644 --- a/src/layout/RootLayout.tsx +++ b/src/layout/RootLayout.tsx @@ -1,4 +1,5 @@ import { Outlet } from 'react-router-dom'; + import { useScrollToTop } from '@/shared/hooks/useScrollToTop'; import { useGenerateWarmup } from '@pages/generate/hooks/useGenerateWarmup'; diff --git a/src/pages/generate/hooks/useGenerateWarmup.ts b/src/pages/generate/hooks/useGenerateWarmup.ts index 5d4b86307..572356763 100644 --- a/src/pages/generate/hooks/useGenerateWarmup.ts +++ b/src/pages/generate/hooks/useGenerateWarmup.ts @@ -11,8 +11,6 @@ import { preloadONNXModel } from './useOnnxModel'; const GENERATE_WARMUP_PATHS = [ ROUTES.GENERATE, - ROUTES.GENERATE_RESULT, - ROUTES.GENERATE_START, ROUTES.IMAGE_SETUP, ]; @@ -31,7 +29,9 @@ const useGenerateWarmupClient = () => { }, [location.pathname]); }; -const useGenerateWarmupServer = () => {}; +const useGenerateWarmupServer = () => { + // 서버 모드 no-op 훅 +}; export const useGenerateWarmup = IS_CLIENT_DETECTION_ENABLED ? useGenerateWarmupClient diff --git a/src/pages/generate/hooks/useOnnxModel.ts b/src/pages/generate/hooks/useOnnxModel.ts index 49cc6d6af..93414d37e 100644 --- a/src/pages/generate/hooks/useOnnxModel.ts +++ b/src/pages/generate/hooks/useOnnxModel.ts @@ -222,10 +222,7 @@ interface UseONNXModelOptions { enabled?: boolean; } -export function useONNXModel( - modelPath: string, - options?: UseONNXModelOptions -) { +export function useONNXModel(modelPath: string, options?: UseONNXModelOptions) { const enabled = options?.enabled ?? true; const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(true); diff --git a/src/pages/generate/hooks/useStartPageModelPreload.ts b/src/pages/generate/hooks/useStartPageModelPreload.ts index a806d3bb8..c3dfd4156 100644 --- a/src/pages/generate/hooks/useStartPageModelPreload.ts +++ b/src/pages/generate/hooks/useStartPageModelPreload.ts @@ -11,7 +11,9 @@ const useStartPageModelPreloadClient = () => { }, []); }; -const useStartPageModelPreloadServer = () => {}; +const useStartPageModelPreloadServer = () => { + // 서버 모드 no-op 훅 +}; export const useStartPageModelPreload = IS_CLIENT_DETECTION_ENABLED ? useStartPageModelPreloadClient From 501213edca3440a728a1ff5a6695ded215c37ecd Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 16:04:42 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=83=81=EC=88=98=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=84=A0=EC=96=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/generate/constants/curationDetectionMode.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/pages/generate/constants/curationDetectionMode.ts b/src/pages/generate/constants/curationDetectionMode.ts index 75e084974..0cc7cb0d3 100644 --- a/src/pages/generate/constants/curationDetectionMode.ts +++ b/src/pages/generate/constants/curationDetectionMode.ts @@ -1,12 +1,11 @@ export type CurationDetectionMode = 'server' | 'client'; -export const CURATION_DETECTION_MODE: CurationDetectionMode = - import.meta.env.VITE_CURATION_DETECTION_MODE === 'client' +export const CURATION_DETECTION_MODE = + (import.meta.env.VITE_CURATION_DETECTION_MODE === 'client' ? 'client' - : 'server'; + : 'server') satisfies CurationDetectionMode; -export const IS_CLIENT_DETECTION_ENABLED = - CURATION_DETECTION_MODE === 'client'; +export const IS_CLIENT_DETECTION_ENABLED = CURATION_DETECTION_MODE === 'client'; export const getCategoryQueryDetectedObjects = (detectedObjects: T[]) => IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined; From ce5fb2e84d5b983ed0deebea0761d3b467683e66 Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 16:23:40 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=ED=8F=AC=EB=A7=B7=EA=B3=BC=20=EC=8B=A0=ED=98=B8=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/curationDetectionMode.ts | 9 +++--- src/pages/generate/hooks/useGenerateWarmup.ts | 5 +--- .../hooks/useDetectionPrefetch.client.ts | 28 +++++++++++++------ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/pages/generate/constants/curationDetectionMode.ts b/src/pages/generate/constants/curationDetectionMode.ts index 0cc7cb0d3..96b8dfc55 100644 --- a/src/pages/generate/constants/curationDetectionMode.ts +++ b/src/pages/generate/constants/curationDetectionMode.ts @@ -1,13 +1,14 @@ export type CurationDetectionMode = 'server' | 'client'; -export const CURATION_DETECTION_MODE = - (import.meta.env.VITE_CURATION_DETECTION_MODE === 'client' +export const CURATION_DETECTION_MODE = ( + import.meta.env.VITE_CURATION_DETECTION_MODE === 'client' ? 'client' - : 'server') satisfies CurationDetectionMode; + : 'server' +) satisfies CurationDetectionMode; export const IS_CLIENT_DETECTION_ENABLED = CURATION_DETECTION_MODE === 'client'; -export const getCategoryQueryDetectedObjects = (detectedObjects: T[]) => +export const getCategoryQueryDetectedObjects = (detectedObjects: T[]) => IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined; export const isCategoryQueryEnabled = ( diff --git a/src/pages/generate/hooks/useGenerateWarmup.ts b/src/pages/generate/hooks/useGenerateWarmup.ts index 572356763..b398e23ba 100644 --- a/src/pages/generate/hooks/useGenerateWarmup.ts +++ b/src/pages/generate/hooks/useGenerateWarmup.ts @@ -9,10 +9,7 @@ import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; import { preloadONNXModel } from './useOnnxModel'; -const GENERATE_WARMUP_PATHS = [ - ROUTES.GENERATE, - ROUTES.IMAGE_SETUP, -]; +const GENERATE_WARMUP_PATHS = [ROUTES.GENERATE, ROUTES.IMAGE_SETUP]; const useGenerateWarmupClient = () => { const location = useLocation(); diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.client.ts b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts index a513325b5..03cabd1f2 100644 --- a/src/pages/mypage/hooks/useDetectionPrefetch.client.ts +++ b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts @@ -10,14 +10,13 @@ import { mapHotspotsToDetectedObjects, } from '@pages/generate/utils/detectedObjectMapper'; -import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; -import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; -import type { ProcessedDetections } from '@pages/generate/types/detection'; - import type { DetectionPrefetchOptions, PrefetchTask, } from './detectionPrefetch.types'; +import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; +import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; +import type { ProcessedDetections } from '@pages/generate/types/detection'; const PREFETCH_DELAY_MS = 120; const MAX_CONCURRENCY = 1; @@ -190,7 +189,12 @@ export const useDetectionPrefetchClient = () => { if (pendingRef.current.has(imageId)) return; const cached = useDetectionCacheStore.getState().images[imageId]; if (cached) return; - if (modelStateRef.current.isLoading || modelStateRef.current.error) return; + if ( + modelStateRef.current.isLoading || + modelStateRef.current.error + ) { + return; + } if (!isMountedRef.current) return; const controller = new AbortController(); @@ -202,9 +206,13 @@ export const useDetectionPrefetchClient = () => { targetImage = await loadImageElement(imageUrl, controller.signal); } catch { if (controller.signal.aborted || !isMountedRef.current) return; - targetImage = await loadCorsImage(imageUrl); + targetImage = await loadCorsImage(imageUrl, controller.signal); } - if (!targetImage || controller.signal.aborted || !isMountedRef.current) { + if ( + !targetImage || + controller.signal.aborted || + !isMountedRef.current + ) { return; } @@ -219,7 +227,7 @@ export const useDetectionPrefetchClient = () => { inferenceError instanceof DOMException && inferenceError.name === 'SecurityError' ) { - const corsImage = await loadCorsImage(imageUrl); + const corsImage = await loadCorsImage(imageUrl, controller.signal); if ( !corsImage || controller.signal.aborted || @@ -313,7 +321,9 @@ export const useDetectionPrefetchClient = () => { queueRef.current = []; pendingRef.current.clear(); activeCountRef.current = 0; - inflightControllersRef.current.forEach((controller) => controller.abort()); + inflightControllersRef.current.forEach((controller) => + controller.abort() + ); inflightControllersRef.current.clear(); waitersRef.current.splice(0).forEach((resolve) => resolve()); }; From 2e7b5cdd46e2cfe8b812b1e2633bd7cab3060337 Mon Sep 17 00:00:00 2001 From: Kyoungho Eom Date: Fri, 6 Feb 2026 16:46:21 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EA=B0=90=EC=A7=80=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EB=84=A4=EB=A6=AD=20=ED=91=9C=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/generate/constants/curationDetectionMode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/generate/constants/curationDetectionMode.ts b/src/pages/generate/constants/curationDetectionMode.ts index 96b8dfc55..0cba2d701 100644 --- a/src/pages/generate/constants/curationDetectionMode.ts +++ b/src/pages/generate/constants/curationDetectionMode.ts @@ -8,7 +8,7 @@ export const CURATION_DETECTION_MODE = ( export const IS_CLIENT_DETECTION_ENABLED = CURATION_DETECTION_MODE === 'client'; -export const getCategoryQueryDetectedObjects = (detectedObjects: T[]) => +export const getCategoryQueryDetectedObjects = (detectedObjects: T[]) => IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined; export const isCategoryQueryEnabled = (