1919import org .springframework .stereotype .Repository ;
2020
2121import java .util .List ;
22+ import java .util .Set ;
2223
2324@ Repository
2425@ RequiredArgsConstructor
@@ -125,7 +126,7 @@ public Slice<WaybleZoneSearchResponseDto> searchWaybleZonesByCondition(WaybleZon
125126 }
126127
127128 /**
128- * 30m 이내이고 이름이 유사한 WaybleZone 찾기
129+ * 30m 이내이고 이름이 유사한 WaybleZone 찾기 (최적화된 버전)
129130 * @param cond 검색 조건 (위도, 경도, 이름 포함)
130131 * @return 조건에 맞는 첫 번째 결과 또는 null
131132 */
@@ -134,88 +135,144 @@ public WaybleZoneSearchResponseDto findSimilarWaybleZone(WaybleZoneSearchConditi
134135 return null ;
135136 }
136137
137- // 30m 이내 검색
138- Query query = Query .of (q -> q
139- .bool (b -> {
140- // 이름 유사도 검색 (fuzzy + match 조합)
141- b .should (s -> s
142- .match (m -> m
143- .field ("zoneName" )
144- .query (cond .zoneName ())
145- .boost (2.0f ) // 정확한 매치에 높은 점수
146- )
147- );
148- b .should (s -> s
149- .fuzzy (f -> f
150- .field ("zoneName" )
151- .value (cond .zoneName ())
152- .fuzziness ("AUTO" ) // 오타 허용
153- .boost (1.5f )
154- )
155- );
156- // 부분 매치도 포함 (공백 제거 후 검색)
157- String cleanedName = cond .zoneName ().replaceAll ("\\ s+" , "" );
158- b .should (s -> s
159- .wildcard (w -> w
160- .field ("zoneName" )
161- .value ("*" + cleanedName + "*" )
162- .boost (1.0f )
163- )
164- );
165-
166- // 최소 하나의 should 조건은 만족해야 함
167- b .minimumShouldMatch ("1" );
168-
169- // 30m 이내 필터
170- b .filter (f -> f
171- .geoDistance (gd -> gd
172- .field ("address.location" )
173- .location (loc -> loc
174- .latlon (ll -> ll
175- .lat (cond .latitude ())
176- .lon (cond .longitude ())
177- )
178- )
179- .distance ("30m" )
180- )
181- );
182- return b ;
183- })
184- );
138+ // Step 1: 30m 이내 모든 후보 조회 (지리적 필터만)
139+ List <WaybleZoneDocument > candidates = findNearbyZones (cond );
185140
186- // 정렬: 점수 + 거리 조합
187- SortOptions scoreSort = SortOptions .of (s -> s .score (sc -> sc .order (SortOrder .Desc )));
188- SortOptions geoSort = SortOptions .of (s -> s
189- .geoDistance (gds -> gds
141+ // Step 2: 메모리에서 텍스트 유사도 검사
142+ return candidates .stream ()
143+ .filter (zone -> isTextSimilar (zone .getZoneName (), cond .zoneName ()))
144+ .findFirst ()
145+ .map (doc -> WaybleZoneSearchResponseDto .from (doc , null ))
146+ .orElse (null );
147+ }
148+
149+ /**
150+ * 30m 이내 모든 WaybleZone 후보 조회
151+ */
152+ private List <WaybleZoneDocument > findNearbyZones (WaybleZoneSearchConditionDto cond ) {
153+ Query geoQuery = Query .of (q -> q
154+ .geoDistance (gd -> gd
190155 .field ("address.location" )
191- .location (GeoLocation .of (gl -> gl
192- .latlon (ll -> ll
193- .lat (cond .latitude ())
194- .lon (cond .longitude ())
195- )
156+ .location (loc -> loc .latlon (ll -> ll
157+ .lat (cond .latitude ())
158+ .lon (cond .longitude ())
196159 ))
197- .order ( SortOrder . Asc )
160+ .distance ( "30m" )
198161 )
199162 );
200163
201164 NativeQuery nativeQuery = NativeQuery .builder ()
202- .withQuery (query )
203- .withSort (scoreSort )
204- .withSort (geoSort )
205- .withPageable (PageRequest .of (0 , 1 )) // 첫 번째 결과만
165+ .withQuery (geoQuery )
166+ .withPageable (PageRequest .of (0 , 10 )) // 30m 이내는 보통 10개 미만
206167 .build ();
207168
208169 SearchHits <WaybleZoneDocument > hits =
209170 operations .search (nativeQuery , WaybleZoneDocument .class , INDEX );
210171
211- if (hits .isEmpty ()) {
212- return null ;
172+ return hits .stream ()
173+ .map (hit -> hit .getContent ())
174+ .toList ();
175+ }
176+
177+ /**
178+ * 텍스트 유사도 검사 (메모리 기반)
179+ */
180+ private boolean isTextSimilar (String zoneName , String searchName ) {
181+ if (zoneName == null || searchName == null ) {
182+ return false ;
183+ }
184+
185+ String normalizedZone = normalize (zoneName );
186+ String normalizedSearch = normalize (searchName );
187+
188+ // 1. 완전 일치
189+ if (normalizedZone .equals (normalizedSearch )) {
190+ return true ;
191+ }
192+
193+ // 2. 포함 관계 (기존 wildcard와 유사)
194+ if (normalizedZone .contains (normalizedSearch ) ||
195+ normalizedSearch .contains (normalizedZone )) {
196+ return true ;
197+ }
198+
199+ // 3. 편집 거리 (기존 fuzzy와 유사) - 70% 이상 유사
200+ if (calculateLevenshteinSimilarity (normalizedZone , normalizedSearch ) > 0.7 ) {
201+ return true ;
202+ }
203+
204+ // 4. 자카드 유사도 (토큰 기반, 기존 match와 유사) - 60% 이상 유사
205+ return calculateJaccardSimilarity (normalizedZone , normalizedSearch ) > 0.6 ;
206+ }
207+
208+ /**
209+ * 텍스트 정규화 (공백, 특수문자 제거)
210+ */
211+ private String normalize (String text ) {
212+ return text .replaceAll ("\\ s+" , "" ) // 공백 제거
213+ .replaceAll ("[^가-힣a-zA-Z0-9]" , "" ) // 특수문자 제거
214+ .toLowerCase ();
215+ }
216+
217+ /**
218+ * 레벤슈타인 거리 기반 유사도 (0.0 ~ 1.0)
219+ */
220+ private double calculateLevenshteinSimilarity (String s1 , String s2 ) {
221+ if (s1 .isEmpty () || s2 .isEmpty ()) {
222+ return 0.0 ;
213223 }
214224
215- WaybleZoneDocument doc = hits .getSearchHit (0 ).getContent ();
216- Double distanceInMeters = (Double ) hits .getSearchHit (0 ).getSortValues ().get (1 ); // 거리는 두 번째 정렬값
217- Double distanceInKm = distanceInMeters / 1000.0 ;
218-
219- return WaybleZoneSearchResponseDto .from (doc , distanceInKm );
225+ int distance = levenshteinDistance (s1 , s2 );
226+ int maxLength = Math .max (s1 .length (), s2 .length ());
227+ return 1.0 - (double ) distance / maxLength ;
228+ }
229+
230+ /**
231+ * 레벤슈타인 거리 계산
232+ */
233+ private int levenshteinDistance (String s1 , String s2 ) {
234+ int [][] dp = new int [s1 .length () + 1 ][s2 .length () + 1 ];
235+
236+ for (int i = 0 ; i <= s1 .length (); i ++) {
237+ dp [i ][0 ] = i ;
238+ }
239+ for (int j = 0 ; j <= s2 .length (); j ++) {
240+ dp [0 ][j ] = j ;
241+ }
242+
243+ for (int i = 1 ; i <= s1 .length (); i ++) {
244+ for (int j = 1 ; j <= s2 .length (); j ++) {
245+ if (s1 .charAt (i - 1 ) == s2 .charAt (j - 1 )) {
246+ dp [i ][j ] = dp [i - 1 ][j - 1 ];
247+ } else {
248+ dp [i ][j ] = 1 + Math .min (Math .min (dp [i - 1 ][j ], dp [i ][j - 1 ]), dp [i - 1 ][j - 1 ]);
249+ }
250+ }
251+ }
252+
253+ return dp [s1 .length ()][s2 .length ()];
254+ }
255+
256+ /**
257+ * 자카드 유사도 (문자 집합 기반, 0.0 ~ 1.0)
258+ */
259+ private double calculateJaccardSimilarity (String s1 , String s2 ) {
260+ if (s1 .isEmpty () && s2 .isEmpty ()) {
261+ return 1.0 ;
262+ }
263+ if (s1 .isEmpty () || s2 .isEmpty ()) {
264+ return 0.0 ;
265+ }
266+
267+ Set <Character > set1 = s1 .chars ().mapToObj (c -> (char ) c ).collect (java .util .stream .Collectors .toSet ());
268+ Set <Character > set2 = s2 .chars ().mapToObj (c -> (char ) c ).collect (java .util .stream .Collectors .toSet ());
269+
270+ Set <Character > intersection = new java .util .HashSet <>(set1 );
271+ intersection .retainAll (set2 );
272+
273+ Set <Character > union = new java .util .HashSet <>(set1 );
274+ union .addAll (set2 );
275+
276+ return (double ) intersection .size () / union .size ();
220277 }
221278}
0 commit comments