Skip to content

refactor: 행정구역 검색 포함하고 coroutineScope 활용한 병렬 요청#88

Merged
LeeHanEum merged 11 commits intodevelopfrom
refactor/administrative-district-search
Jan 31, 2026
Merged

refactor: 행정구역 검색 포함하고 coroutineScope 활용한 병렬 요청#88
LeeHanEum merged 11 commits intodevelopfrom
refactor/administrative-district-search

Conversation

@LeeHanEum
Copy link
Collaborator

@LeeHanEum LeeHanEum commented Jan 31, 2026

🌱 관련 이슈

📌 작업 내용 및 특이 사항

  • 행정구역(District) 도메인 모델링
  • Landmark + District 포함한 place 패키지 구성
  • 랜드마크 및 벚꽃길 검색 API에 행정구역 검색 포함
  • coroutineScope 사용하여 각 장소 검색 병렬 요청

📝 참고

  • 검색 시 행정구역은 최대 2개, 랜드마크는 최대 5개 포함
  • 행정구역 데이터 셋은 노션에 기재
  • /api/v1/place/search로 검색 API 엔드포인트 변경됨
  • 지하철 우선 검색 대응 필요

📌 체크 리스트

  • 리뷰어를 추가하셨나요 ?
  • 변경사항에 대해 충분히 설명하고 있나요 ?

Summary by CodeRabbit

  • New Features

    • Unified place search endpoint returning districts, landmarks, and flower spots.
    • POST /places to persist district data and searchable district records.
  • Refactor

    • Consolidated separate searches into a single place-search flow.
    • Replaced standalone flower-spot search with the unified place search.
  • Performance

    • Added caching-backed district and flower-spot lookups for faster repeated searches.

✏️ Tip: You can customize this high-level summary in your review settings.

@LeeHanEum LeeHanEum requested a review from char-yb January 31, 2026 01:09
@LeeHanEum LeeHanEum self-assigned this Jan 31, 2026
@LeeHanEum LeeHanEum linked an issue Jan 31, 2026 that may be closed by this pull request
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 31, 2026

📝 Walkthrough

Walkthrough

Consolidates landmark types under com.pida.place, removes the FlowerSpot-only search endpoint and related search types, and adds a unified Place search flow with District domain, persistence, caching, and an orchestration facade that concurrently queries districts, landmarks, and flower spots.

Changes

