[Developer] #533 언급 적은 식당 제외 필터 (min_mentions + video_count)

- backend: findAll 응답에 video_count(strong/unknown 링크수) + min_mentions 서버필터
  - Restaurant.videoCount, RestaurantMapper(xml/iface), Service, Controller(+cache key)
- frontend: Restaurant.video_count 타입 + 데스크톱 툴바 "언급"(2/3/5회+) 클라 필터
- 검증: min_mentions=2→116, =3→36 (위반 0). 빌드/tsc 통과
- 설계서 docs/design/533-min-mentions-filter/README.md

Refs #533
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-06-28 16:23:46 +09:00
parent 4760b5a284
commit 7639fd009d
9 changed files with 100 additions and 10 deletions

View File

@@ -6,6 +6,16 @@
## 2026-06-28
### ✨ 언급 적은(관련도 낮은) 식당 제외 필터 (#533)
- 신호: "의미있는 언급 수" = video_restaurants 중 relevance ∈ (strong, unknown) 개수 (weak/incidental 제외)
- 백엔드: 식당 목록 응답에 `video_count` 추가 + `min_mentions` 서버 필터 파라미터
- RestaurantMapper.xml findAll: 스칼라 서브쿼리 video_count + minMentions 필터
- Restaurant.videoCount 필드 / Service·Controller 스레드 / 캐시키 `mm=` 포함
- 검증: min_mentions=2 → 116개(전부 ≥2, 위반 0), =3 → 36개
- 프론트: Restaurant.video_count 타입 + 데스크톱 툴바 "언급" 필터(2/3/5회+) 클라이언트 필터
- 후속: 모바일 FilterSheet 적용 + 서버사이드 일원화(#535)
- 설계서: `docs/design/533-min-mentions-filter/README.md`
### 🐛 지역 표시 계층화 — "한국|서울" → "한국 서울" (#532)
- 문제: 식당 상세/리스트가 region 원본(`한국|서울|강남구`, 파이프 노출)을 그대로 표시
- 해결: 공용 유틸 `lib/region.ts` `formatRegion()` 신설 — 빈토큰/`null`/더미(`나라`) 제거 후 ` ` 계층 조인

View File

@@ -56,17 +56,18 @@ public class RestaurantController {
@RequestParam(defaultValue = "0") int offset,
@RequestParam(required = false) String cuisine,
@RequestParam(required = false) String region,
@RequestParam(required = false) String channel) {
@RequestParam(required = false) String channel,
@RequestParam(name = "min_mentions", required = false) Integer minMentions) {
if (limit > 500) limit = 500;
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
"c=" + cuisine, "r=" + region, "ch=" + channel);
"c=" + cuisine, "r=" + region, "ch=" + channel, "mm=" + minMentions);
String cached = cache.getRaw(key);
if (cached != null) {
try {
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
}
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
var result = restaurantService.findAll(limit, offset, cuisine, region, channel, minMentions, false);
cache.set(key, result);
return result;
}

View File

@@ -39,4 +39,7 @@ public class Restaurant {
// Transient enrichment fields
private List<String> channels;
private List<String> foodsMentioned;
// #533 — 의미있는(strong/unknown) 영상 언급 수
private Integer videoCount;
}

View File

@@ -15,6 +15,7 @@ public interface RestaurantMapper {
@Param("cuisine") String cuisine,
@Param("region") String region,
@Param("channel") String channel,
@Param("minMentions") Integer minMentions,
@Param("includeHidden") boolean includeHidden);
// #322 LLM 검증: hidden 표시 갱신

View File

@@ -21,11 +21,13 @@ public class RestaurantService {
}
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
return findAll(limit, offset, cuisine, region, channel, false);
return findAll(limit, offset, cuisine, region, channel, null, false);
}
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) {
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden);
// #533 — minMentions: 의미있는 언급 수가 N 미만인 식당 제외 (null/0이면 미적용)
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel,
Integer minMentions, boolean includeHidden) {
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, minMentions, includeHidden);
enrichRestaurants(restaurants);
return restaurants;
}

View File

