From 3e75be80485dc7c08ff39952a4b0cf3fc5d3e876 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:23:20 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=A7=80=EB=8F=84=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=83=88=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=BB=AC=EB=A0=89=EC=85=98=20=EA=B2=80=EC=83=89=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../collection/api/CollectionController.java | 50 +++++++++++++ .../SearchNearbyCollectionsRequest.java | 37 ++++++++++ .../SearchNearbyCollectionsResponse.java | 22 ++++++ .../application/CollectionQueryService.java | 74 +++++++++++++++++-- .../dto/SearchNearbyCollectionsCommand.java | 11 +++ .../core/repository/CollectionRepository.java | 47 ++++++++++++ 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java diff --git a/build.gradle b/build.gradle index 83107669..effb9872 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java index e84e1dc7..ea298752 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.collection.api.dto.request.*; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.CreateCollectionImageResponse; @@ -17,11 +18,13 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.PresignResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionResponse; import org.devkor.apu.saerok_server.domain.collection.application.CollectionCommandService; import org.devkor.apu.saerok_server.domain.collection.application.CollectionImageCommandService; import org.devkor.apu.saerok_server.domain.collection.application.CollectionQueryService; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.NearbyCollectionsMode; import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionWebMapper; import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; @@ -370,6 +373,53 @@ public void deleteCollectionImage( collectionImageCommandService.deleteCollectionImage(userId, collectionId, imageId); } + @PostMapping("/nearby/search") + @PermitAll + @Operation( + summary = "새 이름으로 주위 컬렉션 검색 (인증: optional)", + security = @SecurityRequirement(name = "bearerAuth"), + description = """ + 현재 위치를 기준으로 새 한국어 이름에 검색어가 포함된 컬렉션을 조회합니다. + + - 최초 검색 반경은 기본 5km이며 `radiusMeters`로 변경할 수 있습니다. + - 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다. + - 최대 반경에도 결과가 없으면 `noResults`가 true로 반환됩니다. + - 검색 결과는 기본 최대 60건이며, 반경 확장마다 최대 개수가 절반으로 줄고 기본 요청은 최소 10건으로 유지됩니다. + 클라이언트가 10건보다 작은 limit을 지정하면 그 값을 넘지 않습니다. + - 현재는 동정 완료된 새의 한국어 이름만 검색합니다. `tempBirdName`은 아직 검색에 포함하지 않습니다. + """, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = SearchNearbyCollectionsRequest.class)) + ), + responses = { + @ApiResponse( + responseCode = "200", description = "검색 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SearchNearbyCollectionsResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content) + } + ) + public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @Valid @RequestBody SearchNearbyCollectionsRequest request + ) { + Long userId = userPrincipal == null ? null : userPrincipal.getId(); + return collectionQueryService.searchNearbyCollectionsByBirdName( + new SearchNearbyCollectionsCommand( + userId, + request.getLatitude(), + request.getLongitude(), + request.getRadiusMeters(), + request.getQuery(), + request.getLimit() + ) + ); + } + @GetMapping("/nearby") @PermitAll @Operation( diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java new file mode 100644 index 00000000..c70e6d57 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java @@ -0,0 +1,37 @@ +package org.devkor.apu.saerok_server.domain.collection.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "새 이름으로 주위 컬렉션을 검색하는 요청 DTO") +public class SearchNearbyCollectionsRequest { + + @NotNull(message = "위도를 입력해주세요.") + @Schema(description = "검색 중심 위도", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED) + private Double latitude; + + @NotNull(message = "경도를 입력해주세요.") + @Schema(description = "검색 중심 경도", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED) + private Double longitude; + + @NotBlank(message = "query를 입력해주세요.") + @Schema(description = "새 이름 검색어", example = "까치", requiredMode = Schema.RequiredMode.REQUIRED) + private String query; + + @NotNull(message = "radiusMeters를 입력해주세요.") + @Positive(message = "radiusMeters는 0보다 커야 해요.") + @Schema(description = "최초 검색 반경 (m). 생략 시 5km", example = "5000") + private Double radiusMeters = 5_000D; + + @Positive(message = "limit은 1 이상의 양수여야 해요.") + @Max(value = 60, message = "limit은 60 이하여야 해요.") + @Schema(description = "최초 검색 결과 최대 개수 (생략 시 60, 최대 60). 검색 반경이 늘어날수록 반환 개수도 줄어듭니다.", example = "60") + private Integer limit; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java new file mode 100644 index 00000000..50d010be --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/SearchNearbyCollectionsResponse.java @@ -0,0 +1,22 @@ +package org.devkor.apu.saerok_server.domain.collection.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "새 이름으로 검색한 주위 컬렉션 조회 응답 DTO") +public class SearchNearbyCollectionsResponse { + + @Schema(description = "검색 결과 컬렉션 목록") + private List items; + + @Schema( + description = "최대 검색 반경까지 확장했지만 검색 결과가 없는지 여부", + example = "false" + ) + private boolean noResults; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index 57f320d6..e21defdb 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -4,8 +4,10 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionEditDataResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetCollectionEditDataCommand; import org.devkor.apu.saerok_server.domain.collection.application.dto.GetNearbyCollectionsCommand; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.application.strategy.NearbyCollectionsStrategy; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; @@ -39,6 +41,11 @@ @Transactional(readOnly = true) public class CollectionQueryService { + private static final double MAX_BIRD_NAME_SEARCH_RADIUS_METERS = 150_000; + private static final int DEFAULT_BIRD_NAME_SEARCH_LIMIT = 60; + private static final int MAX_BIRD_NAME_SEARCH_LIMIT = 60; + private static final int MIN_BIRD_NAME_SEARCH_LIMIT = 10; + private final CollectionRepository collectionRepository; private final CollectionImageRepository collectionImageRepository; private final CollectionLikeRepository collectionLikeRepository; @@ -185,6 +192,62 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom return response; } + response.setItems(toNearbyCollectionItems(collections, command.userId())); + return response; + } + + public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName(SearchNearbyCollectionsCommand command) { + if (command.userId() != null) { + userRepository.findById(command.userId()).orElseThrow(() -> new NotFoundException("유효하지 않은 사용자 id예요")); + } + + // 1) 근거리 컬렉션 조회 (전략 기반) + Point refPoint = PointFactory.create(command.latitude(), command.longitude()); + double radiusMeters = Math.min(command.initialRadiusMeters(), MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + int attempt = 0; + + while (true) { + List collections = collectionRepository.findNearbyByBirdName( + refPoint, + radiusMeters, + command.query().trim(), + command.userId(), + calculateBirdNameSearchLimit(command.limit(), attempt) + ); + + if (!collections.isEmpty()) { + SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse(); + response.setItems(toNearbyCollectionItems(collections, command.userId())); + response.setNoResults(false); + return response; + } + + if (radiusMeters >= MAX_BIRD_NAME_SEARCH_RADIUS_METERS) { + SearchNearbyCollectionsResponse response = new SearchNearbyCollectionsResponse(); + response.setItems(List.of()); + response.setNoResults(true); + return response; + } + + radiusMeters = Math.min(radiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + attempt++; + } + } + + private int calculateBirdNameSearchLimit(Integer requestedLimit, int attempt) { + int initialLimit = Math.min( + requestedLimit == null ? DEFAULT_BIRD_NAME_SEARCH_LIMIT : requestedLimit, + MAX_BIRD_NAME_SEARCH_LIMIT + ); + int minimumLimit = Math.min(initialLimit, MIN_BIRD_NAME_SEARCH_LIMIT); + return Math.max(minimumLimit, initialLimit / (1 << attempt)); + } + + private List toNearbyCollectionItems( + List collections, + Long userId + ) { + // 2) 연관(작성자/새) 엔티티를 미리 불러와(prefetch) LAZY N+1 차단 List collectionIds = collections.stream().map(UserBirdCollection::getId).toList(); collectionRepository.prefetchUserAndBirdByIds(collectionIds); @@ -198,8 +261,8 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom Map commentCounts = collectionCommentRepository.countByCollectionIds(collectionIds); // 5) 내가 좋아요 눌렀는지 배치 조회 - Map myLikeMap = (command.userId() != null) - ? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(command.userId(), collectionIds) + Map myLikeMap = (userId != null) + ? collectionLikeRepository.findLikeStatusByUserIdAndCollectionIds(userId, collectionIds) : new LinkedHashMap<>(); // 6) 작성자 프로필 이미지 배치 조회 @@ -212,10 +275,10 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom .map(c -> { long likeCount = likeCounts.getOrDefault(c.getId(), 0L); long commentCount = commentCounts.getOrDefault(c.getId(), 0L); - boolean isLikedByMe = command.userId() != null && myLikeMap.getOrDefault(c.getId(), false); + boolean isLikedByMe = userId != null && myLikeMap.getOrDefault(c.getId(), false); String userProfileImageUrl = profileImageMap.get(c.getUser().getId()); String thumbnailProfileImageUrl = thumbnailProfileImageMap.get(c.getUser().getId()); - boolean isMine = command.userId() != null && command.userId().equals(c.getUser().getId()); + boolean isMine = userId != null && userId.equals(c.getUser().getId()); return collectionWebMapper.toGetNearbyCollectionsResponseItem( c, @@ -231,7 +294,6 @@ public GetNearbyCollectionsResponse getNearbyCollections(GetNearbyCollectionsCom }) .toList(); - response.setItems(items); - return response; + return items; } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java new file mode 100644 index 00000000..fcf0a496 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/dto/SearchNearbyCollectionsCommand.java @@ -0,0 +1,11 @@ +package org.devkor.apu.saerok_server.domain.collection.application.dto; + +public record SearchNearbyCollectionsCommand( + Long userId, + Double latitude, + Double longitude, + Double initialRadiusMeters, + String query, + Integer limit +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java index e5f1c80a..130db7f0 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java @@ -111,6 +111,53 @@ ORDER BY ST_Distance( return query.getResultList(); } + /** + * 중심점 반경 안에서 새 한국어 이름으로 컬렉션을 부분 검색한다. + * tempBirdName은 아직 사용하지 않으므로 현재 검색 대상에 포함하지 않는다. + */ + @SuppressWarnings("unchecked") + public List findNearbyByBirdName( + Point ref, + double radiusMeters, + String query, + Long userId, + Integer limit + ) { + String sql = """ + SELECT c.* + FROM user_bird_collection c + JOIN bird b ON b.id = c.bird_id + WHERE ST_DWithin( + c.location::geography, + CAST(:refPoint AS geography), + :radius + ) + AND b.korean_name LIKE :query + AND ( + c.access_level = 'PUBLIC' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) + AND ( + b.conservation_grade = 'NONE' + OR (CAST(:userId AS bigint) IS NOT NULL AND c.user_id = :userId) + ) + ORDER BY ST_Distance( + c.location::geography, + CAST(:refPoint AS geography) + ) + """; + + var nativeQuery = em.createNativeQuery(sql, UserBirdCollection.class) + .setParameter("refPoint", ref) + .setParameter("radius", radiusMeters) + .setParameter("query", "%" + query + "%") + .setParameter("userId", userId); + if (limit != null) { + nativeQuery.setMaxResults(limit); + } + return nativeQuery.getResultList(); + } + public long countNearbyCandidates(Point ref, double radiusMeters, Long userId, boolean isMineOnly) { if (isMineOnly && userId != null) { From 234e48163a1b69e79c899983cb4913c95bd2af06 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:23:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20geogaphy=EC=97=90=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V96__add_geography_location_index_to_collection.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql diff --git a/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql b/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql new file mode 100644 index 00000000..ba16b1a6 --- /dev/null +++ b/src/main/resources/db/migration/V96__add_geography_location_index_to_collection.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_user_collection_location_geography + ON user_bird_collection USING GIST ((location::geography)); From e8ab27671a45929e04cc58830fe54593591c6c46 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:26:17 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollectionQueryServiceTest.java | 81 +++++++++++++++++++ .../repository/CollectionRepositoryTest.java | 56 +++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java index 03f6cc30..2dec1428 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java @@ -2,6 +2,8 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionDetailResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.MyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.api.dto.response.SearchNearbyCollectionsResponse; +import org.devkor.apu.saerok_server.domain.collection.application.dto.SearchNearbyCollectionsCommand; import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; @@ -31,6 +33,9 @@ import static org.junit.jupiter.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verifyNoInteractions; @ExtendWith(MockitoExtension.class) @@ -257,4 +262,80 @@ void getMyCollections_includesCanSuggestBirdId() throws IllegalAccessException { .extracting(MyCollectionsResponse.Item::canSuggestBirdId) .containsExactly(true, false); } + + @Test + @DisplayName("새 이름 주변 검색은 반경 확장마다 반환 제한을 줄이고 최대 150km에서 결과 없음 플래그를 반환한다") + void searchNearbyCollectionsByBirdName_expandsRadiusUntilMaximumAndReturnsNoResults() { + SearchNearbyCollectionsCommand command = new SearchNearbyCollectionsCommand( + null, + 37.5665, + 126.9780, + 5_000.0, + "까치", + null + ); + given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + + SearchNearbyCollectionsResponse response = collectionQueryService.searchNearbyCollectionsByBirdName(command); + + assertTrue(response.isNoResults()); + assertTrue(response.getItems().isEmpty()); + + var inOrder = inOrder(collectionRepository); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10)); + } + + @Test + @DisplayName("새 이름 주변 검색은 확장된 반경에서 결과를 찾으면 해당 결과를 반환한다") + void searchNearbyCollectionsByBirdName_returnsCollectionsFoundAfterRadiusExpansion() throws IllegalAccessException { + User owner = new User(); + userIdField.set(owner, 10L); + UserBirdCollection collection = new UserBirdCollection(); + collectionIdField.set(collection, 1L); + collectionUserField.set(collection, owner); + + SearchNearbyCollectionsCommand command = new SearchNearbyCollectionsCommand( + null, + 37.5665, + 126.9780, + 5_000.0, + "까치", + 5 + ); + given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5))) + .willReturn(List.of(collection)); + given(collectionImageUrlService.getPrimaryImageUrlsFor(List.of(collection))).willReturn(Map.of()); + given(collectionImageUrlService.getPrimaryImageThumbnailUrlsFor(List.of(collection))).willReturn(Map.of()); + given(collectionLikeRepository.countLikesByCollectionIds(List.of(1L))).willReturn(Map.of()); + given(collectionCommentRepository.countByCollectionIds(List.of(1L))).willReturn(Map.of()); + given(userProfileImageUrlService.getProfileImageUrlsFor(List.of(owner))).willReturn(Map.of()); + given(userProfileImageUrlService.getProfileThumbnailImageUrlsFor(List.of(owner))).willReturn(Map.of()); + + SearchNearbyCollectionsResponse response = collectionQueryService.searchNearbyCollectionsByBirdName(command); + + assertFalse(response.isNoResults()); + assertEquals(1, response.getItems().size()); + + var inOrder = inOrder(collectionRepository); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5)); + } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java index c0d23ce5..a1e0e1d4 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepositoryTest.java @@ -176,6 +176,62 @@ void findNearby_withLimit_appliesMaxResults() throws Exception { assertEquals(2, result.size()); } + @Test + @DisplayName("새 이름 주변 검색은 반경 안의 부분 일치 컬렉션만 반환한다") + void findNearbyByBirdName_returnsOnlyMatchingCollectionsWithinRadius() throws Exception { + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + Point far = gf.createPoint(new Coordinate(127.1000, 37.5665)); + + Bird matchingBird = new BirdBuilder(em).korName("까치").build(); + Bird otherBird = new BirdBuilder(em).korName("직박구리").build(); + + UserBirdCollection matchingCollection = newCollection(owner, matchingBird, near, AccessLevelType.PUBLIC); + newCollection(owner, otherBird, near, AccessLevelType.PUBLIC); + newCollection(owner, matchingBird, far, AccessLevelType.PUBLIC); + + em.flush(); + em.clear(); + + List result = collectionRepository.findNearbyByBirdName( + ref, + 1_000, + "까", + null, + null + ); + + assertEquals(1, result.size()); + assertEquals(matchingCollection.getId(), result.getFirst().getId()); + } + + @Test + @DisplayName("익명 새 이름 주변 검색은 공개 컬렉션만 반환한다") + void findNearbyByBirdName_anonymous_returnsOnlyPublicCollections() throws Exception { + User owner = newUser(); + Point ref = gf.createPoint(new Coordinate(126.9780, 37.5665)); + Point near = gf.createPoint(new Coordinate(126.9781, 37.5664)); + Bird magpie = new BirdBuilder(em).korName("까치").build(); + + UserBirdCollection publicCollection = newCollection(owner, magpie, near, AccessLevelType.PUBLIC); + newCollection(owner, magpie, near, AccessLevelType.PRIVATE); + + em.flush(); + em.clear(); + + List result = collectionRepository.findNearbyByBirdName( + ref, + 1_000, + "까치", + null, + null + ); + + assertEquals(1, result.size()); + assertEquals(publicCollection.getId(), result.getFirst().getId()); + } + @Test @DisplayName("주변 조회는 보호등급 새 컬렉션을 결과에서 제외한다") void findNearby_excludesProtectedBirdCollections() throws Exception { From db9755cce914017f4953b76268f6c219f538cebf Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 25 Jul 2026 16:56:58 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=9C=84=EB=8F=84/=EA=B2=BD?= =?UTF-8?q?=EB=8F=84=EC=97=90=20=EC=A0=9C=ED=95=9C=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=EB=B0=98=EA=B2=BD=20=ED=99=95=EC=9E=A5=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collection/api/CollectionController.java | 5 ++-- .../SearchNearbyCollectionsRequest.java | 14 ++++++--- .../application/CollectionQueryService.java | 10 ++++++- .../CollectionQueryServiceTest.java | 30 +++++++++++-------- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java index ea298752..4b8c9e1c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/CollectionController.java @@ -381,8 +381,9 @@ public void deleteCollectionImage( description = """ 현재 위치를 기준으로 새 한국어 이름에 검색어가 포함된 컬렉션을 조회합니다. - - 최초 검색 반경은 기본 5km이며 `radiusMeters`로 변경할 수 있습니다. - - 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다. + - `radiusMeters`는 필수이며, 프론트 지도 반경을 미터 단위로 전달합니다. + - `radiusMeters`가 1.25km 미만이면 해당 반경을 한 번 검색한 뒤 1.25km로 확장합니다. + - 이후 검색 결과가 없으면 반경을 두 배씩 확장하고, 최대 150km까지 조회합니다. - 최대 반경에도 결과가 없으면 `noResults`가 true로 반환됩니다. - 검색 결과는 기본 최대 60건이며, 반경 확장마다 최대 개수가 절반으로 줄고 기본 요청은 최소 10건으로 유지됩니다. 클라이언트가 10건보다 작은 limit을 지정하면 그 값을 넘지 않습니다. diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java index c70e6d57..3ebde062 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/SearchNearbyCollectionsRequest.java @@ -1,6 +1,8 @@ package org.devkor.apu.saerok_server.domain.collection.api.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -14,11 +16,15 @@ public class SearchNearbyCollectionsRequest { @NotNull(message = "위도를 입력해주세요.") - @Schema(description = "검색 중심 위도", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED) + @DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 해요.") + @DecimalMax(value = "90.0", message = "위도는 90 이하여야 해요.") + @Schema(description = "검색 중심 위도 (-90~90)", example = "37.5665", requiredMode = Schema.RequiredMode.REQUIRED) private Double latitude; @NotNull(message = "경도를 입력해주세요.") - @Schema(description = "검색 중심 경도", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED) + @DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 해요.") + @DecimalMax(value = "180.0", message = "경도는 180 이하여야 해요.") + @Schema(description = "검색 중심 경도 (-180~180)", example = "126.9780", requiredMode = Schema.RequiredMode.REQUIRED) private Double longitude; @NotBlank(message = "query를 입력해주세요.") @@ -27,8 +33,8 @@ public class SearchNearbyCollectionsRequest { @NotNull(message = "radiusMeters를 입력해주세요.") @Positive(message = "radiusMeters는 0보다 커야 해요.") - @Schema(description = "최초 검색 반경 (m). 생략 시 5km", example = "5000") - private Double radiusMeters = 5_000D; + @Schema(description = "최초 검색 반경 (m).", example = "10", requiredMode = Schema.RequiredMode.REQUIRED) + private Double radiusMeters; @Positive(message = "limit은 1 이상의 양수여야 해요.") @Max(value = 60, message = "limit은 60 이하여야 해요.") diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java index e21defdb..81280608 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryService.java @@ -41,6 +41,7 @@ @Transactional(readOnly = true) public class CollectionQueryService { + private static final double BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS = 1_250; private static final double MAX_BIRD_NAME_SEARCH_RADIUS_METERS = 150_000; private static final int DEFAULT_BIRD_NAME_SEARCH_LIMIT = 60; private static final int MAX_BIRD_NAME_SEARCH_LIMIT = 60; @@ -229,11 +230,18 @@ public SearchNearbyCollectionsResponse searchNearbyCollectionsByBirdName(SearchN return response; } - radiusMeters = Math.min(radiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + radiusMeters = nextBirdNameSearchRadius(radiusMeters); attempt++; } } + private double nextBirdNameSearchRadius(double currentRadiusMeters) { + if (currentRadiusMeters < BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS) { + return BIRD_NAME_SEARCH_FALLBACK_RADIUS_METERS; + } + return Math.min(currentRadiusMeters * 2, MAX_BIRD_NAME_SEARCH_RADIUS_METERS); + } + private int calculateBirdNameSearchLimit(Integer requestedLimit, int attempt) { int initialLimit = Math.min( requestedLimit == null ? DEFAULT_BIRD_NAME_SEARCH_LIMIT : requestedLimit, diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java index 2dec1428..8f0f792a 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionQueryServiceTest.java @@ -270,15 +270,19 @@ void searchNearbyCollectionsByBirdName_expandsRadiusUntilMaximumAndReturnsNoResu null, 37.5665, 126.9780, - 5_000.0, + 1_250.0, "까치", null ); - given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60))) + given(collectionRepository.findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(60))) .willReturn(List.of()); - given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30))) + given(collectionRepository.findNearbyByBirdName(any(), eq(2_500.0), eq("까치"), eq(null), eq(30))) .willReturn(List.of()); - given(collectionRepository.findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15))) + given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(15))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(10))) + .willReturn(List.of()); + given(collectionRepository.findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(10))) .willReturn(List.of()); given(collectionRepository.findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10))) .willReturn(List.of()); @@ -293,9 +297,11 @@ void searchNearbyCollectionsByBirdName_expandsRadiusUntilMaximumAndReturnsNoResu assertTrue(response.getItems().isEmpty()); var inOrder = inOrder(collectionRepository); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(60)); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(30)); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(15)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(60)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(2_500.0), eq("까치"), eq(null), eq(30)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(15)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(10)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(20_000.0), eq("까치"), eq(null), eq(10)); inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(40_000.0), eq("까치"), eq(null), eq(10)); inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(80_000.0), eq("까치"), eq(null), eq(10)); inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(150_000.0), eq("까치"), eq(null), eq(10)); @@ -314,13 +320,13 @@ void searchNearbyCollectionsByBirdName_returnsCollectionsFoundAfterRadiusExpansi null, 37.5665, 126.9780, - 5_000.0, + 10.0, "까치", 5 ); - given(collectionRepository.findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5))) + given(collectionRepository.findNearbyByBirdName(any(), eq(10.0), eq("까치"), eq(null), eq(5))) .willReturn(List.of()); - given(collectionRepository.findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5))) + given(collectionRepository.findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(5))) .willReturn(List.of(collection)); given(collectionImageUrlService.getPrimaryImageUrlsFor(List.of(collection))).willReturn(Map.of()); given(collectionImageUrlService.getPrimaryImageThumbnailUrlsFor(List.of(collection))).willReturn(Map.of()); @@ -335,7 +341,7 @@ void searchNearbyCollectionsByBirdName_returnsCollectionsFoundAfterRadiusExpansi assertEquals(1, response.getItems().size()); var inOrder = inOrder(collectionRepository); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(5_000.0), eq("까치"), eq(null), eq(5)); - inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10_000.0), eq("까치"), eq(null), eq(5)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(10.0), eq("까치"), eq(null), eq(5)); + inOrder.verify(collectionRepository).findNearbyByBirdName(any(), eq(1_250.0), eq("까치"), eq(null), eq(5)); } }