Skip to content

Feature/#3 map api#15

Merged
yangjunsik merged 2 commits intomainfrom
feature/#3-map-api
Aug 21, 2025
Merged

Feature/#3 map api#15
yangjunsik merged 2 commits intomainfrom
feature/#3-map-api

Conversation

@yangjunsik
Copy link
Copy Markdown
Contributor

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

GET /api/places/search

Query Params

이름 타입 필수 설명
q string 선택 주소/키워드. 좌표 없을 시 서버가 카카오 주소검색으로 좌표 확보 시도
lat number 선택 중심 위도
lng number 선택 중심 경도
radiusKm number 선택(기본 1) 반경 km
includeExternal boolean 선택(기본 true) true 시 카카오 결과 병합
page, size number 선택 페이지네이션

응답 (요약)

{
  "data": {
    "content": [
      {
        "placeId": 101,
        "landlordId": 55,
        "address": "부산 해운대구 ...",
        "addressDetail": "101동 1001호",
        "geohash": "u4pruyd...",
        "lat": 35.1661,
        "lng": 129.1322,
        "createdAt": "2025-08-12T13:00:00",
        "updatedAt": "2025-08-18T11:30:00",
        "externalProvider": null,
        "externalPlaceId": null
      },
      {
        "placeId": null,
        "landlordId": null,
        "address": "부산 해운대구 ...",
        "lat": 35.1668,
        "lng": 129.1330,
        "externalProvider": "KAKAO",
        "externalPlaceId": "123456"
      }
    ],
    "page": 0, "size": 20, "totalElements": 2, "totalPages": 1
  },
  "status": "SUCCESS",
  "serverDateTime": "2025-08-21T20:00:00",
  "errorCode": null,
  "errorMessage": null
}

동작 방식

  1. 좌표 유무 판단

    • lat/lng가 없고 q만 있을 때 → KakaoLocalClient.searchAddress(q)좌표 확보 시도

  2. 내부 검색

    • 좌표가 있으면 반경 검색, 없으면 address LIKE %q%

  3. 외부 결과 병합 (옵션)

    • includeExternal=true인 경우 searchKeyword(q, [x,y,radius]) 호출

    • 내부 + 외부 결과를 위·경도 1e-5 라운딩 키중복 제거 (내부 placeId 존재 항목 우선)

    • 내부 우선 정렬 후 메모리 페이징하여 반환


설정/환경

  • WebClientConfig

    @Bean
    public WebClient kakaoWebClient(@Value("${kakao.rest-api-key}") String kakaoKey) {
        return WebClient.builder()
            .baseUrl("https://dapi.kakao.com")
            .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoKey)
            .build();
    }
    
  • application.yml

    kakao:
      rest-api-key: ${KAKAO_REST_API_KEY}
    
  • Kakao Developers 콘솔

    • 해당 앱에서 제품 설정 → 로컬(Local) 활성화 필요

    • 미활성화 시: 403 FORBIDDEN NotAuthorizedError: App ... disabled OPEN_MAP_AND_LOCAL service.


에러 처리 / 로깅

  • KakaoLocalClient에서 onStatus로 2xx 외 상태코드 시 본문 로깅 후 예외 발생시킴
    (디버깅 편의 / 장애 인지 용이)

  • 컨트롤러 공통 응답 포맷으로 INTERNAL_ERROR 반환


테스트 방법

  1. 환경 변수 세팅

    export KAKAO_REST_API_KEY=xxxx
    
  2. 카카오 콘솔에서 로컬(Local) 제품 활성화

  3. 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 발생 시:

    Kakao keyword API error 403 FORBIDDEN body={"errorType":"NotAuthorizedError","message":"App(...) disabled OPEN_MAP_AND_LOCAL service."}
    

@yangjunsik yangjunsik self-assigned this Aug 21, 2025
@yangjunsik yangjunsik merged commit affa34d into main Aug 21, 2025
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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

  1. 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.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

이 PR은 내부 DB와 카카오 로컬 API를 결합하여 매물을 검색하는 새로운 API를 추가하는 중요한 기능 개선을 담고 있습니다. 전반적으로 WebClient를 사용한 외부 API 연동, DTO 설계, Repository 확장 등 구현이 체계적으로 잘 이루어졌습니다. 다만, 몇 가지 중요한 개선점을 제안합니다. 외부 결과를 포함할 때 페이지네이션 로직에 치명적인 오류가 있어 수정이 필요하며, 비동기 I/O의 이점을 살리지 못하는 동기식 호출 방식은 성능 저하를 유발할 수 있어 개선이 필요합니다. 또한, 코드의 안정성과 유지보수성을 높이기 위한 몇 가지 제안(타임아웃 설정, 예외 처리, 로깅 강화 등)을 포함했습니다. 이 피드백들이 더 안정적이고 효율적인 기능을 만드는 데 도움이 되길 바랍니다.

Comment on lines +29 to +115
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())
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

외부(카카오) 검색 결과를 포함할 때의 페이지네이션 로직에 심각한 오류가 있습니다. 현재 로직은 내부 검색 결과의 특정 페이지만 가져온 뒤 외부 결과와 병합하여 다시 페이지네이션을 수행합니다. 이 방식은 다음과 같은 문제를 야기합니다:

  1. 데이터 유실: 내부 검색 결과의 앞 페이지에 있던 항목이 외부 결과에 밀려 보이지 않게 되면, 다음 페이지 요청 시에도 해당 항목을 다시 볼 수 없습니다.
  2. 데이터 중복: 외부 검색 결과는 페이지 요청과 상관없이 항상 동일하므로, 다른 페이지에서 동일한 외부 결과가 중복으로 나타날 수 있습니다.

