diff --git a/apps/web/app/mypage/reviews/page.tsx b/apps/web/app/mypage/reviews/page.tsx index c9e96691c..f8fa188c6 100644 --- a/apps/web/app/mypage/reviews/page.tsx +++ b/apps/web/app/mypage/reviews/page.tsx @@ -41,7 +41,7 @@ import { myPageListContentClass, myPageResponsiveListClass, } from "@/components/mypage/MyPageSectionFrame"; -import { findCanonicalVisitedRestaurant } from "@/lib/restaurant-visit-matching"; +import { createCanonicalVisitedLookup } from "@/lib/restaurant-visit-matching"; import type { Restaurant } from "@/types/restaurant"; const REVIEW_DELETE_CONFIRMATION = "리뷰삭제"; @@ -222,6 +222,11 @@ export default function ReviewsPage() { : { data: [] }; const approvedRestaurants = approvedRestaurantRows || []; + // 리뷰 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다. + const resolveCanonicalRestaurant = createCanonicalVisitedLookup( + approvedRestaurants as Restaurant[], + ); + // 3. 리뷰 데이터 매핑 const reviews: MyReview[] = reviewsData.map((review) => { const reviewedRestaurant = @@ -229,11 +234,10 @@ export default function ReviewsPage() { const canonicalRestaurant = reviewedRestaurant?.status === "approved" ? reviewedRestaurant - : ((findCanonicalVisitedRestaurant({ - reviewedRestaurant: reviewedRestaurant as Restaurant | null, - reviewedRestaurantId: review.restaurant_id, - approvedRestaurants: approvedRestaurants as Restaurant[], - }) as RestaurantData | null) ?? reviewedRestaurant); + : ((resolveCanonicalRestaurant( + reviewedRestaurant as Restaurant | null, + review.restaurant_id, + ) as RestaurantData | null) ?? reviewedRestaurant); return { id: review.id, diff --git a/apps/web/components/feed/FeedContent.tsx b/apps/web/components/feed/FeedContent.tsx index 66fd50568..e5f243f29 100644 --- a/apps/web/components/feed/FeedContent.tsx +++ b/apps/web/components/feed/FeedContent.tsx @@ -16,7 +16,7 @@ import { FeedSkeleton } from "@/components/ui/skeleton-loaders"; import { useReviewLikesRealtime } from '@/hooks/use-review-likes-realtime'; import { ReviewCard } from '@/components/reviews/ReviewCard'; import { useMobileBottomNavAutoHide } from '@/hooks/use-mobile-bottom-nav-auto-hide'; -import { findCanonicalVisitedRestaurant } from '@/lib/restaurant-visit-matching'; +import { createCanonicalVisitedLookup } from '@/lib/restaurant-visit-matching'; import { readPublicProfileSummariesLookup, resolvePublicReviewerDisplay } from '@/lib/public-profile-read'; import { describeErrorCodeForLog } from '@/lib/debug-log'; @@ -316,15 +316,13 @@ export default function FeedContent({ const approvedRestaurants = ((approvedRestaurantRowsRaw ?? []) as FeedRestaurantRecord[]) .map(normalizeFeedRestaurantRecord); + // 리뷰 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다. + const resolveCanonicalFeedRestaurant = createCanonicalVisitedLookup(approvedRestaurants as never); const resolveFeedRestaurant = (reviewRow: FeedReviewRow) => { const reviewedRestaurant = restaurantsMap.get(reviewRow.restaurant_id) ?? null; if (reviewedRestaurant?.status === 'approved') return reviewedRestaurant; - return findCanonicalVisitedRestaurant({ - reviewedRestaurant: reviewedRestaurant as never, - reviewedRestaurantId: reviewRow.restaurant_id, - approvedRestaurants: approvedRestaurants as never, - }) as FeedRestaurantRecord | null ?? reviewedRestaurant; + return resolveCanonicalFeedRestaurant(reviewedRestaurant as never, reviewRow.restaurant_id) as FeedRestaurantRecord | null ?? reviewedRestaurant; }; let userLikesMap = new Map(); diff --git a/apps/web/hooks/use-bookmarks.tsx b/apps/web/hooks/use-bookmarks.tsx index 2f018efc7..7566a92de 100644 --- a/apps/web/hooks/use-bookmarks.tsx +++ b/apps/web/hooks/use-bookmarks.tsx @@ -3,7 +3,7 @@ import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/contexts/AuthContext"; import { useCallback, useMemo } from "react"; import type { Restaurant } from "@/types/restaurant"; -import { findCanonicalVisitedRestaurant } from "@/lib/restaurant-visit-matching"; +import { createCanonicalVisitedLookup } from "@/lib/restaurant-visit-matching"; import { getRestaurantReviewLookupName, selectRelatedRestaurantReviewIds, @@ -93,17 +93,18 @@ async function fetchApprovedCandidatesByRestaurantNames(restaurants: Restaurant[ })); } +type CanonicalBookmarkedRestaurantResolver = ( + reviewedRestaurant: Restaurant | null, + reviewedRestaurantId: string +) => Restaurant | null; + function resolveCanonicalBookmarkedRestaurant( bookmarkedRestaurant: Restaurant, - approvedRestaurants: Restaurant[] + resolveCanonical: CanonicalBookmarkedRestaurantResolver ): Restaurant { if (bookmarkedRestaurant.status === 'approved') return bookmarkedRestaurant; - return (findCanonicalVisitedRestaurant({ - reviewedRestaurant: bookmarkedRestaurant, - reviewedRestaurantId: bookmarkedRestaurant.id, - approvedRestaurants, - }) as Restaurant | null) ?? bookmarkedRestaurant; + return resolveCanonical(bookmarkedRestaurant, bookmarkedRestaurant.id) ?? bookmarkedRestaurant; } async function fetchRelatedBookmarkRestaurantIds(restaurantId: string): Promise { @@ -165,9 +166,11 @@ export function useBookmarks(options: UseBookmarksOptions = {}) { // 3. 데이터 병합 const bookmarkedRestaurants = ((restaurantsData ?? []) as unknown as RestaurantRow[]).map(toRestaurant); const approvedRestaurants = await fetchApprovedCandidatesByRestaurantNames(bookmarkedRestaurants); + // 북마크 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다. + const resolveCanonical = createCanonicalVisitedLookup(approvedRestaurants) as CanonicalBookmarkedRestaurantResolver; const restaurantsMap = new Map(bookmarkedRestaurants.map((restaurant) => [ restaurant.id, - resolveCanonicalBookmarkedRestaurant(restaurant, approvedRestaurants), + resolveCanonicalBookmarkedRestaurant(restaurant, resolveCanonical), ])); return (bookmarksData as BookmarkRow[]) @@ -251,8 +254,10 @@ export function useBookmarkIds() { const bookmarkedRestaurants = ((restaurantsData ?? []) as unknown as RestaurantRow[]).map(toRestaurant); const approvedRestaurants = await fetchApprovedCandidatesByRestaurantNames(bookmarkedRestaurants); + // 북마크 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다. + const resolveCanonical = createCanonicalVisitedLookup(approvedRestaurants) as CanonicalBookmarkedRestaurantResolver; const canonicalIds = bookmarkedRestaurants - .map((restaurant) => resolveCanonicalBookmarkedRestaurant(restaurant, approvedRestaurants).id) + .map((restaurant) => resolveCanonicalBookmarkedRestaurant(restaurant, resolveCanonical).id) .filter(Boolean); return [...new Set([...bookmarkedRestaurantIds, ...canonicalIds])]; diff --git a/apps/web/hooks/useUserProfile.ts b/apps/web/hooks/useUserProfile.ts index 88e7bbe18..52026b401 100644 --- a/apps/web/hooks/useUserProfile.ts +++ b/apps/web/hooks/useUserProfile.ts @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import { Restaurant } from "@/types/restaurant"; import { Tables } from "@/integrations/supabase/types"; -import { findCanonicalVisitedRestaurant } from "@/lib/restaurant-visit-matching"; +import { createCanonicalVisitedLookup } from "@/lib/restaurant-visit-matching"; import { getRestaurantDisplayName, withRestaurantDisplayName } from "@/lib/restaurant-display-name"; import { readPublicProfileSummaries } from "@/lib/public-profile-read"; @@ -156,22 +156,23 @@ async function fetchApprovedCanonicalRestaurantCandidates(reviewedRestaurants: R return ((data ?? []) as Restaurant[]).map(withRestaurantDisplayName); } +type CanonicalVisitedRestaurantResolver = ( + reviewedRestaurant: Restaurant | null, + reviewedRestaurantId: string +) => Restaurant | null; + function resolveCanonicalReviewedRestaurant({ reviewedRestaurant, reviewedRestaurantId, - approvedRestaurants, + resolveCanonical, }: { reviewedRestaurant: Restaurant | null | undefined; reviewedRestaurantId: string; - approvedRestaurants: Restaurant[]; + resolveCanonical: CanonicalVisitedRestaurantResolver; }): Restaurant | null { if (reviewedRestaurant?.status === 'approved') return reviewedRestaurant; - return (findCanonicalVisitedRestaurant({ - reviewedRestaurant: reviewedRestaurant ?? null, - reviewedRestaurantId, - approvedRestaurants, - }) as Restaurant | null) ?? reviewedRestaurant ?? null; + return resolveCanonical(reviewedRestaurant ?? null, reviewedRestaurantId) ?? reviewedRestaurant ?? null; } // ============================================================================ @@ -347,6 +348,9 @@ export function useUserReviews(userId: string, viewerId?: string) { typedRestaurants as Restaurant[] ); + // 리뷰 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다. + const resolveCanonical = createCanonicalVisitedLookup(approvedRestaurants) as CanonicalVisitedRestaurantResolver; + const userLikedMap = new Map(); ((viewerLikesResult.data ?? []) as ReviewLikeRow[]).forEach(l => { userLikedMap.set(l.review_id, true); @@ -363,7 +367,7 @@ export function useUserReviews(userId: string, viewerId?: string) { const restaurant = resolveCanonicalReviewedRestaurant({ reviewedRestaurant, reviewedRestaurantId: r.restaurant_id, - approvedRestaurants, + resolveCanonical, }); return { @@ -508,16 +512,15 @@ export function useUserStamps(userId: string) { const approvedRestaurants = ((approvedRestaurantRows ?? []) as Restaurant[]) .map(withRestaurantDisplayName); + // 리뷰 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다. + const resolveCanonical = createCanonicalVisitedLookup(approvedRestaurants) as CanonicalVisitedRestaurantResolver; + // 4. 데이터 병합 return typedReviews.map((r) => { const reviewedRestaurant = restaurantMap.get(r.restaurant_id); const restaurant = reviewedRestaurant?.status === 'approved' ? reviewedRestaurant - : findCanonicalVisitedRestaurant({ - reviewedRestaurant: reviewedRestaurant ?? null, - reviewedRestaurantId: r.restaurant_id, - approvedRestaurants, - }) ?? reviewedRestaurant; + : resolveCanonical(reviewedRestaurant ?? null, r.restaurant_id) ?? reviewedRestaurant; // 맛집 정보가 없으면 스킵되어야 하지만, 일단 타입 안전을 위해 빈 객체 또는 처리 필요 if (!restaurant) return null; diff --git a/apps/web/lib/restaurant-review-lookup.ts b/apps/web/lib/restaurant-review-lookup.ts index 9b9025e27..21dcd571c 100644 --- a/apps/web/lib/restaurant-review-lookup.ts +++ b/apps/web/lib/restaurant-review-lookup.ts @@ -257,3 +257,131 @@ export function getRestaurantReviewLookupName(restaurant: ReviewLookupRestaurant const name = restaurant ? getLookupName(restaurant) : ''; return name || null; } + +// 승인 맛집 목록을 주소/직접 ID 기준으로 한 번만 색인해 두고, 리뷰 행마다 전체 목록을 다시 훑지 +// 않고 관련 맛집을 찾습니다. 선정 결과는 선형 탐색(selectRelatedRestaurantReviewIds + find)과 같아야 하며, +// apps/web/tests-unit/restaurant-visit-matching-index.test.ts에서 두 경로의 동일성을 확인합니다. +// +// 선형 탐색에서 결과가 될 수 있는 맛집은 두 종류뿐입니다. +// 1) 직접 ID(맛집 자신 또는 병합된 레코드의 id)에 찾는 리뷰 ID가 들어 있는 맛집 +// 2) 후보의 주소 게이트와 이름 게이트를 모두 통과하고, 후보 id가 찾는 리뷰 ID와 같은 맛집 +// 그래서 주소별 버킷과 직접 ID 버킷만 만들어 두면 나머지 맛집은 주소 게이트에서 이미 탈락한 것과 같습니다. +export function createRelatedRestaurantReviewIndex( + restaurants: ReviewLookupRestaurant[] +): (candidate: ReviewLookupCandidate | null, relatedReviewId: string | null | undefined) => ReviewLookupRestaurant | null { + const entries = (Array.isArray(restaurants) ? restaurants : []).filter(Boolean); + const addressBuckets = new Map(); + const addresslessIndices: number[] = []; + const directIdIndices = new Map(); + + entries.forEach((restaurant, index) => { + const addresses = prepareRestaurantLookupAddresses(restaurant).addresses; + if (addresses.size === 0) { + addresslessIndices.push(index); + } else { + addresses.forEach((address) => { + const bucket = addressBuckets.get(address); + if (bucket) bucket.push(index); + else addressBuckets.set(address, [index]); + }); + } + + collectDirectRestaurantReviewIds(restaurant).forEach((id) => { + if (!id) return; + const bucket = directIdIndices.get(id); + if (bucket) bucket.push(index); + else directIdIndices.set(id, [index]); + }); + }); + + return (candidate, relatedReviewId) => { + if (!relatedReviewId) return null; + + const directIndices = directIdIndices.get(relatedReviewId); + // 직접 ID 버킷은 색인 순서대로 쌓이므로 첫 항목이 가장 앞선 맛집입니다. + let bestIndex = directIndices ? directIndices[0] : undefined; + + // 후보 id가 찾는 리뷰 ID와 다르면 후보 경로는 결과에 아무것도 더하지 않습니다(선형 탐색과 동일). + if (candidate && candidate.id && candidate.id === relatedReviewId) { + const candidateAddresses = prepareCandidateLookupAddresses(candidate).addresses; + let candidateNames: PreparedLookupNames | null = null; + + // 주소 버킷을 전부 펼쳐 정렬하지 않고, 버킷 커서를 옮겨 가장 앞선 항목만 순서대로 봅니다. + // 가장 앞선 통과 항목을 찾는 즉시 멈추므로 버킷이 커도 검사 수가 늘지 않습니다. + forEachCandidateIndex(candidateAddresses, addressBuckets, addresslessIndices, (index) => { + // 직접 ID로 이미 앞선 맛집을 찾았다면 그 뒤 색인은 결과를 바꿀 수 없습니다. + // 선형 탐색도 그 지점에서 멈추므로, 색인 경로가 더 많이 검사하지 않도록 여기서 끝냅니다. + if (bestIndex !== undefined && index >= bestIndex) return true; + + reviewLookupPerfCounters.candidateVisits += 1; + + const lookupNames = prepareLookupNames(entries[index]); + if (lookupNames.names.length > 0) { + if (!candidateNames) candidateNames = prepareLookupNames(candidate); + if (candidateNames.names.length > 0) { + reviewLookupPerfCounters.nameGates += 1; + if (!hasCompatibleLookupName(lookupNames, candidateNames)) return false; + } + } + + if (bestIndex === undefined || index < bestIndex) bestIndex = index; + return true; + }); + } + + return bestIndex === undefined ? null : entries[bestIndex] ?? null; + }; +} + +// 후보의 주소 집합에 해당하는 승인 맛집 색인을 오름차순으로 하나씩 넘겨줍니다. +// visit가 true를 돌려주면 순회를 멈춥니다. 각 주소 버킷은 색인 순서대로 쌓여 있어 정렬이 필요 없습니다. +function forEachCandidateIndex( + candidateAddresses: Set, + addressBuckets: Map, + addresslessIndices: number[], + visit: (index: number) => boolean +): void { + if (candidateAddresses.size === 0) { + for (const index of addresslessIndices) { + if (visit(index)) return; + } + return; + } + + const buckets: number[][] = []; + candidateAddresses.forEach((address) => { + const bucket = addressBuckets.get(address); + if (bucket && bucket.length > 0) buckets.push(bucket); + }); + + if (buckets.length === 0) return; + if (buckets.length === 1) { + for (const index of buckets[0]) { + if (visit(index)) return; + } + return; + } + + const cursors = new Array(buckets.length).fill(0); + let lastVisited = -1; + for (;;) { + let chosen = -1; + let chosenIndex = Number.POSITIVE_INFINITY; + for (let bucketIndex = 0; bucketIndex < buckets.length; bucketIndex += 1) { + const cursor = cursors[bucketIndex]; + if (cursor >= buckets[bucketIndex].length) continue; + const candidateIndex = buckets[bucketIndex][cursor]; + if (candidateIndex < chosenIndex) { + chosenIndex = candidateIndex; + chosen = bucketIndex; + } + } + if (chosen === -1) return; + + cursors[chosen] += 1; + // 주소가 여러 개면 같은 맛집이 두 버킷에 들어 있을 수 있어 한 번만 봅니다. + if (chosenIndex === lastVisited) continue; + lastVisited = chosenIndex; + if (visit(chosenIndex)) return; + } +} diff --git a/apps/web/lib/restaurant-visit-matching.ts b/apps/web/lib/restaurant-visit-matching.ts index de8b4f81b..300b72e55 100644 --- a/apps/web/lib/restaurant-visit-matching.ts +++ b/apps/web/lib/restaurant-visit-matching.ts @@ -1,5 +1,5 @@ import type { Restaurant } from '@/types/restaurant'; -import { selectRelatedRestaurantReviewIds } from '@/lib/restaurant-review-lookup'; +import { createRelatedRestaurantReviewIndex, selectRelatedRestaurantReviewIds } from '@/lib/restaurant-review-lookup'; type VisitMatchRestaurant = Pick< Restaurant, @@ -51,3 +51,25 @@ export function findCanonicalVisitedRestaurant({ reviewedRestaurants, })) ?? null; } + +/** + * 같은 승인 맛집 목록으로 여러 리뷰 행을 해석할 때 쓰는 색인 기반 해석기입니다. + * + * findCanonicalVisitedRestaurant는 호출마다 승인 맛집 목록을 처음부터 끝까지 훑기 때문에, 리뷰 행마다 + * 호출하면 행 수 x 승인 맛집 수만큼 후보를 다시 봅니다. 목록을 주소/직접 ID로 한 번만 색인해 두면 + * 주소가 겹치지 않는 맛집은 후보가 되지 않아 같은 결과를 훨씬 적은 검사로 얻습니다. + * 선정 결과는 findCanonicalVisitedRestaurant와 같아야 하며, 두 경로의 동일성은 + * apps/web/tests-unit/restaurant-visit-matching-index.test.ts에서 확인합니다. + */ +export function createCanonicalVisitedLookup( + approvedRestaurants: VisitMatchRestaurant[] +): (reviewedRestaurant: VisitMatchCandidate | null, reviewedRestaurantId?: string | null) => VisitMatchRestaurant | null { + const findRelatedRestaurant = createRelatedRestaurantReviewIndex(approvedRestaurants); + + return (reviewedRestaurant, reviewedRestaurantId) => { + const relatedReviewId = reviewedRestaurantId || reviewedRestaurant?.id; + if (!relatedReviewId) return null; + + return findRelatedRestaurant(reviewedRestaurant, relatedReviewId); + }; +} diff --git a/apps/web/performance/review-canonical-index-20260922/benchmark-review-canonical-index.mjs b/apps/web/performance/review-canonical-index-20260922/benchmark-review-canonical-index.mjs new file mode 100644 index 000000000..45a4432c8 --- /dev/null +++ b/apps/web/performance/review-canonical-index-20260922/benchmark-review-canonical-index.mjs @@ -0,0 +1,265 @@ +// 리뷰 행마다 승인 맛집 목록을 처음부터 다시 훑는 비용을 색인 도입 전후로 비교하는 벤치마크. +// 실행: bun apps/web/performance/review-canonical-index-20260922/benchmark-review-canonical-index.mjs +// +// 비교 대상(둘 다 같은 맛집을 골라야 한다) +// - S0: 동결 사본(restaurant-visit-matching.pre-index.ts)의 findCanonicalVisitedRestaurant. +// 리뷰 행마다 승인 맛집 목록 전체를 선형 탐색한다. +// - S1: lib/restaurant-visit-matching.ts의 createCanonicalVisitedLookup. +// 주소/직접 ID 색인을 한 번 만들고 행마다 조회한다. +// +// 시나리오 +// - unique-address: 맛집마다 주소가 다른 실제 데이터 모양(중복 주소는 병합 레코드에서만 생긴다). +// - shared-address: 승인 맛집 전부가 한 주소를 공유하는 퇴화 모양. 색인이 줄일 수 없는 최악의 경우를 남긴다. +// +// 측정 항목 +// - 결과 동등성(행마다 고른 맛집 id 지문) +// - 결정적 연산 카운터(후보 검사 수, 이름 게이트 평가 수) +// - 한 번의 파이프라인 실행 시간(중앙값, p95, MAD) +import { writeFileSync } from 'node:fs'; +import { findCanonicalVisitedRestaurant as findCanonicalPreIndex } from './restaurant-visit-matching.pre-index'; +import { createCanonicalVisitedLookup } from '../../lib/restaurant-visit-matching'; +import { + getReviewLookupPerfCounters, + resetReviewLookupPerfCounters, +} from '../../lib/restaurant-review-lookup'; + +const APPROVED_COUNT = 1200; +const REVIEW_ROWS = 400; +const RESOLVING_ROWS = 300; +const REPETITIONS = 21; +// 표본 하나를 짧게 재면 타이머 분해능과 GC 때문에 상대 노이즈가 커진다. 표본마다 같은 작업을 +// 여러 번 돌리고 다시 실행 하나의 시간으로 나눠 보고한다. +const SAMPLE_LOOPS = 5; + +export const REVIEW_CANONICAL_INDEX_BUDGETS = Object.freeze({ + absolute: Object.freeze({ + pipelineMedianMsMax: 60, + rule: '색인 경로의 한 번의 파이프라인 실행 중앙값이 60ms 이하여야 합니다.', + }), + relative: Object.freeze({ + minMedianSpeedup: 2, + rule: '색인 이전 경로 대비 중앙값 2배 이상 빨라져야 개선으로 인정합니다.', + }), + noise: Object.freeze({ + madRelativeMax: 0.15, + repetitions: REPETITIONS, + rule: '각 경로의 반복 표본 MAD/중앙값이 15% 이내이고, 중앙값 개선폭이 양쪽 상대 노이즈 합보다 클 때만 개선으로 인정합니다.', + }), +}); + +function approvedRow(index, address) { + return { + id: 'approved-' + index, + name: '쯔동분식 ' + index, + approved_name: '쯔동분식 ' + index, + road_address: address, + jibun_address: address, + mergedRestaurants: [], + }; +} + +function reviewRow(index, address, name) { + return { + id: 'deleted-' + index, + name, + approved_name: name, + road_address: address, + jibun_address: address, + }; +} + +// 실제 데이터에서 리뷰는 삭제/미승인 중복 레코드에 붙어 있고, 승인 카드와 주소는 같고 이름만 +// 조금 다른 경우가 대부분이다. 나머지 행은 어떤 승인 맛집과도 겹치지 않아 결과가 없다. +function makeRows({ sharedAddress }) { + const approved = []; + for (let index = 0; index < APPROVED_COUNT; index += 1) { + const address = sharedAddress ? '서울 중구 쯔동로 1' : '서울 중구 쯔동로 ' + index; + approved.push(approvedRow(index, address)); + } + + const rows = []; + for (let index = 0; index < REVIEW_ROWS; index += 1) { + if (index < RESOLVING_ROWS) { + const target = index % APPROVED_COUNT; + const address = approved[target].road_address; + rows.push({ + reviewedRestaurant: reviewRow(index, address, approved[target].name + ' 지점'), + reviewedRestaurantId: 'deleted-' + index, + }); + } else { + const address = '서울 중구 없는로 ' + index; + rows.push({ + reviewedRestaurant: reviewRow(index, address, '없는집 ' + index + ' 지점'), + reviewedRestaurantId: 'deleted-' + index, + }); + } + } + + return { approved, rows }; +} + +function runPreIndex({ approved, rows }) { + return rows.map((row) => findCanonicalPreIndex({ + reviewedRestaurant: row.reviewedRestaurant, + reviewedRestaurantId: row.reviewedRestaurantId, + approvedRestaurants: approved, + })); +} + +function runIndexed({ approved, rows }) { + const resolve = createCanonicalVisitedLookup(approved); + return rows.map((row) => resolve(row.reviewedRestaurant, row.reviewedRestaurantId)); +} + +function fingerprint(results) { + let hash = 0; + for (const result of results) { + const id = result ? result.id : 'null'; + for (let index = 0; index < id.length; index += 1) { + hash = (hash * 31 + id.charCodeAt(index)) % 4294967296; + } + hash = (hash * 31 + 17) % 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 countPreIndex(workload) { + resetReviewLookupPerfCounters(); + const results = runPreIndex(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, description: '맛집마다 주소가 다른 실제 데이터 모양' }, + { id: 'shared-address', sharedAddress: true, description: '승인 맛집 전부가 한 주소를 공유하는 퇴화 모양' }, +]) { + const workload = makeRows({ sharedAddress: scenario.sharedAddress }); + + const before = countPreIndex(workload); + const after = countIndexed(workload); + + const beforeFingerprint = fingerprint(before.results); + const afterFingerprint = fingerprint(after.results); + const equivalent = beforeFingerprint === afterFingerprint; + + const timingBefore = measure(() => runPreIndex(workload)); + const timingAfter = measure(() => runIndexed(workload)); + + const relativeImprovement = timingBefore.medianMs / timingAfter.medianMs; + const combinedRelativeNoise = timingBefore.madRelative + timingAfter.madRelative; + const acceptance = { + absoluteBudgetMet: timingAfter.medianMs <= REVIEW_CANONICAL_INDEX_BUDGETS.absolute.pipelineMedianMsMax, + relativeBudgetMet: relativeImprovement >= REVIEW_CANONICAL_INDEX_BUDGETS.relative.minMedianSpeedup, + noiseWithinBudget: + timingBefore.madRelative <= REVIEW_CANONICAL_INDEX_BUDGETS.noise.madRelativeMax + && timingAfter.madRelative <= REVIEW_CANONICAL_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-pre-index', resultCount: before.results.length, fingerprint: beforeFingerprint }, + { id: 'S1-indexed', resultCount: after.results.length, fingerprint: afterFingerprint }, + ], + counters: [ + { + id: 'S0-pre-index', + 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-pre-index', ...timingBefore }, + { id: 'S1-indexed', ...timingAfter }, + ], + acceptance, + ratio: { + speedupByMedian: relativeImprovement, + speedupByP95: timingBefore.p95Ms / timingAfter.p95Ms, + candidateVisitReduction: before.counters.candidateVisits / after.counters.candidateVisits, + nameGateReduction: before.counters.nameGates / after.counters.nameGates, + }, + }); +} + +const report = { + generatedAt: new Date().toISOString(), + baselineSource: { + file: 'apps/web/performance/review-canonical-index-20260922/restaurant-visit-matching.pre-index.ts', + note: '이전 경로는 손으로 다시 쓴 근사가 아니라 색인 도입 직전 lib/restaurant-visit-matching.ts의 동결 사본입니다.', + }, + workload: { + approvedRestaurants: APPROVED_COUNT, + reviewRows: REVIEW_ROWS, + resolvingRows: RESOLVING_ROWS, + repetitions: REPETITIONS, + sampleLoops: SAMPLE_LOOPS, + }, + budgets: REVIEW_CANONICAL_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, + 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/review-canonical-index-20260922/benchmark.json b/apps/web/performance/review-canonical-index-20260922/benchmark.json new file mode 100644 index 000000000..a29e85d3a --- /dev/null +++ b/apps/web/performance/review-canonical-index-20260922/benchmark.json @@ -0,0 +1,151 @@ +{ + "generatedAt": "2026-09-22T01:25:30.514Z", + "baselineSource": { + "file": "apps/web/performance/review-canonical-index-20260922/restaurant-visit-matching.pre-index.ts", + "note": "이전 경로는 손으로 다시 쓴 근사가 아니라 색인 도입 직전 lib/restaurant-visit-matching.ts의 동결 사본입니다." + }, + "workload": { + "approvedRestaurants": 1200, + "reviewRows": 400, + "resolvingRows": 300, + "repetitions": 21, + "sampleLoops": 5 + }, + "budgets": { + "absolute": { + "pipelineMedianMsMax": 60, + "rule": "색인 경로의 한 번의 파이프라인 실행 중앙값이 60ms 이하여야 합니다." + }, + "relative": { + "minMedianSpeedup": 2, + "rule": "색인 이전 경로 대비 중앙값 2배 이상 빨라져야 개선으로 인정합니다." + }, + "noise": { + "madRelativeMax": 0.15, + "repetitions": 21, + "rule": "각 경로의 반복 표본 MAD/중앙값이 15% 이내이고, 중앙값 개선폭이 양쪽 상대 노이즈 합보다 클 때만 개선으로 인정합니다." + } + }, + "scenarios": [ + { + "id": "unique-address", + "description": "맛집마다 주소가 다른 실제 데이터 모양", + "equivalence": "identical", + "equivalenceDetail": [ + { + "id": "S0-pre-index", + "resultCount": 400, + "fingerprint": "b2922e62" + }, + { + "id": "S1-indexed", + "resultCount": 400, + "fingerprint": "b2922e62" + } + ], + "counters": [ + { + "id": "S0-pre-index", + "candidateVisits": 165150, + "nameGates": 300, + "addressGates": 165150 + }, + { + "id": "S1-indexed", + "candidateVisits": 300, + "nameGates": 300, + "addressGates": 0 + } + ], + "measurements": [ + { + "id": "S0-pre-index", + "medianMs": 48.107783399999974, + "p95Ms": 56.081066599999986, + "madRelative": 0.03941513962998435 + }, + { + "id": "S1-indexed", + "medianMs": 0.42399999999997817, + "p95Ms": 0.5554165999999896, + "madRelative": 0.0966391509435694 + } + ], + "acceptance": { + "absoluteBudgetMet": true, + "relativeBudgetMet": true, + "noiseWithinBudget": true, + "combinedRelativeNoise": 0.13605429057355375, + "relativeImprovement": 113.46175330189257, + "deltaExceedsNoise": true, + "accepted": true + }, + "ratio": { + "speedupByMedian": 113.46175330189257, + "speedupByP95": 100.9711747902404, + "candidateVisitReduction": 550.5, + "nameGateReduction": 1 + } + }, + { + "id": "shared-address", + "description": "승인 맛집 전부가 한 주소를 공유하는 퇴화 모양", + "equivalence": "identical", + "equivalenceDetail": [ + { + "id": "S0-pre-index", + "resultCount": 400, + "fingerprint": "ed462b7b" + }, + { + "id": "S1-indexed", + "resultCount": 400, + "fingerprint": "ed462b7b" + } + ], + "counters": [ + { + "id": "S0-pre-index", + "candidateVisits": 121095, + "nameGates": 1095, + "addressGates": 121095 + }, + { + "id": "S1-indexed", + "candidateVisits": 1095, + "nameGates": 1095, + "addressGates": 0 + } + ], + "measurements": [ + { + "id": "S0-pre-index", + "medianMs": 43.22767500000009, + "p95Ms": 57.47733320000007, + "madRelative": 0.039227374592782985 + }, + { + "id": "S1-indexed", + "medianMs": 0.4520916000001307, + "p95Ms": 0.635716599999796, + "madRelative": 0.06106815521476455 + } + ], + "acceptance": { + "absoluteBudgetMet": true, + "relativeBudgetMet": true, + "noiseWithinBudget": true, + "combinedRelativeNoise": 0.10029552980754754, + "relativeImprovement": 95.61707185001356, + "deltaExceedsNoise": true, + "accepted": true + }, + "ratio": { + "speedupByMedian": 95.61707185001356, + "speedupByP95": 90.41345341622117, + "candidateVisitReduction": 110.58904109589041, + "nameGateReduction": 1 + } + } + ] +} diff --git a/apps/web/performance/review-canonical-index-20260922/restaurant-visit-matching.pre-index.ts b/apps/web/performance/review-canonical-index-20260922/restaurant-visit-matching.pre-index.ts new file mode 100644 index 000000000..e46d8ef38 --- /dev/null +++ b/apps/web/performance/review-canonical-index-20260922/restaurant-visit-matching.pre-index.ts @@ -0,0 +1,58 @@ +// 색인 도입 직전 커밋의 apps/web/lib/restaurant-visit-matching.ts를 그대로 복제한 동결 사본입니다. +// 재현 벤치마크에서 이전 경로를 손으로 흉내내지 않고 실제 구현으로 측정하기 위한 것이며, +// 프로덕션 코드 경로에서는 사용하지 않습니다. 원본이 바뀌어도 이 사본은 이 벤치마크의 기준선 근거로만 남깁니다. +// 원본과 다른 점: 런타임 import 경로만 벤치마크 위치에 맞춰 상대 경로로 바꿨습니다. + +import type { Restaurant } from '@/types/restaurant'; +import { selectRelatedRestaurantReviewIds } from '../../lib/restaurant-review-lookup'; + +type VisitMatchRestaurant = Pick< + Restaurant, + 'id' | 'name' | 'approved_name' | 'road_address' | 'jibun_address' | 'mergedRestaurants' +>; + +type VisitMatchCandidate = Pick< + Restaurant, + 'id' | 'name' | 'approved_name' | 'road_address' | 'jibun_address' +>; + +type HasRelatedVerifiedUserReviewInput = { + restaurant: VisitMatchRestaurant | null; + reviewedRestaurantIds: Set; + reviewedRestaurants: VisitMatchCandidate[]; +}; + +type FindCanonicalVisitedRestaurantInput = { + reviewedRestaurant: VisitMatchCandidate | null; + approvedRestaurants: VisitMatchRestaurant[]; + reviewedRestaurantId?: string | null; +}; + +export function hasRelatedVerifiedUserReview({ + restaurant, + reviewedRestaurantIds, + reviewedRestaurants, +}: HasRelatedVerifiedUserReviewInput) { + if (!restaurant || reviewedRestaurantIds.size === 0) return false; + + return selectRelatedRestaurantReviewIds(restaurant, reviewedRestaurants) + .some((restaurantId) => reviewedRestaurantIds.has(restaurantId)); +} + +export function findCanonicalVisitedRestaurant({ + reviewedRestaurant, + approvedRestaurants, + reviewedRestaurantId, +}: FindCanonicalVisitedRestaurantInput) { + const relatedReviewId = reviewedRestaurantId || reviewedRestaurant?.id; + if (!relatedReviewId) return null; + + const reviewedRestaurantIds = new Set([relatedReviewId]); + const reviewedRestaurants = reviewedRestaurant ? [reviewedRestaurant] : []; + + return approvedRestaurants.find((restaurant) => hasRelatedVerifiedUserReview({ + restaurant, + reviewedRestaurantIds, + reviewedRestaurants, + })) ?? null; +} diff --git a/apps/web/tests-unit/restaurant-visit-matching-index.test.ts b/apps/web/tests-unit/restaurant-visit-matching-index.test.ts new file mode 100644 index 000000000..55db0c4ef --- /dev/null +++ b/apps/web/tests-unit/restaurant-visit-matching-index.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from 'bun:test'; + +import type { Restaurant } from '../types/restaurant'; +import { + createCanonicalVisitedLookup, + findCanonicalVisitedRestaurant, +} from '../lib/restaurant-visit-matching'; + +// 색인 기반 해석기(createCanonicalVisitedLookup)가 선형 탐색(findCanonicalVisitedRestaurant)과 +// 완전히 같은 맛집을 고르는지 확인합니다. 결과는 배열 안의 같은 객체여야 하므로 참조 비교로 검사합니다. +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 resolveLinear(approved: Row[], candidate: Row | null, reviewedRestaurantId?: string | null): Row | null { + return findCanonicalVisitedRestaurant({ + reviewedRestaurant: candidate as never, + reviewedRestaurantId, + approvedRestaurants: approved as never, + }) as Row | null; +} + +function resolveIndexed(approved: Row[], candidate: Row | null, reviewedRestaurantId?: string | null): Row | null { + const resolve = createCanonicalVisitedLookup(approved as never) as unknown as ( + candidate: Row | null, + reviewedRestaurantId?: string | null + ) => Row | null; + + return resolve(candidate, reviewedRestaurantId); +} + +function expectSameResolution( + approved: Row[], + candidate: Row | null, + reviewedRestaurantId?: string | null +): Row | null { + const linear = resolveLinear(approved, candidate, reviewedRestaurantId); + const indexed = resolveIndexed(approved, candidate, reviewedRestaurantId); + + // 같은 객체를 골라야 합니다(개수가 아니라 선택 자체가 같아야 함). + expect(indexed).toBe(linear); + return linear; +} + +describe('canonical visited lookup index', () => { + test('같은 주소에 이름이 맞는 맛집을 고른다', () => { + const approved = [row('approved-1', '쯔동분식 12', '서울 중구 쯔동로 12')]; + const candidate = row('deleted-1', '쯔동분식 12 지점', '서울 중구 쯔동로 12'); + + expect(expectSameResolution(approved, candidate)?.id).toBe('approved-1'); + }); + + test('주소가 다르면 같은 이름이라도 고르지 않는다', () => { + const approved = [row('approved-1', '쯔동분식 12', '서울 중구 쯔동로 12')]; + const candidate = row('deleted-1', '쯔동분식 12', '서울 중구 다른로 99'); + + expect(expectSameResolution(approved, candidate)).toBeNull(); + }); + + test('주소는 같지만 이름이 맞지 않으면 건너뛴다', () => { + const approved = [row('approved-1', '쯔동분식 12', '서울 중구 쯔동로 12')]; + const candidate = row('deleted-1', '완전히다른집', '서울 중구 쯔동로 12'); + + expect(expectSameResolution(approved, candidate)).toBeNull(); + }); + + test('이름 게이트를 통과하는 맛집이 뒤에 있어도 가장 앞선 맛집을 고른다', () => { + const approved = [ + row('approved-address-only', '완전히다른집', '서울 중구 쯔동로 12'), + row('approved-name-match', '쯔동분식 12', '서울 중구 쯔동로 12'), + ]; + const candidate = row('deleted-1', '쯔동분식 12 지점', '서울 중구 쯔동로 12'); + + expect(expectSameResolution(approved, candidate)?.id).toBe('approved-name-match'); + }); + + test('주소+이름으로 찾은 맛집이 직접 ID로 찾은 맛집보다 앞서면 앞선 맛집을 고른다', () => { + const approved = [ + row('approved-address-match', '쯔동분식 12', '서울 중구 쯔동로 12'), + row('approved-direct-id', '다른이름집', '서울 중구 다른로 7', undefined, [ + row('deleted-1', '다른이름집', '서울 중구 다른로 7'), + ]), + ]; + const candidate = row('deleted-1', '쯔동분식 12 지점', '서울 중구 쯔동로 12'); + + expect(expectSameResolution(approved, candidate)?.id).toBe('approved-address-match'); + }); + + test('직접 ID로 찾은 맛집이 앞서면 그 맛집을 고른다', () => { + const approved = [ + row('approved-direct-id', '다른이름집', '서울 중구 다른로 7', undefined, [ + row('deleted-1', '다른이름집', '서울 중구 다른로 7'), + ]), + row('approved-address-match', '쯔동분식 12', '서울 중구 쯔동로 12'), + ]; + const candidate = row('deleted-1', '쯔동분식 12 지점', '서울 중구 쯔동로 12'); + + expect(expectSameResolution(approved, candidate)?.id).toBe('approved-direct-id'); + }); + + test('병합된 레코드의 주소도 후보 주소와 맞으면 고른다', () => { + const approved = [ + row('approved-merged', '쯔동분식 12', '서울 중구 쯔동로 12', undefined, [ + row('approved-merged-old', '쯔동분식 12', '서울 중구 옛주소 3'), + ]), + ]; + const candidate = row('deleted-1', '쯔동분식 12 지점', '서울 중구 옛주소 3'); + + expect(expectSameResolution(approved, candidate)?.id).toBe('approved-merged'); + }); + + test('주소가 없는 후보는 주소가 없는 맛집에만 붙는다', () => { + const approved = [ + row('approved-with-address', '쯔동분식 12', '서울 중구 쯔동로 12'), + row('approved-addressless', '쯔동분식 13', null), + ]; + const addresslessCandidate = row('deleted-13', '쯔동분식 13 지점', null); + + expect(expectSameResolution(approved, addresslessCandidate)?.id).toBe('approved-addressless'); + + const candidateWithAddress = row('deleted-13b', '쯔동분식 13 지점', '서울 중구 쯔동로 14'); + expect(expectSameResolution(approved, candidateWithAddress)).toBeNull(); + }); + + test('리뷰 ID만 있고 후보 레코드가 없으면 직접 ID로만 찾는다', () => { + const approved = [ + row('approved-1', '쯔동분식 12', '서울 중구 쯔동로 12', undefined, [ + row('deleted-1', '쯔동분식 12', '서울 중구 쯔동로 12'), + ]), + ]; + + expect(expectSameResolution(approved, null, 'deleted-1')?.id).toBe('approved-1'); + expect(expectSameResolution(approved, null, 'unknown-id')).toBeNull(); + expect(expectSameResolution(approved, null, null)).toBeNull(); + }); + + test('찾는 리뷰 ID와 후보 ID가 다르면 후보 경로는 결과를 만들지 않는다', () => { + const approved = [row('approved-1', '쯔동분식 12', '서울 중구 쯔동로 12')]; + const candidate = row('deleted-1', '쯔동분식 12 지점', '서울 중구 쯔동로 12'); + + expect(expectSameResolution(approved, candidate, 'other-id')).toBeNull(); + }); + + test('후보의 두 주소가 같은 맛집에 걸려도 한 번만 보고 고른다', () => { + const approved = [row('approved-0', '쯔동분식 1', '주소A', '주소B')]; + const candidate = row('deleted-1', '쯔동분식 1 지점', '주소B', '주소A'); + + expect(expectSameResolution(approved, candidate)?.id).toBe('approved-0'); + }); + + test('서로 다른 주소 버킷에 걸린 두 맛집 중 앞선 맛집을 고른다', () => { + const approved = [ + row('approved-0', '쯔동분식 1', '주소A', '주소X'), + row('approved-1', '쯔동분식 1', '주소B', '주소Y'), + ]; + const candidate = row('deleted-1', '쯔동분식 1 지점', '주소B', '주소X'); + + // approved-0은 지번 주소로, approved-1은 도로명 주소로 걸린다. 앞선 색인이 이겨야 한다. + expect(expectSameResolution(approved, candidate)?.id).toBe('approved-0'); + + const reversed = [ + row('approved-0', '쯔동분식 1', '주소A', '주소Y'), + row('approved-1', '쯔동분식 1', '주소B', '주소X'), + ]; + expect(expectSameResolution(reversed, candidate)?.id).toBe('approved-1'); + }); + + test('무작위 워크로드에서 두 경로의 선택이 모두 같다', () => { + let state = 20260922; + const random = () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; + const pick = (values: T[]): T => values[Math.floor(random() * values.length)]; + const addresses = ['서울 중구 쯔동로 1', '서울 중구 쯔동로 2', '서울 중구 쯔동로 3', null]; + const names = ['쯔동분식 1', '쯔동분식 2', '쯔동분식 3', '완전히다른집']; + + const approved: Row[] = []; + for (let index = 0; index < 60; index += 1) { + const address = pick(addresses); + const merged = random() < 0.25 + ? [row('merged-' + index, pick(names), pick(addresses))] + : undefined; + approved.push(row('approved-' + index, pick(names), address, address, merged)); + } + + const candidates: Row[] = []; + for (let index = 0; index < 60; index += 1) { + const address = pick(addresses); + candidates.push(row('deleted-' + index, pick(names), address, address)); + } + candidates.push(row('approved-7', '쯔동분식 1', '서울 중구 쯔동로 1')); + candidates.push(row('merged-3', '쯔동분식 2', '서울 중구 쯔동로 2')); + candidates.push(row('deleted-noaddr', '쯔동분식 1', null)); + + let comparisons = 0; + for (const candidate of candidates) { + for (const reviewedRestaurantId of [undefined, candidate.id, 'approved-7', 'merged-3', 'missing-id']) { + expectSameResolution(approved, candidate, reviewedRestaurantId); + comparisons += 1; + } + } + + expect(comparisons).toBe(candidates.length * 5); + }); +});