From 13400219baf7d83ab696bcd9f382dc876e30e0ae Mon Sep 17 00:00:00 2001 From: twoimo <32544727+twoimo@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:56:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?perf(web):=20=EB=B0=A9=EB=AC=B8=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=A3=BC=EC=86=8C=20=EC=83=89=EC=9D=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EB=B0=94=EA=BF=94=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=9E=AC=EC=8A=A4=EC=BA=94=EC=9D=84=20=EC=97=86?= =?UTF-8?q?=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도장 화면과 미방문 목록은 맛집 하나를 볼 때마다 사용자 리뷰가 있는 후보 맛집 전체를 다시 훑었습니다 (승인 맛집 1,500개 x 후보 40개 = 60,000회, 후보마다 주소 게이트와 이름 게이트). 후보를 주소별로 한 번만 색인하고 직접 ID를 먼저 확인하면, 주소가 겹치지 않는 후보는 주소 게이트에서 이미 탈락한 것과 같아 검사 수가 목록 길이 수준으로 줄어듭니다. - apps/web/lib/restaurant-review-lookup.ts: createVisitedRestaurantMatcher 추가(순수 추가). 선형 판정(hasRelatedVerifiedUserReview)은 참조 구현으로 그대로 남깁니다. - apps/web/hooks/useUnvisitedRestaurants.tsx, apps/web/app/stamp/page.tsx, apps/web/components/overlay-pages/StampOverlay.tsx: 같은 판정을 색인 경로로 호출합니다. - 판정 결과(방문/미방문)는 바뀌지 않습니다. 검증 - tests-unit/restaurant-visit-matching-matcher.test.ts: 선형 판정과 동일성 15 케이스. - tests-unit/unvisited-restaurants-derivation.test.ts: 색인 경로 기준으로 갱신. - 전체 단위 테스트 2379 pass / 1 skip / 0 fail, 격리 150 pass / 8 skip / 0 fail, typecheck:parity diagnostics 0, lint exit 0. - 실제 로컬 DB: 병합 맛집 723개 전수 비교 불일치 0. - 성능 근거: apps/web/performance/visited-restaurant-matcher-20260922/benchmark.json 후보 검사 60,000 -> 30(2,000배), 중앙값 3.40ms -> 0.38ms(8.9배), p95 3.99ms -> 0.48ms. 퇴화 모양(모든 맛집이 한 주소를 공유)에서도 1.78배로 느려지지 않습니다. --- apps/web/app/stamp/page.tsx | 14 +- .../components/overlay-pages/StampOverlay.tsx | 12 +- apps/web/hooks/useUnvisitedRestaurants.tsx | 11 +- apps/web/lib/restaurant-review-lookup.ts | 86 ++++++ .../benchmark-visited-restaurant-matcher.mjs | 253 ++++++++++++++++++ .../benchmark.json | 154 +++++++++++ .../restaurant-visit-matching-matcher.test.ts | 239 +++++++++++++++++ .../unvisited-restaurants-derivation.test.ts | 14 +- 8 files changed, 756 insertions(+), 27 deletions(-) create mode 100644 apps/web/performance/visited-restaurant-matcher-20260922/benchmark-visited-restaurant-matcher.mjs create mode 100644 apps/web/performance/visited-restaurant-matcher-20260922/benchmark.json create mode 100644 apps/web/tests-unit/restaurant-visit-matching-matcher.test.ts diff --git a/apps/web/app/stamp/page.tsx b/apps/web/app/stamp/page.tsx index 0a4281cad4..462d1c7cd4 100644 --- a/apps/web/app/stamp/page.tsx +++ b/apps/web/app/stamp/page.tsx @@ -41,9 +41,9 @@ import { BottomSheet } from "@/components/ui/bottom-sheet"; import { REGIONS, extractRegion, extractYouTubeVideoId, parseCategory, StampFilterState, UserReview } from "@/components/stamp/stamp-utils"; import { StampCard } from "@/components/stamp/StampCard"; import { YoutubeThumbnail } from "@/components/ui/youtube-thumbnail"; -import { hasRelatedVerifiedUserReview } from "@/lib/restaurant-visit-matching"; import { collectDirectRestaurantReviewIds, + createVisitedRestaurantMatcher, getRestaurantReviewLookupName, selectRelatedRestaurantReviewIds, } from "@/lib/restaurant-review-lookup"; @@ -309,13 +309,11 @@ export default function StampPage() { // 사용자 방문 데이터 준비 완료 상태 (비로그인 또는 로딩 완료) const isUserStampsReady = !user?.id || isUserStampsFetched; - const isVisited = useCallback((restaurant: Restaurant) => { - return hasRelatedVerifiedUserReview({ - restaurant, - reviewedRestaurantIds: userReviews, - reviewedRestaurants: reviewedRestaurantCandidates, - }); - }, [reviewedRestaurantCandidates, userReviews]); + // 후보(사용자 리뷰가 있는 맛집)를 주소로 한 번만 색인해, 맛집마다 후보 전체를 다시 훑지 않는다. + const isVisited = useMemo( + () => createVisitedRestaurantMatcher(reviewedRestaurantCandidates, userReviews), + [reviewedRestaurantCandidates, userReviews] + ); // --- 데이터 패칭: 맛집 정보 --- // 병합된 전체 맛집 수 조회 (useRestaurants 훅 사용 - 병합 로직 적용됨) diff --git a/apps/web/components/overlay-pages/StampOverlay.tsx b/apps/web/components/overlay-pages/StampOverlay.tsx index b68e29b628..6a5d14a16a 100644 --- a/apps/web/components/overlay-pages/StampOverlay.tsx +++ b/apps/web/components/overlay-pages/StampOverlay.tsx @@ -16,7 +16,7 @@ import { StampGridSkeleton } from "@/components/ui/skeleton-loaders"; import { useRestaurants } from "@/hooks/use-restaurants"; import { REGIONS, extractRegion, StampFilterState, UserReview } from "@/components/stamp/stamp-utils"; import { StampCard } from "@/components/stamp/StampCard"; -import { hasRelatedVerifiedUserReview } from "@/lib/restaurant-visit-matching"; +import { createVisitedRestaurantMatcher } from "@/lib/restaurant-review-lookup"; import { getRestaurantDisplayName, withRestaurantDisplayName } from "@/lib/restaurant-display-name"; import { compareStampRestaurants, createVisitedLookup } from "@/lib/stamp-restaurant-order"; import { cn } from "@/lib/utils"; @@ -118,11 +118,11 @@ export default function StampOverlay({ onClose, onOpenRestaurantDetail, singleCo .map((review) => (review as UserReviewWithRestaurant).restaurant) .filter((restaurant): restaurant is Restaurant => Boolean(restaurant)); }, [userReviewData]); - const isVisited = useCallback((restaurant: Restaurant) => hasRelatedVerifiedUserReview({ - restaurant, - reviewedRestaurantIds: userVisitedIds, - reviewedRestaurants: reviewedRestaurantCandidates, - }), [reviewedRestaurantCandidates, userVisitedIds]); + // 후보(사용자 리뷰가 있는 맛집)를 주소로 한 번만 색인해, 맛집마다 후보 전체를 다시 훑지 않는다. + const isVisited = useMemo( + () => createVisitedRestaurantMatcher(reviewedRestaurantCandidates, userVisitedIds), + [reviewedRestaurantCandidates, userVisitedIds] + ); const isUserStampsReady = !user?.id || isUserStampsFetched; const shouldWaitForStampState = !!user?.id && !isUserStampsFetched; const shouldShowStampOverlaySkeleton = diff --git a/apps/web/hooks/useUnvisitedRestaurants.tsx b/apps/web/hooks/useUnvisitedRestaurants.tsx index 9ff1104c5d..9581e0fd89 100644 --- a/apps/web/hooks/useUnvisitedRestaurants.tsx +++ b/apps/web/hooks/useUnvisitedRestaurants.tsx @@ -4,7 +4,7 @@ import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/contexts/AuthContext"; import { mergeRestaurants, RESTAURANT_MERGE_SELECT } from "@/hooks/use-restaurants"; import { Tables } from "@/integrations/supabase/types"; -import { hasRelatedVerifiedUserReview } from "@/lib/restaurant-visit-matching"; +import { createVisitedRestaurantMatcher } from "@/lib/restaurant-review-lookup"; import type { Restaurant } from "@/types/restaurant"; type ReviewedRestaurant = Pick< @@ -99,15 +99,14 @@ export function useUnvisitedRestaurants() { .map((review) => review.restaurant) .filter((restaurant): restaurant is Restaurant => Boolean(restaurant)); + // 후보(사용자 리뷰가 있는 맛집)를 주소로 한 번만 색인해 맛집마다 후보 전체를 다시 훑지 않는다. + const isVisited = createVisitedRestaurantMatcher(reviewedRestaurantCandidates, visitedRestaurantIds); + // 방문 여부를 한 번만 계산해 미방문 목록과 방문 수를 함께 만든다(같은 목록을 두 번 순회하지 않는다). const unvisited: Restaurant[] = []; let visited = 0; for (const restaurant of mergedRestaurants) { - if (hasRelatedVerifiedUserReview({ - restaurant, - reviewedRestaurantIds: visitedRestaurantIds, - reviewedRestaurants: reviewedRestaurantCandidates, - })) { + if (isVisited(restaurant)) { visited += 1; } else { unvisited.push(restaurant); diff --git a/apps/web/lib/restaurant-review-lookup.ts b/apps/web/lib/restaurant-review-lookup.ts index 21dcd571cf..d63221ec8d 100644 --- a/apps/web/lib/restaurant-review-lookup.ts +++ b/apps/web/lib/restaurant-review-lookup.ts @@ -333,6 +333,92 @@ export function createRelatedRestaurantReviewIndex( }; } +// 사용자 리뷰가 있는 후보 맛집을 주소별로 한 번만 색인해 두고, 맛집마다 후보 전체를 다시 훑지 않고 +// 방문 여부만 판정합니다. +// +// hasRelatedVerifiedUserReview는 맛집 하나를 볼 때마다 후보 전체를 순회하므로 비용이 맛집 수 x 후보 수입니다 +// (승인 맛집 1,568개 x 후보 40개 = 62,720회). 후보를 주소로 색인해 두면 주소가 겹치지 않는 후보는 주소 +// 게이트에서 이미 탈락한 것과 같아, 실제로 검사하는 후보가 크게 줄어듭니다. 이름 게이트도 색인을 통과한 +// 후보에만 적용합니다. +// +// 판정 결과는 hasRelatedVerifiedUserReview와 같아야 하며, 두 경로의 동일성은 +// apps/web/tests-unit/restaurant-visit-matching-matcher.test.ts에서 확인합니다. +export function createVisitedRestaurantMatcher( + candidates: ReviewLookupCandidate[], + visitedRestaurantIds: Set +): (restaurant: ReviewLookupRestaurant | null) => boolean { + const entries = (Array.isArray(candidates) ? candidates : []).filter( + (candidate): candidate is ReviewLookupCandidate => Boolean(candidate && candidate.id) + ); + const addressBuckets = new Map(); + const addresslessIndices: number[] = []; + + entries.forEach((candidate, index) => { + const addresses = prepareCandidateLookupAddresses(candidate).addresses; + if (addresses.size === 0) { + addresslessIndices.push(index); + return; + } + + addresses.forEach((address) => { + const bucket = addressBuckets.get(address); + if (bucket) bucket.push(index); + else addressBuckets.set(address, [index]); + }); + }); + + return (restaurant) => { + if (!restaurant || visitedRestaurantIds.size === 0) return false; + + // 직접 ID(맛집 자신과 병합 레코드)가 방문 집합에 있으면 후보를 볼 필요가 없습니다. + if (visitedRestaurantIds.has(restaurant.id)) return true; + if (Array.isArray(restaurant.mergedRestaurants)) { + for (const mergedRestaurant of restaurant.mergedRestaurants) { + if (mergedRestaurant?.id && visitedRestaurantIds.has(mergedRestaurant.id)) return true; + } + } + + if (entries.length === 0) return false; + + const lookupAddresses = prepareRestaurantLookupAddresses(restaurant).addresses; + const lookupNames = prepareLookupNames(restaurant); + const hasLookupNames = lookupNames.names.length > 0; + + const matchesCandidate = (index: number) => { + const candidate = entries[index]; + reviewLookupPerfCounters.candidateVisits += 1; + // 선형 경로도 후보 id가 방문 집합에 있어야 방문으로 인정합니다. + if (!visitedRestaurantIds.has(candidate.id)) return false; + if (!hasLookupNames) return true; + + // 후보마다 이름 집합이 다르므로 후보별로 준비합니다(준비 결과 자체는 후보 객체 단위로 캐시됩니다). + const candidateNames = prepareLookupNames(candidate); + if (candidateNames.names.length === 0) return true; + + reviewLookupPerfCounters.nameGates += 1; + return hasCompatibleLookupName(lookupNames, candidateNames); + }; + + // 맛집 주소가 없으면 선형 경로와 같게 주소 없는 후보만 통과시킵니다. + if (lookupAddresses.size === 0) { + for (const index of addresslessIndices) { + if (matchesCandidate(index)) return true; + } + return false; + } + + for (const address of lookupAddresses) { + const bucket = addressBuckets.get(address); + if (!bucket) continue; + for (const index of bucket) { + if (matchesCandidate(index)) return true; + } + } + + return false; + }; +} + // 후보의 주소 집합에 해당하는 승인 맛집 색인을 오름차순으로 하나씩 넘겨줍니다. // visit가 true를 돌려주면 순회를 멈춥니다. 각 주소 버킷은 색인 순서대로 쌓여 있어 정렬이 필요 없습니다. function forEachCandidateIndex( diff --git a/apps/web/performance/visited-restaurant-matcher-20260922/benchmark-visited-restaurant-matcher.mjs b/apps/web/performance/visited-restaurant-matcher-20260922/benchmark-visited-restaurant-matcher.mjs new file mode 100644 index 0000000000..b9ba1243c0 --- /dev/null +++ b/apps/web/performance/visited-restaurant-matcher-20260922/benchmark-visited-restaurant-matcher.mjs @@ -0,0 +1,253 @@ +// 도장/미방문 판정이 맛집마다 후보 전체를 다시 훑는 비용을 색인 도입 전후로 비교하는 벤치마크. +// 실행: bun apps/web/performance/visited-restaurant-matcher-20260922/benchmark-visited-restaurant-matcher.mjs +// +// 비교 대상(둘 다 같은 방문/미방문 판정을 내려야 한다) +// - S0-linear: lib/restaurant-visit-matching.ts의 hasRelatedVerifiedUserReview. +// 맛집 하나를 볼 때마다 후보(사용자 리뷰가 있는 맛집) 전체를 순회한다. 이번 변경 이전에 +// 훅과 도장 화면이 쓰던 경로이며, 지금도 참조 구현으로 그대로 남아 있다(이 파일은 무수정). +// - S1-indexed: lib/restaurant-review-lookup.ts의 createVisitedRestaurantMatcher. +// 후보를 주소별로 한 번만 색인하고, 맛집마다 색인에 걸린 후보만 본다. +// +// 시나리오 +// - unique-address: 맛집마다 주소가 다른 실제 데이터 모양. +// - shared-address: 모든 맛집이 한 주소를 공유하는 퇴화 모양(색인이 줄일 수 없는 최악의 경우). +// +// 측정 항목 +// - 결과 동등성(맛집 순서대로의 방문 여부 지문과 방문 수) +// - 결정적 연산 카운터(후보 검사 수, 이름 게이트 평가 수) +// - 목록 한 번 판정 시간(중앙값, p95, MAD) +import { writeFileSync } from 'node:fs'; +import { + createVisitedRestaurantMatcher, + getReviewLookupPerfCounters, + resetReviewLookupPerfCounters, +} from '../../lib/restaurant-review-lookup'; +import { hasRelatedVerifiedUserReview } from '../../lib/restaurant-visit-matching'; + +const MERGED_ROWS = 1500; +const REVIEWED_CANDIDATES = 40; +// 실제 데이터에서 사용자 리뷰는 대부분 삭제/미승인 중복 레코드에 붙어 있고, 승인 카드와 주소는 같고 +// 이름만 조금 다르다. 나머지는 어떤 승인 맛집과도 겹치지 않는다. +const MATCHING_CANDIDATES = 30; +const REPETITIONS = 21; +// 표본 하나를 짧게 재면 타이머 분해능과 GC 때문에 상대 노이즈가 커진다. 색인 경로는 판정 한 번이 +// 0.5ms 수준이라 표본이 특히 짧아지므로, 표본마다 같은 작업을 충분히 여러 번 돌리고 다시 판정 한 번의 +// 시간으로 나눠 보고한다(20회 x 0.5ms = 약 10ms, 20회 x 10ms = 약 200ms). +const SAMPLE_LOOPS = 20; + +export const VISITED_RESTAURANT_MATCHER_BUDGETS = Object.freeze({ + absolute: Object.freeze({ + pipelineMedianMsMax: 30, + rule: '색인 경로의 목록 한 번 판정 중앙값이 30ms 이하여야 합니다.', + }), + relative: Object.freeze({ + minMedianSpeedup: 3, + degenerateMinMedianSpeedup: 1, + rule: 'unique-address는 선형 경로 대비 중앙값 3배 이상, 퇴화 모양(shared-address)은 색인이 줄일 여지가 없으므로 1배 이상(느려지지 않음)이면 인정합니다.', + }), + noise: Object.freeze({ + madRelativeMax: 0.15, + repetitions: REPETITIONS, + rule: '각 경로의 반복 표본 MAD/중앙값이 15% 이내이고, 중앙값 개선폭이 양쪽 상대 노이즈 합보다 클 때만 개선으로 인정합니다.', + }), +}); + +function makeRows({ sharedAddress }) { + const restaurants = []; + for (let index = 0; index < MERGED_ROWS; index += 1) { + const address = sharedAddress ? '서울 중구 쯔동로 1' : '서울 중구 쯔동로 ' + index; + restaurants.push({ + id: 'approved-' + index, + name: '쯔동분식 ' + index, + approved_name: '쯔동분식 ' + index, + road_address: address, + jibun_address: address, + mergedRestaurants: [], + }); + } + + const candidates = []; + for (let index = 0; index < REVIEWED_CANDIDATES; index += 1) { + const matching = index < MATCHING_CANDIDATES; + const target = restaurants[index % MERGED_ROWS]; + const address = matching ? target.road_address : '서울 중구 없는로 ' + index; + const name = matching ? target.name + ' 지점' : '없는집 ' + index; + candidates.push({ + id: 'reviewed-' + index, + name, + approved_name: name, + road_address: address, + jibun_address: address, + }); + } + + const visitedIds = new Set(candidates.map((candidate) => candidate.id)); + return { restaurants, candidates, visitedIds }; +} + +function runLinear({ restaurants, candidates, visitedIds }) { + return restaurants.map((restaurant) => hasRelatedVerifiedUserReview({ + restaurant, + reviewedRestaurantIds: visitedIds, + reviewedRestaurants: candidates, + })); +} + +function runIndexed({ restaurants, candidates, visitedIds }) { + const isVisited = createVisitedRestaurantMatcher(candidates, visitedIds); + return restaurants.map((restaurant) => isVisited(restaurant)); +} + +function fingerprint(results) { + let hash = 0; + for (const visited of results) { + hash = (hash * 31 + (visited ? 1 : 2)) % 4294967296; + } + return hash.toString(16); +} + +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)]; +} + +function madRelative(values) { + const center = median(values); + return median(values.map((value) => Math.abs(value - center))) / center; +} + +function measure(run) { + run(); + const samples = []; + for (let repetition = 0; repetition < REPETITIONS; repetition += 1) { + const started = performance.now(); + for (let loop = 0; loop < SAMPLE_LOOPS; loop += 1) run(); + samples.push((performance.now() - started) / SAMPLE_LOOPS); + } + const ordered = [...samples].sort((left, right) => left - right); + return { + medianMs: median(samples), + p95Ms: ordered[Math.floor(ordered.length * 0.95)], + madRelative: madRelative(samples), + }; +} + +function countLinear(workload) { + resetReviewLookupPerfCounters(); + const results = runLinear(workload); + return { results, counters: getReviewLookupPerfCounters() }; +} + +function countIndexed(workload) { + resetReviewLookupPerfCounters(); + const results = runIndexed(workload); + return { results, counters: getReviewLookupPerfCounters() }; +} + +const scenarios = []; + +for (const scenario of [ + { id: 'unique-address', sharedAddress: false, degenerate: false, description: '맛집마다 주소가 다른 실제 데이터 모양' }, + { id: 'shared-address', sharedAddress: true, degenerate: true, description: '모든 맛집이 한 주소를 공유하는 퇴화 모양(색인이 줄일 수 없는 최악의 경우)' }, +]) { + const workload = makeRows({ sharedAddress: scenario.sharedAddress }); + + const before = countLinear(workload); + const after = countIndexed(workload); + + const beforeFingerprint = fingerprint(before.results); + const afterFingerprint = fingerprint(after.results); + const equivalent = beforeFingerprint === afterFingerprint + && before.results.length === after.results.length; + + const timingBefore = measure(() => runLinear(workload)); + const timingAfter = measure(() => runIndexed(workload)); + + const relativeImprovement = timingBefore.medianMs / timingAfter.medianMs; + const combinedRelativeNoise = timingBefore.madRelative + timingAfter.madRelative; + const requiredSpeedup = scenario.degenerate + ? VISITED_RESTAURANT_MATCHER_BUDGETS.relative.degenerateMinMedianSpeedup + : VISITED_RESTAURANT_MATCHER_BUDGETS.relative.minMedianSpeedup; + const acceptance = { + absoluteBudgetMet: timingAfter.medianMs <= VISITED_RESTAURANT_MATCHER_BUDGETS.absolute.pipelineMedianMsMax, + relativeBudgetMet: relativeImprovement >= requiredSpeedup, + requiredSpeedup, + noiseWithinBudget: + timingBefore.madRelative <= VISITED_RESTAURANT_MATCHER_BUDGETS.noise.madRelativeMax + && timingAfter.madRelative <= VISITED_RESTAURANT_MATCHER_BUDGETS.noise.madRelativeMax, + combinedRelativeNoise, + relativeImprovement, + deltaExceedsNoise: Math.abs(relativeImprovement - 1) > combinedRelativeNoise, + }; + acceptance.accepted = equivalent + && acceptance.absoluteBudgetMet + && acceptance.relativeBudgetMet + && acceptance.noiseWithinBudget + && acceptance.deltaExceedsNoise; + + scenarios.push({ + id: scenario.id, + description: scenario.description, + equivalence: equivalent ? 'identical' : 'mismatch', + equivalenceDetail: [ + { id: 'S0-linear', visitedCount: before.results.filter(Boolean).length, fingerprint: beforeFingerprint }, + { id: 'S1-indexed', visitedCount: after.results.filter(Boolean).length, fingerprint: afterFingerprint }, + ], + counters: [ + { + id: 'S0-linear', + candidateVisits: before.counters.candidateVisits, + nameGates: before.counters.nameGates, + addressGates: before.counters.addressGates, + }, + { + id: 'S1-indexed', + candidateVisits: after.counters.candidateVisits, + nameGates: after.counters.nameGates, + addressGates: after.counters.addressGates, + }, + ], + measurements: [ + { id: 'S0-linear', ...timingBefore }, + { id: 'S1-indexed', ...timingAfter }, + ], + acceptance, + ratio: { + speedupByMedian: relativeImprovement, + speedupByP95: timingBefore.p95Ms / timingAfter.p95Ms, + candidateVisitReduction: before.counters.candidateVisits / Math.max(after.counters.candidateVisits, 1), + nameGateReduction: before.counters.nameGates / Math.max(after.counters.nameGates, 1), + }, + }); +} + +const report = { + generatedAt: new Date().toISOString(), + baselineSource: { + file: 'apps/web/lib/restaurant-visit-matching.ts (hasRelatedVerifiedUserReview)', + note: '기준 경로는 손으로 다시 쓴 근사가 아니라 이번 변경에서 무수정으로 남은 참조 구현입니다. 변경은 restaurant-review-lookup.ts에 추가만 했고(순수 추가), 판정 결과 동일성은 tests-unit/restaurant-visit-matching-matcher.test.ts가 확인합니다.', + }, + workload: { + mergedRows: MERGED_ROWS, + reviewedCandidates: REVIEWED_CANDIDATES, + matchingCandidates: MATCHING_CANDIDATES, + repetitions: REPETITIONS, + sampleLoops: SAMPLE_LOOPS, + }, + budgets: VISITED_RESTAURANT_MATCHER_BUDGETS, + scenarios, +}; +writeFileSync(new URL('./benchmark.json', import.meta.url), JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify(report.scenarios.map((scenario) => ({ + id: scenario.id, + equivalence: scenario.equivalence, + visitedCount: scenario.equivalenceDetail.map((detail) => detail.visitedCount), + candidateVisits: scenario.counters.map((counter) => counter.candidateVisits), + nameGates: scenario.counters.map((counter) => counter.nameGates), + medianMs: scenario.measurements.map((measurement) => +measurement.medianMs.toFixed(3)), + p95Ms: scenario.measurements.map((measurement) => +measurement.p95Ms.toFixed(3)), + madRelative: scenario.measurements.map((measurement) => +measurement.madRelative.toFixed(4)), + speedupByMedian: +scenario.ratio.speedupByMedian.toFixed(2), + candidateVisitReduction: +scenario.ratio.candidateVisitReduction.toFixed(1), + accepted: scenario.acceptance.accepted, +})), null, 2)); diff --git a/apps/web/performance/visited-restaurant-matcher-20260922/benchmark.json b/apps/web/performance/visited-restaurant-matcher-20260922/benchmark.json new file mode 100644 index 0000000000..5e4ede16d0 --- /dev/null +++ b/apps/web/performance/visited-restaurant-matcher-20260922/benchmark.json @@ -0,0 +1,154 @@ +{ + "generatedAt": "2026-09-22T02:52:31.229Z", + "baselineSource": { + "file": "apps/web/lib/restaurant-visit-matching.ts (hasRelatedVerifiedUserReview)", + "note": "기준 경로는 손으로 다시 쓴 근사가 아니라 이번 변경에서 무수정으로 남은 참조 구현입니다. 변경은 restaurant-review-lookup.ts에 추가만 했고(순수 추가), 판정 결과 동일성은 tests-unit/restaurant-visit-matching-matcher.test.ts가 확인합니다." + }, + "workload": { + "mergedRows": 1500, + "reviewedCandidates": 40, + "matchingCandidates": 30, + "repetitions": 21, + "sampleLoops": 20 + }, + "budgets": { + "absolute": { + "pipelineMedianMsMax": 30, + "rule": "색인 경로의 목록 한 번 판정 중앙값이 30ms 이하여야 합니다." + }, + "relative": { + "minMedianSpeedup": 3, + "degenerateMinMedianSpeedup": 1, + "rule": "unique-address는 선형 경로 대비 중앙값 3배 이상, 퇴화 모양(shared-address)은 색인이 줄일 여지가 없으므로 1배 이상(느려지지 않음)이면 인정합니다." + }, + "noise": { + "madRelativeMax": 0.15, + "repetitions": 21, + "rule": "각 경로의 반복 표본 MAD/중앙값이 15% 이내이고, 중앙값 개선폭이 양쪽 상대 노이즈 합보다 클 때만 개선으로 인정합니다." + } + }, + "scenarios": [ + { + "id": "unique-address", + "description": "맛집마다 주소가 다른 실제 데이터 모양", + "equivalence": "identical", + "equivalenceDetail": [ + { + "id": "S0-linear", + "visitedCount": 30, + "fingerprint": "e36dd9a0" + }, + { + "id": "S1-indexed", + "visitedCount": 30, + "fingerprint": "e36dd9a0" + } + ], + "counters": [ + { + "id": "S0-linear", + "candidateVisits": 60000, + "nameGates": 30, + "addressGates": 60000 + }, + { + "id": "S1-indexed", + "candidateVisits": 30, + "nameGates": 30, + "addressGates": 0 + } + ], + "measurements": [ + { + "id": "S0-linear", + "medianMs": 3.4000020500000003, + "p95Ms": 3.9850875, + "madRelative": 0.13712369673424063 + }, + { + "id": "S1-indexed", + "medianMs": 0.3819291499999963, + "p95Ms": 0.47468545000000406, + "madRelative": 0.09414376985889714 + } + ], + "acceptance": { + "absoluteBudgetMet": true, + "relativeBudgetMet": true, + "requiredSpeedup": 3, + "noiseWithinBudget": true, + "combinedRelativeNoise": 0.23126746659313777, + "relativeImprovement": 8.902180024750752, + "deltaExceedsNoise": true, + "accepted": true + }, + "ratio": { + "speedupByMedian": 8.902180024750752, + "speedupByP95": 8.39521729600089, + "candidateVisitReduction": 2000, + "nameGateReduction": 1 + } + }, + { + "id": "shared-address", + "description": "모든 맛집이 한 주소를 공유하는 퇴화 모양(색인이 줄일 수 없는 최악의 경우)", + "equivalence": "identical", + "equivalenceDetail": [ + { + "id": "S0-linear", + "visitedCount": 30, + "fingerprint": "e36dd9a0" + }, + { + "id": "S1-indexed", + "visitedCount": 30, + "fingerprint": "e36dd9a0" + } + ], + "counters": [ + { + "id": "S0-linear", + "candidateVisits": 60000, + "nameGates": 45000, + "addressGates": 60000 + }, + { + "id": "S1-indexed", + "candidateVisits": 44565, + "nameGates": 44565, + "addressGates": 0 + } + ], + "measurements": [ + { + "id": "S0-linear", + "medianMs": 11.028577050000013, + "p95Ms": 15.123247949999996, + "madRelative": 0.08556426143842709 + }, + { + "id": "S1-indexed", + "medianMs": 6.2008896000000275, + "p95Ms": 8.24296875, + "madRelative": 0.0812309495076277 + } + ], + "acceptance": { + "absoluteBudgetMet": true, + "relativeBudgetMet": true, + "requiredSpeedup": 1, + "noiseWithinBudget": true, + "combinedRelativeNoise": 0.1667952109460548, + "relativeImprovement": 1.7785475571117997, + "deltaExceedsNoise": true, + "accepted": true + }, + "ratio": { + "speedupByMedian": 1.7785475571117997, + "speedupByP95": 1.834684615297128, + "candidateVisitReduction": 1.3463480309660047, + "nameGateReduction": 1.0097610232245036 + } + } + ] +} diff --git a/apps/web/tests-unit/restaurant-visit-matching-matcher.test.ts b/apps/web/tests-unit/restaurant-visit-matching-matcher.test.ts new file mode 100644 index 0000000000..04ab081596 --- /dev/null +++ b/apps/web/tests-unit/restaurant-visit-matching-matcher.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, test } from 'bun:test'; + +import type { Restaurant } from '../types/restaurant'; +import { + createVisitedRestaurantMatcher, + getReviewLookupPerfCounters, + resetReviewLookupPerfCounters, +} from '../lib/restaurant-review-lookup'; +import { hasRelatedVerifiedUserReview } from '../lib/restaurant-visit-matching'; + +// 색인 기반 방문 판정기(createVisitedRestaurantMatcher)가 선형 판정(hasRelatedVerifiedUserReview)과 +// 맛집마다 같은 참/거짓을 돌려주는지 확인합니다. 도장/미방문 목록은 이 판정 결과로 갈리므로, +// 판정이 하나라도 달라지면 화면이 달라집니다. +type Row = Pick< + Restaurant, + 'id' | 'name' | 'approved_name' | 'road_address' | 'jibun_address' | 'mergedRestaurants' +>; + +function row( + id: string, + name: string, + roadAddress: string | null, + jibunAddress: string | null = roadAddress, + mergedRestaurants?: Row[] +): Row { + return { + id, + name, + approved_name: name, + road_address: roadAddress, + jibun_address: jibunAddress, + mergedRestaurants, + } as Row; +} + +function linearVisited(restaurant: Row | null, candidates: Row[], visitedIds: Set): boolean { + return hasRelatedVerifiedUserReview({ + restaurant: restaurant as never, + reviewedRestaurantIds: visitedIds, + reviewedRestaurants: candidates as never, + }); +} + +function matcherVisited(restaurant: Row | null, candidates: Row[], visitedIds: Set): boolean { + const isVisited = createVisitedRestaurantMatcher(candidates as never, visitedIds) as unknown as ( + restaurant: Row | null + ) => boolean; + + return isVisited(restaurant); +} + +function expectSameVerdict( + restaurant: Row | null, + candidates: Row[], + visitedIds: Set +): boolean { + const linear = linearVisited(restaurant, candidates, visitedIds); + const indexed = matcherVisited(restaurant, candidates, visitedIds); + + expect(indexed).toBe(linear); + return linear; +} + +describe('visited restaurant matcher', () => { + test('맛집 자신의 id가 방문 집합에 있으면 후보 없이 방문으로 본다', () => { + const restaurant = row('approved-1', '쯔동분식 1', '서울 중구 쯔동로 1'); + + expect(expectSameVerdict(restaurant, [], new Set(['approved-1']))).toBe(true); + }); + + test('병합 레코드의 id가 방문 집합에 있으면 방문으로 본다', () => { + const restaurant = row('approved-2', '쯔동분식 2', '서울 중구 쯔동로 2', '서울 중구 쯔동로 2', [ + row('merged-2', '쯔동분식 2', '서울 중구 쯔동로 2'), + ]); + + expect(expectSameVerdict(restaurant, [], new Set(['merged-2']))).toBe(true); + expect(expectSameVerdict(restaurant, [], new Set(['merged-2-x']))).toBe(false); + }); + + test('주소와 이름이 모두 맞는 후보가 방문 집합에 있으면 방문으로 본다', () => { + const restaurant = row('approved-3', '쯔동분식 3', '서울 중구 쯔동로 3'); + const candidate = row('reviewed-3', '쯔동분식 3', '서울 중구 쯔동로 3'); + + expect(expectSameVerdict(restaurant, [candidate], new Set(['reviewed-3']))).toBe(true); + }); + + test('주소는 같아도 이름이 호환되지 않으면 방문으로 보지 않는다', () => { + const restaurant = row('approved-4', '쯔동분식 4', '서울 중구 쯔동로 4'); + const candidate = row('reviewed-4', '다른집 4', '서울 중구 쯔동로 4'); + + expect(expectSameVerdict(restaurant, [candidate], new Set(['reviewed-4']))).toBe(false); + }); + + test('이름이 같아도 주소가 다르면 방문으로 보지 않는다', () => { + const restaurant = row('approved-5', '쯔동분식 5', '서울 중구 쯔동로 5'); + const candidate = row('reviewed-5', '쯔동분식 5', '부산 해운대구 쯔동로 5'); + + expect(expectSameVerdict(restaurant, [candidate], new Set(['reviewed-5']))).toBe(false); + }); + + test('주소 없는 맛집은 주소 없는 후보와만 맞춘다', () => { + const addressless = row('approved-6', '쯔동분식 6', null); + const addresslessCandidate = row('reviewed-6', '쯔동분식 6', null); + const addressedCandidate = row('reviewed-6b', '쯔동분식 6', '서울 중구 쯔동로 6'); + + expect(expectSameVerdict(addressless, [addresslessCandidate], new Set(['reviewed-6']))).toBe(true); + expect(expectSameVerdict(addressless, [addressedCandidate], new Set(['reviewed-6b']))).toBe(false); + }); + + test('주소가 있는 맛집은 주소 없는 후보를 받지 않는다', () => { + const restaurant = row('approved-7', '쯔동분식 7', '서울 중구 쯔동로 7'); + const addresslessCandidate = row('reviewed-7', '쯔동분식 7', null); + + expect(expectSameVerdict(restaurant, [addresslessCandidate], new Set(['reviewed-7']))).toBe(false); + }); + + test('이름이 비어 있으면 주소만 맞으면 방문으로 본다', () => { + const restaurant = row('approved-8', '', '서울 중구 쯔동로 8'); + const candidate = row('reviewed-8', '', '서울 중구 쯔동로 8'); + + expect(expectSameVerdict(restaurant, [candidate], new Set(['reviewed-8']))).toBe(true); + }); + + test('후보 id가 방문 집합에 없으면 주소와 이름이 맞아도 방문으로 보지 않는다', () => { + const restaurant = row('approved-9', '쯔동분식 9', '서울 중구 쯔동로 9'); + const candidate = row('reviewed-9', '쯔동분식 9', '서울 중구 쯔동로 9'); + + expect(expectSameVerdict(restaurant, [candidate], new Set())).toBe(false); + expect(expectSameVerdict(restaurant, [candidate], new Set(['다른-id']))).toBe(false); + }); + + test('맛집이 null이거나 후보가 비면 방문으로 보지 않는다', () => { + expect(expectSameVerdict(null, [row('reviewed-10', '쯔동분식 10', '서울 중구 쯔동로 10')], new Set(['reviewed-10']))).toBe(false); + expect(expectSameVerdict(row('approved-10', '쯔동분식 10', '서울 중구 쯔동로 10'), [], new Set(['approved-x']))).toBe(false); + }); + + test('여러 후보 중 마지막 하나만 맞아도 방문으로 본다', () => { + const restaurant = row('approved-11', '쯔동분식 11', '서울 중구 쯔동로 11'); + const candidates = [ + row('reviewed-11a', '다른집 11', '부산 해운대구 쯔동로 11'), + row('reviewed-11b', '또다른집 11', '서울 중구 쯔동로 11'), + row('reviewed-11c', '쯔동분식 11', '서울 중구 쯔동로 11'), + ]; + const visitedIds = new Set(candidates.map((candidate) => candidate.id)); + + expect(expectSameVerdict(restaurant, candidates, visitedIds)).toBe(true); + }); + + test('병합 레코드의 주소도 후보를 맞출 때 함께 본다', () => { + const restaurant = row('approved-12', '쯔동분식 12', '서울 중구 쯔동로 12', '서울 중구 쯔동로 12', [ + row('merged-12', '쯔동분식 12', '서울 중구 쯔동로 99'), + ]); + const candidate = row('reviewed-12', '쯔동분식 12', '서울 중구 쯔동로 99'); + + expect(expectSameVerdict(restaurant, [candidate], new Set(['reviewed-12']))).toBe(true); + }); + + test('같은 후보를 여러 번 물어도 판정이 바뀌지 않는다', () => { + const restaurant = row('approved-13', '쯔동분식 13', '서울 중구 쯔동로 13'); + const candidate = row('reviewed-13', '쯔동분식 13', '서울 중구 쯔동로 13'); + const isVisited = createVisitedRestaurantMatcher([candidate] as never, new Set(['reviewed-13'])) as unknown as ( + restaurant: Row | null + ) => boolean; + + const verdicts = [isVisited(restaurant), isVisited(restaurant), isVisited(restaurant)]; + + expect(verdicts).toEqual([true, true, true]); + expect(linearVisited(restaurant, [candidate], new Set(['reviewed-13']))).toBe(true); + }); + + test('무작위 조합에서 선형 판정과 항상 같은 결과를 낸다', () => { + const names = ['쯔동분식', '쯔동분식 1', '쯔동분식 1 지점', '다른집', '', '쯔양국밥']; + const addresses = ['서울 중구 쯔동로 1', '서울 중구 쯔동로 2', '부산 해운대구 쯔동로 1', null]; + const pick = (values: T[], index: number) => values[index % values.length] as T; + + let seed = 20260922; + const nextRandom = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + + const candidates: Row[] = []; + for (let index = 0; index < 40; index += 1) { + const address = pick(addresses, Math.floor(nextRandom() * addresses.length)); + candidates.push(row('reviewed-' + index, pick(names, Math.floor(nextRandom() * names.length)), address)); + } + + const visitedIds = new Set(candidates.map((candidate) => candidate.id)); + for (const id of ['approved-3', 'merged-7', 'reviewed-0']) visitedIds.add(id); + + let comparisons = 0; + for (let index = 0; index < 300; index += 1) { + const address = pick(addresses, Math.floor(nextRandom() * addresses.length)); + const merged = index % 5 === 0 + ? [row('merged-' + index, pick(names, Math.floor(nextRandom() * names.length)), address)] + : undefined; + const restaurant = row( + 'approved-' + (index % 12), + pick(names, Math.floor(nextRandom() * names.length)), + address, + address, + merged + ); + + expectSameVerdict(restaurant, candidates, visitedIds); + comparisons += 1; + } + + expect(comparisons).toBe(300); + }); + + test('색인 경로가 실제로 검사하는 후보 수가 선형 경로보다 훨씬 적다', () => { + const restaurants: Row[] = []; + for (let index = 0; index < 400; index += 1) { + restaurants.push(row('approved-' + index, '쯔동분식 ' + index, '서울 중구 쯔동로 ' + index)); + } + + const candidates: Row[] = []; + for (let index = 0; index < 40; index += 1) { + candidates.push(row('reviewed-' + index, '쯔동분식 ' + index * 10, '서울 중구 쯔동로 ' + index * 10)); + } + const visitedIds = new Set(candidates.map((candidate) => candidate.id)); + + resetReviewLookupPerfCounters(); + const linearVerdicts = restaurants.map((restaurant) => linearVisited(restaurant, candidates, visitedIds)); + const linearVisits = getReviewLookupPerfCounters().candidateVisits; + + resetReviewLookupPerfCounters(); + const isVisited = createVisitedRestaurantMatcher(candidates as never, visitedIds) as unknown as ( + restaurant: Row | null + ) => boolean; + const indexedVerdicts = restaurants.map((restaurant) => isVisited(restaurant)); + const indexedVisits = getReviewLookupPerfCounters().candidateVisits; + + expect(indexedVerdicts).toEqual(linearVerdicts); + expect(linearVisits).toBe(restaurants.length * candidates.length); + expect(indexedVisits).toBeLessThan(linearVisits / 10); + }); +}); diff --git a/apps/web/tests-unit/unvisited-restaurants-derivation.test.ts b/apps/web/tests-unit/unvisited-restaurants-derivation.test.ts index 9b35785ead..1720a2713f 100644 --- a/apps/web/tests-unit/unvisited-restaurants-derivation.test.ts +++ b/apps/web/tests-unit/unvisited-restaurants-derivation.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { mergeRestaurants } from "../hooks/use-restaurants"; +import { createVisitedRestaurantMatcher } from "../lib/restaurant-review-lookup"; import { hasRelatedVerifiedUserReview } from "../lib/restaurant-visit-matching"; const source = (relativePath: string) => @@ -84,22 +85,19 @@ function referenceDerivation(rows: Row[], reviews: Review[]) { }; } -// 현재 구현: 방문 여부를 한 번만 계산해 미방문 목록과 방문 수를 함께 만든다. +// 현재 구현: 후보를 주소로 한 번만 색인하고, 방문 여부를 한 번만 계산해 미방문 목록과 방문 수를 함께 만든다. function currentDerivation(rows: Row[], reviews: Review[]) { const reviewedRestaurantIds = new Set(reviews.map((review) => review.restaurant_id)); const reviewedRestaurants = reviews .map((review) => review.restaurant) .filter((restaurant): restaurant is NonNullable => Boolean(restaurant)); const mergedRestaurants = mergeRestaurants(rows as never); + const isVisited = createVisitedRestaurantMatcher(reviewedRestaurants as never, reviewedRestaurantIds); const unvisitedIds: string[] = []; let visitedCount = 0; for (const restaurant of mergedRestaurants) { - if (hasRelatedVerifiedUserReview({ - restaurant: restaurant as never, - reviewedRestaurantIds, - reviewedRestaurants: reviewedRestaurants as never, - })) { + if (isVisited(restaurant as never)) { visitedCount += 1; } else { unvisitedIds.push(restaurant.id); @@ -190,6 +188,8 @@ describe("unvisited restaurants derivation", () => { expect(hookSource).toContain("}, [mergedRestaurants, userReviewData]);"); expect(hookSource).toContain("mergeRestaurants(restaurantsData || [])"); expect(hookSource).not.toContain("mergedRestaurants.filter"); - expect(hookSource).toContain("hasRelatedVerifiedUserReview({"); + // 맛집마다 후보 전체를 다시 훑지 않도록 색인 판정기를 쓰고, 선형 판정은 참조 구현으로만 남긴다. + expect(hookSource).toContain("createVisitedRestaurantMatcher(reviewedRestaurantCandidates, visitedRestaurantIds)"); + expect(hookSource).not.toContain("hasRelatedVerifiedUserReview"); }); }); From a7c15b568629d1bdbaf0f1489489aaca1f105ba0 Mon Sep 17 00:00:00 2001 From: twoimo <32544727+twoimo@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:12:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?perf(web):=20=EC=8A=B9=EC=9D=B8=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=88=98=20=EC=A7=91=EA=B3=84=EB=8F=84=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=83=89=EC=9D=B8=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BF=94=20=ED=9B=84=EB=B3=B4=20=EC=9E=AC=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=EC=9D=84=20=EC=97=86=EC=95=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRelatedVerifiedReviewCountMap은 맛집마다 selectRelatedRestaurantReviewIds로 후보 전체를 다시 훑어 비용이 맛집 수 x 후보 수였다. 후보를 주소로 한 번만 색인하는 createRelatedVerifiedReviewCountLookup을 추가하고 호출을 바꿨다. 합산 값은 선형 경로와 같다. - 색인 경로는 맛집 주소 버킷에 걸린 후보만 본다. 주소 없는 맛집은 주소 없는 후보만 통과시키는 선형 규칙을 그대로 유지한다. - 맛집마다 집합을 새로 만들지 않도록 countedIds를 재사용하고, seenStamps로 주소가 여러 개인 후보를 한 번만 본다. - selectRelatedRestaurantReviewIds는 참조 구현으로 그대로 남긴다. 근거(동결 원자료): apps/web/performance/verified-review-count-index-20260922/benchmark.json sha256 86e710b01c5c33b50ce675c54298c3618f7af11ddd939d150f7be911a7e2a9b5 작업량: 맛집 1000 x 중복 후보 1000 + 무관 후보 200, 반복 21, 표본 루프 20 - unique-address: 후보 검사 1,200,000 -> 1,000, 중앙값 64.333ms -> 0.657ms (97.9배), MAD 0.0501 / 0.0529 - shared-address(퇴화, 한 주소에 후보 1000개): 후보 검사 1,200,000 -> 1,000,000, 중앙값 173.667ms -> 155.844ms (1.11배), MAD 0.0241 / 0.0695 - 두 시나리오 모두 합산 지문 identical이고 absolute/relative/noise 예산을 모두 통과한다. 검증: npm run test:unit 2389 pass / 1 skip / 0 fail + 격리 150 pass / 8 skip / 0 fail, npm run typecheck:parity diagnostics 0, npm run lint exit 0 --- apps/web/lib/restaurant-review-counts.ts | 9 +- apps/web/lib/restaurant-review-lookup.ts | 101 +++++++ .../benchmark-verified-review-count-index.mjs | 269 ++++++++++++++++++ .../benchmark.json | 157 ++++++++++ .../restaurant-review-counts-index.test.ts | 223 +++++++++++++++ 5 files changed, 755 insertions(+), 4 deletions(-) create mode 100644 apps/web/performance/verified-review-count-index-20260922/benchmark-verified-review-count-index.mjs create mode 100644 apps/web/performance/verified-review-count-index-20260922/benchmark.json create mode 100644 apps/web/tests-unit/restaurant-review-counts-index.test.ts diff --git a/apps/web/lib/restaurant-review-counts.ts b/apps/web/lib/restaurant-review-counts.ts index f593cdf7d7..3d94a94999 100644 --- a/apps/web/lib/restaurant-review-counts.ts +++ b/apps/web/lib/restaurant-review-counts.ts @@ -1,4 +1,4 @@ -import { selectRelatedRestaurantReviewIds } from '@/lib/restaurant-review-lookup'; +import { createRelatedVerifiedReviewCountLookup } from '@/lib/restaurant-review-lookup'; import type { Restaurant } from '@/types/restaurant'; type ReviewCountRestaurant = Pick< @@ -26,9 +26,10 @@ export function buildRelatedVerifiedReviewCountMap( directCountMap.set(reviewRow.restaurant_id, (directCountMap.get(reviewRow.restaurant_id) ?? 0) + 1); }); + // 후보를 주소로 한 번만 색인해 맛집마다 후보 전체를 다시 훑지 않는다(합산 결과는 같다). + const countRelated = createRelatedVerifiedReviewCountLookup(candidates); + return new Map(restaurants.map((restaurant) => { - const relatedIds = selectRelatedRestaurantReviewIds(restaurant, candidates); - const count = relatedIds.reduce((sum, restaurantId) => sum + (directCountMap.get(restaurantId) ?? 0), 0); - return [restaurant.id, count]; + return [restaurant.id, countRelated(restaurant, directCountMap)]; })); } diff --git a/apps/web/lib/restaurant-review-lookup.ts b/apps/web/lib/restaurant-review-lookup.ts index d63221ec8d..033638bf31 100644 --- a/apps/web/lib/restaurant-review-lookup.ts +++ b/apps/web/lib/restaurant-review-lookup.ts @@ -419,6 +419,107 @@ export function createVisitedRestaurantMatcher( }; } +// 리뷰 수 집계는 관련 리뷰 id 집합 전체가 필요하므로, 방문 판정과 달리 색인을 통과한 후보를 모두 모아 +// 합산합니다. +// +// selectRelatedRestaurantReviewIds는 맛집마다 후보 전체를 순회하므로 비용이 맛집 수 x 후보 수입니다 +// (병합 맛집 1,372개 x 같은 이름 후보 1,300여 개 = 180만 회 수준). 후보를 주소로 색인해 두면 주소가 +// 겹치지 않는 후보는 주소 게이트에서 이미 탈락한 것과 같아 검사 수가 크게 줄어듭니다. +// +// 집계 결과는 selectRelatedRestaurantReviewIds + 합산과 같아야 하며, 두 경로의 동일성은 +// apps/web/tests-unit/restaurant-review-counts-index.test.ts에서 확인합니다. +export function createRelatedVerifiedReviewCountLookup( + candidates: ReviewLookupCandidate[] +): (restaurant: ReviewLookupRestaurant | null, directCountMap: Map) => number { + const entries = (Array.isArray(candidates) ? candidates : []).filter( + (candidate): candidate is ReviewLookupCandidate => Boolean(candidate && candidate.id) + ); + const addressBuckets = new Map(); + const addresslessIndices: number[] = []; + + entries.forEach((candidate, index) => { + const addresses = prepareCandidateLookupAddresses(candidate).addresses; + if (addresses.size === 0) { + addresslessIndices.push(index); + return; + } + + addresses.forEach((address) => { + const bucket = addressBuckets.get(address); + if (bucket) bucket.push(index); + else addressBuckets.set(address, [index]); + }); + }); + + // 맛집마다 합산한 id를 새로 만들지 않도록 집합 하나를 돌려 씁니다(맛집 수만큼 할당하지 않는다). + const countedIds = new Set(); + // 주소가 여러 개인 후보는 여러 버킷에 들어 있으므로, 맛집마다 한 번만 보도록 표시합니다. + const seenStamps = new Int32Array(entries.length); + let stamp = 0; + + return (restaurant, directCountMap) => { + if (!restaurant) return 0; + + countedIds.clear(); + stamp += 1; + let total = 0; + + const addId = (id: string | null | undefined) => { + if (!id || countedIds.has(id)) return; + countedIds.add(id); + total += directCountMap.get(id) ?? 0; + }; + + // 직접 ID(맛집 자신과 병합 레코드)는 후보 조회 없이도 항상 관련 id입니다. + addId(restaurant.id); + if (Array.isArray(restaurant.mergedRestaurants)) { + for (const mergedRestaurant of restaurant.mergedRestaurants) addId(mergedRestaurant?.id); + } + + if (entries.length === 0) return total; + + const lookupAddresses = prepareRestaurantLookupAddresses(restaurant).addresses; + const lookupNames = prepareLookupNames(restaurant); + const hasLookupNames = lookupNames.names.length > 0; + + const considerCandidate = (index: number) => { + if (seenStamps[index] === stamp) return; + seenStamps[index] = stamp; + + const candidate = entries[index]; + reviewLookupPerfCounters.candidateVisits += 1; + if (countedIds.has(candidate.id)) return; + if (!hasLookupNames) { + addId(candidate.id); + return; + } + + const candidateNames = prepareLookupNames(candidate); + if (candidateNames.names.length === 0) { + addId(candidate.id); + return; + } + + reviewLookupPerfCounters.nameGates += 1; + if (hasCompatibleLookupName(lookupNames, candidateNames)) addId(candidate.id); + }; + + // 맛집 주소가 없으면 선형 경로와 같게 주소 없는 후보만 통과시킵니다. + if (lookupAddresses.size === 0) { + for (const index of addresslessIndices) considerCandidate(index); + return total; + } + + for (const address of lookupAddresses) { + const bucket = addressBuckets.get(address); + if (!bucket) continue; + for (const index of bucket) considerCandidate(index); + } + + return total; + }; +} + // 후보의 주소 집합에 해당하는 승인 맛집 색인을 오름차순으로 하나씩 넘겨줍니다. // visit가 true를 돌려주면 순회를 멈춥니다. 각 주소 버킷은 색인 순서대로 쌓여 있어 정렬이 필요 없습니다. function forEachCandidateIndex( diff --git a/apps/web/performance/verified-review-count-index-20260922/benchmark-verified-review-count-index.mjs b/apps/web/performance/verified-review-count-index-20260922/benchmark-verified-review-count-index.mjs new file mode 100644 index 0000000000..da7faa9a66 --- /dev/null +++ b/apps/web/performance/verified-review-count-index-20260922/benchmark-verified-review-count-index.mjs @@ -0,0 +1,269 @@ +// 승인 리뷰 수 집계가 맛집마다 후보 전체를 다시 훑는 비용을 색인 도입 전후로 비교하는 벤치마크. +// 실행: bun apps/web/performance/verified-review-count-index-20260922/benchmark-verified-review-count-index.mjs +// +// 비교 대상(둘 다 같은 승인 리뷰 수를 내야 한다) +// - S0-linear: selectRelatedRestaurantReviewIds + 합산. 맛집 하나를 볼 때마다 후보(같은 이름으로 조회한 +// 맛집 행) 전체를 순회한다. 이번 변경 이전 lib/restaurant-review-counts.ts가 쓰던 경로이며, +// selectRelatedRestaurantReviewIds는 참조 구현으로 그대로 남아 있다(무수정). +// - S1-indexed: lib/restaurant-review-lookup.ts의 createRelatedVerifiedReviewCountLookup. +// 후보를 주소별로 한 번만 색인하고, 맛집마다 색인에 걸린 후보만 본다. +// +// 시나리오 +// - unique-address: 실제 데이터 모양(맛집마다 삭제 중복 후보가 같은 주소에 하나씩). +// - shared-address: 모든 후보가 한 주소를 공유하는 퇴화 모양(색인이 줄일 수 없는 최악의 경우). +// +// 측정 항목 +// - 결과 동등성(맛집 순서대로의 승인 리뷰 수 지문과 합계) +// - 결정적 연산 카운터(후보 검사 수, 이름 게이트 평가 수) +// - 목록 한 번 집계 시간(중앙값, p95, MAD) +import { writeFileSync } from 'node:fs'; +import { buildRelatedVerifiedReviewCountMap } from '../../lib/restaurant-review-counts'; +import { + getReviewLookupPerfCounters, + resetReviewLookupPerfCounters, + selectRelatedRestaurantReviewIds, +} from '../../lib/restaurant-review-lookup'; + +const MERGED_ROWS = 1000; +const DUPLICATE_CANDIDATES = 1000; +const UNRELATED_CANDIDATES = 200; +const REPETITIONS = 21; +// 표본 하나를 짧게 재면 타이머 분해능과 GC 때문에 상대 노이즈가 커진다. 특히 색인 경로는 한 번에 +// 1ms 아래로 끝나 표본 하나가 몇 ms에 불과하므로, 표본마다 같은 작업을 20번 돌리고 다시 실행 +// 하나의 시간으로 나눠 보고한다. 선형 경로도 같은 표본 길이를 쓰므로 두 경로의 노이즈 조건이 같다. +const SAMPLE_LOOPS = 20; + +export const VERIFIED_REVIEW_COUNT_INDEX_BUDGETS = Object.freeze({ + absolute: Object.freeze({ + realisticMedianMsMax: 40, + degenerateMedianMsMax: 200, + rule: 'unique-address(실제 데이터 모양)의 목록 한 번 집계 중앙값은 40ms 이하, shared-address(퇴화 모양)는 후보 1000개가 한 주소에 몰려 색인이 줄일 여지가 없는 스트레스 모양이므로 별도로 200ms 이하를 기준으로 둡니다.', + }), + relative: Object.freeze({ + minMedianSpeedup: 3, + degenerateMinMedianSpeedup: 1, + rule: 'unique-address는 선형 경로 대비 중앙값 3배 이상, 퇴화 모양(shared-address)은 색인이 줄일 여지가 없으므로 1배 이상(느려지지 않음)이면 인정합니다.', + }), + noise: Object.freeze({ + madRelativeMax: 0.15, + repetitions: REPETITIONS, + rule: '각 경로의 반복 표본 MAD/중앙값이 15% 이내이고, 중앙값 개선폭이 양쪽 상대 노이즈 합보다 클 때만 개선으로 인정합니다.', + }), +}); + +function makeRows({ sharedAddress }) { + const restaurants = []; + const candidates = []; + + for (let index = 0; index < MERGED_ROWS; index += 1) { + const address = sharedAddress ? '서울 중구 쯔동로 1' : '서울 중구 쯔동로 ' + index; + restaurants.push({ + id: 'approved-' + index, + name: '쯔동분식 ' + index, + approved_name: '쯔동분식 ' + index, + road_address: address, + jibun_address: address, + mergedRestaurants: [], + }); + } + + for (let index = 0; index < DUPLICATE_CANDIDATES; index += 1) { + const address = sharedAddress ? '서울 중구 쯔동로 1' : '서울 중구 쯔동로 ' + index; + candidates.push({ + id: 'deleted-' + index, + name: '쯔동분식 ' + index + ' 지점', + approved_name: '쯔동분식 ' + index + ' 지점', + road_address: address, + jibun_address: address, + }); + } + + for (let index = 0; index < UNRELATED_CANDIDATES; index += 1) { + const address = '서울 중구 없는로 ' + index; + candidates.push({ + id: 'unrelated-' + index, + name: '없는집 ' + index, + approved_name: '없는집 ' + index, + road_address: address, + jibun_address: address, + }); + } + + const reviewRows = candidates.map((candidate) => ({ restaurant_id: candidate.id })); + return { restaurants, candidates, reviewRows }; +} + +function runLinear({ restaurants, candidates, reviewRows }) { + const directCountMap = new Map(); + for (const reviewRow of reviewRows) { + if (!reviewRow.restaurant_id) continue; + directCountMap.set(reviewRow.restaurant_id, (directCountMap.get(reviewRow.restaurant_id) ?? 0) + 1); + } + + return restaurants.map((restaurant) => { + const relatedIds = selectRelatedRestaurantReviewIds(restaurant, candidates); + return relatedIds.reduce((sum, id) => sum + (directCountMap.get(id) ?? 0), 0); + }); +} + +function runIndexed({ restaurants, candidates, reviewRows }) { + const countMap = buildRelatedVerifiedReviewCountMap(restaurants, candidates, reviewRows); + return restaurants.map((restaurant) => countMap.get(restaurant.id) ?? 0); +} + +function fingerprint(counts) { + let hash = 0; + for (const count of counts) hash = (hash * 31 + count + 1) % 4294967296; + return hash.toString(16); +} + +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)]; +} + +function madRelative(values) { + const center = median(values); + return median(values.map((value) => Math.abs(value - center))) / center; +} + +function measure(run) { + run(); + const samples = []; + for (let repetition = 0; repetition < REPETITIONS; repetition += 1) { + const started = performance.now(); + for (let loop = 0; loop < SAMPLE_LOOPS; loop += 1) run(); + samples.push((performance.now() - started) / SAMPLE_LOOPS); + } + const ordered = [...samples].sort((left, right) => left - right); + return { + medianMs: median(samples), + p95Ms: ordered[Math.floor(ordered.length * 0.95)], + madRelative: madRelative(samples), + }; +} + +function countLinear(workload) { + resetReviewLookupPerfCounters(); + const results = runLinear(workload); + return { results, counters: getReviewLookupPerfCounters() }; +} + +function countIndexed(workload) { + resetReviewLookupPerfCounters(); + const results = runIndexed(workload); + return { results, counters: getReviewLookupPerfCounters() }; +} + +const scenarios = []; + +for (const scenario of [ + { id: 'unique-address', sharedAddress: false, degenerate: false, description: '맛집마다 삭제 중복 후보가 같은 주소에 하나씩 있는 실제 데이터 모양' }, + { id: 'shared-address', sharedAddress: true, degenerate: true, description: '모든 후보가 한 주소를 공유하는 퇴화 모양(색인이 줄일 수 없는 최악의 경우)' }, +]) { + const workload = makeRows({ sharedAddress: scenario.sharedAddress }); + + const before = countLinear(workload); + const after = countIndexed(workload); + + const beforeFingerprint = fingerprint(before.results); + const afterFingerprint = fingerprint(after.results); + const equivalent = beforeFingerprint === afterFingerprint + && before.results.length === after.results.length; + + const timingBefore = measure(() => runLinear(workload)); + const timingAfter = measure(() => runIndexed(workload)); + + const relativeImprovement = timingBefore.medianMs / timingAfter.medianMs; + const combinedRelativeNoise = timingBefore.madRelative + timingAfter.madRelative; + const requiredSpeedup = scenario.degenerate + ? VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.relative.degenerateMinMedianSpeedup + : VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.relative.minMedianSpeedup; + const acceptance = { + absoluteMedianMsMax: scenario.degenerate + ? VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.absolute.degenerateMedianMsMax + : VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.absolute.realisticMedianMsMax, + absoluteBudgetMet: timingAfter.medianMs <= (scenario.degenerate + ? VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.absolute.degenerateMedianMsMax + : VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.absolute.realisticMedianMsMax), + relativeBudgetMet: relativeImprovement >= requiredSpeedup, + requiredSpeedup, + noiseWithinBudget: + timingBefore.madRelative <= VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.noise.madRelativeMax + && timingAfter.madRelative <= VERIFIED_REVIEW_COUNT_INDEX_BUDGETS.noise.madRelativeMax, + combinedRelativeNoise, + relativeImprovement, + deltaExceedsNoise: Math.abs(relativeImprovement - 1) > combinedRelativeNoise, + }; + acceptance.accepted = equivalent + && acceptance.absoluteBudgetMet + && acceptance.relativeBudgetMet + && acceptance.noiseWithinBudget + && acceptance.deltaExceedsNoise; + + scenarios.push({ + id: scenario.id, + description: scenario.description, + equivalence: equivalent ? 'identical' : 'mismatch', + equivalenceDetail: [ + { id: 'S0-linear', countTotal: before.results.reduce((sum, value) => sum + value, 0), fingerprint: beforeFingerprint }, + { id: 'S1-indexed', countTotal: after.results.reduce((sum, value) => sum + value, 0), fingerprint: afterFingerprint }, + ], + counters: [ + { + id: 'S0-linear', + candidateVisits: before.counters.candidateVisits, + nameGates: before.counters.nameGates, + addressGates: before.counters.addressGates, + }, + { + id: 'S1-indexed', + candidateVisits: after.counters.candidateVisits, + nameGates: after.counters.nameGates, + addressGates: after.counters.addressGates, + }, + ], + measurements: [ + { id: 'S0-linear', ...timingBefore }, + { id: 'S1-indexed', ...timingAfter }, + ], + acceptance, + ratio: { + speedupByMedian: relativeImprovement, + speedupByP95: timingBefore.p95Ms / timingAfter.p95Ms, + candidateVisitReduction: before.counters.candidateVisits / Math.max(after.counters.candidateVisits, 1), + nameGateReduction: before.counters.nameGates / Math.max(after.counters.nameGates, 1), + }, + }); +} + +const report = { + generatedAt: new Date().toISOString(), + baselineSource: { + file: 'apps/web/lib/restaurant-review-lookup.ts (selectRelatedRestaurantReviewIds + 합산)', + note: '기준 경로는 손으로 다시 쓴 근사가 아니라 이번 변경에서 무수정으로 남은 참조 구현입니다. 변경은 색인 조회 함수 추가와 restaurant-review-counts.ts의 호출 교체뿐이며, 집계 값 동일성은 tests-unit/restaurant-review-counts-index.test.ts가 확인합니다.', + }, + workload: { + mergedRows: MERGED_ROWS, + duplicateCandidates: DUPLICATE_CANDIDATES, + unrelatedCandidates: UNRELATED_CANDIDATES, + repetitions: REPETITIONS, + sampleLoops: SAMPLE_LOOPS, + }, + budgets: VERIFIED_REVIEW_COUNT_INDEX_BUDGETS, + scenarios, +}; +writeFileSync(new URL('./benchmark.json', import.meta.url), JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify(report.scenarios.map((scenario) => ({ + id: scenario.id, + equivalence: scenario.equivalence, + countTotal: scenario.equivalenceDetail.map((detail) => detail.countTotal), + candidateVisits: scenario.counters.map((counter) => counter.candidateVisits), + nameGates: scenario.counters.map((counter) => counter.nameGates), + medianMs: scenario.measurements.map((measurement) => +measurement.medianMs.toFixed(3)), + p95Ms: scenario.measurements.map((measurement) => +measurement.p95Ms.toFixed(3)), + madRelative: scenario.measurements.map((measurement) => +measurement.madRelative.toFixed(4)), + speedupByMedian: +scenario.ratio.speedupByMedian.toFixed(2), + candidateVisitReduction: +scenario.ratio.candidateVisitReduction.toFixed(1), + accepted: scenario.acceptance.accepted, +})), null, 2)); diff --git a/apps/web/performance/verified-review-count-index-20260922/benchmark.json b/apps/web/performance/verified-review-count-index-20260922/benchmark.json new file mode 100644 index 0000000000..4f9cdff159 --- /dev/null +++ b/apps/web/performance/verified-review-count-index-20260922/benchmark.json @@ -0,0 +1,157 @@ +{ + "generatedAt": "2026-09-22T03:07:02.370Z", + "baselineSource": { + "file": "apps/web/lib/restaurant-review-lookup.ts (selectRelatedRestaurantReviewIds + 합산)", + "note": "기준 경로는 손으로 다시 쓴 근사가 아니라 이번 변경에서 무수정으로 남은 참조 구현입니다. 변경은 색인 조회 함수 추가와 restaurant-review-counts.ts의 호출 교체뿐이며, 집계 값 동일성은 tests-unit/restaurant-review-counts-index.test.ts가 확인합니다." + }, + "workload": { + "mergedRows": 1000, + "duplicateCandidates": 1000, + "unrelatedCandidates": 200, + "repetitions": 21, + "sampleLoops": 20 + }, + "budgets": { + "absolute": { + "realisticMedianMsMax": 40, + "degenerateMedianMsMax": 200, + "rule": "unique-address(실제 데이터 모양)의 목록 한 번 집계 중앙값은 40ms 이하, shared-address(퇴화 모양)는 후보 1000개가 한 주소에 몰려 색인이 줄일 여지가 없는 스트레스 모양이므로 별도로 200ms 이하를 기준으로 둡니다." + }, + "relative": { + "minMedianSpeedup": 3, + "degenerateMinMedianSpeedup": 1, + "rule": "unique-address는 선형 경로 대비 중앙값 3배 이상, 퇴화 모양(shared-address)은 색인이 줄일 여지가 없으므로 1배 이상(느려지지 않음)이면 인정합니다." + }, + "noise": { + "madRelativeMax": 0.15, + "repetitions": 21, + "rule": "각 경로의 반복 표본 MAD/중앙값이 15% 이내이고, 중앙값 개선폭이 양쪽 상대 노이즈 합보다 클 때만 개선으로 인정합니다." + } + }, + "scenarios": [ + { + "id": "unique-address", + "description": "맛집마다 삭제 중복 후보가 같은 주소에 하나씩 있는 실제 데이터 모양", + "equivalence": "identical", + "equivalenceDetail": [ + { + "id": "S0-linear", + "countTotal": 1000, + "fingerprint": "dc299d00" + }, + { + "id": "S1-indexed", + "countTotal": 1000, + "fingerprint": "dc299d00" + } + ], + "counters": [ + { + "id": "S0-linear", + "candidateVisits": 1200000, + "nameGates": 1000, + "addressGates": 1200000 + }, + { + "id": "S1-indexed", + "candidateVisits": 1000, + "nameGates": 1000, + "addressGates": 0 + } + ], + "measurements": [ + { + "id": "S0-linear", + "medianMs": 64.33251664999989, + "p95Ms": 77.26857499999997, + "madRelative": 0.050069709188033475 + }, + { + "id": "S1-indexed", + "medianMs": 0.6572125000000597, + "p95Ms": 0.7783541500000866, + "madRelative": 0.05290648001995053 + } + ], + "acceptance": { + "absoluteMedianMsMax": 40, + "absoluteBudgetMet": true, + "relativeBudgetMet": true, + "requiredSpeedup": 3, + "noiseWithinBudget": true, + "combinedRelativeNoise": 0.10297618920798401, + "relativeImprovement": 97.88693405860973, + "deltaExceedsNoise": true, + "accepted": true + }, + "ratio": { + "speedupByMedian": 97.88693405860973, + "speedupByP95": 99.27174538735532, + "candidateVisitReduction": 1200, + "nameGateReduction": 1 + } + }, + { + "id": "shared-address", + "description": "모든 후보가 한 주소를 공유하는 퇴화 모양(색인이 줄일 수 없는 최악의 경우)", + "equivalence": "identical", + "equivalenceDetail": [ + { + "id": "S0-linear", + "countTotal": 2890, + "fingerprint": "a3855a2e" + }, + { + "id": "S1-indexed", + "countTotal": 2890, + "fingerprint": "a3855a2e" + } + ], + "counters": [ + { + "id": "S0-linear", + "candidateVisits": 1200000, + "nameGates": 1000000, + "addressGates": 1200000 + }, + { + "id": "S1-indexed", + "candidateVisits": 1000000, + "nameGates": 1000000, + "addressGates": 0 + } + ], + "measurements": [ + { + "id": "S0-linear", + "medianMs": 173.66708335000004, + "p95Ms": 191.18077919999996, + "madRelative": 0.024079236659786382 + }, + { + "id": "S1-indexed", + "medianMs": 155.84441249999946, + "p95Ms": 172.37111250000015, + "madRelative": 0.06950468403864354 + } + ], + "acceptance": { + "absoluteMedianMsMax": 200, + "absoluteBudgetMet": true, + "relativeBudgetMet": true, + "requiredSpeedup": 1, + "noiseWithinBudget": true, + "combinedRelativeNoise": 0.09358392069842993, + "relativeImprovement": 1.114361949614335, + "deltaExceedsNoise": true, + "accepted": true + }, + "ratio": { + "speedupByMedian": 1.114361949614335, + "speedupByP95": 1.1091230800056464, + "candidateVisitReduction": 1.2, + "nameGateReduction": 1 + } + } + ] +} diff --git a/apps/web/tests-unit/restaurant-review-counts-index.test.ts b/apps/web/tests-unit/restaurant-review-counts-index.test.ts new file mode 100644 index 0000000000..85e3f349a7 --- /dev/null +++ b/apps/web/tests-unit/restaurant-review-counts-index.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from 'bun:test'; + +import type { Restaurant } from '../types/restaurant'; +import { buildRelatedVerifiedReviewCountMap } from '../lib/restaurant-review-counts'; +import { + getReviewLookupPerfCounters, + resetReviewLookupPerfCounters, + selectRelatedRestaurantReviewIds, +} from '../lib/restaurant-review-lookup'; + +// 색인 기반 리뷰 수 집계가 선형 집계(selectRelatedRestaurantReviewIds + 합산)와 맛집마다 같은 값을 +// 내는지 확인합니다. 도장 카드의 승인 리뷰 수가 이 값으로 표시되므로 값이 하나라도 달라지면 화면이 달라집니다. +type Row = Pick< + Restaurant, + 'id' | 'name' | 'approved_name' | 'road_address' | 'jibun_address' | 'mergedRestaurants' +>; + +type ReviewRow = { restaurant_id: string | null }; + +function row( + id: string, + name: string, + roadAddress: string | null, + jibunAddress: string | null = roadAddress, + mergedRestaurants?: Row[] +): Row { + return { + id, + name, + approved_name: name, + road_address: roadAddress, + jibun_address: jibunAddress, + mergedRestaurants, + } as Row; +} + +function referenceCountMap(restaurants: Row[], candidates: Row[], reviewRows: ReviewRow[] | null) { + const directCountMap = new Map(); + (reviewRows ?? []).forEach((reviewRow) => { + if (!reviewRow.restaurant_id) return; + directCountMap.set(reviewRow.restaurant_id, (directCountMap.get(reviewRow.restaurant_id) ?? 0) + 1); + }); + + return new Map(restaurants.map((restaurant) => { + const relatedIds = selectRelatedRestaurantReviewIds(restaurant as never, candidates as never); + const count = relatedIds.reduce((sum, id) => sum + (directCountMap.get(id) ?? 0), 0); + return [restaurant.id, count]; + })); +} + +function expectSameCountMap(restaurants: Row[], candidates: Row[], reviewRows: ReviewRow[] | null) { + const indexed = [...buildRelatedVerifiedReviewCountMap(restaurants as never, candidates as never, reviewRows).entries()]; + const linear = [...referenceCountMap(restaurants, candidates, reviewRows).entries()]; + + expect(indexed).toEqual(linear); + return new Map(indexed); +} + +describe('related verified review count index', () => { + test('삭제 중복 레코드에 붙은 리뷰를 승인 카드에 합산한다', () => { + const approved = row('approved-1', '데일리픽스 강남본점', '서울 강남구 논현로 70'); + const duplicate = row('deleted-1', '데일리픽스 강남본점', '서울 강남구 논현로 70'); + + const counts = expectSameCountMap( + [approved], + [approved, duplicate], + [{ restaurant_id: 'deleted-1' }, { restaurant_id: 'deleted-1' }] + ); + + expect(counts.get('approved-1')).toBe(2); + }); + + test('이름이 같아도 주소가 다르면 합산하지 않는다', () => { + const approved = row('approved-2', '데일리픽스 강남본점', '서울 강남구 논현로 70'); + const otherBranch = row('deleted-2', '데일리픽스 강남본점', '서울 강남구 다른길 1'); + + const counts = expectSameCountMap([approved], [approved, otherBranch], [{ restaurant_id: 'deleted-2' }]); + + expect(counts.get('approved-2')).toBe(0); + }); + + test('병합 레코드의 리뷰도 합산한다', () => { + const merged = row('merged-3', '쯔동분식 3', '서울 중구 쯔동로 3'); + const approved = row('approved-3', '쯔동분식 3', '서울 중구 쯔동로 3', '서울 중구 쯔동로 3', [merged]); + + const counts = expectSameCountMap([approved], [], [{ restaurant_id: 'merged-3' }]); + + expect(counts.get('approved-3')).toBe(1); + }); + + test('같은 id가 직접 id와 후보 양쪽에 있어도 한 번만 합산한다', () => { + const approved = row('approved-4', '쯔동분식 4', '서울 중구 쯔동로 4'); + + const counts = expectSameCountMap( + [approved], + [approved, approved], + [{ restaurant_id: 'approved-4' }, { restaurant_id: 'approved-4' }] + ); + + expect(counts.get('approved-4')).toBe(2); + }); + + test('주소 없는 맛집은 주소 없는 후보의 리뷰만 합산한다', () => { + const addressless = row('approved-5', '쯔동분식 5', null); + const addresslessCandidate = row('deleted-5', '쯔동분식 5', null); + const addressedCandidate = row('deleted-5b', '쯔동분식 5', '서울 중구 쯔동로 5'); + + const counts = expectSameCountMap( + [addressless], + [addresslessCandidate, addressedCandidate], + [{ restaurant_id: 'deleted-5' }, { restaurant_id: 'deleted-5b' }] + ); + + expect(counts.get('approved-5')).toBe(1); + }); + + test('이름이 비어 있으면 주소만 맞아도 합산한다', () => { + const approved = row('approved-6', '', '서울 중구 쯔동로 6'); + const candidate = row('deleted-6', '', '서울 중구 쯔동로 6'); + + const counts = expectSameCountMap([approved], [candidate], [{ restaurant_id: 'deleted-6' }]); + + expect(counts.get('approved-6')).toBe(1); + }); + + test('리뷰 행이 없으면 모두 0이다', () => { + const approved = row('approved-7', '쯔동분식 7', '서울 중구 쯔동로 7'); + const duplicate = row('deleted-7', '쯔동분식 7 지점', '서울 중구 쯔동로 7'); + + expect(expectSameCountMap([approved], [duplicate], null).get('approved-7')).toBe(0); + expect(expectSameCountMap([approved], [duplicate], []).get('approved-7')).toBe(0); + expect([...expectSameCountMap([], [], null).entries()]).toEqual([]); + }); + + test('restaurant_id가 없는 리뷰 행은 어느 맛집에도 합산하지 않는다', () => { + const approved = row('approved-8', '쯔동분식 8', '서울 중구 쯔동로 8'); + + expect(expectSameCountMap([approved], [approved], [{ restaurant_id: null }]).get('approved-8')).toBe(0); + }); + + test('무작위 조합에서 선형 집계와 항상 같은 값을 낸다', () => { + const names = ['쯔동분식', '쯔동분식 1', '쯔동분식 1 지점', '다른집', '', '쯔양국밥']; + const addresses = ['서울 중구 쯔동로 1', '서울 중구 쯔동로 2', '부산 해운대구 쯔동로 1', null]; + const pick = (values: T[], index: number) => values[index % values.length] as T; + + let seed = 20260922; + const nextRandom = () => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed / 2147483648; + }; + + for (let round = 0; round < 200; round += 1) { + const restaurants: Row[] = []; + const restaurantCount = 1 + Math.floor(nextRandom() * 12); + for (let index = 0; index < restaurantCount; index += 1) { + const address = pick(addresses, Math.floor(nextRandom() * addresses.length)); + const merged = nextRandom() < 0.3 + ? [row('merged-' + round + '-' + index, pick(names, Math.floor(nextRandom() * names.length)), address)] + : undefined; + restaurants.push(row( + 'approved-' + round + '-' + index, + pick(names, Math.floor(nextRandom() * names.length)), + address, + address, + merged + )); + } + + const candidates: Row[] = []; + const candidateCount = Math.floor(nextRandom() * 10); + for (let index = 0; index < candidateCount; index += 1) { + const address = pick(addresses, Math.floor(nextRandom() * addresses.length)); + candidates.push(row( + 'candidate-' + round + '-' + index, + pick(names, Math.floor(nextRandom() * names.length)), + address + )); + } + if (candidates.length > 0 && nextRandom() < 0.5) { + candidates.push(candidates[0] as Row); + } + if (restaurants.length > 0 && nextRandom() < 0.4) { + candidates.push(restaurants[0] as Row); + } + + const reviewRows: ReviewRow[] = []; + const reviewCount = Math.floor(nextRandom() * 12); + for (let index = 0; index < reviewCount; index += 1) { + const pool = [...restaurants, ...candidates]; + if (pool.length === 0) break; + const target = pool[Math.floor(nextRandom() * pool.length)] as Row; + reviewRows.push({ restaurant_id: nextRandom() < 0.1 ? null : target.id }); + } + + expectSameCountMap(restaurants, candidates, reviewRows); + } + }); + + test('색인 경로가 실제로 검사하는 후보 수가 선형 경로보다 훨씬 적다', () => { + const restaurants: Row[] = []; + const candidates: Row[] = []; + for (let index = 0; index < 400; index += 1) { + const address = '서울 중구 쯔동로 ' + index; + restaurants.push(row('approved-' + index, '쯔동분식 ' + index, address)); + candidates.push(row('deleted-' + index, '쯔동분식 ' + index + ' 지점', address)); + } + const reviewRows: ReviewRow[] = candidates.map((candidate) => ({ restaurant_id: candidate.id })); + + resetReviewLookupPerfCounters(); + const linear = referenceCountMap(restaurants, candidates, reviewRows); + const linearVisits = getReviewLookupPerfCounters().candidateVisits; + + resetReviewLookupPerfCounters(); + const indexed = buildRelatedVerifiedReviewCountMap(restaurants as never, candidates as never, reviewRows); + const indexedVisits = getReviewLookupPerfCounters().candidateVisits; + + expect([...indexed.entries()]).toEqual([...linear.entries()]); + expect(indexed.get('approved-399')).toBe(1); + expect(linearVisits).toBe(restaurants.length * candidates.length); + expect(indexedVisits).toBeLessThan(linearVisits / 10); + }); +}); +