이 문제를 해결하려면, 외부 결과 포함 시에는 내부 검색에서 페이지네이션을 적용하지 않고 충분한 수의 상위 결과를 가져온 후(예: 200개), 외부 결과와 병합 및 정렬을 수행하고, 그 전체 목록에 대해 메모리에서 페이지네이션을 적용해야 합니다. 이는 현재의 페이지네이션이 상태를 유지하지 않고 매번 독립적으로 동작하기 때문에 발생하는 문제입니다.

Comment on lines +35 to +38
KakaoSearchResponse addr = kakaoLocalClient
.searchAddress(q)
.onErrorResume(e -> Mono.just(new KakaoSearchResponse()))
.block();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

WebClient를 사용하면서 .block()을 호출하여 동기식으로 API를 사용하고 있습니다. PR 설명에 '간단/직관성 우선'으로 동기 호출을 선택했다고 언급되어 있지만, 이는 WebClient의 핵심 장점인 논블로킹(non-blocking) I/O를 활용하지 못하게 만들어 심각한 성능 저하를 유발할 수 있습니다. 특히 트래픽이 많은 상황에서는 스레드 풀이 고갈되어 전체 애플리케이션의 응답성에 영향을 줄 수 있습니다.

장기적으로는 컨트롤러부터 서비스까지 전체 호출 흐름을 Mono<PlaceSearchResponseDTO>를 반환하는 리액티브 파이프라인으로 전환하는 것을 강력히 권장합니다. 이는 시스템의 확장성과 안정성을 크게 향상시킬 것입니다.

Comment on lines +41 to +44
try {
lng = Double.parseDouble(d.getX());
lat = Double.parseDouble(d.getY());
} catch (Exception ignore) { /* 좌표 파싱 실패 시 그냥 q 기반 내부 like 검색으로 진행 */ }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

카카오 API 응답에서 좌표를 파싱할 때 catch (Exception ignore)를 사용하여 모든 예외를 무시하고 있습니다. 이는 NumberFormatException 외에 다른 예기치 않은 런타임 예외까지 숨겨버릴 수 있어 잠재적인 버그를 찾기 어렵게 만듭니다.

예외는 더 구체적인 NumberFormatException으로 잡고, 최소한 warn 레벨의 로그를 남겨 어떤 데이터가 파싱에 실패했는지 추적할 수 있도록 하는 것이 좋습니다. 이는 디버깅과 시스템 모니터링에 큰 도움이 됩니다.

Comment on lines +37 to +53
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

count 쿼리와 page 쿼리를 생성하고 파라미터를 설정하는 코드가 중복되어 있습니다. 이는 향후 쿼리 조건이 변경될 때 실수를 유발할 수 있습니다. 이 로직을 별도의 private 헬퍼 메서드로 추출하여 중복을 제거하고 코드의 유지보수성을 높이는 것을 권장합니다.

Comment on lines +20 to +23
.onStatus(s -> !s.is2xxSuccessful(), rsp ->
rsp.bodyToMono(String.class).map(body ->
new IllegalStateException("Kakao address API error " + rsp.statusCode() + " body=" + body)))
.bodyToMono(KakaoSearchResponse.class);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

카카오 API 호출 실패 시 IllegalStateException을 발생시키기 전에 에러 응답의 상태 코드와 본문을 로깅하는 것이 좋습니다. 현재는 예외 메시지에만 정보가 담겨있어, 서비스 운영 중 에러가 발생했을 때 로그만으로 원인을 파악하기 어렵습니다. 에러가 발생한 시점에 로그를 남기면 디버깅이 훨씬 용이해집니다.

Comment on lines +37 to +40
.onStatus(s -> !s.is2xxSuccessful(), rsp ->
rsp.bodyToMono(String.class).map(body ->
new IllegalStateException("Kakao keyword API error " + rsp.statusCode() + " body=" + body)))
.bodyToMono(KakaoSearchResponse.class);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

카카오 API 호출 실패 시 IllegalStateException을 발생시키기 전에 에러 응답의 상태 코드와 본문을 로깅하는 것이 좋습니다. 현재는 예외 메시지에만 정보가 담겨있어, 서비스 운영 중 에러가 발생했을 때 로그만으로 원인을 파악하기 어렵습니다. 에러가 발생한 시점에 로그를 남기면 디버깅이 훨씬 용이해집니다.

Comment on lines +13 to +18
public WebClient kakaoWebClient(@Value("${kakao.rest-api-key}") String kakaoKey) {
return WebClient.builder()
.baseUrl("https://dapi.kakao.com")
.defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoKey)
.build();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

컨트롤러의 includeExternal 파라미터 기본값이 false로 설정되어 있습니다. 하지만 PR 설명의 API 사양에는 기본값이 true라고 명시되어 있어 문서와 실제 동작이 일치하지 않습니다. API 사용자의 혼란을 막기 위해 둘 중 하나로 통일하는 것이 좋습니다.

Suggested change
@RequestParam(defaultValue = "false") boolean includeExternal,
@RequestParam(defaultValue = "true") boolean includeExternal,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant