feat(util): #348 isNameSimilar 한국어 자모 + Sørensen-Dice

- HangulSimilarity 유틸 신규
  - decompose: Unicode NFD 분해 (한글 음절 → 초성/중성/종성)
  - 공백·구두점 제거 + 소문자화
  - bigram multiset 기반 Sørensen-Dice 계수
  - 빈 입력/포함 관계 가드
- RestaurantController.isNameSimilar 임계값 0.45 (이전 Jaccard 0.4와 유사 보수성)
- 기존 normalize 헬퍼 제거 (HangulSimilarity 내부로 이동)

DDG/DTO/UNIQUE는 별도 후속:
- 외부 검색 API 선정 (Naver/Kakao/Google CSE)
- RestaurantUpdateDTO + @Valid
- google_place_id 중복 정리 후 UNIQUE 제약

설계서: docs/design/348-name-similarity/README.md

Refs: #348 (Developer 단계)
This commit is contained in:
joungmin
2026-06-15 16:10:44 +09:00
parent 49ef0322ac
commit 3815221535
2 changed files with 72 additions and 18 deletions

View File

@@ -524,25 +524,12 @@ public class RestaurantController {
* 식당 이름과 검색 결과 제목의 유사도 검사.
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
*/
/**
* #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45).
* 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확.
*/
private boolean isNameSimilar(String restaurantName, String resultTitle) {
String a = normalize(restaurantName);
String b = normalize(resultTitle);
if (a.isEmpty() || b.isEmpty()) return false;
// 포함 관계 체크
if (a.contains(b) || b.contains(a)) return true;
// 공통 문자 비율 (Jaccard-like)
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
long common = setA.stream().filter(setB::contains).count();
double ratio = (double) common / Math.max(setA.size(), setB.size());
return ratio >= 0.4;
}
private String normalize(String s) {
if (s == null) return "";
return s.replaceAll("[\\\\-_()\\[\\]【】]", "").toLowerCase();
return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45;
}
private void emit(SseEmitter emitter, Map<String, Object> data) {

View File

@@ -0,0 +1,67 @@
package com.tasteby.util;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.Map;
/**
* #348 — 한국어 자모 분해(Unicode NFD) + Sørensen-Dice bigram 유사도.
*
* 음절 단위 Jaccard보다 짧은 한국어 이름에 정확. 예:
* similarity("스타벅스 강남", "스타벅스 강남점") ≈ 0.85+
* similarity("스타벅스 강남", "스타벅스 종로") ≈ 0.55~0.85
* similarity("스타벅스", "맥도날드") < 0.20
*
* 공백/구두점은 제거하고 소문자화한 뒤 NFD 분해.
*/
public final class HangulSimilarity {
private HangulSimilarity() {}
/** 공백/구두점 제거 + 소문자화 + NFD 분해(한글 음절 → 자모). */
public static String decompose(String s) {
if (s == null || s.isEmpty()) return "";
String stripped = s.replaceAll("[\\\\-_()\\[\\]【】]", "").toLowerCase();
return Normalizer.normalize(stripped, Normalizer.Form.NFD);
}
/**
* Sørensen-Dice 계수 (bigram multiset 기반). 0.0~1.0.
* 동일 문자열 → 1.0. 빈 입력 → 0.0.
*/
public static double similarity(String a, String b) {
String da = decompose(a);
String db = decompose(b);
if (da.isEmpty() || db.isEmpty()) return 0.0;
if (da.equals(db)) return 1.0;
// 포함 관계는 강한 신호로 1.0 처리 (기존 동작과 일관)
if (da.contains(db) || db.contains(da)) return 1.0;
if (da.length() < 2 || db.length() < 2) {
return 0.0;
}
Map<String, Integer> bigramsA = bigrams(da);
Map<String, Integer> bigramsB = bigrams(db);
int common = 0;
for (var e : bigramsA.entrySet()) {
Integer countB = bigramsB.get(e.getKey());
if (countB != null) {
common += Math.min(e.getValue(), countB);
}
}
int sizeA = da.length() - 1;
int sizeB = db.length() - 1;
return (2.0 * common) / (sizeA + sizeB);
}
private static Map<String, Integer> bigrams(String s) {
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < s.length() - 1; i++) {
String gram = s.substring(i, i + 2);
map.merge(gram, 1, Integer::sum);
}
return map;
}
}