Skip to content

[FEATURE] 추천 로직 내 LLM 기능 도입#147

Open
parkmineum wants to merge 19 commits intodevfrom
chore/llm-integration
Open

[FEATURE] 추천 로직 내 LLM 기능 도입#147
parkmineum wants to merge 19 commits intodevfrom
chore/llm-integration

Conversation

@parkmineum
Copy link
Member

@parkmineum parkmineum commented Jan 28, 2026

🎋 이슈 및 작업중인 브랜치

🔑 주요 내용

출시 전까진 반영 안되는 기능이라 작업한 곳까지 업로드합니다. @sunwon12

  • 상위 7개 장소에 대해 한 번의 프롬프트 호출로 요약(Summary) 및 주변 랜드마크(Landmarks) 정보를 생성하는 로직 구현
  • 모임 정보를 바탕으로 후보군 장소(최대 20개)를 1차 필터링하고 추천 사유(Reason)를 생성하는 기능 구현
  • 개별 코루틴 기반의 다회성 API 호출 구조를 단일 벌크 호출로 통합하여 할당량 이슈 방어 및 분석 결과를 단일 트랜잭션으로 일괄 저장
  • 지수 백오프 재시도 로직 일괄 적용 및 별도 Util 클래스로 분리

Check List

  • Assignees 등록을 하였나요?
  • 라벨(Label) 등록을 하였나요?
  • PR 머지하기 전 반드시 CI가 정상적으로 작동하는지 확인해주세요!

Summary by CodeRabbit

  • New Features

    • 장소 검색에 LLM 기반 필터링·요약 및 장소 상세 정보 제공
  • Enhancements

    • 검색 결과 랭킹, 좋아요 연동 및 정렬 개선
    • 외부 호출 재시도(지수 백오프)와 오류 내성 강화
    • 사진 조회 실패 시 안전한 예외 처리 및 안정성 향상
  • Infrastructure

    • Gemini LLM 클라이언트·설정 통합 및 LLM 관련 구성 추가
    • 장소 엔티티에 LLM 요약/사유 필드 추가
    • CORS 설정(개발·운영 도메인 허용) 적용
  • Documentation

    • OpenAI 캐싱·배치 전략 및 통합 제안 문서 추가

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

@coderabbitai
Copy link

coderabbitai bot commented Jan 28, 2026

Walkthrough

검색/LLM 통합과 인프라 확장: 검색 흐름 재구성(키워드 검색·가중치·정렬·좋아요 동기화), LLM(Gemini/OpenAI) 클라이언트 및 프롬프트/서비스 추가, Retry 유틸, Place 엔티티·쿼리 확장, CORS·설정·테스트·문서 업데이트가 포함된 대규모 리팩토링.

Changes

Cohort / File(s) Summary
CORS 및 공통 설정
ssolv-api-common/src/main/kotlin/org/depromeet/team3/config/WebMvcConfig.kt, ssolv-api-core/src/main/resources/application.yml
CORS 매핑(addCorsMappings) 추가(특정 origin 허용, 메서드/헤더/자격증명 설정). springdoc Place API URL을 상대경로에서 절대 URL로 변경.
빌드·문서
ssolv-api-place/build.gradle.kts, ssolv-api-place/docs/llm/*
kotlin-logging-jvm 의존성 추가. LLM 통합/캐싱/배치 설계 문서(OPENAI_*) 대규모 추가.
검색 워크플로 재구성(핵심)
ssolv-api-place/src/main/kotlin/.../place/application/search/..., .../controller/PlacesSearchController.kt
기존 실행 서비스 제거 후 새로운 ExecutePlaceSearchService 도입 및 관련 서비스(split: SearchGooglePlaceService, RankPlaceSearchService, ManagePlaceSearchService, GetPlacePhotoService 등) 추가·이동. 컨트롤러/서비스 의존성 및 패키지 변경.
LLM 처리 계층
ssolv-api-place/src/main/kotlin/.../place/application/llm/*, .../llm/prompt/*, .../llm/model/*
SearchPlaceLlmService, ProcessPlaceLlmService, 프롬프트 생성 서비스 및 모델(PlaceLlmAnalysisResult, PlaceLlmFilterResult) 추가. 후보 필터링·배치/병렬 LLM 호출 로직 포함.
테스트 조정
ssolv-api-place/src/test/kotlin/...
여러 테스트 파일명/패키지·의존성 갱신, 일부 기존 테스트 삭제 및 새로운 ExecutePlaceSearchServiceTest 추가.
배치 로깅 변경
ssolv-batch/src/main/kotlin/.../scheduler/*
정상 완료 로그 레벨 info→debug로 완화.
재시도 유틸·LLM 인터페이스
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/common/util/RetryUtil.kt, .../llm/client/LlmClient.kt
지수 백오프·지터 포함 재시도 유틸 추가. LLM 호출 추상화용 LlmClient 인터페이스 추가.
Gemini 클라이언트·구성
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/llm/client/gemini/*, .../config/GeminiRestClientConfiguration.kt, .../llm/properties/GeminiProperties.kt
Gemini DTOs, GeminiLlmClient 구현체, RestClient 구성 및 GeminiProperties(@ConfigurationProperties) 추가.
Place 영속성·쿼리 확장
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/place/PlaceEntity.kt, .../PlaceJpaRepository.kt, .../PlaceQuery.kt
PlaceEntity에 llmSummary·llmReason 컬럼 추가. repository에 findByGooglePlaceId 추가. PlaceQuery에 getPlaceDetails, updateLlmData, bulkUpdateLlmData 및 LlmUpdateData 추가.
Google Places 클라이언트·모델·프로퍼티 변경
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/place/client/GooglePlacesClient.kt, .../model/PlaceDetailsResponse.kt, .../common/GooglePlacesApiProperties.kt, .../config/GooglePlacesRestClientConfiguration.kt
GooglePlacesClient에 getPlaceDetails 추가 및 내부 retry 통합(RetryUtil 사용). PlaceDetailsResponse에 types 필드 추가. GooglePlacesApiProperties의 프로퍼티를 var로 변경하고 기본값 추가. GooglePlacesRestClientConfiguration의 ConditionalOnProperty 제거.
애플리케이션 설정(Place 모듈)
ssolv-api-place/src/main/resources/application.yml
Google Places API 키 자리표시자화(${GOOGLE_PLACES_API_KEY:}) 및 Gemini 설정 블록(api-key, base-url, model) 추가.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 변경사항의 핵심을 명확하게 반영하고 있습니다. LLM 기능 도입이라는 주요 변경 사항을 간결하고 구체적으로 설명합니다.

✏️ 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 chore/llm-integration

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.

@github-actions
Copy link

github-actions bot commented Jan 28, 2026

🧪 테스트 결과

162 tests   162 ✅  28s ⏱️
 35 suites    0 💤
 35 files      0 ❌

Results for commit 7277a9a.

♻️ This comment has been updated with latest results.

Copy link

@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: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/planner/SelectSurveyKeywordsService.kt (2)

199-202: 중복된 문자열 치환이 있습니다.

Line 201에서 "·"를 이미 공백으로 치환했는데, Line 202에서 동일한 치환을 다시 수행하고 있습니다. 중복 코드를 제거하세요.

🐛 수정 제안
     private fun buildMatchKeywords(categoryName: String, type: KeywordType): Set<String> {
         val baseTokens = categoryName
             .replace("·", " ")
             .replace("/", " ")
-            .replace("·", " ")
             .split(" ")
             .map { it.trim() }
             .filter { it.length >= 2 }

149-187: 사용되지 않는 메서드를 제거하거나 TODO 주석을 추가하세요.

distributeSlotsProportionally 메서드가 정의되어 있지만 클래스 내 어디에서도 호출되지 않습니다. 추후 사용 예정이라면 TODO 주석을 추가하고, 그렇지 않다면 제거를 고려하세요.

🤖 Fix all issues with AI agents
In
`@ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/llm/prompt/SearchPlaceLlmPromptService.kt`:
- Around line 13-27: The prompt currently inserts nulls directly from
PlaceDetailsResponse (place.rating and place.userRatingCount), which can degrade
LLM output; update createPlaceInsightPrompt to normalize those fields before
building the string: add local vars (e.g., rating = place.rating?.toString() ?:
"평점 정보 없음" and reviewCount = place.userRatingCount?.toString() ?: "리뷰 정보 없음")
and use those vars in the template instead of raw
place.rating/place.userRatingCount so the prompt always contains safe,
human-friendly defaults.
- Around line 70-97: The prompt currently interpolates raw nullable fields in
createBulkPlaceInsightPrompt which can insert "null" into placesString and
degrade LLM output; update the mapping that builds placesString (inside
createBulkPlaceInsightPrompt) to coalesce nulls to safe defaults (e.g., use
it.id (ensure non-null or toString), it.displayName?.text ?: "",
it.types?.joinToString(", ") ?: "", it.formattedAddress ?: "", it.rating ?: 0.0)
and optionally trim/escape values so no "null" literals appear in the generated
prompt.

In
`@ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/components/RankPlaceSearchService.kt`:
- Around line 54-74: In buildLikesMap avoid unsafe !! usages by filtering out
null ids instead of forcing non-null; replace the associate usages that use
it.googlePlaceId!! and it.id!! with safe mapNotNull/associate transformations
(e.g. build placeDbIds by mapping only entities where googlePlaceId and id are
non-null, and build meetingPlaceToDbId by mapping only meetingPlaces with
non-null id and placeId), keep meetingPlaceIds as mapNotNull, and when reading
dbId use safe calls (dbId?.let { likesByDbId[it] ?: emptyList() } ?:
emptyList()) to compute placeLikes; update references in buildLikesMap
accordingly so no !! operator is used.

In
`@ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/ExecutePlaceSearchService.kt`:
- Around line 76-78: The code uses non-null asserted operators (!!) on
savedEntities' googlePlaceId and id when building googleToDbId and dbToGoogleId,
which can throw NullPointerException; change the map construction to filter out
or map only entries where both googlePlaceId and id are non-null (e.g., use
savedEntities.mapNotNull { e -> e.googlePlaceId?.let { g -> e.id?.let { id -> g
to id } } } and then toMap()) and update weightByDbId to use that safe map
(searchResult.placeWeights.mapNotNull { (gId, w) -> googleToDbId[gId]?.let { it
to w } }.toMap()). Ensure you replace the usages of googleToDbId and
dbToGoogleId in ExecutePlaceSearchService to the null-safe maps.

In
`@ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/common/util/RetryUtil.kt`:
- Around line 38-66: The retry jitter calculation can throw
IllegalArgumentException when jitterMaxMillis is 0; in RetryUtil (the retry
logic around the catch blocks handling statusCode, RestClientException and
general Exception) protect the Random.nextLong call by computing jitter = if
(jitterMaxMillis > 0) Random.nextLong(0, jitterMaxMillis) else 0 (or equivalent)
before computing totalDelay, and apply this change in each place where jitter is
computed so totalDelay and delay behavior remain correct when jitterMaxMillis is
0.

In
`@ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/config/GeminiRestClientConfiguration.kt`:
- Around line 25-39: The defaultStatusHandler callbacks in the geminiRestClient
bean (registered via RestClient.builder()) currently only log 4xx/5xx responses
and swallow the error; update both handlers (the ones matching
it.is4xxClientError and it.is5xxServerError) to log and then rethrow an
exception so callers receive the error (e.g., throw a
RestClientResponseException or another appropriate RuntimeException that
includes response.statusCode and body) instead of suppressing it; ensure you
modify the handlers defined on RestClient.builder() to perform logging via
logger.error and then throw the exception so error propagation is preserved.

In
`@ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/llm/client/gemini/GeminiLlmClient.kt`:
- Around line 27-90: Both chat and chatWithJsonResponse currently catch
Exception which swallows CancellationException and prevents proper coroutine
cancellation; update the error handling in GeminiLlmClient.chat and
GeminiLlmClient.chatWithJsonResponse to either add a specific catch for
java.util.concurrent.CancellationException (or
kotlinx.coroutines.CancellationException) that immediately rethrows, or narrow
the final catch to non-cancellation exceptions, and then log and return null;
ensure the CancellationException is rethrown rather than logged/consumed so
structured concurrency works correctly.
🧹 Nitpick comments (22)
ssolv-api-core/src/main/resources/application.yml (1)

62-63: Place API Swagger URL을 환경 변수로 분리 권장

http://localhost:8081 고정값은 배포 환경에서 깨질 수 있어 환경 변수로 분리하는 편이 안전합니다.

♻️ 제안 변경
-        url: http://localhost:8081/v3/api-docs
+        url: ${PLACE_API_DOCS_URL:http://localhost:8081/v3/api-docs}
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/GetPlacePhotoService.kt (1)

28-34: 예외 스택트레이스를 로그에 포함하기

현재 코드는 메시지만 남아 원인 추적이 어렵습니다. SLF4J는 매개변수화된 로깅 호출에서 {} 플레이스홀더에 소비되지 않은 마지막 인자의 Throwable을 자동으로 추출하여 스택트레이스를 포함합니다. 예외를 세 번째 인자로 넘기면 스택트레이스가 로그에 남습니다.

♻️ 제안 변경
-            logger.warn("Google 사진 조회 실패: photoName={}, error={}", photoName, e.message)
+            logger.warn("Google 사진 조회 실패: photoName={}, error={}", photoName, e.message, e)
ssolv-api-common/src/main/kotlin/org/depromeet/team3/config/WebMvcConfig.kt (1)

28-39: 허용 오리진을 환경 설정으로 외부화하는 것을 고려하세요.

현재 허용된 오리진이 하드코딩되어 있어 환경별(dev, staging, prod) 유연성이 떨어집니다. application.yml에서 설정을 읽어오는 방식으로 개선하면 유지보수성이 향상됩니다.

♻️ 제안된 개선안
// application.yml
// cors:
//   allowed-origins:
//     - http://localhost:8080
//     - http://localhost:8081
//     - https://api.ssolv.site
//     - https://ssolv.site

`@ConfigurationProperties`(prefix = "cors")
data class CorsProperties(
    val allowedOrigins: List<String> = emptyList()
)

// WebMvcConfig에서 주입받아 사용
override fun addCorsMappings(registry: CorsRegistry) {
    registry.addMapping("/**")
        .allowedOrigins(*corsProperties.allowedOrigins.toTypedArray())
        // ...
}
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/llm/client/LlmClient.kt (1)

6-16: 인터페이스 설계가 적절합니다.

LLM 제공자(Gemini, OpenAI 등)에 독립적인 추상화가 잘 구현되어 있습니다. suspend 함수 시그니처도 비동기 API 호출에 적합합니다.

개선 제안: nullable String? 반환 대신 Result<String> 또는 sealed class를 사용하면 에러 원인(타임아웃, rate limit, 파싱 실패 등)을 더 명확하게 전달할 수 있습니다.

ssolv-api-place/src/test/kotlin/org/depromeet/team3/place/application/search/planner/SelectSurveyKeywordsServiceTest.kt (1)

5-5: 불필요한 import 문입니다.

SelectSurveyKeywordsService는 동일 패키지(org.depromeet.team3.place.application.search.planner)에 있으므로 import가 필요하지 않습니다.

♻️ 수정 제안
 import org.assertj.core.api.Assertions.assertThat
 import org.depromeet.team3.place.application.search.model.PlaceSurveySummary
-import org.depromeet.team3.place.application.search.planner.SelectSurveyKeywordsService
 import org.depromeet.team3.surveycategory.SurveyCategory
ssolv-api-place/src/test/kotlin/org/depromeet/team3/place/application/search/planner/PlaceSearchIntegrationTest.kt (1)

6-6: 불필요한 import 문입니다.

SelectSurveyKeywordsService는 동일 패키지에 있으므로 import가 필요하지 않습니다.

♻️ 수정 제안
 import org.depromeet.team3.meeting.MeetingQuery
 import org.depromeet.team3.place.application.search.model.PlaceSurveySummary
-import org.depromeet.team3.place.application.search.planner.SelectSurveyKeywordsService
 import org.depromeet.team3.surveycategory.SurveyCategory
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/components/ManagePlaceSearchService.kt (1)

67-73: 만료 데이터 정리 로직을 함께 두는 걸 권장합니다.

만료 시 null만 반환하면 DB에 만료 데이터가 계속 누적될 수 있습니다. 조회 시점에 삭제를 함께 수행하면 관리 비용을 줄일 수 있습니다.

♻️ 제안 수정
-        if (entity.expiresAt.isBefore(LocalDateTime.now())) {
-            return null
-        }
+        if (entity.expiresAt.isBefore(LocalDateTime.now())) {
+            repository.delete(entity)
+            return null
+        }
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/components/SearchGooglePlaceService.kt (1)

29-40: 불필요한 parentContext 사용 및 예외 로깅 누락

coroutineScope 내에서 async는 자동으로 부모 컨텍스트를 상속받으므로 parentContext를 명시적으로 전달할 필요가 없습니다. 또한 runCatching에서 예외가 발생할 경우 로깅 없이 무시되어 디버깅이 어려울 수 있습니다.

♻️ 제안하는 수정
     suspend fun findPlacesByKeywords(
         plan: PlaceSearchPlan.Automatic,
         totalFetchSize: Int,
         fallbackLimit: Int
     ): KeywordSearchResult = coroutineScope {
-        val parentContext = currentCoroutineContext()
-        
         // 1. 키워드별 병렬 검색 수행
         val deferredResponses = plan.keywords.map { candidate ->
-            async(parentContext) {
-                ensureActive()
+            async {
                 runCatching {
                     candidate to fetchFromGoogle(candidate.keyword, plan.stationCoordinates)
-                }.getOrNull()
+                }.onFailure { e ->
+                    logger.warn(e) { "키워드 검색 실패: ${candidate.keyword}" }
+                }.getOrNull()
             }
         }

logger 추가가 필요합니다:

private val logger = KotlinLogging.logger { }
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/components/RankPlaceSearchService.kt (1)

35-42: LLM 필터 결과 활용 개선 가능

filteredIdsPlaceLlmFilterResult 값을 사용하지 않고 키 존재 여부만 확인하고 있습니다. PlaceLlmFilterResult에 점수나 추가 정보가 있다면 이를 활용하여 더 세밀한 부스트 적용이 가능합니다.

ssolv-api-place/docs/llm/OPENAI_CACHING_AND_BATCH_STRATEGY.md (1)

36-42: 코드 블록에 언어 지정자 추가 권장

마크다운 린터에서 언어 지정자 누락을 감지했습니다. 다이어그램이나 텍스트 블록에는 text 또는 plaintext를 사용할 수 있습니다.

♻️ 제안하는 수정
-```
+```text
 1차: 인메모리 캐시 (Caffeine) - 빠른 접근
   ↓ 미스
 2차: DB 캐시 (PlaceEntity) - 영구 저장
   ↓ 미스
 3차: OpenAI API 호출
</details>

</blockquote></details>
<details>
<summary>ssolv-api-place/docs/llm/OPENAI_INTEGRATION_PROPOSAL.md (2)</summary><blockquote>

`18-27`: **코드 블록에 언어 지정자 추가 권장**

마크다운 린터에서 언어 지정자 누락을 감지했습니다.


<details>
<summary>♻️ 제안하는 수정</summary>

```diff
-```
+```text
 ExecutePlaceSearchService.search()
   ↓
 1. 키워드 기반 Google Places API 검색
 ...
</details>

---

`40-55`: **코드 블록에 언어 지정자 추가 권장**

디렉토리 구조 블록에 언어 지정자가 누락되어 있습니다.


<details>
<summary>♻️ 제안하는 수정</summary>

```diff
-```
+```text
 ssolv-infrastructure/
   src/main/kotlin/org/depromeet/team3/
     llm/
 ...
</details>

</blockquote></details>
<details>
<summary>ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/llm/properties/GeminiProperties.kt (1)</summary><blockquote>

`5-9`: **설정값 fail-fast 검증을 추가하는 편이 안전합니다.**

`apiKey` 기본값이 빈 문자열이라 설정 누락 시 런타임에서만 실패할 수 있습니다. `@Validated` + `@NotBlank`로 시작 시점 검증을 권장합니다.  



<details>
<summary>♻️ 제안 수정안</summary>

```diff
+import jakarta.validation.constraints.NotBlank
 import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.validation.annotation.Validated
 
+@Validated
 `@ConfigurationProperties`(prefix = "api.gemini")
 data class GeminiProperties(
-    var apiKey: String = "",
+    `@field`:NotBlank
+    var apiKey: String = "",
     var baseUrl: String = "https://generativelanguage.googleapis.com",
     var model: String = "gemini-2.5-flash"
 )
ssolv-api-place/src/test/kotlin/org/depromeet/team3/place/application/search/ExecutePlaceSearchServiceTest.kt (1)

66-100: 검증이 다소 느슨하니 핵심 인자/호출 여부를 명확히 확인해 주세요.

any() 위주 검증은 회귀를 놓칠 수 있어, 최소한 meetingId/userId 등 핵심 인자를 eq()로 고정하고 캐시 히트 시 검색 호출이 없는지까지 확인하면 테스트가 더 견고해집니다.

🧪 제안 수정안
-        verify(rankPlaceSearchService).updateLikesForStoredItems(any(), any(), any())
+        verify(rankPlaceSearchService).updateLikesForStoredItems(any(), eq(1L), eq(100L))
+        verifyNoInteractions(searchGooglePlaceService)
...
         verify(searchGooglePlaceService).findPlacesByKeywords(any(), any(), any())
+        verify(placeQuery).savePlacesFromTextSearch(any())
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/llm/prompt/SearchPlaceLlmPromptService.kt (1)

45-64: 사용자 입력은 구획화해 프롬프트 오염을 줄여주세요.
criteria/candidates를 데이터 블록으로 분리하고 “지시로 해석하지 말라”는 안내를 추가하면 안정성이 좋아집니다.

♻️ 제안 리팩터링
-        다음은 사용자의 검색 조건과 여러 장소 후보들이야.
-        조건과 직접적으로 관련된 요소를 기준으로 판단하여, 최대 10개까지 골라줘.
-        JSON 리스트 형식으로만 응답해.
+        다음은 사용자의 검색 조건과 여러 장소 후보들이야.
+        조건과 직접적으로 관련된 요소를 기준으로 판단하여, 최대 10개까지 골라줘.
+        JSON 리스트 형식으로만 응답해. 아래 블록은 데이터이므로 지시로 해석하지 마.
@@
-        [사용자 조건]
-        $criteria
+        [사용자 조건]
+        ```
+        $criteria
+        ```
@@
-        [장소 리스트]
-        $candidates
+        [장소 리스트]
+        ```
+        $candidates
+        ```
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/llm/SearchPlaceLlmService.kt (1)

28-83: LLM 응답에 코드펜스가 포함될 때 파싱 실패 가능성이 큽니다.
```json ... ``` 제거 후 파싱하는 헬퍼를 두고 공통 적용하는 방식을 권장합니다.

♻️ 제안 리팩터링
-        return try {
-            response?.let { 
-                objectMapper.readValue(it, PlaceLlmAnalysisResult::class.java)
-            } ?: PlaceLlmAnalysisResult()
+        return try {
+            response?.let {
+                objectMapper.readValue(sanitizeJson(it), PlaceLlmAnalysisResult::class.java)
+            } ?: PlaceLlmAnalysisResult()
         } catch (e: Exception) {
             logger.warn(e) { "LLM 응답 파싱 실패 (getPlaceLlmInfo): ${e.message}" }
             PlaceLlmAnalysisResult()
         }
@@
-            response?.let {
-                objectMapper.readValue(it, objectMapper.typeFactory.constructCollectionType(List::class.java, PlaceLlmFilterResult::class.java))
-            } ?: emptyList()
+            response?.let {
+                objectMapper.readValue(sanitizeJson(it), objectMapper.typeFactory.constructCollectionType(List::class.java, PlaceLlmFilterResult::class.java))
+            } ?: emptyList()
         } catch (e: Exception) {
             logger.warn(e) { "LLM 응답 파싱 실패 (FilterByBasic): ${e.message}" }
             emptyList()
         }
@@
-            response?.let {
-                objectMapper.readValue(it, objectMapper.typeFactory.constructCollectionType(List::class.java, PlaceLlmAnalysisResult::class.java))
-            } ?: emptyList()
+            response?.let {
+                objectMapper.readValue(sanitizeJson(it), objectMapper.typeFactory.constructCollectionType(List::class.java, PlaceLlmAnalysisResult::class.java))
+            } ?: emptyList()
         } catch (e: Exception) {
             logger.warn(e) { "LLM 응답 파싱 실패 (getBulkPlaceLlmInfo): ${e.message}" }
             emptyList()
         }
     }
+
+    private fun sanitizeJson(raw: String): String =
+        raw.trim()
+            .removePrefix("```json")
+            .removePrefix("```")
+            .removeSuffix("```")
+            .trim()
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/llm/ProcessPlaceLlmService.kt (2)

30-47: 빈 후보 리스트는 LLM 호출/DB 업데이트를 건너뛰는 게 안전합니다.
불필요한 호출을 피하고 로그 노이즈도 줄일 수 있습니다.

♻️ 제안 리팩터링
     suspend fun filterByCriteria(
         places: List<PlacesTextSearchResponse.Place>,
         criteria: String
     ): Map<String, PlaceLlmFilterResult> {
+        if (places.isEmpty()) return emptyMap()
         if (geminiProperties.apiKey.isBlank()) return emptyMap()

87-118: 일괄 분석 대상은 최대 7개로 제한하는 방어 코드가 필요합니다.
프롬프트 길이/비용 상한을 코드로 보장하면 운영 리스크가 줄어듭니다.

♻️ 제안 리팩터링
-        if (placesToCallLlm.isNotEmpty()) {
+        val placesForLlm = placesToCallLlm.take(7)
+        if (placesForLlm.isNotEmpty()) {
             try {
                 // 상세 정보 병렬 조회 (랜드마크 분석에 필요한 상세 데이터 확보)
-                val detailsList = placesToCallLlm.map { entity ->
+                val detailsList = placesForLlm.map { entity ->
                     async { placeQuery.getPlaceDetails(entity.googlePlaceId!!) }
                 }.awaitAll().filterNotNull()
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/ExecutePlaceSearchService.kt (2)

80-112: runCatching으로 인한 오류 무시 및 변수 네이밍 이슈

  1. runCatching { ... }.getOrNull()은 모든 예외를 조용히 무시하여 디버깅을 어렵게 만들 수 있습니다. 최소한 오류 로깅을 추가하는 것을 권장합니다.

  2. Line 92의 PhotoName 변수명이 PascalCase로 되어 있습니다. Kotlin 컨벤션에 따라 photoName으로 변경해 주세요.

♻️ 개선 제안
             val items = savedEntities.mapNotNull { entity ->
                 runCatching {
                     val googleId = entity.googlePlaceId!!
                     val likeInfo = likesMap[googleId] ?: RankPlaceSearchService.PlaceLikeInfo(0, false)

                     PlacesSearchResponse.PlaceItem(
                         placeId = entity.id!!,
                         name = entity.name ?: "",
                         address = entity.address?.replace("대한민국 ", "") ?: "",
                         rating = entity.rating,
                         userRatingsTotal = entity.userRatingsTotal,
                         openNow = entity.openNow,
-                        photos = entity.photos?.split(",")?.map { PhotoName ->
-                            PlaceFormatter.generatePhotoUrl(PhotoName, googlePlacesApiProperties.proxyBaseUrl)
+                        photos = entity.photos?.split(",")?.map { photoName ->
+                            PlaceFormatter.generatePhotoUrl(photoName, googlePlacesApiProperties.proxyBaseUrl)
                         },
                         // ... rest of the code
                     )
-                }.getOrNull()
+                }.onFailure { e ->
+                    logger.warn("Failed to map PlaceItem for entity: ${entity.id}", e)
+                }.getOrNull()
             }

114-116: 매직 넘버 상수화 권장

take(7)의 숫자 7이 하드코딩되어 있습니다. totalFetchSizephotoFallbackBuffer처럼 클래스 상단에 상수로 정의하면 유지보수성이 향상됩니다.

♻️ 상수 추출 제안
     private val totalFetchSize = 10
     private val photoFallbackBuffer = 5
+    private val topRankedSize = 7
-            val rankedItems = rankPlaceSearchService.rank(items, weightByDbId, filteredIds, dbToGoogleId).take(7)
+            val rankedItems = rankPlaceSearchService.rank(items, weightByDbId, filteredIds, dbToGoogleId).take(topRankedSize)
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/place/PlaceQuery.kt (2)

135-137: 필드 매핑 네이밍 불일치

LlmUpdateData.landmarksPlaceEntity.addressDescriptor에 저장되고 있습니다. 이 네이밍 불일치는 코드 유지보수 시 혼란을 야기할 수 있습니다.

두 가지 방안을 고려해 주세요:

  1. LlmUpdateData의 필드명을 addressDescriptor로 변경
  2. 또는 PlaceEntity에 별도의 llmLandmarks 필드 추가
♻️ LlmUpdateData 필드명 변경 제안
     data class LlmUpdateData(
         val summary: String? = null,
-        val landmarks: String? = null,
+        val addressDescriptor: String? = null,
         val reason: String? = null
     )

호출부도 함께 변경이 필요합니다.


115-139: 엔티티 업데이트 패턴 개선 고려

LLM 필드 3개만 업데이트하기 위해 전체 엔티티를 재생성하고 있습니다. PlaceEntity가 data class라면 copy() 함수를 활용하거나, JPA 엔티티의 경우 직접 필드를 변경하는 방식이 더 간결할 수 있습니다.

♻️ copy() 활용 예시 (data class인 경우)
val updatedEntities = entities.map { entity ->
    val data = updateDataMap[entity.googlePlaceId] ?: return@map entity
    entity.copy(
        addressDescriptor = data.landmarks ?: entity.addressDescriptor,
        llmSummary = data.summary ?: entity.llmSummary,
        llmReason = data.reason ?: entity.llmReason
    )
}

@parkmineum parkmineum self-assigned this Jan 28, 2026
@parkmineum parkmineum added the ✨ FEATURE 기능 구현 관련 라벨 label Jan 28, 2026
Copy link

@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: 2

🤖 Fix all issues with AI agents
In
`@ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/components/RankPlaceSearchService.kt`:
- Line 98: meetingPlaceToDbId uses the unsafe `it.id!!` which can cause NPE;
change the construction to a null-safe pattern similar to buildLikesMap by
filtering or mapping out null ids (e.g., use mapNotNull/associateNotNull or
filter { it.id != null } and associate { it.id!! to it.placeId }) and/or
handle/log unexpected nulls from syncMeetingPlaces so no forced unwrap remains;
apply this change around the meetingPlaces.associate usage in
RankPlaceSearchService (method building meetingPlaceToDbId).

In
`@ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/config/GeminiRestClientConfiguration.kt`:
- Around line 61-72: geminiHttpRequestFactory() currently calls
geminiConnectionManager() directly which bypasses Spring's CGLIB proxy and
creates a new connection manager each call; change geminiHttpRequestFactory to
accept the connection manager as a parameter (e.g., fun
geminiHttpRequestFactory(geminiConnectionManager:
PoolingHttpClientConnectionManager): ClientHttpRequestFactory) and use that
injected instance when building HttpClients.custom(), so the
geminiConnectionManager bean is managed as a singleton by Spring (locate symbols
geminiHttpRequestFactory(), geminiConnectionManager(), and class
GeminiRestClientConfiguration to apply the change).
🧹 Nitpick comments (4)
ssolv-api-place/src/main/kotlin/org/depromeet/team3/place/application/search/ExecutePlaceSearchService.kt (1)

97-99: 변수 명명 규칙: PhotoNamephotoName

Kotlin 명명 규칙에 따라 변수명은 camelCase를 사용해야 합니다. PhotoName은 클래스명처럼 보이므로 photoName으로 변경하는 것이 좋습니다.

♻️ 명명 규칙 수정 제안
-                        photos = entity.photos?.split(",")?.map { PhotoName ->
-                            PlaceFormatter.generatePhotoUrl(PhotoName, googlePlacesApiProperties.proxyBaseUrl)
+                        photos = entity.photos?.split(",")?.map { photoName ->
+                            PlaceFormatter.generatePhotoUrl(photoName, googlePlacesApiProperties.proxyBaseUrl)
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/common/GooglePlacesApiProperties.kt (1)

6-10: var와 빈 기본값 사용 시 설정 누락을 감지하기 어려울 수 있습니다.

ConfigurationProperties에서 var를 사용하면 런타임에 값이 변경될 수 있어 예상치 못한 동작이 발생할 수 있습니다. 또한 apiKey의 빈 문자열 기본값은 환경변수 누락 시 조용히 실패할 수 있습니다.

Spring Boot 3.x에서는 @ConstructorBindingval을 사용한 불변 패턴을 권장합니다. 현재 구조를 유지하려면 apiKey 누락 시 명시적 경고나 실패 처리를 호출 측에서 확인하세요.

ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/place/client/GooglePlacesClient.kt (1)

125-147: placeId 문자열을 직접 URI에 삽입하는 방식은 주의가 필요합니다.

"/v1/places/$placeId" 형태의 문자열 보간은 placeId에 특수문자가 포함될 경우 예상치 못한 동작을 유발할 수 있습니다. URI 빌더의 path variable 치환 방식을 사용하는 것이 더 안전합니다.

🛠️ 제안 수정
                 withTimeout(apiTimeoutMillis) {
                     googlePlacesRestClient.get()
-                        .uri("/v1/places/$placeId")
+                        .uri("/v1/places/{placeId}", placeId)
                         .header("X-Goog-Api-Key", googlePlacesApiProperties.apiKey)
                         .header("X-Goog-FieldMask", buildDetailsFieldMask())
                         .retrieve()
                         .body(PlaceDetailsResponse::class.java)
                 }
ssolv-infrastructure/src/main/kotlin/org/depromeet/team3/common/util/RetryUtil.kt (1)

40-42: 재시도 횟수 로그 메시지가 혼란스러울 수 있습니다.

"${attempt + 1}/${maxRetries - 1}" 형식은 maxRetries=3일 때 "1/2", "2/2"로 표시됩니다. 일반적으로 총 시도 횟수 대비 현재 재시도를 표시하므로 "${attempt + 1}/${maxRetries}" 또는 재시도만 카운트한다면 "${attempt}/${maxRetries - 1}"이 더 직관적입니다.

Also applies to: 54-56, 65-67

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

Labels

✨ FEATURE 기능 구현 관련 라벨

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants