Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions apps/web/app/stamp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 훅 사용 - 병합 로직 적용됨)
Expand Down
12 changes: 6 additions & 6 deletions apps/web/components/overlay-pages/StampOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand Down
11 changes: 5 additions & 6 deletions apps/web/hooks/useUnvisitedRestaurants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions apps/web/lib/restaurant-review-counts.ts
Original file line number Diff line number Diff line change
@@ -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<
Expand Down Expand Up @@ -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)];
}));
}
187 changes: 187 additions & 0 deletions apps/web/lib/restaurant-review-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,193 @@ 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<string>
): (restaurant: ReviewLookupRestaurant | null) => boolean {
const entries = (Array.isArray(candidates) ? candidates : []).filter(
(candidate): candidate is ReviewLookupCandidate => Boolean(candidate && candidate.id)
);
const addressBuckets = new Map<string, number[]>();
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;
};
}

// 리뷰 수 집계는 관련 리뷰 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<string, number>) => number {
const entries = (Array.isArray(candidates) ? candidates : []).filter(
(candidate): candidate is ReviewLookupCandidate => Boolean(candidate && candidate.id)
);
const addressBuckets = new Map<string, number[]>();
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<string>();
// 주소가 여러 개인 후보는 여러 버킷에 들어 있으므로, 맛집마다 한 번만 보도록 표시합니다.
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(
Expand Down
Loading
Loading