Cohort / File(s) Summary
Package moves — Landmark → place
pida-core/core-domain/src/main/kotlin/com/pida/place/Landmark.kt, .../LandmarkAppender.kt, .../LandmarkFetchEvent.kt, .../LandmarkFinder.kt, .../LandmarkRepository.kt, .../LandmarkSearchClient.kt, .../LandmarkService.kt, .../NewLandmark.kt
Changed package namespace from com.pida.landmark to com.pida.place; no behavioral/signature changes.
Import updates (clients & storage)
pida-clients/map-client/src/main/kotlin/.../KakaoMapClient.kt, pida-storage/db-core/src/main/kotlin/.../landmark/LandmarkCoreRepository.kt, .../LandmarkEntity.kt
Updated imports to reference com.pida.place types; no functional changes.
API removal — FlowerSpot search
pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/flowerspot/FlowerSpotController.kt
Removed searchFlowerSpot endpoint and associated imports/types.
Place API additions & DTOs
pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/PlaceController.kt, .../request/AddDistrictRequest.kt, .../response/PlaceSearchResponse.kt
Added PlaceController (POST /places, GET /places/search), AddDistrictRequest DTO, and renamed/expanded response to include districts (PlaceSearchResultResponse/PlaceSearchResponse).
Domain: District model & services
pida-core/core-domain/src/main/kotlin/com/pida/place/District.kt, DistrictService.kt, DistrictFinder.kt, DistrictAppender.kt, DistrictRepository.kt
Added District domain, transactional appender, cache-enabled finder, service with multi-keyword search and addAll API.
Place orchestration facade
pida-core/core-domain/src/main/kotlin/com/pida/place/PlaceFacade.kt, PlaceSearchResult.kt
New PlaceFacade service that concurrently searches districts, landmarks, and flower spots, applies region filtering, publishes events, and returns aggregated PlaceSearchResult.
FlowerSpot search refactor & removals
pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFacade.kt, FlowerSpotFinder.kt, FlowerSpotService.kt, FlowerSpotSearchResult.kt
Removed landmark integration and search method from FlowerSpotFacade and deleted FlowerSpotSearchResult; converted search methods to suspend and added caching/SEARCH_KEY.
Persistence: District storage
pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictEntity.kt, DistrictCoreRepository.kt, DistrictCustomRepository.kt, DistrictJpaRepository.kt
Added JPA entity for District with geometry handling, core+custom repositories (complex keyword search with CASE ordering), and JpaRepository interface.
Region mapping
pida-core/core-domain/src/main/kotlin/com/pida/support/geo/Region.kt
Broadened string matching for Seoul and Gyeonggi region recognition (additional prefix variants).

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Controller as PlaceController
    participant Facade as PlaceFacade
    participant District as DistrictService
    participant Landmark as LandmarkService
    participant FlowerSpot as FlowerSpotService
    participant LandmarkAPI as LandmarkSearchClient
    participant Events as EventPublisher

    Client->>Controller: GET /places/search?query&user
    Controller->>Facade: search(query, user)
    Facade->>Events: publishSearchEvent(query, user)
    par Concurrent queries
        Facade->>District: searchDistricts(query)
        District-->>Facade: List<District]
        Facade->>Landmark: searchLandmarks(query)
        Landmark-->>Facade: List<Landmark]
        Facade->>FlowerSpot: searchFlowerSpots(query)
        FlowerSpot-->>Facade: List<FlowerSpot]
    end
    alt stored landmarks sufficient
        Facade->>Events: publishLandmarkFetchEvent(stored)
    else insufficient
        Facade->>LandmarkAPI: searchByKeyword(query)
        LandmarkAPI-->>Facade: List<NewLandmark]
        Facade->>Events: publishLandmarkFetchEvent(external)
    end
    Facade-->>Controller: PlaceSearchResult(districts, landmarks, flowerSpots)
    Controller-->>Client: PlaceSearchResultResponse
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • char-yb

Poem

🐰 I hopped through districts, petals, and place,

Landmarks gathered, all in one space,
Caches hum softly, events take flight,
Geodata guiding day and night,
A rabbit cheers — search now feels right! 🌸

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main changes: adding administrative district search and implementing parallel requests using coroutineScope.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #87: District domain model is defined, search logic includes districts, and results expose districts at the top level via PlaceSearchResultResponse.
Out of Scope Changes check ✅ Passed All changes are scope-aligned. District modeling, place package restructuring, parallel search implementation, and FlowerSpotFacade removal are all directly tied to issue #87 objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/administrative-district-search

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In
`@pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/PlaceController.kt`:
- Around line 32-36: PlaceController currently uses `@Valid` on the
List<AddDistrictRequest> in placeAdd but lacks class-level validation and
authorization; add the Spring `@Validated` annotation to the PlaceController class
so each AddDistrictRequest in the List is validated, and secure the endpoint by
adding a Spring Security annotation (e.g.,
`@PreAuthorize`("hasRole('ROLE_ADMIN')") or an appropriate expression) either on
the placeAdd method or the controller class to restrict access before calling
districtService.addAll(requests.map { it.toDistrict() }); ensure imports for
`@Validated` and `@PreAuthorize` are added and adjust the security expression to
match your app's roles/authorities.

In `@pida-core/core-domain/src/main/kotlin/com/pida/place/DistrictFinder.kt`:
- Around line 17-24: The cache invocation in searchByKeyword (the suspend fun
searchByKeyword) passes a lambda that calls the blocking
districtRepository.searchByKeyword() and CacheAdvice.invoke currently performs
blocking cacheRepository.get()/put() calls on the caller dispatcher; wrap the
blocking DB and cache operations in withContext(Dispatchers.IO) (either by
changing the lambda passed to cacheAdvice.invoke to call
withContext(Dispatchers.IO) { districtRepository.searchByKeyword(keyword) } or,
better, modify CacheAdvice.invoke to switch to withContext(Dispatchers.IO)
around cacheRepository.get()/put() and any repository calls) so all blocking I/O
runs on the IO dispatcher and not the main coroutine dispatcher.

