Conversation
There was a problem hiding this comment.
Summary of Changes
Hello @yangjunsik, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!
이 PR은 사용자가 내부 데이터베이스와 카카오 로컬 API의 매물 정보를 통합하여 검색할 수 있는 새로운 API를 도입합니다. 사용자가 주소나 키워드만 입력해도 시스템이 자동으로 좌표를 확보하고, 내부 및 외부 검색 결과를 효율적으로 병합하여 중복을 제거하고 페이지네이션된 형태로 제공합니다. 이는 사용자에게 더욱 풍부하고 정확한 매물 검색 경험을 제공하며, 외부 API 연동을 통해 서비스의 확장성을 높입니다.
Highlights
- 새로운 통합 검색 API 엔드포인트 추가: 사용자가 주소나 키워드만 입력해도 내부 DB와 카카오 로컬 API의 결과를 통합하여 조회하는 새로운 검색 API 엔드포인트
/api/places/search가 추가되었습니다. - 카카오 Local API 연동을 통한 주소 지오코딩: 주소/키워드 검색 시 좌표가 없는 경우, 카카오 주소 지오코딩 API를 사용하여 자동으로 위도/경도를 확보하는 기능이 구현되었습니다.
- 내부 및 외부 검색 결과의 지능형 병합 및 중복 제거: 내부 DB 검색 결과와 카카오 API 검색 결과를 병합하고, 위도/경도 기반의 중복 제거 로직을 통해 내부 매물 데이터를 우선시하여 반환합니다.
- Kakao API 연동을 위한 인프라 및 데이터 모델 구축: Kakao API 연동을 위한 WebClient 설정 및 관련 DTO(PlaceSearchItemDTO, PlaceSearchResponseDTO, KakaoSearchResponse)가 추가되어 외부 데이터 처리가 용이해졌습니다.
- 정확한 위치 기반 검색을 위한 사용자 정의 리포지토리 기능 확장: 원형 반경 검색을 지원하는 사용자 정의 리포지토리 메서드가 추가되어, 특정 좌표를 중심으로 한 매물 검색이 가능해졌습니다.
Using Gemini Code Assist
The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.
Invoking Gemini
You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.
| Feature | Command | Description |
|---|---|---|
| Code Review | /gemini review |
Performs a code review for the current pull request in its current state. |
| Pull Request Summary | /gemini summary |
Provides a summary of the current pull request in its current state. |
| Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments. |
| Help | /gemini help |
Displays a list of available commands. |
Customization
To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.
Limitations & Feedback
Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.
You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.
Footnotes
-
Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩
There was a problem hiding this comment.
Code Review
이 PR은 내부 DB와 카카오 로컬 API를 결합하여 매물을 검색하는 새로운 API를 추가하는 중요한 기능 개선을 담고 있습니다. 전반적으로 WebClient를 사용한 외부 API 연동, DTO 설계, Repository 확장 등 구현이 체계적으로 잘 이루어졌습니다. 다만, 몇 가지 중요한 개선점을 제안합니다. 외부 결과를 포함할 때 페이지네이션 로직에 치명적인 오류가 있어 수정이 필요하며, 비동기 I/O의 이점을 살리지 못하는 동기식 호출 방식은 성능 저하를 유발할 수 있어 개선이 필요합니다. 또한, 코드의 안정성과 유지보수성을 높이기 위한 몇 가지 제안(타임아웃 설정, 예외 처리, 로깅 강화 등)을 포함했습니다. 이 피드백들이 더 안정적이고 효율적인 기능을 만드는 데 도움이 되길 바랍니다.
| public PlaceSearchResponseDTO searchForUser( | ||
| String q, Double lat, Double lng, Double radiusKm, | ||
| boolean includeExternal, Pageable pageable) { | ||
|
|
||
| // 0) 좌표가 없고 q만 주어졌을 때 → 카카오 address로 좌표 확보 | ||
| if ((lat == null || lng == null) && q != null && !q.isBlank()) { | ||
| KakaoSearchResponse addr = kakaoLocalClient | ||
| .searchAddress(q) | ||
| .onErrorResume(e -> Mono.just(new KakaoSearchResponse())) | ||
| .block(); | ||
| if (addr != null && addr.getDocuments() != null && !addr.getDocuments().isEmpty()) { | ||
| var d = addr.getDocuments().get(0); | ||
| try { | ||
| lng = Double.parseDouble(d.getX()); | ||
| lat = Double.parseDouble(d.getY()); | ||
| } catch (Exception ignore) { /* 좌표 파싱 실패 시 그냥 q 기반 내부 like 검색으로 진행 */ } | ||
| } | ||
| } | ||
|
|
||
| // 1) 내부 검색 | ||
| var internal = internalSearch.searchForUser(q, lat, lng, radiusKm, pageable); | ||
| if (!includeExternal) return internal; | ||
|
|
||
| // 2) 외부 검색 (키워드 기준, 좌표가 있으면 중심 반경) | ||
| KakaoSearchResponse ext = null; | ||
| if (q != null && !q.isBlank()) { | ||
| ext = kakaoLocalClient | ||
| .searchKeyword(q, (lng != null ? lng : null), (lat != null ? lat : null), | ||
| (radiusKm != null ? (int)Math.round(radiusKm * 1000) : null)) | ||
| .block(); | ||
| } | ||
|
|
||
| // 3) 내부 + 외부 병합 | ||
| var merged = new ArrayList<PlaceSearchItemDTO>(internal.getContent()); | ||
| if (ext != null && ext.getDocuments() != null) { | ||
| for (var d : ext.getDocuments()) { | ||
| Double elat = safeParse(d.getY()); | ||
| Double elng = safeParse(d.getX()); | ||
| if (elat == null || elng == null) continue; | ||
|
|
||
| PlaceSearchItemDTO dto = new PlaceSearchItemDTO(); | ||
| dto.setPlaceId(null); | ||
| dto.setLandlordId(null); | ||
| dto.setAddress(firstNonBlank(d.getRoad_address_name(), d.getAddress_name(), d.getPlace_name())); | ||
| dto.setAddressDetail(null); | ||
| dto.setPostCode(null); | ||
| dto.setGeohash(null); // 필요하면 지오해시 라이브러리로 생성 | ||
| dto.setLat(BigDecimal.valueOf(elat)); | ||
| dto.setLng(BigDecimal.valueOf(elng)); | ||
| dto.setCreatedAt(null); | ||
| dto.setUpdatedAt(null); | ||
| dto.setExternalProvider("KAKAO"); | ||
| dto.setExternalPlaceId(d.getId()); | ||
| merged.add(dto); | ||
| } | ||
| } | ||
|
|
||
| // 4) 중복 제거 (위경도 1e-5 라운딩 키) — 내부(placeId != null) 우선 | ||
| Map<String, PlaceSearchItemDTO> dedup = new LinkedHashMap<>(); | ||
| for (var item : merged) { | ||
| String key = keyByLatLng(item.getLat(), item.getLng()); | ||
| if (dedup.containsKey(key)) { | ||
| var exist = dedup.get(key); | ||
| if (exist.getPlaceId() == null && item.getPlaceId() != null) dedup.put(key, item); | ||
| } else dedup.put(key, item); | ||
| } | ||
|
|
||
| // 5) 내부 우선 정렬 + 페이지 슬라이싱(메모리) | ||
| var list = new ArrayList<>(dedup.values()); | ||
| list.sort(Comparator | ||
| .comparing((PlaceSearchItemDTO it) -> it.getPlaceId() == null) // false(내부) 먼저 | ||
| .thenComparing(PlaceSearchItemDTO::getCreatedAt, | ||
| Comparator.nullsLast(Comparator.reverseOrder())) | ||
| ); | ||
|
|
||
| int from = Math.toIntExact(pageable.getOffset()); | ||
| int to = Math.min(from + pageable.getPageSize(), list.size()); | ||
| var slice = from >= list.size() ? List.<PlaceSearchItemDTO>of() : list.subList(from, to); | ||
|
|
||
| return new PlaceSearchResponseDTO( | ||
| slice, | ||
| pageable.getPageNumber(), | ||
| pageable.getPageSize(), | ||
| list.size(), | ||
| (int) Math.ceil((double) list.size() / pageable.getPageSize()) | ||
| ); | ||
| } |
There was a problem hiding this comment.
외부(카카오) 검색 결과를 포함할 때의 페이지네이션 로직에 심각한 오류가 있습니다. 현재 로직은 내부 검색 결과의 특정 페이지만 가져온 뒤 외부 결과와 병합하여 다시 페이지네이션을 수행합니다. 이 방식은 다음과 같은 문제를 야기합니다:
- 데이터 유실: 내부 검색 결과의 앞 페이지에 있던 항목이 외부 결과에 밀려 보이지 않게 되면, 다음 페이지 요청 시에도 해당 항목을 다시 볼 수 없습니다.
- 데이터 중복: 외부 검색 결과는 페이지 요청과 상관없이 항상 동일하므로, 다른 페이지에서 동일한 외부 결과가 중복으로 나타날 수 있습니다.
이 문제를 해결하려면, 외부 결과 포함 시에는 내부 검색에서 페이지네이션을 적용하지 않고 충분한 수의 상위 결과를 가져온 후(예: 200개), 외부 결과와 병합 및 정렬을 수행하고, 그 전체 목록에 대해 메모리에서 페이지네이션을 적용해야 합니다. 이는 현재의 페이지네이션이 상태를 유지하지 않고 매번 독립적으로 동작하기 때문에 발생하는 문제입니다.
| KakaoSearchResponse addr = kakaoLocalClient | ||
| .searchAddress(q) | ||
| .onErrorResume(e -> Mono.just(new KakaoSearchResponse())) | ||
| .block(); |
There was a problem hiding this comment.
WebClient를 사용하면서 .block()을 호출하여 동기식으로 API를 사용하고 있습니다. PR 설명에 '간단/직관성 우선'으로 동기 호출을 선택했다고 언급되어 있지만, 이는 WebClient의 핵심 장점인 논블로킹(non-blocking) I/O를 활용하지 못하게 만들어 심각한 성능 저하를 유발할 수 있습니다. 특히 트래픽이 많은 상황에서는 스레드 풀이 고갈되어 전체 애플리케이션의 응답성에 영향을 줄 수 있습니다.
장기적으로는 컨트롤러부터 서비스까지 전체 호출 흐름을 Mono<PlaceSearchResponseDTO>를 반환하는 리액티브 파이프라인으로 전환하는 것을 강력히 권장합니다. 이는 시스템의 확장성과 안정성을 크게 향상시킬 것입니다.
| try { | ||
| lng = Double.parseDouble(d.getX()); | ||
| lat = Double.parseDouble(d.getY()); | ||
| } catch (Exception ignore) { /* 좌표 파싱 실패 시 그냥 q 기반 내부 like 검색으로 진행 */ } |
There was a problem hiding this comment.
| var countQ = em.createQuery("SELECT COUNT(p) " + base, Long.class) | ||
| .setParameter("minLat", BigDecimal.valueOf(minLat)) | ||
| .setParameter("maxLat", BigDecimal.valueOf(maxLat)) | ||
| .setParameter("minLng", BigDecimal.valueOf(minLng)) | ||
| .setParameter("maxLng", BigDecimal.valueOf(maxLng)); | ||
| if (landlordId != null) countQ.setParameter("landlordId", landlordId); | ||
| long total = countQ.getSingleResult(); | ||
|
|
||
| // page (createdAt 최신순) | ||
| var query = em.createQuery("SELECT p " + base + " ORDER BY p.createdAt DESC", Place.class) | ||
| .setParameter("minLat", BigDecimal.valueOf(minLat)) | ||
| .setParameter("maxLat", BigDecimal.valueOf(maxLat)) | ||
| .setParameter("minLng", BigDecimal.valueOf(minLng)) | ||
| .setParameter("maxLng", BigDecimal.valueOf(maxLng)) | ||
| .setFirstResult((int) pageable.getOffset()) | ||
| .setMaxResults(pageable.getPageSize()); | ||
| if (landlordId != null) query.setParameter("landlordId", landlordId); |
| .onStatus(s -> !s.is2xxSuccessful(), rsp -> | ||
| rsp.bodyToMono(String.class).map(body -> | ||
| new IllegalStateException("Kakao address API error " + rsp.statusCode() + " body=" + body))) | ||
| .bodyToMono(KakaoSearchResponse.class); |
| .onStatus(s -> !s.is2xxSuccessful(), rsp -> | ||
| rsp.bodyToMono(String.class).map(body -> | ||
| new IllegalStateException("Kakao keyword API error " + rsp.statusCode() + " body=" + body))) | ||
| .bodyToMono(KakaoSearchResponse.class); |
| public WebClient kakaoWebClient(@Value("${kakao.rest-api-key}") String kakaoKey) { | ||
| return WebClient.builder() | ||
| .baseUrl("https://dapi.kakao.com") | ||
| .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoKey) | ||
| .build(); | ||
| } |
There was a problem hiding this comment.
WebClient Bean을 설정할 때 타임아웃을 지정하지 않았습니다. 외부 API가 응답하지 않을 경우, 해당 요청을 처리하는 스레드가 무한정 대기 상태에 빠져 시스템 전체의 가용성에 영향을 줄 수 있습니다. HttpClient를 사용하여 connection/response 타임아웃을 설정하여 외부 서비스 장애로부터 애플리케이션을 보호하는 것이 매우 중요합니다.
public WebClient kakaoWebClient(@Value("${kakao.rest-api-key}") String kakaoKey) {
io.netty.channel.ChannelOption connectTimeoutMillis = io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS;
reactor.netty.http.client.HttpClient httpClient = reactor.netty.http.client.HttpClient.create()
.option(connectTimeoutMillis, 5000) // 5초
.responseTimeout(java.time.Duration.ofMillis(5000));
return WebClient.builder()
.baseUrl("https://dapi.kakao.com")
.clientConnector(new org.springframework.http.client.reactive.ReactorClientHttpConnector(httpClient))
.defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoKey)
.build();
}| @RequestParam(required = false) Double radiusKm, | ||
|
|
||
| @Parameter(description = "외부(카카오) 결과 포함 여부", example = "true") | ||
| @RequestParam(defaultValue = "false") boolean includeExternal, |
There was a problem hiding this comment.
feat: 사용자용 매물 검색 API (내부 DB + 카카오 합성)
요약
사용자가 주소/키워드만 입력해도 내부 DB와 카카오 로컬 API 결과를 한 번에 조회하는 검색 API 추가
좌표가 없으면 서버가 카카오 주소 지오코딩으로 lat/lng를 확보 후 반경 검색
결과는 내부 매물 우선으로 중복 제거/정렬하여 페이지네이션 형태로 반환
주요 변경사항
Service
PlaceSearchService#searchForUser(...): 내부 DB 전용 사용자 검색CombinedPlaceSearchService#searchForUser(...): 내부 + 외부(카카오) 합성 검색External
KakaoLocalClient: 카카오 주소/키워드 검색 WebClient 클라이언트WebClientConfig: Kakao WebClient Bean (Authorization 헤더 주입)DTO
PlaceSearchItemDTO: 외부 결과 표기를 위한externalProvider,externalPlaceId필드 포함PlaceSearchResponseDTO: 페이지 정보 포함 응답Controller
GET /api/places/search: 사용자용 검색 엔드포인트Swagger 문서화(내부-only/외부 포함 예시 2종, 파라미터 예시)
API 사양
Endpoint
Query Params
응답 (요약)
동작 방식
좌표 유무 판단
lat/lng가 없고q만 있을 때 →KakaoLocalClient.searchAddress(q)로 좌표 확보 시도내부 검색
좌표가 있으면 반경 검색, 없으면
address LIKE %q%외부 결과 병합 (옵션)
includeExternal=true인 경우searchKeyword(q, [x,y,radius])호출내부 + 외부 결과를 위·경도 1e-5 라운딩 키로 중복 제거 (내부
placeId존재 항목 우선)내부 우선 정렬 후 메모리 페이징하여 반환
설정/환경
WebClientConfigapplication.ymlKakao Developers 콘솔
해당 앱에서 제품 설정 → 로컬(Local) 활성화 필요
미활성화 시:
403 FORBIDDEN NotAuthorizedError: App ... disabled OPEN_MAP_AND_LOCAL service.에러 처리 / 로깅
KakaoLocalClient에서onStatus로 2xx 외 상태코드 시 본문 로깅 후 예외 발생시킴(디버깅 편의 / 장애 인지 용이)
컨트롤러 공통 응답 포맷으로
INTERNAL_ERROR반환테스트 방법
환경 변수 세팅
카카오 콘솔에서 로컬(Local) 제품 활성화
Swagger 호출
내부만:
/api/places/search?q=센텀&page=0&size=20외부 포함:
/api/places/search?q=남천동&includeExternal=true&page=0&size=20좌표 반경:
/api/places/search?lat=35.1661&lng=129.1322&radiusKm=1&includeExternal=true성능/운영 메모
초기 버전은 동기(block) 호출 및 메모리 페이징 (간단/직관성 우선)
추후 개선 포인트
WebClient 타임아웃/리트라이/캐시(Caffeine 1~5분)
외부 결과 수 제한/정렬 개선
메모리 페이징 → Cursor/Keyset 페이징 전환
“부산” 등 지역 프리픽스/바운딩 박스 적용(노이즈 감소)
보안/과금
외부 API 키는 환경변수로 주입, 코드/레포에 노출 금지
불필요한 외부 호출을 줄이기 위해 최소 검색 길이(예: 2자) 검증 고려
호환/영향 범위
기존 진단 API와 독립. 프론트는 검색 결과에서
placeId유무로 내부/외부 구분 가능DB 스키마 변경 없음 (단,
PlaceSearchItemDTO에 외부 표기 필드 추가)체크리스트
GET /api/places/search컨트롤러 추가 및 Swagger 문서내부/외부 합성 로직 및 중복 제거
Kakao WebClient/Config/키 주입
403 NotAuthorizedError 재현 및 문서화
성공/에러 예시 응답 추가
스크린샷/로그 (참고)
403 발생 시: