From 4261ac6d98d32f3277c70149cdfea8bada6c8389 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 20 Jun 2026 23:06:03 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20URL=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=A0=84=ED=99=98=20=EC=86=8D?= =?UTF-8?q?=EB=8F=84=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/_layout.tsx | 8 ++- app/(tabs)/(home)/add-link.tsx | 52 ++-------------- app/(tabs)/(home)/scanning.tsx | 51 ++++++++++++++- context/analysis-result-cache-context.tsx | 49 +++++++++++++++ hooks/use-analysis-result.ts | 54 ++++++++++++++-- utils/performance-trace.ts | 76 +++++++++++++++++++++++ 6 files changed, 234 insertions(+), 56 deletions(-) create mode 100644 context/analysis-result-cache-context.tsx create mode 100644 utils/performance-trace.ts diff --git a/app/(tabs)/(home)/_layout.tsx b/app/(tabs)/(home)/_layout.tsx index e219776..ec66833 100644 --- a/app/(tabs)/(home)/_layout.tsx +++ b/app/(tabs)/(home)/_layout.tsx @@ -1,5 +1,11 @@ import { Stack } from 'expo-router/stack'; +import { AnalysisResultCacheProvider } from '@/context/analysis-result-cache-context'; + export default function HomeLayout() { - return ; + return ( + + + + ); } diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index 600c6ce..26d6fa5 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -14,10 +14,9 @@ import { } from 'react-native'; import { Stack, router, useLocalSearchParams } from 'expo-router'; -import { ApiError } from '@/api/api-client'; -import { checkSavedLinkUrl } from '@/api/saved-links'; import { ScanButton } from '@/components/ui/scan-button'; import { Colors, Typography } from '@/constants/theme'; +import { markPerformance } from '@/utils/performance-trace'; import { useGuardedPress } from '@/utils/press-guard'; import { normalizeHttpUrlInput } from '@/utils/shared-url'; @@ -33,7 +32,7 @@ function getSharedUrlParam(value: string | string[] | undefined): string { } export default function AddLinkScreen() { - const { getToken, isLoaded, isSignedIn } = useAuth(); + const { isLoaded, isSignedIn } = useAuth(); const { sharedUrl } = useLocalSearchParams<{ sharedUrl?: string }>(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const initialSharedUrl = getSharedUrlParam(sharedUrl); @@ -43,7 +42,6 @@ export default function AddLinkScreen() { const [isNavigating, setIsNavigating] = useState(false); const isNavigatingRef = useRef(false); const urlRef = useRef(initialSharedUrl); - const getTokenRef = useRef(getToken); const urlInputRef = useRef(null); const urlFocusTimerRef = useRef | null>(null); @@ -54,9 +52,6 @@ export default function AddLinkScreen() { } }, []); - useEffect(() => { - getTokenRef.current = getToken; - }, [getToken]); useFocusEffect( useCallback(() => { @@ -121,29 +116,8 @@ export default function AddLinkScreen() { isNavigatingRef.current = true; setIsNavigating(true); - try { - const response = await checkSavedLinkUrl(() => getTokenRef.current(), normalizedUrl); - const currentNormalizedUrl = normalizeHttpUrlInput(urlRef.current.trim()); - - if (currentNormalizedUrl !== normalizedUrl) { - isNavigatingRef.current = false; - setIsNavigating(false); - return; - } - - if (response.exists) { - setError('이미 저장된 링크입니다.'); - isNavigatingRef.current = false; - setIsNavigating(false); - return; - } - - router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } }); - } catch (error) { - setError(getSavedLinkUrlCheckErrorMessage(error)); - isNavigatingRef.current = false; - setIsNavigating(false); - } + markPerformance('scan_button_pressed'); + router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } }); }; const handleChangeUrl = (value: string) => { @@ -248,24 +222,6 @@ export default function AddLinkScreen() { ); } -function getSavedLinkUrlCheckErrorMessage(error: unknown) { - if (error instanceof ApiError) { - if (error.status === 401 || error.status === 403) { - return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; - } - - return error.message || '저장된 링크 확인에 실패했습니다. 잠시 후 다시 시도해주세요.'; - } - - if (error instanceof Error) { - if (error.message === 'Missing Clerk session token') { - return '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; - } - } - - return '저장된 링크 확인에 실패했습니다. 잠시 후 다시 시도해주세요.'; -} - const styles = StyleSheet.create({ safeArea: { flex: 1, diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx index b41864c..029d590 100644 --- a/app/(tabs)/(home)/scanning.tsx +++ b/app/(tabs)/(home)/scanning.tsx @@ -8,8 +8,11 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { fetchAnalysis, requestAnalysis, type AnalysisResponse, type AnalysisVerdict } from '@/api/analyses'; import { ApiError } from '@/api/api-client'; +import { checkSavedLinkUrl } from '@/api/saved-links'; import { Colors, Typography } from '@/constants/theme'; import { AppIcon } from '@/components/ui/app-icon'; +import { useAnalysisResultCache } from '@/context/analysis-result-cache-context'; +import { markPerformance, measurePerformance } from '@/utils/performance-trace'; import { useGuardedPress } from '@/utils/press-guard'; const POLLING_INTERVAL_MS = 2_000; @@ -20,18 +23,25 @@ const DEFAULT_ANIMATION_SIZE = 280; const SHORT_ANIMATION_SIZE = 216; const VERY_SHORT_ANIMATION_SIZE = 188; const PAGE_UNAVAILABLE_ERROR_CODE = 'PAGE_UNAVAILABLE'; +const SCAN_STEP_MESSAGES = { + checkingDuplicate: '\uC800\uC7A5 \uC5EC\uBD80\uB97C \uD655\uC778\uD558\uACE0 \uC788\uC5B4\uC694', + requestingAnalysis: '\uBCF4\uC548 \uAC80\uC0AC\uB97C \uC694\uCCAD\uD558\uACE0 \uC788\uC5B4\uC694', + pollingAnalysis: '\uAC80\uC0AC \uACB0\uACFC\uB97C \uD655\uC778\uD558\uACE0 \uC788\uC5B4\uC694', +}; const PAGE_UNAVAILABLE_DEFAULT_MESSAGE = '페이지에 연결할 수 없습니다.'; const PAGE_UNAVAILABLE_HELP_MESSAGE = '사이트 접속이 제한되었거나 일시적으로 응답하지 않을 수 있습니다.'; export default function ScanningScreen() { const { getToken, isLoaded, isSignedIn } = useAuth(); + const { setAnalysisResult } = useAnalysisResultCache(); const isFocused = useIsFocused(); const { url: urlParam } = useLocalSearchParams<{ url?: string | string[] }>(); const url = getUrlParam(urlParam); const { height: windowHeight } = useWindowDimensions(); const tabBarHeight = useBottomTabBarHeight(); const [errorMessage, setErrorMessage] = useState(''); + const [scanStepMessage, setScanStepMessage] = useState(SCAN_STEP_MESSAGES.checkingDuplicate); const [retryKey, setRetryKey] = useState(0); const getTokenRef = useRef(getToken); const currentAbortControllerRef = useRef(null); @@ -107,13 +117,17 @@ export default function ScanningScreen() { }; } + measurePerformance('scan_button_to_scanning_screen', 'scan_button_pressed'); setErrorMessage(''); + setScanStepMessage(SCAN_STEP_MESSAGES.checkingDuplicate); screenTimeoutId = setTimeout(handleScreenTimeout, SCAN_SCREEN_TIMEOUT_MS); runAnalysisPolling({ getToken: () => getTokenRef.current(), url, signal: abortController.signal, + onStep: setScanStepMessage, + onResolvedAnalysis: setAnalysisResult, canNavigate: () => isActive && !didTimeout && !hasNavigated, onNavigate: () => { hasNavigated = true; @@ -144,7 +158,7 @@ export default function ScanningScreen() { } abortController.abort(); }; - }, [isLoaded, isSignedIn, retryKey, url]); + }, [isLoaded, isSignedIn, retryKey, setAnalysisResult, url]); const hasError = errorMessage.length > 0; const isScanning = !hasError; @@ -240,7 +254,7 @@ export default function ScanningScreen() { {hasError ? '검사를 완료하지 못했어요' : '보안 검사 중입니다'} - {hasError ? errorMessage : '약 5-10초 정도 소요돼요'} + {hasError ? errorMessage : scanStepMessage} {/* 검사 대상 카드 */} @@ -278,20 +292,43 @@ async function runAnalysisPolling({ getToken, url, signal, + onStep, + onResolvedAnalysis, canNavigate, onNavigate, }: { getToken: () => Promise; url: string; signal: AbortSignal; + onStep: (message: string) => void; + onResolvedAnalysis: (analysis: AnalysisResponse) => void; canNavigate: () => boolean; onNavigate: () => void; }) { const deadline = Date.now() + SCAN_SCREEN_TIMEOUT_MS; + markPerformance('saved_link_duplicate_check_started'); + onStep(SCAN_STEP_MESSAGES.checkingDuplicate); + const savedLinkCheck = await checkSavedLinkUrl(getToken, url, { signal }); + measurePerformance('saved_link_duplicate_check_completed', 'saved_link_duplicate_check_started'); + + if (savedLinkCheck.exists) { + throw new Error('DUPLICATE_SAVED_LINK'); + } + + markPerformance('analysis_request_started'); + onStep(SCAN_STEP_MESSAGES.requestingAnalysis); let analysis = await requestAnalysis(getToken, url, { signal }); + measurePerformance('analysis_request_completed', 'analysis_request_started'); while (!signal.aborted) { - const handled = handleAnalysisResult(analysis, url, signal, canNavigate, onNavigate); + const handled = handleAnalysisResult( + analysis, + url, + signal, + canNavigate, + onNavigate, + onResolvedAnalysis, + ); if (handled) { return; @@ -304,6 +341,7 @@ async function runAnalysisPolling({ } await wait(Math.min(POLLING_INTERVAL_MS, remainingMs), signal); + onStep(SCAN_STEP_MESSAGES.pollingAnalysis); analysis = await fetchAnalysis(getToken, analysis.analysisId, { signal }); } } @@ -314,6 +352,7 @@ function handleAnalysisResult( signal: AbortSignal, canNavigate: () => boolean, onNavigate: () => void, + onResolvedAnalysis: (analysis: AnalysisResponse) => void, ) { if (analysis.status === 'queued') { return false; @@ -328,6 +367,8 @@ function handleAnalysisResult( } if (!signal.aborted && canNavigate()) { + markPerformance('analysis_result_received'); + onResolvedAnalysis(analysis); onNavigate(); router.replace({ pathname: getResultPath(analysis.verdict), @@ -406,6 +447,10 @@ function getAnalysisErrorMessage(error: unknown) { return '분석 결과를 확인할 수 없습니다. 잠시 후 다시 시도해주세요.'; } + if (error.message === 'DUPLICATE_SAVED_LINK') { + return '\uC774\uBBF8 \uC800\uC7A5\uB41C \uB9C1\uD06C\uC785\uB2C8\uB2E4.'; + } + if (error.message && error.message !== 'ANALYSIS_FAILED') { return error.message; } diff --git a/context/analysis-result-cache-context.tsx b/context/analysis-result-cache-context.tsx new file mode 100644 index 0000000..dea0a96 --- /dev/null +++ b/context/analysis-result-cache-context.tsx @@ -0,0 +1,49 @@ +import { createContext, type ReactNode, useCallback, useContext, useRef } from 'react'; + +import type { AnalysisResponse } from '@/api/analyses'; + +type AnalysisResultCacheContextValue = { + getAnalysisResult: (analysisId?: string | null) => AnalysisResponse | null; + setAnalysisResult: (analysis: AnalysisResponse) => void; + clearAnalysisResult: (analysisId: string) => void; +}; + +const AnalysisResultCacheContext = createContext(null); + +export function AnalysisResultCacheProvider({ children }: { children: ReactNode }) { + const cacheRef = useRef(new Map()); + + const getAnalysisResult = useCallback((analysisId?: string | null) => { + if (!analysisId) { + return null; + } + + return cacheRef.current.get(analysisId) ?? null; + }, []); + + const setAnalysisResult = useCallback((analysis: AnalysisResponse) => { + cacheRef.current.set(analysis.analysisId, analysis); + }, []); + + const clearAnalysisResult = useCallback((analysisId: string) => { + cacheRef.current.delete(analysisId); + }, []); + + return ( + + {children} + + ); +} + +export function useAnalysisResultCache() { + const context = useContext(AnalysisResultCacheContext); + + if (!context) { + throw new Error('useAnalysisResultCache must be used within AnalysisResultCacheProvider'); + } + + return context; +} diff --git a/hooks/use-analysis-result.ts b/hooks/use-analysis-result.ts index 8e3fd18..73e803c 100644 --- a/hooks/use-analysis-result.ts +++ b/hooks/use-analysis-result.ts @@ -3,14 +3,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchAnalysis, type AnalysisResponse } from '@/api/analyses'; import { ApiError } from '@/api/api-client'; +import { useAnalysisResultCache } from '@/context/analysis-result-cache-context'; +import { logPerformanceEvent, logPerformanceSummary, markPerformance, measurePerformance } from '@/utils/performance-trace'; const LOGIN_REQUIRED_MESSAGE = '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; export function useAnalysisResult(analysisId?: string) { const { getToken, isLoaded, isSignedIn } = useAuth(); - const [analysis, setAnalysis] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const { getAnalysisResult, setAnalysisResult } = useAnalysisResultCache(); + const cachedAnalysis = getAnalysisResult(analysisId); + const [analysis, setAnalysis] = useState(cachedAnalysis); + const [isLoading, setIsLoading] = useState(Boolean(analysisId && !cachedAnalysis)); const [errorMessage, setErrorMessage] = useState(''); const getTokenRef = useRef(getToken); @@ -27,6 +31,23 @@ export function useAnalysisResult(analysisId?: string) { return; } + const nextCachedAnalysis = getAnalysisResult(analysisId); + + if (nextCachedAnalysis) { + setAnalysis(nextCachedAnalysis); + setIsLoading(false); + setErrorMessage(''); + logPerformanceEvent('analysis_result_cache_hit', { analysisId }); + measurePerformance('scan_result_screen_with_cached_analysis', 'analysis_result_received'); + logPerformanceSummary('core_scan_flow_cached_result', [ + 'scan_button_to_scanning_screen', + 'saved_link_duplicate_check_completed', + 'analysis_request_completed', + 'scan_result_screen_with_cached_analysis', + ]); + return; + } + if (!isLoaded) { return; } @@ -41,6 +62,8 @@ export function useAnalysisResult(analysisId?: string) { setAnalysis(null); setIsLoading(true); setErrorMessage(''); + logPerformanceEvent('analysis_result_cache_miss', { analysisId }); + markPerformance('analysis_result_fetch_started'); try { const nextAnalysis = await fetchAnalysis( @@ -54,6 +77,14 @@ export function useAnalysisResult(analysisId?: string) { } setAnalysis(nextAnalysis); + setAnalysisResult(nextAnalysis); + measurePerformance('analysis_result_fetch_completed', 'analysis_result_fetch_started'); + logPerformanceSummary('core_scan_flow_refetched_result', [ + 'scan_button_to_scanning_screen', + 'saved_link_duplicate_check_completed', + 'analysis_request_completed', + 'analysis_result_fetch_completed', + ]); } catch (error) { if (signal?.aborted) { return; @@ -70,7 +101,7 @@ export function useAnalysisResult(analysisId?: string) { } } }, - [analysisId, isLoaded, isSignedIn], + [analysisId, getAnalysisResult, isLoaded, isSignedIn, setAnalysisResult], ); useEffect(() => { @@ -85,12 +116,27 @@ export function useAnalysisResult(analysisId?: string) { return; } + if (cachedAnalysis) { + setAnalysis(cachedAnalysis); + setIsLoading(false); + setErrorMessage(''); + logPerformanceEvent('analysis_result_cache_hit', { analysisId }); + measurePerformance('scan_result_screen_with_cached_analysis', 'analysis_result_received'); + logPerformanceSummary('core_scan_flow_cached_result', [ + 'scan_button_to_scanning_screen', + 'saved_link_duplicate_check_completed', + 'analysis_request_completed', + 'scan_result_screen_with_cached_analysis', + ]); + return; + } + const abortController = new AbortController(); void loadAnalysis(abortController.signal); return () => abortController.abort(); - }, [analysisId, isLoaded, loadAnalysis]); + }, [analysisId, cachedAnalysis, isLoaded, loadAnalysis]); return { analysis, diff --git a/utils/performance-trace.ts b/utils/performance-trace.ts new file mode 100644 index 0000000..f69aa7c --- /dev/null +++ b/utils/performance-trace.ts @@ -0,0 +1,76 @@ +type PerformanceMeasurement = { + name: string; + durationMs: number; +}; + +const marks = new Map(); +const measurements: PerformanceMeasurement[] = []; + +function getNow() { + return globalThis.performance?.now?.() ?? Date.now(); +} + +export function markPerformance(name: string) { + if (!__DEV__) { + return; + } + + marks.set(name, getNow()); + console.log(`[perf] mark ${name}`); +} + +export function measurePerformance(name: string, from: string) { + if (!__DEV__) { + return; + } + + const start = marks.get(from); + + if (start == null) { + console.log(`[perf] ${name}: missing start mark "${from}"`); + return; + } + + const durationMs = getNow() - start; + measurements.push({ name, durationMs }); + console.log(`[perf] ${name}: ${durationMs.toFixed(1)}ms`); +} + +export function logPerformanceEvent(name: string, details?: Record) { + if (!__DEV__) { + return; + } + + const detailText = details + ? ` ${Object.entries(details) + .map(([key, value]) => `${key}=${value}`) + .join(' ')}` + : ''; + + console.log(`[perf] ${name}${detailText}`); +} + +export function logPerformanceSummary(label: string, names: string[]) { + if (!__DEV__) { + return; + } + + const summary = names + .map((name) => { + const measurement = findLatestMeasurement(name); + return measurement ? `${name}=${measurement.durationMs.toFixed(1)}ms` : `${name}=n/a`; + }) + .join(' '); + + console.log(`[perf:summary] ${label} ${summary}`); +} + +function findLatestMeasurement(name: string) { + for (let index = measurements.length - 1; index >= 0; index -= 1) { + if (measurements[index].name === name) { + return measurements[index]; + } + } + + return null; +} \ No newline at end of file From 6d43efa2ca929cc9093c2e277c0677c77dbff188 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Sat, 27 Jun 2026 03:36:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=8F=B4=EB=8D=94=20URL=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=8C=80=EA=B8=B0=20=EC=8B=9C=EA=B0=84=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/[id].tsx | 60 ++++++++++++++++++++++++-- app/(tabs)/(folder)/folder-add-url.tsx | 24 +++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/app/(tabs)/(folder)/[id].tsx b/app/(tabs)/(folder)/[id].tsx index 494c6b1..f3de870 100644 --- a/app/(tabs)/(folder)/[id].tsx +++ b/app/(tabs)/(folder)/[id].tsx @@ -12,8 +12,9 @@ import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import { useFolders } from '@/context/folders-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { showAlert } from '@/utils/guarded-alert'; +import { logPerformanceSummary, measurePerformance } from '@/utils/performance-trace'; type MoreMenuState = { visible: boolean; @@ -22,13 +23,21 @@ type MoreMenuState = { }; export default function FolderDetailScreen() { - const { id, urlAdded } = useLocalSearchParams<{ id: string; urlAdded?: string }>(); + const { id, urlAdded, addedLinkIds } = useLocalSearchParams<{ + id: string; + urlAdded?: string; + addedLinkIds?: string | string[]; + }>(); const router = useRouter(); const folderId = Number(id); const { links, bookmarkingLinkIds, toggleBookmark, assignCategory, updateTitle } = useSavedLinks(); const { folders, refreshFolders } = useFolders(); - const folderLinks = links.filter((l) => l.categoryId === folderId); + const folderLinks = useMemo( + () => links.filter((link) => link.categoryId === folderId), + [folderId, links], + ); + const addedLinkIdList = useMemo(() => parseAddedLinkIds(addedLinkIds), [addedLinkIds]); const folderName = folders.find((f) => f.id === folderId)?.name ?? '폴더'; const [menuState, setMenuState] = useState({ visible: false }); @@ -37,11 +46,42 @@ export default function FolderDetailScreen() { const [addToastVisible, setAddToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); const deletingFromFolderIdsRef = useRef>(new Set()); + const measuredAddedLinksRef = useRef(null); useEffect(() => { - if (urlAdded === '1') setAddToastVisible(true); + if (urlAdded !== '1') { + return; + } + + measurePerformance('folder_detail_screen_visible', 'folder_detail_navigation_started'); + setAddToastVisible(true); }, [urlAdded]); + useEffect(() => { + if (urlAdded !== '1' || addedLinkIdList.length === 0) { + return; + } + + const measurementKey = addedLinkIdList.join(','); + if (measuredAddedLinksRef.current === measurementKey) { + return; + } + + const folderLinkIds = new Set(folderLinks.map((link) => link.id)); + const allAddedLinksVisible = addedLinkIdList.every((linkId) => folderLinkIds.has(linkId)); + if (!allAddedLinksVisible) { + return; + } + + measuredAddedLinksRef.current = measurementKey; + measurePerformance('folder_added_links_visible', 'folder_add_button_pressed'); + logPerformanceSummary('folder_add_url_flow_after', [ + 'folder_assign_completed', + 'folder_detail_screen_visible', + 'folder_added_links_visible', + ]); + }, [addedLinkIdList, folderLinks, urlAdded]); + const handleMore = (linkId: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, linkId }); }; @@ -292,3 +332,15 @@ const styles = StyleSheet.create({ color: Colors.brand.textHint, }, }); + +function parseAddedLinkIds(value: string | string[] | undefined) { + const rawValue = Array.isArray(value) ? value[0] : value; + if (!rawValue) { + return []; + } + + return rawValue + .split(',') + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)); +} diff --git a/app/(tabs)/(folder)/folder-add-url.tsx b/app/(tabs)/(folder)/folder-add-url.tsx index a81062e..d822db8 100644 --- a/app/(tabs)/(folder)/folder-add-url.tsx +++ b/app/(tabs)/(folder)/folder-add-url.tsx @@ -13,6 +13,7 @@ import { Colors, Typography } from '@/constants/theme'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; import { useSavedLinks } from '@/context/saved-links-context'; import { showAlert } from '@/utils/guarded-alert'; +import { markPerformance, measurePerformance } from '@/utils/performance-trace'; // ─── Screen ─────────────────────────────────────────────────────────────────── @@ -45,15 +46,32 @@ export default function FolderAddUrlScreen() { const handleAdd = async () => { if (isAddingRef.current || selectedIds.size === 0) return; + const selectedLinkIds = [...selectedIds]; isAddingRef.current = true; setIsAdding(true); + markPerformance('folder_add_button_pressed'); try { - await assignCategory([...selectedIds], Number(folderId)); - await refreshFolders(); + markPerformance('folder_assign_started'); + await assignCategory(selectedLinkIds, Number(folderId)); + measurePerformance('folder_assign_completed', 'folder_assign_started'); + markPerformance('folder_detail_navigation_started'); router.replace({ pathname: '/(tabs)/(folder)/[id]', - params: { id: folderId, urlAdded: '1' }, + params: { + id: folderId, + urlAdded: '1', + addedLinkIds: selectedLinkIds.join(','), + }, }); + markPerformance('folder_refresh_after_navigation_started'); + void refreshFolders() + .catch(() => undefined) + .finally(() => { + measurePerformance( + 'folder_refresh_completed_after_navigation', + 'folder_refresh_after_navigation_started', + ); + }); } catch (error) { showAlert( 'URL 추가 실패', From 317e82f9b12ba59091f3599b14da54e0081c98d6 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Mon, 29 Jun 2026 22:17:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=EC=9A=A9=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(folder)/[id].tsx | 60 ++------------------ app/(tabs)/(folder)/folder-add-url.tsx | 16 +----- app/(tabs)/(home)/add-link.tsx | 2 - app/(tabs)/(home)/scanning.tsx | 7 --- hooks/use-analysis-result.ts | 26 --------- utils/performance-trace.ts | 76 -------------------------- 6 files changed, 5 insertions(+), 182 deletions(-) delete mode 100644 utils/performance-trace.ts diff --git a/app/(tabs)/(folder)/[id].tsx b/app/(tabs)/(folder)/[id].tsx index f3de870..494c6b1 100644 --- a/app/(tabs)/(folder)/[id].tsx +++ b/app/(tabs)/(folder)/[id].tsx @@ -12,9 +12,8 @@ import { Colors, Typography } from '@/constants/theme'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import { useFolders } from '@/context/folders-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { showAlert } from '@/utils/guarded-alert'; -import { logPerformanceSummary, measurePerformance } from '@/utils/performance-trace'; type MoreMenuState = { visible: boolean; @@ -23,21 +22,13 @@ type MoreMenuState = { }; export default function FolderDetailScreen() { - const { id, urlAdded, addedLinkIds } = useLocalSearchParams<{ - id: string; - urlAdded?: string; - addedLinkIds?: string | string[]; - }>(); + const { id, urlAdded } = useLocalSearchParams<{ id: string; urlAdded?: string }>(); const router = useRouter(); const folderId = Number(id); const { links, bookmarkingLinkIds, toggleBookmark, assignCategory, updateTitle } = useSavedLinks(); const { folders, refreshFolders } = useFolders(); - const folderLinks = useMemo( - () => links.filter((link) => link.categoryId === folderId), - [folderId, links], - ); - const addedLinkIdList = useMemo(() => parseAddedLinkIds(addedLinkIds), [addedLinkIds]); + const folderLinks = links.filter((l) => l.categoryId === folderId); const folderName = folders.find((f) => f.id === folderId)?.name ?? '폴더'; const [menuState, setMenuState] = useState({ visible: false }); @@ -46,42 +37,11 @@ export default function FolderDetailScreen() { const [addToastVisible, setAddToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); const deletingFromFolderIdsRef = useRef>(new Set()); - const measuredAddedLinksRef = useRef(null); useEffect(() => { - if (urlAdded !== '1') { - return; - } - - measurePerformance('folder_detail_screen_visible', 'folder_detail_navigation_started'); - setAddToastVisible(true); + if (urlAdded === '1') setAddToastVisible(true); }, [urlAdded]); - useEffect(() => { - if (urlAdded !== '1' || addedLinkIdList.length === 0) { - return; - } - - const measurementKey = addedLinkIdList.join(','); - if (measuredAddedLinksRef.current === measurementKey) { - return; - } - - const folderLinkIds = new Set(folderLinks.map((link) => link.id)); - const allAddedLinksVisible = addedLinkIdList.every((linkId) => folderLinkIds.has(linkId)); - if (!allAddedLinksVisible) { - return; - } - - measuredAddedLinksRef.current = measurementKey; - measurePerformance('folder_added_links_visible', 'folder_add_button_pressed'); - logPerformanceSummary('folder_add_url_flow_after', [ - 'folder_assign_completed', - 'folder_detail_screen_visible', - 'folder_added_links_visible', - ]); - }, [addedLinkIdList, folderLinks, urlAdded]); - const handleMore = (linkId: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, linkId }); }; @@ -332,15 +292,3 @@ const styles = StyleSheet.create({ color: Colors.brand.textHint, }, }); - -function parseAddedLinkIds(value: string | string[] | undefined) { - const rawValue = Array.isArray(value) ? value[0] : value; - if (!rawValue) { - return []; - } - - return rawValue - .split(',') - .map((item) => Number(item)) - .filter((item) => Number.isFinite(item)); -} diff --git a/app/(tabs)/(folder)/folder-add-url.tsx b/app/(tabs)/(folder)/folder-add-url.tsx index d822db8..caa97ec 100644 --- a/app/(tabs)/(folder)/folder-add-url.tsx +++ b/app/(tabs)/(folder)/folder-add-url.tsx @@ -13,7 +13,6 @@ import { Colors, Typography } from '@/constants/theme'; import { getFolderErrorMessage, useFolders } from '@/context/folders-context'; import { useSavedLinks } from '@/context/saved-links-context'; import { showAlert } from '@/utils/guarded-alert'; -import { markPerformance, measurePerformance } from '@/utils/performance-trace'; // ─── Screen ─────────────────────────────────────────────────────────────────── @@ -49,29 +48,16 @@ export default function FolderAddUrlScreen() { const selectedLinkIds = [...selectedIds]; isAddingRef.current = true; setIsAdding(true); - markPerformance('folder_add_button_pressed'); try { - markPerformance('folder_assign_started'); await assignCategory(selectedLinkIds, Number(folderId)); - measurePerformance('folder_assign_completed', 'folder_assign_started'); - markPerformance('folder_detail_navigation_started'); router.replace({ pathname: '/(tabs)/(folder)/[id]', params: { id: folderId, urlAdded: '1', - addedLinkIds: selectedLinkIds.join(','), }, }); - markPerformance('folder_refresh_after_navigation_started'); - void refreshFolders() - .catch(() => undefined) - .finally(() => { - measurePerformance( - 'folder_refresh_completed_after_navigation', - 'folder_refresh_after_navigation_started', - ); - }); + void refreshFolders().catch(() => undefined); } catch (error) { showAlert( 'URL 추가 실패', diff --git a/app/(tabs)/(home)/add-link.tsx b/app/(tabs)/(home)/add-link.tsx index 26d6fa5..c76d8be 100644 --- a/app/(tabs)/(home)/add-link.tsx +++ b/app/(tabs)/(home)/add-link.tsx @@ -16,7 +16,6 @@ import { Stack, router, useLocalSearchParams } from 'expo-router'; import { ScanButton } from '@/components/ui/scan-button'; import { Colors, Typography } from '@/constants/theme'; -import { markPerformance } from '@/utils/performance-trace'; import { useGuardedPress } from '@/utils/press-guard'; import { normalizeHttpUrlInput } from '@/utils/shared-url'; @@ -116,7 +115,6 @@ export default function AddLinkScreen() { isNavigatingRef.current = true; setIsNavigating(true); - markPerformance('scan_button_pressed'); router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: normalizedUrl } }); }; diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx index 029d590..cbc04ad 100644 --- a/app/(tabs)/(home)/scanning.tsx +++ b/app/(tabs)/(home)/scanning.tsx @@ -12,7 +12,6 @@ import { checkSavedLinkUrl } from '@/api/saved-links'; import { Colors, Typography } from '@/constants/theme'; import { AppIcon } from '@/components/ui/app-icon'; import { useAnalysisResultCache } from '@/context/analysis-result-cache-context'; -import { markPerformance, measurePerformance } from '@/utils/performance-trace'; import { useGuardedPress } from '@/utils/press-guard'; const POLLING_INTERVAL_MS = 2_000; @@ -117,7 +116,6 @@ export default function ScanningScreen() { }; } - measurePerformance('scan_button_to_scanning_screen', 'scan_button_pressed'); setErrorMessage(''); setScanStepMessage(SCAN_STEP_MESSAGES.checkingDuplicate); screenTimeoutId = setTimeout(handleScreenTimeout, SCAN_SCREEN_TIMEOUT_MS); @@ -306,19 +304,15 @@ async function runAnalysisPolling({ onNavigate: () => void; }) { const deadline = Date.now() + SCAN_SCREEN_TIMEOUT_MS; - markPerformance('saved_link_duplicate_check_started'); onStep(SCAN_STEP_MESSAGES.checkingDuplicate); const savedLinkCheck = await checkSavedLinkUrl(getToken, url, { signal }); - measurePerformance('saved_link_duplicate_check_completed', 'saved_link_duplicate_check_started'); if (savedLinkCheck.exists) { throw new Error('DUPLICATE_SAVED_LINK'); } - markPerformance('analysis_request_started'); onStep(SCAN_STEP_MESSAGES.requestingAnalysis); let analysis = await requestAnalysis(getToken, url, { signal }); - measurePerformance('analysis_request_completed', 'analysis_request_started'); while (!signal.aborted) { const handled = handleAnalysisResult( @@ -367,7 +361,6 @@ function handleAnalysisResult( } if (!signal.aborted && canNavigate()) { - markPerformance('analysis_result_received'); onResolvedAnalysis(analysis); onNavigate(); router.replace({ diff --git a/hooks/use-analysis-result.ts b/hooks/use-analysis-result.ts index 73e803c..a0bd4fe 100644 --- a/hooks/use-analysis-result.ts +++ b/hooks/use-analysis-result.ts @@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchAnalysis, type AnalysisResponse } from '@/api/analyses'; import { ApiError } from '@/api/api-client'; import { useAnalysisResultCache } from '@/context/analysis-result-cache-context'; -import { logPerformanceEvent, logPerformanceSummary, markPerformance, measurePerformance } from '@/utils/performance-trace'; const LOGIN_REQUIRED_MESSAGE = '로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'; @@ -37,14 +36,6 @@ export function useAnalysisResult(analysisId?: string) { setAnalysis(nextCachedAnalysis); setIsLoading(false); setErrorMessage(''); - logPerformanceEvent('analysis_result_cache_hit', { analysisId }); - measurePerformance('scan_result_screen_with_cached_analysis', 'analysis_result_received'); - logPerformanceSummary('core_scan_flow_cached_result', [ - 'scan_button_to_scanning_screen', - 'saved_link_duplicate_check_completed', - 'analysis_request_completed', - 'scan_result_screen_with_cached_analysis', - ]); return; } @@ -62,8 +53,6 @@ export function useAnalysisResult(analysisId?: string) { setAnalysis(null); setIsLoading(true); setErrorMessage(''); - logPerformanceEvent('analysis_result_cache_miss', { analysisId }); - markPerformance('analysis_result_fetch_started'); try { const nextAnalysis = await fetchAnalysis( @@ -78,13 +67,6 @@ export function useAnalysisResult(analysisId?: string) { setAnalysis(nextAnalysis); setAnalysisResult(nextAnalysis); - measurePerformance('analysis_result_fetch_completed', 'analysis_result_fetch_started'); - logPerformanceSummary('core_scan_flow_refetched_result', [ - 'scan_button_to_scanning_screen', - 'saved_link_duplicate_check_completed', - 'analysis_request_completed', - 'analysis_result_fetch_completed', - ]); } catch (error) { if (signal?.aborted) { return; @@ -120,14 +102,6 @@ export function useAnalysisResult(analysisId?: string) { setAnalysis(cachedAnalysis); setIsLoading(false); setErrorMessage(''); - logPerformanceEvent('analysis_result_cache_hit', { analysisId }); - measurePerformance('scan_result_screen_with_cached_analysis', 'analysis_result_received'); - logPerformanceSummary('core_scan_flow_cached_result', [ - 'scan_button_to_scanning_screen', - 'saved_link_duplicate_check_completed', - 'analysis_request_completed', - 'scan_result_screen_with_cached_analysis', - ]); return; } diff --git a/utils/performance-trace.ts b/utils/performance-trace.ts deleted file mode 100644 index f69aa7c..0000000 --- a/utils/performance-trace.ts +++ /dev/null @@ -1,76 +0,0 @@ -type PerformanceMeasurement = { - name: string; - durationMs: number; -}; - -const marks = new Map(); -const measurements: PerformanceMeasurement[] = []; - -function getNow() { - return globalThis.performance?.now?.() ?? Date.now(); -} - -export function markPerformance(name: string) { - if (!__DEV__) { - return; - } - - marks.set(name, getNow()); - console.log(`[perf] mark ${name}`); -} - -export function measurePerformance(name: string, from: string) { - if (!__DEV__) { - return; - } - - const start = marks.get(from); - - if (start == null) { - console.log(`[perf] ${name}: missing start mark "${from}"`); - return; - } - - const durationMs = getNow() - start; - measurements.push({ name, durationMs }); - console.log(`[perf] ${name}: ${durationMs.toFixed(1)}ms`); -} - -export function logPerformanceEvent(name: string, details?: Record) { - if (!__DEV__) { - return; - } - - const detailText = details - ? ` ${Object.entries(details) - .map(([key, value]) => `${key}=${value}`) - .join(' ')}` - : ''; - - console.log(`[perf] ${name}${detailText}`); -} - -export function logPerformanceSummary(label: string, names: string[]) { - if (!__DEV__) { - return; - } - - const summary = names - .map((name) => { - const measurement = findLatestMeasurement(name); - return measurement ? `${name}=${measurement.durationMs.toFixed(1)}ms` : `${name}=n/a`; - }) - .join(' '); - - console.log(`[perf:summary] ${label} ${summary}`); -} - -function findLatestMeasurement(name: string) { - for (let index = measurements.length - 1; index >= 0; index -= 1) { - if (measurements[index].name === name) { - return measurements[index]; - } - } - - return null; -} \ No newline at end of file