In `@pida-core/core-domain/src/main/kotlin/com/pida/place/DistrictService.kt`:
- Around line 15-16: The current logic splits query into keywords and calls
districtFinder.searchByKeyword with keywords.first(), which can be an empty
string for a blank query; update DistrictService.kt to guard against blank
queries by trimming and filtering out empty tokens (e.g., val trimmed =
query.trim(); val keywords = trimmed.split("\\s+".toRegex()).filter {
it.isNotBlank() }) and if keywords is empty return an empty result (or
appropriate empty response) instead of calling districtFinder.searchByKeyword;
ensure any use of keywords.first() is only done after this validation.
- Around line 14-27: The searchDistricts function should first handle
blank/whitespace-only queries by returning an empty list (or early exit) instead
of calling districtFinder.searchByKeyword with an empty string; trim and check
query.isBlank() and also split into keywords while filtering out empty tokens so
keywords.first() is valid. For the in-memory filtering step (in searchDistricts)
replace the case-sensitive contains(keyword) checks with casefolded comparisons:
normalize both the fullText and each keyword to a common case/locale (e.g.,
lowercase with Locale/Korean-aware folding) before calling contains, and
consider normalizing known variant characters if needed; keep
districtFinder.searchByKeyword usage unchanged but ensure you pass a non-empty
first keyword.

In
`@pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCoreRepository.kt`:
- Around line 22-23: In DistrictCoreRepository inside the districts.map block
the expression "it.pinPoint as GeoJson.Point" is an unsafe cast; replace it with
a safe cast and validation (e.g., use "it.pinPoint as? GeoJson.Point" and check
for null) and handle non-Point cases gracefully (skip the entry, provide a
default, or log a warning) so you avoid ClassCastException when pinPoint is null
or a different GeoJson subtype; update any downstream usage that assumes a
non-null Point accordingly (e.g., in the map transformation or subsequent
functions that rely on point coordinates).

