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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ public CommonResponse<List<WaybleZoneDistrictResponseDto>> findMostLikesWaybleZo
));
}

@GetMapping("/validate")
public CommonResponse<WaybleZoneSearchResponseDto> findIsValidWaybleZone(
@Valid @ModelAttribute WaybleZoneSearchConditionDto conditionDto
)
{
return CommonResponse.success(waybleZoneSearchService.isValidWaybleZone(conditionDto));
}

@PostMapping("")
public CommonResponse<String> registerDocumentFromDto(@RequestBody WaybleZoneRegisterDto registerDto) {
waybleZoneDocumentService.saveDocumentFromDto(registerDto);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ public record WaybleZoneSearchConditionDto(
@NotNull(message = "경도 입력은 필수입니다.")
Double longitude,

@DecimalMin(value = "0.1", message = "검색 반경은 100미터 이상이어야 합니다.")
Double radiusKm,

@Size(min = 2, message = "zoneName은 최소 2글자 이상이어야 합니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,99 @@ public Slice<WaybleZoneSearchResponseDto> searchWaybleZonesByCondition(WaybleZon

return new SliceImpl<>(dtos, pageable, hasNext);
}

/**
* 30m 이내이고 이름이 유사한 WaybleZone 찾기
* @param cond 검색 조건 (위도, 경도, 이름 포함)
* @return 조건에 맞는 첫 번째 결과 또는 null
*/
public WaybleZoneSearchResponseDto findSimilarWaybleZone(WaybleZoneSearchConditionDto cond) {
if (cond.zoneName() == null || cond.zoneName().isBlank()) {
return null;
}

// 30m 이내 검색
Query query = Query.of(q -> q
.bool(b -> {
// 이름 유사도 검색 (fuzzy + match 조합)
b.should(s -> s
.match(m -> m
.field("zoneName")
.query(cond.zoneName())
.boost(2.0f) // 정확한 매치에 높은 점수
)
);
b.should(s -> s
.fuzzy(f -> f
.field("zoneName")
.value(cond.zoneName())
.fuzziness("AUTO") // 오타 허용
.boost(1.5f)
)
);
// 부분 매치도 포함 (공백 제거 후 검색)
String cleanedName = cond.zoneName().replaceAll("\\s+", "");
b.should(s -> s
.wildcard(w -> w
.field("zoneName")
.value("*" + cleanedName + "*")
.boost(1.0f)
)
);
Comment on lines +157 to +164

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

공백 허용 유사 매치(wildcard) 로직이 의도대로 동작하지 않을 가능성 높음

입력의 공백을 제거한 cleanedName으로 zoneName 필드에 *cleanedName* 와일드카드 매칭을 수행하면, 인덱스의 실제 값에 공백이 포함된 경우(예: "스 타 벅 스") 매칭이 실패할 수 있습니다. 공백을 “임의 문자”로 간주하도록 입력의 공백을 *로 치환하는 편이 의도(공백 무시)에 더 부합합니다. 또한 wildcard는 일반적으로 keyword 서브필드에서 수행하는 것이 안전합니다.

아래와 같이 수정 제안합니다:

-                    String cleanedName = cond.zoneName().replaceAll("\\s+", "");
-                    b.should(s -> s
-                            .wildcard(w -> w
-                                    .field("zoneName")
-                                    .value("*" + cleanedName + "*")
-                                    .boost(1.0f)
-                            )
-                    );
+                    // 공백을 임의 문자로 허용: "스 타" -> "*스*타*"
+                    String wildcardPattern = "*" + cond.zoneName().trim().replaceAll("\\s+", "*") + "*";
+                    b.should(s -> s
+                            .wildcard(w -> w
+                                    .field("zoneName.keyword") // keyword 서브필드 사용 권장
+                                    .value(wildcardPattern)
+                                    .caseInsensitive(true)
+                                    .boost(1.0f)
+                            )
+                    );

참고: 매핑에 zoneName.keyword가 없다면, 매핑/인덱싱 단계에서 공백 제거나 소문자 정규화를 수행하는 별도의 서브필드를 추가하는 것이 가장 견고합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
String cleanedName = cond.zoneName().replaceAll("\\s+", "");
b.should(s -> s
.wildcard(w -> w
.field("zoneName")
.value("*" + cleanedName + "*")
.boost(1.0f)
)
);
// 공백을 임의 문자로 허용: "스 타" -> "*스*타*"
String wildcardPattern = "*" + cond.zoneName().trim().replaceAll("\\s+", "*") + "*";
b.should(s -> s
.wildcard(w -> w
.field("zoneName.keyword") // keyword 서브필드 사용 권장
.value(wildcardPattern)
.caseInsensitive(true)
.boost(1.0f)
)
);
🤖 Prompt for AI Agents
In
src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java
around lines 157 to 164, the code strips all whitespace from cond.zoneName() and
performs a wildcard match "*cleanedName*" which will fail when indexed values
contain spaces; instead replace each whitespace run with "*" so input spaces
become wildcards (e.g. "star bucks" -> "star*bucks") and apply the wildcard
against a keyword/normalized subfield (e.g. zoneName.keyword) or change the
mapping to add a lowercased/whitespace-normalized subfield; update the wildcard
value to "*" + whitespaceReplacedName + "*" and point the field to the
keyword/normalized subfield to make matches robust.


// 최소 하나의 should 조건은 만족해야 함
b.minimumShouldMatch("1");

// 30m 이내 필터
b.filter(f -> f
.geoDistance(gd -> gd
.field("address.location")
.location(loc -> loc
.latlon(ll -> ll
.lat(cond.latitude())
.lon(cond.longitude())
)
)
.distance("30m")
)
);
return b;
})
);

// 정렬: 점수 + 거리 조합
SortOptions scoreSort = SortOptions.of(s -> s.score(sc -> sc.order(SortOrder.Desc)));
SortOptions geoSort = SortOptions.of(s -> s
.geoDistance(gds -> gds
.field("address.location")
.location(GeoLocation.of(gl -> gl
.latlon(ll -> ll
.lat(cond.latitude())
.lon(cond.longitude())
)
))
.order(SortOrder.Asc)
)
);
Comment on lines +186 to +199

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

거리 sort 값 인덱스 의존 취약성 — 정렬값 배열 구조 변경 시 오동작 위험

현재 코드는 두 번째 정렬값을 거리로 가정합니다(Line 216). _score 정렬값 포함 여부나 순서 변경에 취약합니다. 마지막 정렬값을 거리로 취급하는 방식으로 방어적으로 처리하세요(현재 코드에서 거리 정렬이 마지막으로 추가됨).

수정 제안:

-        SortOptions scoreSort = SortOptions.of(s -> s.score(sc -> sc.order(SortOrder.Desc)));
+        SortOptions scoreSort = SortOptions.of(s -> s.score(sc -> sc.order(SortOrder.Desc)));
         SortOptions geoSort = SortOptions.of(s -> s
                 .geoDistance(gds -> gds
                         .field("address.location")
                         .location(GeoLocation.of(gl -> gl
                                 .latlon(ll -> ll
                                         .lat(cond.latitude())
                                         .lon(cond.longitude())
                                 )
                         ))
                         .order(SortOrder.Asc)
                 )
         );
...
-        WaybleZoneDocument doc = hits.getSearchHit(0).getContent();
-        Double distanceInMeters = (Double) hits.getSearchHit(0).getSortValues().get(1); // 거리는 두 번째 정렬값
+        WaybleZoneDocument doc = hits.getSearchHit(0).getContent();
+        java.util.List<Object> sortValues = hits.getSearchHit(0).getSortValues();
+        Double distanceInMeters = (Double) sortValues.get(sortValues.size() - 1); // 마지막 정렬값을 거리로 취급
         Double distanceInKm = distanceInMeters / 1000.0;

검증 권장: 정렬값 배열에 _score가 포함되지 않는 환경(클러스터/클라이언트 버전 차이)에서도 안정적으로 동작하는지 확인해 주세요.

Also applies to: 215-219


🏁 Script executed:

#!/bin/bash
# 1. WaybleZoneQuerySearchRepository.java 내 getSearchHit 사용 위치 확인
grep -R "hits.getSearchHit" -n src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java -A3

# 2. getSortValues 호출 위치 및 컨텍스트 확인
grep -R "getSortValues" -n src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java -A3

# 3. SearchRequest 에 전달되는 SortOptions 리스트 생성부 확인
grep -R "searchAsync" -n src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java -A5

Length of output: 1229


거리 정렬값 인덱스 참조 방어 로직 필요
정렬값 배열에서 특정 인덱스를 직접 참조하고 있어, _score 포함 여부나 순서 변경 시 잘못된 값(거리)을 가져올 위험이 있습니다. 방어적으로 마지막 인덱스를 참조하도록 수정해주세요.

수정 대상 파일:

  • src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java
    • 112행: hit.getSortValues().get(0)
    • 216행: hits.getSearchHit(0).getSortValues().get(1)

제안된 수정:

-        Double distanceInMeters = (Double) hit.getSortValues().get(0);
+        java.util.List<Object> sortValues = hit.getSortValues();
+        Double distanceInMeters = (Double) sortValues.get(sortValues.size() - 1);

-        Double distanceInMeters = (Double) hits.getSearchHit(0).getSortValues().get(1); // 거리는 두 번째 정렬값
+        java.util.List<Object> sortValues = hits.getSearchHit(0).getSortValues();
+        Double distanceInMeters = (Double) sortValues.get(sortValues.size() - 1); // 마지막 정렬값을 거리로 취급
🤖 Prompt for AI Agents
In
src/main/java/com/wayble/server/explore/repository/search/WaybleZoneQuerySearchRepository.java
around lines 112 and 216, the code directly accesses sort values by fixed
indices (hit.getSortValues().get(0) and
hits.getSearchHit(0).getSortValues().get(1)), which can break if _score is
included or order changes; change both to defensive access by checking the list
size first, compute int last = sortValues.size() - 1, use sortValues.get(last)
for the distance value (or null/optional if empty), and handle the empty case
gracefully (return null/default or skip) to avoid IndexOutOfBoundsException and
incorrect value reads.


NativeQuery nativeQuery = NativeQuery.builder()
.withQuery(query)
.withSort(scoreSort)
.withSort(geoSort)
.withPageable(PageRequest.of(0, 1)) // 첫 번째 결과만
.build();

SearchHits<WaybleZoneDocument> hits =
operations.search(nativeQuery, WaybleZoneDocument.class, INDEX);

if (hits.isEmpty()) {
return null;
}

WaybleZoneDocument doc = hits.getSearchHit(0).getContent();
Double distanceInMeters = (Double) hits.getSearchHit(0).getSortValues().get(1); // 거리는 두 번째 정렬값
Double distanceInKm = distanceInMeters / 1000.0;

return WaybleZoneSearchResponseDto.from(doc, distanceInKm);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,8 @@ public List<WaybleZoneDistrictResponseDto> searchMostLikesWaybleZoneByDistrict(S

return waybleZoneRepository.findTop3likesWaybleZonesByDistrict(district);
}

public WaybleZoneSearchResponseDto isValidWaybleZone(WaybleZoneSearchConditionDto condition) {
return waybleZoneQuerySearchRepository.findSimilarWaybleZone(condition);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,83 @@ public void findMostLikesWaybleZoneByDistrict() throws Exception{
}
}

@Test
@DisplayName("위도, 경도, 이름 정보를 바탕으로 웨이블존이 맞는지 여부 반환 테스트")
public void findIsValidWaybleZoneTest () throws Exception{
List<WaybleZone> waybleZoneList = waybleZoneRepository.findAll();
WaybleZone waybleZone = waybleZoneList.get(0);
String zoneName = waybleZone.getZoneName();
MvcResult result = mockMvc.perform(get(baseUrl + "/validate")
.header("Authorization", "Bearer " + token)
.param("latitude", String.valueOf(waybleZone.getAddress().getLatitude()))
.param("longitude", String.valueOf(waybleZone.getAddress().getLongitude()))
.param("zoneName", zoneName.substring(0, 2))
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().is2xxSuccessful())
.andReturn();

String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode root = objectMapper.readTree(json);
JsonNode dataNode = root.get("data");

System.out.println("==== 응답 결과 ====");
System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(objectMapper.readTree(json)));

WaybleZoneSearchResponseDto dto =
objectMapper.convertValue(
dataNode,
new TypeReference<>() {}
);

// 검증 로직
assertThat(dto).isNotNull();

// 반환된 결과가 유효한 WaybleZoneSearchResponseDto인지 확인
WaybleZoneInfoResponseDto infoDto = dto.waybleZoneInfo();
assertThat(infoDto).isNotNull();
assertThat(infoDto.zoneId()).isNotNull();
assertThat(infoDto.zoneName()).isNotNull();
assertThat(infoDto.zoneType()).isNotNull();
assertThat(infoDto.latitude()).isNotNull();
assertThat(infoDto.longitude()).isNotNull();

// 거리 검증 (30m 이내여야 함)
assertThat(dto.distance())
.withFailMessage("반환된 거리(%.5f km)가 30m(0.03 km)를 초과합니다", dto.distance())
.isLessThanOrEqualTo(0.03);

// 이름 유사성 검증
String requestedName = zoneName.substring(0, 2);
String foundName = infoDto.zoneName();
assertThat(foundName)
.withFailMessage("반환된 이름(%s)이 요청한 이름(%s)과 유사하지 않습니다", foundName, requestedName)
.satisfiesAnyOf(
name -> assertThat(name).contains(requestedName),
name -> assertThat(name.replaceAll("\\s+", "")).contains(requestedName.replaceAll("\\s+", "")),
name -> assertThat(requestedName).contains(name.substring(0, Math.min(2, name.length())))
);

// 정확한 거리 계산 검증
double expectedDistance = haversine(
waybleZone.getAddress().getLatitude(),
waybleZone.getAddress().getLongitude(),
infoDto.latitude(),
infoDto.longitude()
);

// 허용 오차: 0.05 km (≈50m)
assertThat(dto.distance())
.withFailMessage("계산된 거리(%.5f km)와 반환된 거리(%.5f km)가 다릅니다",
expectedDistance, dto.distance())
.isCloseTo(expectedDistance, offset(0.05));

System.out.println(" 요청한 이름: " + requestedName);
System.out.println(" 찾은 이름: " + foundName);
System.out.println(" 거리: " + String.format("%.3f km", dto.distance()));
System.out.println(" 위치: " + infoDto.latitude() + ", " + infoDto.longitude());
}

private double haversine(double lat1, double lon1, double lat2, double lon2) {
final int R = 6_371; // 지구 반지름 (km)
double dLat = Math.toRadians(lat2 - lat1);
Expand Down