-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/#3 map api #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.busan.config; | ||
|
|
||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.context.annotation.*; | ||
| import org.springframework.http.HttpHeaders; | ||
| import org.springframework.web.reactive.function.client.WebClient; | ||
|
|
||
| @Configuration | ||
| public class 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(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
156 changes: 156 additions & 0 deletions
156
src/main/java/com/busan/controller/PlaceSearchController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| package com.busan.controller; | ||
|
|
||
| import com.busan.dto.common.Response; | ||
| import com.busan.dto.place.PlaceSearchResponseDTO; | ||
| import com.busan.service.CombinedPlaceSearchService; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.Parameter; | ||
| import io.swagger.v3.oas.annotations.media.Content; | ||
| import io.swagger.v3.oas.annotations.media.ExampleObject; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springdoc.core.annotations.ParameterObject; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| @Tag(name = "places-controller", description = "매물 검색 API") | ||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/api/places") | ||
| public class PlaceSearchController { | ||
|
|
||
| private final CombinedPlaceSearchService combinedPlaceSearchService; | ||
|
|
||
| @Operation( | ||
| summary = "매물 검색 (내부 + 외부 합성 지원)", | ||
| description = """ | ||
| - 입력: | ||
| - q: 주소/키워드 (예: "센텀동로 45"). q만 주어지면 서버가 카카오 주소검색으로 좌표를 얻습니다. | ||
| - lat/lng: 검색 중심 좌표(선택). 있으면 반경 검색에 사용합니다. | ||
| - radiusKm: 반경(km), 기본=1. | ||
| - includeExternal: true 이면 카카오 결과도 함께 병합하여 반환(내부 결과 우선). | ||
| - page/size: 페이징. | ||
|
|
||
| - 동작 우선순위: | ||
| 1) lat/lng/radiusKm → 내부 DB 반경 검색 (+옵션: 외부 병합) | ||
| 2) q만 있을 때 → 카카오 주소검색으로 lat/lng 확보 후 1)과 동일 | ||
| 3) 어떤 조건도 없으면 내부 DB 최신순 페이지 반환 | ||
|
|
||
| - 예시 호출: | ||
| - /api/places/search?q=센텀&page=0&size=20 | ||
| - /api/places/search?lat=35.1661&lng=129.1322&radiusKm=1&includeExternal=true&page=0&size=20 | ||
| """ | ||
| , | ||
| responses = { | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "200", | ||
| description = "성공 (내부 결과만)", | ||
| content = @Content(examples = @ExampleObject( | ||
| name = "internal-only", | ||
| value = """ | ||
| { | ||
| "data": { | ||
| "content": [ | ||
| { | ||
| "placeId": 101, | ||
| "landlordId": 55, | ||
| "address": "부산 동래구 ...", | ||
| "addressDetail": "101동 1001호", | ||
| "postCode": "47890", | ||
| "geohash": "u4pruyd...", | ||
| "lat": 35.1952, | ||
| "lng": 129.0871, | ||
| "createdAt": "2025-08-12T13:00:00", | ||
| "updatedAt": "2025-08-18T11:30:00", | ||
| "externalProvider": null, | ||
| "externalPlaceId": null | ||
| } | ||
| ], | ||
| "page": 0, | ||
| "size": 20, | ||
| "totalElements": 42, | ||
| "totalPages": 3 | ||
| }, | ||
| "status": "SUCCESS", | ||
| "serverDateTime": "2025-08-18T20:00:00", | ||
| "errorCode": null, | ||
| "errorMessage": null | ||
| } | ||
| """))) | ||
| , | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse( | ||
| responseCode = "200", | ||
| description = "성공 (외부 결과 포함)", | ||
| content = @Content(examples = @ExampleObject( | ||
| name = "with-external", | ||
| value = """ | ||
| { | ||
| "data": { | ||
| "content": [ | ||
| { | ||
| "placeId": 101, | ||
| "landlordId": 55, | ||
| "address": "부산 동래구 ...", | ||
| "addressDetail": "101동 1001호", | ||
| "postCode": "47890", | ||
| "geohash": "u4pruyd...", | ||
| "lat": 35.1952, | ||
| "lng": 129.0871, | ||
| "createdAt": "2025-08-12T13:00:00", | ||
| "updatedAt": "2025-08-18T11:30:00", | ||
| "externalProvider": null, | ||
| "externalPlaceId": null | ||
| }, | ||
| { | ||
| "placeId": null, | ||
| "landlordId": null, | ||
| "address": "부산 해운대구 ...", | ||
| "addressDetail": null, | ||
| "postCode": null, | ||
| "geohash": null, | ||
| "lat": 35.1661, | ||
| "lng": 129.1322, | ||
| "createdAt": null, | ||
| "updatedAt": null, | ||
| "externalProvider": "KAKAO", | ||
| "externalPlaceId": "123456" | ||
| } | ||
| ], | ||
| "page": 0, | ||
| "size": 20, | ||
| "totalElements": 2, | ||
| "totalPages": 1 | ||
| }, | ||
| "status": "SUCCESS", | ||
| "serverDateTime": "2025-08-18T20:00:00", | ||
| "errorCode": null, | ||
| "errorMessage": null | ||
| } | ||
| """))) | ||
|
|
||
| } | ||
| ) | ||
| @GetMapping("/search") | ||
| public ResponseEntity<Response<PlaceSearchResponseDTO>> search( | ||
| @Parameter(description = "주소 키워드(부분검색)", example = "센텀") | ||
| @RequestParam(required = false) String q, | ||
|
|
||
| @Parameter(description = "위도", example = "35.1661") | ||
| @RequestParam(required = false) Double lat, | ||
|
|
||
| @Parameter(description = "경도", example = "129.1322") | ||
| @RequestParam(required = false) Double lng, | ||
|
|
||
| @Parameter(description = "반경(km)", example = "1") | ||
| @RequestParam(required = false) Double radiusKm, | ||
|
|
||
| @Parameter(description = "외부(카카오) 결과 포함 여부", example = "true") | ||
| @RequestParam(defaultValue = "false") boolean includeExternal, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| @ParameterObject Pageable pageable | ||
| ) { | ||
| var dto = combinedPlaceSearchService.searchForUser(q, lat, lng, radiusKm, includeExternal, pageable); | ||
| return ResponseEntity.ok(Response.success(dto)); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.busan.dto.place; | ||
|
|
||
| import lombok.*; | ||
| import java.math.BigDecimal; | ||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class PlaceSearchItemDTO { | ||
| private Long placeId; | ||
| private Long landlordId; | ||
| private String address; | ||
| private String addressDetail; // 있으면 내려줌 | ||
| private String postCode; // 엔티티에 없으면 null 그대로 | ||
| private String geohash; | ||
| private BigDecimal lat; | ||
| private BigDecimal lng; | ||
| private LocalDateTime createdAt; | ||
| private LocalDateTime updatedAt; | ||
|
|
||
| // ↓↓↓ 외부 검색 전용 필드 (우리 DB에 없을 수 있음) | ||
| private String externalProvider; // "KAKAO" | ||
| private String externalPlaceId; // Kakao place_id (Document.id) | ||
| } |
16 changes: 16 additions & 0 deletions
16
src/main/java/com/busan/dto/place/PlaceSearchResponseDTO.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.busan.dto.place; | ||
|
|
||
| import lombok.*; | ||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class PlaceSearchResponseDTO { | ||
| private List<PlaceSearchItemDTO> content; | ||
| private int page; | ||
| private int size; | ||
| private long totalElements; | ||
| private int totalPages; | ||
| } |
36 changes: 36 additions & 0 deletions
36
src/main/java/com/busan/dto/place/external/KakaoSearchResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package com.busan.dto.place.external; | ||
|
|
||
| import lombok.*; | ||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public class KakaoSearchResponse { | ||
| private Meta meta; | ||
| private List<Document> documents; | ||
|
|
||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public static class Meta { | ||
| private int total_count; | ||
| private int pageable_count; | ||
| private boolean is_end; | ||
| } | ||
|
|
||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| @AllArgsConstructor | ||
| public static class Document { | ||
| private String id; // place_id (키워드 검색일 때) | ||
| private String place_name; // 키워드 검색 | ||
| private String address_name; // 지번 주소 | ||
| private String road_address_name; // 도로명 | ||
| private String x; // 경도 문자열 | ||
| private String y; // 위도 문자열 | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 9 additions & 0 deletions
9
src/main/java/com/busan/repository/PlaceRepositoryCustom.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.busan.repository; | ||
|
|
||
| import com.busan.entity.Place; | ||
| import org.springframework.data.domain.*; | ||
|
|
||
| public interface PlaceRepositoryCustom { | ||
| /** lat/lng 중심 반경(km) 원 검색 (Bounding Box + Haversine 보정) */ | ||
| Page<Place> searchByCircle(double lat, double lng, double radiusKm, Long landlordId, Pageable pageable); | ||
| } |
81 changes: 81 additions & 0 deletions
81
src/main/java/com/busan/repository/PlaceRepositoryImpl.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| package com.busan.repository; | ||
|
|
||
| import com.busan.entity.Place; | ||
| import jakarta.persistence.EntityManager; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.*; | ||
| import org.springframework.stereotype.Repository; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.util.List; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class PlaceRepositoryImpl implements PlaceRepositoryCustom { | ||
|
|
||
| private final EntityManager em; | ||
|
|
||
| @Override | ||
| public Page<Place> searchByCircle(double lat, double lng, double radiusKm, Long landlordId, Pageable pageable) { | ||
| // 1) 대략적 Bounding Box (위경도 1도 ≈ 111.32km 기준) | ||
| double degPerKmLat = 1.0 / 111.32; | ||
| double degPerKmLng = 1.0 / (111.32 * Math.cos(Math.toRadians(lat))); | ||
|
|
||
| double minLat = lat - radiusKm * degPerKmLat; | ||
| double maxLat = lat + radiusKm * degPerKmLat; | ||
| double minLng = lng - radiusKm * degPerKmLng; | ||
| double maxLng = lng + radiusKm * degPerKmLng; | ||
|
|
||
| String base = """ | ||
| FROM Place p | ||
| WHERE p.lat BETWEEN :minLat AND :maxLat | ||
| AND p.lng BETWEEN :minLng AND :maxLng | ||
| """; | ||
| if (landlordId != null) base += " AND p.landlord.landlordId = :landlordId"; | ||
|
|
||
| // count | ||
| 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); | ||
|
Comment on lines
+37
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| List<Place> rough = query.getResultList(); | ||
|
|
||
| // 2) (선택) 하버사인으로 실제 원거리 필터링 — 정확도 필요 시 켜기 | ||
| // 지금은 성능/단순화를 위해 생략하거나, 필요 시 아래 주석 해제 | ||
| /* | ||
| List<Place> filtered = rough.stream().filter(p -> { | ||
| double d = haversine(lat, lng, p.getLat().doubleValue(), p.getLng().doubleValue()); | ||
| return d <= radiusKm; | ||
| }).toList(); | ||
| return new PageImpl<>(filtered, pageable, total); | ||
| */ | ||
|
|
||
| return new PageImpl<>(rough, pageable, total); | ||
| } | ||
|
|
||
| // 정확도가 꼭 필요하면 위 주석을 해제하고 사용 | ||
| @SuppressWarnings("unused") | ||
| private static double haversine(double lat1, double lon1, double lat2, double lon2) { | ||
| double R = 6371.0088; // km | ||
| double dLat = Math.toRadians(lat2 - lat1); | ||
| double dLon = Math.toRadians(lon2 - lon1); | ||
| double a = Math.sin(dLat/2)*Math.sin(dLat/2) | ||
| + Math.cos(Math.toRadians(lat1))*Math.cos(Math.toRadians(lat2)) | ||
| + Math.sin(dLon/2)*Math.sin(dLon/2); | ||
| return 2 * R * Math.asin(Math.sqrt(a)); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WebClientBean을 설정할 때 타임아웃을 지정하지 않았습니다. 외부 API가 응답하지 않을 경우, 해당 요청을 처리하는 스레드가 무한정 대기 상태에 빠져 시스템 전체의 가용성에 영향을 줄 수 있습니다.HttpClient를 사용하여 connection/response 타임아웃을 설정하여 외부 서비스 장애로부터 애플리케이션을 보호하는 것이 매우 중요합니다.