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
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
implementation ("org.springframework.boot:spring-boot-starter-webflux")

implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/busan/config/WebClientConfig.java
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();
}
Comment on lines +13 to +18
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();
    }

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Diagnosis", description = "진단하기(합성 API)")
@Tag(name = "diagnosis-controller", description = "진단하기(합성 API)")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
Expand Down
156 changes: 156 additions & 0 deletions src/main/java/com/busan/controller/PlaceSearchController.java
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,
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,


@ParameterObject Pageable pageable
) {
var dto = combinedPlaceSearchService.searchForUser(q, lat, lng, radiusKm, includeExternal, pageable);
return ResponseEntity.ok(Response.success(dto));
}
}
26 changes: 26 additions & 0 deletions src/main/java/com/busan/dto/place/PlaceSearchItemDTO.java
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 src/main/java/com/busan/dto/place/PlaceSearchResponseDTO.java
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;
}
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; // 위도 문자열
}
}
12 changes: 11 additions & 1 deletion src/main/java/com/busan/repository/PlaceRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@

import com.busan.entity.Place;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.*;

import java.util.List;
import java.util.Optional;

public interface PlaceRepository extends JpaRepository<Place, Long> {
public interface PlaceRepository extends JpaRepository<Place, Long>, PlaceRepositoryCustom {
Optional<Place> findTopByAddressAndAddressDetail(String address, String addressDetail);
List<Place> findTop8ByLandlord_LandlordIdOrderByCreatedAtDesc(Long landlordId);


Page<Place> findByAddressContainingIgnoreCase(String q, Pageable pageable);

Page<Place> findByAddressContainingIgnoreCaseAndLandlord_LandlordId(String q, Long landlordId, Pageable pageable);

Page<Place> findByGeohashStartingWith(String geohashPrefix, Pageable pageable);

Page<Place> findByLandlord_LandlordId(Long landlordId, Pageable pageable);
}
9 changes: 9 additions & 0 deletions src/main/java/com/busan/repository/PlaceRepositoryCustom.java
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 src/main/java/com/busan/repository/PlaceRepositoryImpl.java
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
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 헬퍼 메서드로 추출하여 중복을 제거하고 코드의 유지보수성을 높이는 것을 권장합니다.


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));
}
}
Loading