In
`@pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictEntity.kt`:
- Around line 38-48: The toDistrict() conversion in DistrictEntity currently
uses the unsafe non-null assertion id!! which can NPE for unpersisted entities;
change this to an explicit null-check that throws a clear, descriptive exception
(e.g., use requireNotNull(id) or throw IllegalStateException with a message like
"DistrictEntity.id is null — toDistrict() must be called only for persisted
entities") and/or add a KDoc comment on DistrictEntity.toDistrict() stating it
must only be invoked for persisted entities so callers know the precondition.
🧹 Nitpick comments (8)
pida-core/core-domain/src/main/kotlin/com/pida/flowerspot/FlowerSpotFinder.kt (1)

53-60: Caching implementation looks good; consider cache key normalization.

The caching pattern is consistent with other methods in this class. However, using user input (streetName) directly in cache keys could lead to cache fragmentation (e.g., "강남" vs "강남 " with trailing space).

Since FlowerSpotService.searchFlowerSpots already trims the input before calling this method, the immediate risk is mitigated. If this method is called from other paths in the future, consider normalizing the key (e.g., lowercase, trim) within this method for defensive consistency.

pida-core/core-domain/src/main/kotlin/com/pida/support/geo/Region.kt (1)

25-26: Redundant conditions can be simplified.

The additional startsWith checks are redundant since "서울특별시".startsWith("서울") and "경기도".startsWith("경기") are already true. The original shorter prefixes cover all cases.

♻️ Suggested simplification
-        startsWith("서울") || startsWith("서울특별시") -> Region.SEOUL
-        startsWith("경기") || startsWith("경기도") -> Region.GYEONGGI
+        startsWith("서울") -> Region.SEOUL
+        startsWith("경기") -> Region.GYEONGGI
pida-storage/db-core/src/main/kotlin/com/pida/storage/db/core/district/DistrictCustomRepository.kt (1)

14-46: Consider adding a result limit to prevent unbounded queries.

The searchByKeyword method has no result limit, which could return a very large dataset for common prefixes (e.g., "서" could match thousands of districts). Additionally, if keyword is empty, the pattern becomes "%", effectively matching all non-deleted records.

Consider:

  1. Adding a LIMIT clause to the query
  2. Validating that keyword is non-empty or has a minimum length
♻️ Proposed fix to add result limit
 fun searchByKeyword(keyword: String): List<DistrictEntity> {
+    require(keyword.isNotBlank()) { "Keyword must not be blank" }
     val pattern = "$keyword%" // B-tree index 활용을 위한 접두사 일치 패턴

     val query =
         jpql {
             select(entity(DistrictEntity::class))
                 .from(entity(DistrictEntity::class))
                 .whereAnd(
                     path(DistrictEntity::deletedAt).isNull(),
                     or(
                         path(DistrictEntity::sido).like(pattern),
                         path(DistrictEntity::sigungu).like(pattern),
                         path(DistrictEntity::eupmyeondonggu).like(pattern),
                         path(DistrictEntity::eupmyeonridong).like(pattern),
                         path(DistrictEntity::ri).like(pattern),
                     ),
                 ).orderBy(
                     // ... existing ordering ...
                 )
         }

-    return entityManager.createQuery(query, jdslRenderContext).resultList
+    return entityManager.createQuery(query, jdslRenderContext)
+        .setMaxResults(100) // Reasonable limit for search results
+        .resultList
 }
pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/PlaceController.kt (1)

40-44: Consider adding validation for the query parameter.

The query parameter has no validation constraints. Consider adding minimum length requirements to prevent expensive searches with very short or empty queries.

♻️ Proposed validation
 suspend fun searchPlace(
-    `@RequestParam` `@Parameter`(name = "query", description = "검색 키워드") query: String,
+    `@RequestParam` `@Parameter`(name = "query", description = "검색 키워드") `@Size`(min = 1, max = 100) query: String,
     `@Parameter`(hidden = true, required = false) user: User?,
 ): PlaceSearchResultResponse {

Add import:

import jakarta.validation.constraints.Size
pida-core/core-domain/src/main/kotlin/com/pida/place/PlaceFacade.kt (3)

48-53: Redundant region filter on landmarks.

Line 53 applies filter { it.region in SEARCH_REGIONS } to landmarks, but in the else branch (Lines 46-48), the fetched landmarks are already filtered by SEARCH_REGIONS. This results in redundant filtering for externally fetched landmarks.

♻️ Proposed fix to avoid redundant filtering
             val landmarks =
                 if (hasEnough(storedLandmarks)) {
                     publishLandmarkFetchEvent(query, null)
-                    storedLandmarks
+                    storedLandmarks.filter { it.region in SEARCH_REGIONS }
                 } else {
                     val fetched = landmarkSearchClient.searchByKeyword(query)
                     publishLandmarkFetchEvent(query, fetched)
                     fetched.map { it.toLandmark() }.filter { it.region in SEARCH_REGIONS }
                 }

             PlaceSearchResult(
                 districts = districtsDeferred.await().take(MAX_DISTRICT_SEARCH_COUNT),
-                landmarks = landmarks.filter { it.region in SEARCH_REGIONS }.take(MAX_LANDMARK_SEARCH_COUNT),
+                landmarks = landmarks.take(MAX_LANDMARK_SEARCH_COUNT),
                 flowerSpots = flowerSpotsDeferred.await(),
             )

67-77: Consider renaming the search event for clarity.

FlowerSpotSearchEvent is published for all place searches, not just flower spot searches. This naming may cause confusion since the search now includes districts and landmarks. Consider renaming to PlaceSearchEvent for consistency with the new unified search API.


31-56: Consider error handling strategy for parallel coroutines.

If any of the parallel async calls (districts, landmarks, flowerSpots) fails, coroutineScope will cancel all sibling coroutines and propagate the exception. This is fail-fast behavior. If partial results are acceptable (e.g., return landmarks even if district search fails), consider using supervisorScope with individual error handling.

pida-core/core-api/src/main/kotlin/com/pida/presentation/v1/place/response/PlaceSearchResponse.kt (1)

12-17: Property naming inconsistency: district should be plural.

The property district holds a List<PlaceSearchResponse> but uses singular form, while landmarks and flowerSpots correctly use plural. Consider renaming to districts for consistency.

♻️ Proposed fix
 data class PlaceSearchResultResponse(
     `@field`:ArraySchema(
         schema = Schema(implementation = PlaceSearchResponse::class),
         arraySchema = Schema(description = "행정구역 목록"),
     )
-    val district: List<PlaceSearchResponse>,
+    val districts: List<PlaceSearchResponse>,

Note: This change would also need to be reflected in the of() factory method and callers like PlaceController.

@LeeHanEum LeeHanEum merged commit 3630d7a into develop Jan 31, 2026
2 checks passed
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