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
16 changes: 10 additions & 6 deletions apps/web/app/mypage/reviews/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "리뷰삭제";
Expand Down Expand Up @@ -222,18 +222,22 @@ export default function ReviewsPage() {
: { data: [] };
const approvedRestaurants = approvedRestaurantRows || [];

// 리뷰 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다.
const resolveCanonicalRestaurant = createCanonicalVisitedLookup(
approvedRestaurants as Restaurant[],
);

// 3. 리뷰 데이터 매핑
const reviews: MyReview[] = reviewsData.map((review) => {
const reviewedRestaurant =
restaurantsMap.get(review.restaurant_id) ?? null;
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,
Expand Down
10 changes: 4 additions & 6 deletions apps/web/components/feed/FeedContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, boolean>();
Expand Down
23 changes: 14 additions & 9 deletions apps/web/hooks/use-bookmarks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string[]> {
Expand Down Expand Up @@ -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[])
Expand Down Expand Up @@ -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])];
Expand Down
31 changes: 17 additions & 14 deletions apps/web/hooks/useUserProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}

// ============================================================================
Expand Down Expand Up @@ -347,6 +348,9 @@ export function useUserReviews(userId: string, viewerId?: string) {
typedRestaurants as Restaurant[]
);

// 리뷰 행마다 승인 맛집 목록을 다시 훑지 않도록 색인을 한 번만 만듭니다.
const resolveCanonical = createCanonicalVisitedLookup(approvedRestaurants) as CanonicalVisitedRestaurantResolver;

const userLikedMap = new Map<string, boolean>();
((viewerLikesResult.data ?? []) as ReviewLikeRow[]).forEach(l => {
userLikedMap.set(l.review_id, true);
Expand All @@ -363,7 +367,7 @@ export function useUserReviews(userId: string, viewerId?: string) {
const restaurant = resolveCanonicalReviewedRestaurant({
reviewedRestaurant,
reviewedRestaurantId: r.restaurant_id,
approvedRestaurants,
resolveCanonical,
});

return {
Expand Down Expand Up @@ -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;

Expand Down
128 changes: 128 additions & 0 deletions apps/web/lib/restaurant-review-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number[]>();
const addresslessIndices: number[] = [];
const directIdIndices = new Map<string, number[]>();

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<string>,
addressBuckets: Map<string, number[]>,
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<number>(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;
}
}
24 changes: 23 additions & 1 deletion apps/web/lib/restaurant-visit-matching.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
};
}
Loading
Loading