@@ -25,6 +25,7 @@
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
<result property="hiddenReason" column="hidden_reason"/>
<result property="verifiedAt" column="verified_at"/>
<result property="videoCount" column="video_count"/>
</resultMap>
<!-- ===== Queries ===== -->
@@ -33,7 +34,9 @@
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count, r.updated_at,
r.hidden, r.hidden_reason, r.verified_at
r.hidden, r.hidden_reason, r.verified_at,
(SELECT COUNT(*) FROM video_restaurants vrc
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count
FROM restaurants r
<if test="channel != null and channel != ''">
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
@@ -55,6 +58,10 @@
<if test="channel != null and channel != ''">
AND c_f.channel_name = #{channel}
</if>
<if test="minMentions != null and minMentions > 0">
AND (SELECT COUNT(*) FROM video_restaurants vrc
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) &gt;= #{minMentions}
</if>
</where>
ORDER BY r.updated_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY

View File

@@ -0,0 +1,44 @@
# #533 — 언급 적은(관련도 낮은) 식당 제외 필터
## 배경 / 문제
영상에서 잠깐·우연히 언급된(=관련도 낮은) 식당까지 모두 노출되어 신호 대비 잡음이 큼.
사용자가 "언급이 충분한 식당만" 보도록 제외 필터 제공.
## 신호 정의
"의미있는 언급 수(`video_count`)" = 해당 식당의 `video_restaurants` 링크 중
**relevance ∈ ('strong','unknown')** 개수. (weak/incidental = 곁다리 언급 → 제외)
→ 기존 `findVideoLinks` 기본 노출 정책(strong/unknown)과 일관.
## 범위
- **백엔드**: 식당 목록 응답에 `video_count` 포함 + `min_mentions` 서버 필터 파라미터.
- **프론트**: `Restaurant.video_count` 타입 + 클라이언트 필터 UI(전체/2회+/3회+).
- 서버사이드 필터 일원화(현 클라 필터 패턴 통일)는 **#535**로 분리.
## 함수/변경 설계
### 백엔드
1. `domain/Restaurant.java``Integer videoCount` 필드 추가(전이 enrichment).
2. `mapper/RestaurantMapper.xml`
- `restaurantMap` resultMap: `<result property="videoCount" column="video_count"/>`
- `findAll` SELECT에 스칼라 서브쿼리 추가:
`(SELECT COUNT(*) FROM video_restaurants vrc WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count`
- `findAll` `<where>`에 optional 필터:
`<if test="minMentions != null and minMentions > 0"> AND (위 서브쿼리) &gt;= #{minMentions} </if>`
3. `mapper/RestaurantMapper.java``@Param("minMentions") Integer minMentions` 추가.
4. `service/RestaurantService.java``findAll(..., Integer minMentions, boolean includeHidden)`로 스레드. 5-arg 오버로드는 `null, false` 전달.
5. `controller/RestaurantController.java``@RequestParam("min_mentions") Integer minMentions` + 캐시 키에 `mm=` 포함.
### 프론트
6. `lib/api.ts``Restaurant.video_count?: number | null`; `getRestaurants` 파라미터 `min_mentions?`(미래/서버필터용).
7. `app/page.tsx` — 필터 상태 `minMentions`(0/2/3) + `filteredRestaurants``(r.video_count ?? 0) >= minMentions` 가드 + UI 컨트롤 + useMemo deps 추가.
## 엣지케이스
- `video_count` 미산정(findById 등) → null. 클라 필터는 `?? 0`로 안전.
- minMentions=0(기본) → 필터 미적용(기존 동작 유지).
- DISTINCT + 스칼라 서브쿼리: r.id 단위라 결과 불변.
## 검증
- 백엔드 빌드 성공 → PM2 재시작.
- `GET /api/restaurants?min_mentions=2` 가 2회 미만 식당 제외하는지.
- 응답에 `video_count` 포함 확인.
- 프론트 토글로 목록/지도가 줄어드는지.

View File

@@ -196,6 +196,7 @@ export default function Home() {
const [channelFilter, setChannelFilter] = useState("");
const [cuisineFilter, setCuisineFilter] = useState("");
const [priceFilter, setPriceFilter] = useState("");
const [minMentions, setMinMentions] = useState(0); // #533 — 의미있는 언급 N회 미만 식당 제외 (0=전체)
const [viewMode, setViewMode] = useState<"map" | "list">("list");
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -248,6 +249,7 @@ export default function Home() {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
if (minMentions > 0 && (r.video_count ?? 0) < minMentions) return false;
if (countryFilter) {
const parsed = parseRegion(r.region);
if (!parsed || parsed.country !== countryFilter) return false;
@@ -271,7 +273,7 @@ export default function Home() {
if (da !== db) return da - db;
return (b.rating || 0) - (a.rating || 0);
});
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, minMentions, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
// FilterSheet option builders
const cuisineOptions = useMemo<FilterOption[]>(() => {
@@ -366,6 +368,7 @@ export default function Home() {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setMinMentions(0);
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
@@ -477,6 +480,7 @@ export default function Home() {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setMinMentions(0);
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
@@ -818,9 +822,23 @@ export default function Home() {
<option key={g.label} value={g.label}>{g.label}</option>
))}
</select>
{(cuisineFilter || priceFilter) && (
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select
value={minMentions}
onChange={(e) => { setMinMentions(Number(e.target.value)); if (Number(e.target.value)) setBoundsFilterOn(false); }}
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
minMentions > 0 ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
title="영상에서 충분히 언급된(관련도 높은) 식당만 보기"
>
<option value={0}></option>
<option value={2}>2+</option>
<option value={3}>3+</option>
<option value={5}>5+</option>
</select>
{(cuisineFilter || priceFilter || minMentions > 0) && (
<button
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
onClick={() => { setCuisineFilter(""); setPriceFilter(""); setMinMentions(0); }}
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
title="음식 필터 초기화"
>

View File

@@ -51,6 +51,8 @@ export interface Restaurant {
website: string | null;
channels?: string[];
foods_mentioned?: string[];
// #533 — 의미있는(strong/unknown) 영상 언급 수
video_count?: number | null;
// #322 LLM 검증
hidden?: boolean;
hidden_reason?: string | null;
@@ -168,6 +170,7 @@ export const api = {
channel?: string;
limit?: number;
offset?: number;
min_mentions?: number;
}) {
const sp = new URLSearchParams();
if (params?.cuisine) sp.set("cuisine", params.cuisine);
@@ -175,6 +178,7 @@ export const api = {
if (params?.channel) sp.set("channel", params.channel);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
if (params?.min_mentions) sp.set("min_mentions", String(params.min_mentions));
const qs = sp.toString();
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
},