Skip to content

[REFACTOR] 모델 개선 및 프롬프트 개선#145

Merged
beans3142 merged 4 commits intodevfrom
feat/#144_MatchingPrompt
Aug 21, 2025
Merged

[REFACTOR] 모델 개선 및 프롬프트 개선#145
beans3142 merged 4 commits intodevfrom
feat/#144_MatchingPrompt

Conversation

@beans3142
Copy link
Contributor

@beans3142 beans3142 commented Aug 21, 2025

🎋 이슈 및 작업중인 브랜치

🔑 주요 내용

  • 프롬프트 및 모델 변경
image image

Check List

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

Summary by CodeRabbit

  • New Features

    • AI 기반 레시피 생성·저장 흐름 추가(응답에서 JSON 추출·파싱, 이미지 검증 포함).
    • 우선순위 기반 적합도 평가 도입으로 재료·알레르기·건강정보 통합 점수화 제공.
    • OCR/HealthGoal 연동으로 개인 건강정보를 반영한 추천 향상.
    • 카테고리별 조회 및 요약 제공, 제목 캐시와 만료 정리 스케줄 추가.
  • Bug Fixes

    • AI 응답 파싱·중복 검사 강화 및 fallback 결과 비캐시 처리.
    • 이미지 유효성 검증 후 실패 시 저장 건너뛰기 처리로 데이터 품질 개선.
  • Chores

    • 고정밀 프로 모델 선택 옵션 및 모델 라우팅 유연성 추가.

@beans3142 beans3142 self-assigned this Aug 21, 2025
@beans3142 beans3142 added the ♻️ REFACTOR 리팩토링 관련 라벨 label Aug 21, 2025
@coderabbitai
Copy link

coderabbitai bot commented Aug 21, 2025

Walkthrough

  • RecipeRecommendServiceImpl: Gemini 호출을 chatAccurate로 전환하고 프롬프트를 전문 큐레이터 컨셉의 다단계 규칙·워크플로우·JSON 출력 명세로 대대적 재작성. 우선순위 기반 점수화(prompt 빌더 및 callAIForSuitabilityScore), AI 응답에서 JSON 추출·파싱·중복검사·이미지 검증 후 DB저장 흐름(callAIAndSaveRecipes 외) 및 Redis 기반 제목 캐시·만료 정리 스케줄러 추가. OCR/HealthGoal 연동 로직과 보조 메서드들이 다수 분리·추가됨.
  • RecipeServiceImpl: 빌드한 재료 매칭 프롬프트를 규칙·카테고리 기반 구조로 교체하고 geminiClient.chatAccurate 사용, AI 파싱 검증·중복·fallback 캐싱 회피 로직 도입.
  • GeminiClient / GeminiClientConfig / application.yml: accurateModel 필드·파라미터 추가, chatAccurate·chatWithModel 메서드 도입, gemini.api.model_accurate 프로퍼티(gemini-1.5-pro) 추가 및 geminiClient 빈에 accurateModel 주입(Primary 지정).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#144_MatchingPrompt

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

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

Caution

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

⚠️ Outside diff range comments (4)
src/main/java/com/mumuk/global/client/GeminiClient.java (2)

16-17: 중복 빈 생성 위험: @component 제거 필요

GeminiClientConfig에서 @Bean으로 동일 타입을 제공하므로, 여기의 @Component와 중복되어 빈 충돌이 납니다. 하나만 유지하세요. 구성 클래스를 유지할 계획이라면 아래처럼 @Component를 제거하세요.

-@Component
 public class GeminiClient {

38-47: HTTP 에러 상태를 BusinessException으로 매핑하고 응답 크기 제한을 추가하세요.

현재 retrieve()는 4xx/5xx를 기본 예외로 던지며, 메시지 표준화가 부족합니다. 상태코드별 매핑과 페이로드 사이즈 제한을 권장합니다.

-        return webClient.post()
+        return webClient.post()
                 .uri("/v1beta/models/" + modelName + ":generateContent")
                 .bodyValue(body)
-                .retrieve()
+                .retrieve()
+                .onStatus(s -> s.is4xxClientError() || s.is5xxServerError(),
+                          resp -> resp.bodyToMono(String.class)
+                                      .defaultIfEmpty("")
+                                      .map(msg -> new BusinessException(ErrorCode.OPENAI_API_ERROR)))
                 .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
                 .map(this::extractContent);

또한 과도한 응답으로 인한 메모리 사용을 줄이기 위해 서버측 maxResponseSize 설정(게이트웨이/프록시)도 검토해 주세요.

src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java (1)

1195-1219: 배치 JSON 예시에서 '점수' 토큰 대신 숫자 예시를 제공하세요.

현재 예시가 "\"레시피제목\": 점수" 형태라 모델이 문자열 "점수"를 그대로 내보낼 위험이 있습니다. 실제 숫자 예시(예: 7.5)를 넣어 파서의 asDouble과 정합성을 높이세요. 또한 헤더 대체 문자열 불일치(🎯 포함 버전 사용)를 수정하세요.

-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
             .append("반드시 다음 JSON 형태로만 응답해줘:\n")
             .append("{\n")
             .append("  \"scores\": {\n");
@@
-            promptBuilder.append("    \"").append(recipe.getTitle()).append("\": 점수");
+            promptBuilder.append("    \"").append(recipe.getTitle()).append("\": 7.5");
src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (1)

1195-1219: 배치 JSON 예시에서 '점수' 토큰 위험: 숫자 예시로 교체

RecipeServiceImpl과 동일하게 여기서도 "점수" 문자열을 숫자 예시로 바꾸세요. 또한 헤더 대체 문자열에서 🎯 이모지 버전을 사용하고 있어 실제 반환 문자열과 불일치합니다.

-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
@@
-            promptBuilder.append("    \"").append(recipe.getTitle()).append("\": 점수");
+            promptBuilder.append("    \"").append(recipe.getTitle()).append("\": 7.5");
🧹 Nitpick comments (8)
src/main/resources/application.yml (1)

98-98: 정확도 모델 프로퍼티 추가는 적절합니다만, 프로파일별 기본값과 비용/지연 영향도 고려해 주세요.

gemini.api.model_accurate: gemini-1.5-pro 추가는 구성 의도가 명확합니다. 운영/개발 프로파일에서 서로 다른 값을 사용하도록 분리(application-prod.yml, application-dev.yml)하면 비용과 지연(time-to-first-token)이 커질 수 있는 pro 사용을 더 안전하게 통제할 수 있습니다. 또한 현재 @Value("${gemini.api.model_accurate:${gemini.api.model}}")로 폴백이 설정되어 있어, 해당 키가 누락되어도 안전하게 동작합니다.

src/main/java/com/mumuk/global/config/GeminiClientConfig.java (1)

18-24: WebClient 타임아웃/커넥션 설정을 명시적으로 추가하는 것을 권장합니다.

서비스단에서 .block(Duration.ofSeconds(...))을 쓰더라도, 커넥션/읽기 타임아웃은 Netty 레벨에서 별도로 설정해야 예기치 않은 hang을 줄일 수 있습니다.

 @Bean
 public WebClient geminiWebClient() {
-    return WebClient.builder()
+    return WebClient.builder()
             .baseUrl(baseUrl)
             .defaultHeader("x-goog-api-key", apiKey)
+            .clientConnector(new reactor.netty.http.client.HttpClientConnector(
+                reactor.netty.http.client.HttpClient.create()
+                    .responseTimeout(java.time.Duration.ofSeconds(20))
+                    .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
+            ))
             .build();
 }
src/main/java/com/mumuk/global/client/GeminiClient.java (2)

23-28: 생성자 파라미터의 @value 제거로 책임을 단일화하세요.

이 클래스는 구성 클래스에서 명시적으로 인자 주입을 받고 있으므로, 생성자 파라미터의 @Value는 혼란을 유발합니다(컴포넌트 스캔 경로 제거 시 불필요). 아래처럼 제거해 주세요.

-    public GeminiClient(WebClient webClient, @Value("${gemini.api.model}") String model,
-                        @Value("${gemini.api.model_accurate:${gemini.api.model}}") String accurateModel) {
+    public GeminiClient(WebClient webClient, String model, String accurateModel) {
         this.webClient = webClient;
         this.model = model;
         this.accurateModel = accurateModel;
     }

67-76: SafetySettings는 카테고리별 배열 구성 권장

현재 하나의 카테고리만 BLOCK_NONE으로 지정되어 있습니다. 정책에 맞게 여러 카테고리를 명시하거나, 설정 주입 가능하게 빼두면 운영 통제가 수월합니다.

-        Map<String, Object> safetySettings = new HashMap<>();
-        safetySettings.put("category", "HARM_CATEGORY_HARASSMENT");
-        safetySettings.put("threshold", "BLOCK_NONE");
-        
-        List<Map<String, Object>> safetySettingsList = new ArrayList<>();
-        safetySettingsList.add(safetySettings);
+        List<Map<String, Object>> safetySettingsList = new ArrayList<>();
+        for (String cat : List.of("HARM_CATEGORY_HARASSMENT","HARM_CATEGORY_SEXUAL",
+                                  "HARM_CATEGORY_HATE_SPEECH","HARM_CATEGORY_DANGEROUS_CONTENT")) {
+            Map<String, Object> s = new HashMap<>();
+            s.put("category", cat);
+            s.put("threshold", "BLOCK_NONE");
+            safetySettingsList.add(s);
+        }
         body.put("safetySettings", safetySettingsList);
src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java (1)

510-517: 정확도 모델로의 전환 적절합니다.

geminiClient.chatAccurate(..., 15s)로의 변경은 본 파일의 분석 태스크 특성상 타당합니다. 필요 시 15s 타임아웃은 호출 경로별로 주입 가능하게 분리해 재사용성을 높일 수 있습니다.

src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (3)

987-994: 정확도 모델 사용 검토: 생성/분류 경로별 모델을 명시적으로 선택하세요.

본 서비스는 배치/개별 점수화, 레시피 생성 등 다양한 호출을 포함합니다. 구조화(JSON) 응답 신뢰도가 중요한 경로(배치 스코어링 등)는 chatAccurate, 경량 생성은 chat을 명시적으로 선택하도록 분리하면 운영 제어가 수월합니다. 최소한 아래처럼 callAI를 정확도 모델로 변경하거나, callAI(modelType) 형태로 확장하는 것을 권장합니다.

-    private String callAI(String prompt) {
+    private String callAI(String prompt) {
         try {
-            return geminiClient.chat(prompt).block(Duration.ofSeconds(30));
+            return geminiClient.chatAccurate(prompt).block(Duration.ofSeconds(30));
         } catch (Exception e) {
             log.error("Gemini API 호출 실패: {}", e.getMessage());
             throw new BusinessException(ErrorCode.OPENAI_API_ERROR);
         }
     }

1800-1803: 헤더 대체 문자열 불일치(🎯) 수정

buildPriorityBasedSuitabilityPromptCommon()의 헤더는 이모지 없이 정의되어 있습니다. 아래처럼 통일하세요.

-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")

2145-2149: 프롬프트 타이틀 오타 수정

"===최우선 고려사항 알러지 정보보 ===""=== 최우선 고려사항 알레르기 정보 ===". 모델 입력 품질을 위해 타이틀/띄어쓰기를 바로잡으세요.

-        promptBuilder.append("===최우선 고려사항 알러지 정보보 ===\n");
+        promptBuilder.append("=== 최우선 고려사항 알레르기 정보 ===\n");
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d998ab3 and 722a920.

📒 Files selected for processing (5)
  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (1 hunks)
  • src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java (1 hunks)
  • src/main/java/com/mumuk/global/client/GeminiClient.java (1 hunks)
  • src/main/java/com/mumuk/global/config/GeminiClientConfig.java (1 hunks)
  • src/main/resources/application.yml (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: CI
🔇 Additional comments (1)
src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java (1)

541-657: AI 응답 파서 정합성/보정 로직 LGTM

  • 집합화로 상호 배타성 확보
  • 누락 보정으로 레시피 재료 전체 커버
  • replaceable 검증 시 사용자/레시피 집합 교차 검증

안정적으로 보입니다.

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

♻️ Duplicate comments (1)
src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java (1)

474-476: 이전 리뷰 지적된 불필요 문자(‘g’) 제거 확인 – OK

과거 라인 인근의 stray 문자 ‘g’가 제거되어 프롬프트 노이즈 이슈가 해소되었습니다. 그대로 진행해도 됩니다.

🧹 Nitpick comments (3)
src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java (3)

416-503: JSON-only 출력 강제 및 입력을 JSON 데이터로 제시해 파싱·프롬프트 인젝션 리스크 추가 완화 제안

extractJsonFromAIResponse로 앞·뒤 설명을 잘라내긴 하지만, 모델 편차를 최소화하려면 애초에 “JSON만 출력”을 강제하고, 사용자/레시피 재료를 규칙이 아닌 “데이터(JSON)”로 주입하는 편이 더 견고합니다. 또한 replaceable.userIngredient가 반드시 사용자 재료 집합에서만 선택되어야 함을 명시하면 파싱/정합성 보정 비용이 줄어듭니다.

아래처럼 보강을 제안드립니다.

@@
 ## 5단계: JSON 출력 형식
@@
-* 반드시 아래 JSON 구조를 따른다.
-* 모든 레시피 재료는 세 그룹 중 하나에 반드시 포함되어야 한다.
+* 반드시 아래 JSON 구조를 따른다.
+* 출력은 오직 아래 JSON만 반환한다. 설명/해설/마크다운/코드블록을 포함하지 않는다.
+* 모든 레시피 재료는 세 그룹 중 하나에 반드시 포함되어야 한다.
+* match·mismatch·replaceable.recipeIngredient 값은 반드시 레시피 재료 목록에서만 선택한다.
+* replaceable.userIngredient 값은 반드시 사용자 재료 목록에서만 선택한다.
+* 레시피/사용자 재료 표기는 입력 목록의 표기를 그대로 사용한다(대소문자·공백 포함).
+* 키는 정확히 "match", "mismatch", "replaceable"만 사용하고 추가 필드는 금지한다.
@@
-**사용자 재료: %s**
-**레시피 재료: %s**
+입력(JSON):
+{
+    "userIngredients": %s,
+    "recipeIngredients": %s
+}

461-470: ‘유사적 대체’ 적용 조건을 보수적으로 명시하여 절대불가 규칙과의 충돌 여지 축소

“모짜렐라 ↔ 체다 (요리 종류에 따라)”처럼 조건부 예시는 모델이 과감히 대체를 택하도록 유도할 수 있습니다. 레시피 국적/조리법이 명확하지 않으면 mismatch로 귀결되도록 가드라인을 한 줄 보강하세요.

 **3. 유사적 대체 (Similarity-based):** 동일 소속 내에서 품종이나 가공 방식만 약간 다른 경우.
@@
     * `스파게티면` ↔ `링귀니면`
+    * 주의: 레시피의 국적/조리법이 명확하지 않거나 확신이 없으면 본 기준을 적용하지 말고 `mismatch` 처리한다.

511-515: AI 호출 타임아웃 상수화 및 에러 코드 명칭 통일 필요

현재 RecipeServiceImpl(lines 511–515) 구간에서 15초 하드코딩된 타임아웃과 OPENAI_API_ERROR 에러 코드가 사용되고 있습니다. 운영 환경에서 튜닝과 용어 일관성을 고려해 아래와 같이 변경을 권장드립니다.

  • 타임아웃 상수화

    - return geminiClient.chatAccurate(prompt).block(java.time.Duration.ofSeconds(15));
    + return geminiClient.chatAccurate(prompt).block(AI_CALL_TIMEOUT);

    파일 상단(다른 TTL 상수들과 같은 위치)에 다음 상수 추가:

    private static final java.time.Duration AI_CALL_TIMEOUT = java.time.Duration.ofSeconds(15);
  • 에러 코드 명칭 통일
    전체 코드베이스에서 ErrorCode.OPENAI_API_ERROR가 5회 사용되고 있습니다:

    src/main/java/com/mumuk/global/client/GeminiClient.java:157  
    src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java:514  
    src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java:846, 992  
    src/main/java/com/mumuk/global/apiPayload/code/ErrorCode.java:69
    

    (스크립트: rg -n "OPENAI_API_ERROR" -g '!**/build/**' -g '!**/out/**')
    Gemini 클라이언트를 사용하는 만큼, OPENAI_API_ERROR 대신 공용 AI_API_ERROR 혹은 GEMINI_API_ERROR를 새로 정의한 후 일괄 대체해 주세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 722a920 and ab8e043.

📒 Files selected for processing (3)
  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (1 hunks)
  • src/main/java/com/mumuk/domain/recipe/service/RecipeServiceImpl.java (1 hunks)
  • src/main/java/com/mumuk/global/config/GeminiClientConfig.java (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/com/mumuk/global/config/GeminiClientConfig.java
  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java

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

Caution

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

⚠️ Outside diff range comments (4)
src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (4)

1182-1193: JSON 키로 ‘레시피 제목’을 사용하는 설계는 충돌/파싱 실패를 유발합니다 → ‘레시피 ID’로 변경 권장

  • 제목은 중복/변형/특수문자/길이 문제로 안정적 키가 아닙니다. 현재 파서도 scoresNode.path(recipe.getTitle())로 조회하여 키 미스매치 시 기본 5.0이 남발될 수 있습니다.
  • ID를 JSON 키로 사용하면 매칭이 결정적이고, 국제화/이스케이프 이슈가 사라집니다.

프롬프트와 파싱 모두를 아래처럼 최소 변경하세요.

  1. 배치 프롬프트들에서 레시피 라벨 및 JSON 키를 ID로 교체
- String recipeText = "레시피: " + recipe.getTitle() + "\n" +
+ String recipeText = "레시피(id:" + recipe.getId() + "): " + recipe.getTitle() + "\n" +
                      "재료: " + recipe.getIngredients() + "\n\n";
- promptBuilder.append("    \"").append(recipe.getTitle()).append("\": 점수");
+ promptBuilder.append("    \"").append(recipe.getId()).append("\": 점수");

위 두 가지 수정은 다음 블록들에 공통 적용됩니다.

  • 재료 기반 배치: Lines 1182-1184, 1207-1213
  • 건강정보 기반 배치: Lines 1600-1602, 1625-1631
  • 통합 배치: Lines 1787-1789, 1812-1818
  • HealthGoal 배치: Lines 1891-1893, 1916-1923
  1. 파서에서 키 조회를 ID로
- double score = scoresNode.path(recipe.getTitle()).asDouble(5.0);
+ double score = scoresNode.path(String.valueOf(recipe.getId())).asDouble(5.0);

적용 위치:

  • 재료 기반 파싱: Lines 1244-1246
  • 건강정보 기반 파싱: Lines 1662-1664
  • 통합 파싱: Lines 1849-1851
  • HealthGoal 파싱: Lines 1953-1955

부가 제안(선택): 키를 ID로 바꾸되, 모델 친화성을 위해 “출력 형식 예시”에도 실제 숫자 예시를 넣어주면 더 정확한 응답을 유도할 수 있습니다.


Also applies to: 1201-1216, 1600-1611, 1619-1632, 1787-1798, 1806-1819, 1891-1902, 1912-1923, 1243-1247, 1661-1665, 1848-1852, 1952-1956

---

`2146-2149`: **오타/용어 통일: “알러지 정보보” → “알레르기 정보” 및 전체 문구 정돈**

- Line 2146: “정보보” 오타.
- 파일 전반에서 “알러지/알레르기”가 혼재합니다. 사용자 메시지(프롬프트)에서는 용어를 일관되게 쓰는 것이 중요합니다.


```diff
-        promptBuilder.append("===최우선 고려사항 알러지 정보보 ===\n");
+        promptBuilder.append("=== 최우선 고려사항: 알레르기 정보 ===\n");
         promptBuilder.append(buildAllergyPrompt(allergyTypes));
-        promptBuilder.append("위 알러지 성분이 포함된 요리는 절대 추천하지 마세요. (0점 처리)\n\n");
+        promptBuilder.append("위 알레르기 성분이 포함된 요리는 절대 추천하지 마세요. (0점 처리)\n\n");

아래 평가기준 문구도 용어 통일을 권장합니다.

-        return "=== 적합도 평가 기준 (우선순위 순) ===\n" +
-               "1. 알러지 성분 포함 여부 (최우선, 포함시 0점)\n" +
+        return "=== 적합도 평가 기준 (우선순위 순) ===\n" +
+               "1. 알레르기 성분 포함 여부 (최우선, 포함 시 0점)\n" +
                "2. 건강 목표 달성 도움 정도\n" +
                "3. 현재 건강 상태 적합성\n" +
                "4. 보유 재료 활용도\n\n" +
                "점수 기준:\n" +
                "- 9-10점: 모든 조건을 완벽하게 만족 (건강 목표 최적, 건강 상태 적합, 재료 완벽)\n" +
                "- 7-8점: 대부분의 조건을 만족 (건강 목표 적합, 건강 상태 적합, 재료 충분)\n" +
                "- 5-6점: 주요 조건을 만족 (건강 목표 보통, 건강 상태 보통, 재료 가능)\n" +
                "- 3-4점: 일부 조건만 만족 (건강 목표 부적합, 건강 상태 부적합, 재료 부족)\n" +
                "- 1-2점: 대부분의 조건을 만족하지 못함\n" +
-               "- 0점: 알러지 성분 포함 (절대 추천 불가)\n\n" +
+               "- 0점: 알레르기 성분 포함 (절대 추천 불가)\n\n" +
                "적합도 점수만 숫자로 응답해주세요 (예: 8.5)";

Also applies to: 2180-2191


1195-1195: replace 대상 문자열의 이모지 불일치로 치환이 동작하지 않습니다

buildPriorityBasedSuitabilityPromptCommon()이 반환하는 헤더는 이모지 없이

"=== 적합도 평가 기준 (우선순위 순) ==="

이므로, 이모지 포함 검색 문자열을 사용하는 다음 두 곳에서는 치환이 아예 수행되지 않습니다:

  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java:1195
  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java:1800

수정 제안(diff):

--- a/src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java
+++ b/src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java
@@ -1195,1 +1195,1 @@
-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon()
-            .replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", "평가 기준:"))
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon()
+            .replace("=== 적합도 평가 기준 (우선순위 순) ===", "평가 기준:"))
@@ -1800,1 +1800,1 @@
-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon()
-            .replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", "평가 기준:"))
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon()
+            .replace("=== 적합도 평가 기준 (우선순위 순) ===", "평가 기준:"))

대안(권장):
공통 메서드에 “헤더 포맷 옵션” 파라미터를 추가해, 호출부에서 직접 원하는 헤더를 구성하도록 변경하세요. 이모지 유무를 옵션화하면 .replace() 대신 포맷 제어가 명확해집니다.


1371-1375: 로그 레벨 최소화 및 상세 로그 게이트 적용 필요

운영 환경의 기본 로그 레벨(info)에서 사용자의 재료·알레르기·건강정보가 노출되어 개인정보·민감정보 유출 위험이 있습니다. 아래 모든 블록에서 log.infoENABLE_VERBOSE_LOG 게이트(또는 debug 레벨)로 처리하도록 리팩터링하세요.

– 개선 대상 위치

  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java
    • 392–395: 초기 재료 기반 적합도 평가 (=== 적합도 평가 시작 ===)
    • 660–663: OCR 건강 정보 기반 평가 (=== 건강 정보 기반 적합도 평가 시작 ===)
    • 1371–1375: 건강 목표 기반 평가 (=== HealthGoal 기반 적합도 평가 시작 ===)
    • 1399–1404: 통합 평가 (=== 통합 적합도 평가 시작 ===)

– (Optional) src/main/java/com/mumuk/domain/ocr/service/OcrServiceImpl.java
• 140–141: 건강 데이터 파싱 시작 로그
• 278: OCR 결과 저장 완료 로그

-        log.info("=== HealthGoal 기반 적합도 평가 시작 ===");
-        log.info("사용자 보유 재료: {}", String.join(", ", availableIngredients));
-        log.info("사용자 알레르기 정보: {}", allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes));
-        log.info("사용자 건강 목표: {}", String.join(", ", healthGoals));
-        log.info("전체 레시피 수: {}", recipes.size());
+        if (ENABLE_VERBOSE_LOG) {
+            log.info("=== HealthGoal 기반 적합도 평가 시작 ===");
+            log.info("사용자 보유 재료: {}", String.join(", ", availableIngredients));
+            log.info("사용자 알레르기 정보: {}", allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes));
+            log.info("사용자 건강 목표: {}", String.join(", ", healthGoals));
+            log.info("전체 레시피 수: {}", recipes.size());
+        }
🧹 Nitpick comments (8)
src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (8)

1043-1053: 프롬프트 지침 강화 반영 좋습니다. 단, 줄바꿈 누락 및 자잘한 문구 다듬기 권장

  • Line 1050 끝에 개행이 없어 바로 이어지는 "※ 카테고리 선택:"과 붙습니다. 가독성과 파싱 안정성을 위해 개행 추가하세요.
-               "- 다양한 재료를 이용할 수 있는 경우 큰 틀로 작성 (앞다리살/삼겹살/목살 → 돼지고기, 상추/로메인/샐러리 → 샐러드 채소)"+
+               "- 다양한 재료를 이용할 수 있는 경우 큰 틀로 작성 (앞다리살/삼겹살/목살 → 돼지고기, 상추/로메인/샐러리 → 샐러드 채소)\n" +

추가 제안(선택):

  • “알레르기” 용어를 파일 전반에서 통일해 주세요(“알러지” 혼재).
  • 예시 JSON 유도부에 실제 숫자 예시(예: 8.5)를 넣으면 모델이 “점수” 텍스트를 그대로 출력하는 리스크가 줄어듭니다.

231-233: 주석 숫자 ‘48개’가 상수와 불일치합니다.

  • 실제 코드는 RANDOM_SAMPLE_SIZE(현재 12)를 사용합니다. 하드코딩된 “48개” 주석은 혼동을 줍니다.
-        // DB 레벨에서 랜덤 샘플링으로 48개 조회
+        // DB 레벨에서 랜덤 샘플링으로 RANDOM_SAMPLE_SIZE개 조회

1101-1103: 프롬프트 길이/재료 목록 info 로그 → verbose 게이트 권장

  • 프롬프트 길이와 재료 목록은 운영 기본 로그에 상시 남길 필요가 낮습니다. 상단의 ENABLE_VERBOSE_LOG와 정책을 일관시키세요.
-        log.info("프롬프트 길이: {} characters", prompt.length());
-        log.info("전달된 재료: {} (중복제거 후: {}개)", String.join(", ", uniqueIngredients), uniqueIngredients.size());
+        if (ENABLE_VERBOSE_LOG) {
+            log.info("프롬프트 길이: {} characters", prompt.length());
+            log.info("전달된 재료: {} (중복제거 후: {}개)", String.join(", ", uniqueIngredients), uniqueIngredients.size());
+        }

2028-2031: 예외 매핑의 의미 보존 필요(일괄 ‘INVALID_RESPONSE’로 래핑 시 원인 손실)

  • 여기서는 어떤 예외든 OPENAI_INVALID_RESPONSE로 변환됩니다. DB 오류/네트워크 타임아웃 등도 동일 코드로 래핑되어 원인 추적이 어렵습니다.
  • 바로 위 callAIAndSaveRecipes는 이미 BusinessException을 던지므로, BusinessException은 재래핑 없이 통과시키고 일반 예외만 OPENAI_API_ERROR 등으로 매핑하세요.
-        } catch (Exception e) {
-            log.error("AI 랜덤 레시피 생성 실패: {}", e.getMessage(), e);
-            throw new BusinessException(ErrorCode.OPENAI_INVALID_RESPONSE);
+        } catch (BusinessException e) {
+            log.error("AI 랜덤 레시피 생성 실패(Business): {}", e.getMessage(), e);
+            throw e;
+        } catch (Exception e) {
+            log.error("AI 랜덤 레시피 생성 실패(Unexpected): {}", e.getMessage(), e);
+            throw new BusinessException(ErrorCode.OPENAI_API_ERROR);
         }

1171-1173: 주석의 공급자 지칭(OpenAI) → 모델/플랫폼 중립 표현으로 정리

  • 이제 Gemini Accurate 경로를 사용하므로 “OpenAI 토큰 제한 고려” 등 공급자 특정 표현은 혼란을 줄 수 있습니다.
-        // OpenAI 토큰 제한 고려 (대략 1토큰 = 4글자)
+        // 모델 토큰/컨텍스트 제한 고려 (대략 1토큰 ≈ 4자 기준)

Also applies to: 1576-1578, 1776-1778, 1880-1881


761-771: 점수 추출 정규식 보완 제안(다수 숫자 혼재 시 오인 파싱 가능)

  • 현재 replaceAll("[^0-9.]", "")는 “8/10” → “8.10”, “10.0 (8점 권장)” → “10.08” 같이 합쳐질 수 있어 Double.parseDouble 실패 또는 오인값이 됩니다.
  • 첫 번째 부동소수점 숫자 캡처로 안전하게 추출하세요.
-            scoreStr = scoreStr.replaceAll("[^0-9.]", "");
-            if (scoreStr.isEmpty()) {
+            java.util.regex.Matcher m = java.util.regex.Pattern.compile("(\\d{1,2}(?:\\.\\d+)?)").matcher(scoreStr);
+            if (!m.find()) {
                 log.warn("점수 추출 실패, 기본값 5.0 사용");
                 return 5.0;
             }
-            double score = Double.parseDouble(scoreStr);
+            double score = Double.parseDouble(m.group(1));

38-38: 미사용 import 제거 권장: WebClient

RecipeRecommendServiceImpl.java 파일에 선언된 WebClient import가 해당 파일 내에서 전혀 참조되지 않습니다. 빌드 시 경고를 방지하기 위해 아래와 같이 삭제를 권장합니다.

파일: src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java
라인: 38

제거할 코드:

- import org.springframework.web.reactive.function.client.WebClient;

986-994: 타임아웃 처리 및 예외 매핑 개선 제안

callAI 메서드에서 Reactor 이벤트 루프 고착을 방지하고, 장애 유형별 관측성을 높이기 위해 다음 사항을 적용해 보세요.

  • block 호출 대신 Reactor의 timeout 오퍼레이터 사용
  • 타임아웃, HTTP 오류 등 예외 유형별로 로깅 메시지·ErrorCode 매핑
  • 타임아웃 값을 프로퍼티로 외부화해 운영 중 조정 가능하도록 설정

수정 대상:

  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java callAI (라인 986 부근)
@@ src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java:986
-    return geminiClient.chatAccurate(prompt).block(Duration.ofSeconds(30));
+    return geminiClient.chatAccurate(prompt)
+        .timeout(AI_TIMEOUT) // 설정된 Duration
+        .block();
@@ src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java:990
-    log.error("Gemini Pro API 호출 실패: {}", e.getMessage());
-    throw new BusinessException(ErrorCode.OPENAI_API_ERROR);
+    if (e.getCause() instanceof java.util.concurrent.TimeoutException) {
+        log.error("Gemini Pro API 호출 타임아웃: {}", e.getMessage());
+        throw new BusinessException(ErrorCode.OPENAI_API_TIMEOUT);
+    }
+    log.error("Gemini Pro API 호출 실패", e);
+    throw new BusinessException(ErrorCode.OPENAI_API_ERROR);

추가(선택): 클래스 상단에 프로퍼티 외부화 및 상수 정의

@Value("${gemini.api.timeout-seconds:30}")
private long aiTimeoutSec;
private final Duration AI_TIMEOUT = Duration.ofSeconds(aiTimeoutSec);
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ab8e043 and c3deb64.

📒 Files selected for processing (1)
  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (2 hunks)

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

Caution

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

⚠️ Outside diff range comments (6)
src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (6)

1218-1243: 배치 평가 프롬프트에서 출력 지시 충돌 및 replace 대상 불일치로 인한 모델 혼란 가능성

현재 공통 기준 문자열(buildPriorityBasedSuitabilityPromptCommon) 끝에 "적합도 점수만 숫자로 응답" 지시가 포함되어 있는데, 배치 프롬프트에서는 이어서 "JSON만 응답"을 요구합니다. 상충 지시로 모델이 혼란을 겪거나 숫자만 반환할 위험이 있습니다. 또한 일부 위치에서 .replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", ...)처럼 실제 문자열과 불일치(🎯 이모지)하여 치환이 동작하지 않습니다.

권장 수정:

  1. 공통 기준 메서드에서 "숫자만 응답" 문구 제거(기준만 제공).
  2. 배치 프롬프트에서는 JSON 지시만 포함.
  3. 단건 평가 프롬프트(create*SuitabilityPrompt 계열)에서만 "숫자만 응답"을 별도 추가.
  4. replace 오타(🎯) 제거 또는 단순히 기준을 그대로 붙이기.

필수 diff(출력 지시 제거 및 replace 대상 수정):

@@ private String buildPriorityBasedSuitabilityPromptCommon() {
-        return "=== 적합도 평가 기준 (우선순위 순) ===\n" +
+        return "=== 적합도 평가 기준 (우선순위 순) ===\n" +
                "1. 알러지 성분 포함 여부 (최우선, 포함시 0점)\n" +
                "2. 건강 목표 달성 도움 정도\n" +
                "3. 현재 건강 상태 적합성\n" +
                "4. 보유 재료 활용도\n\n" +
-               "점수 기준:\n" +
+               "점수 기준:\n" +
                "- 9-10점: 모든 조건을 완벽하게 만족 (건강 목표 최적, 건강 상태 적합, 재료 완벽)\n" +
                "- 7-8점: 대부분의 조건을 만족 (건강 목표 적합, 건강 상태 적합, 재료 충분)\n" +
                "- 5-6점: 주요 조건을 만족 (건강 목표 보통, 건강 상태 보통, 재료 가능)\n" +
                "- 3-4점: 일부 조건만 만족 (건강 목표 부적합, 건강 상태 부적합, 재료 부족)\n" +
                "- 1-2점: 대부분의 조건을 만족하지 못함\n" +
-               "- 0점: 알러지 성분 포함 (절대 추천 불가)\n\n" +
-               "적합도 점수만 숫자로 응답해주세요 (예: 8.5)";
+               "- 0점: 알러지 성분 포함 (절대 추천 불가)\n";
 }
@@ private String createBatchIngredientSuitabilityPrompt(...)
-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon()).append("\n\n")
             .append("반드시 다음 JSON 형태로만 응답해줘:\n")
@@ private String createBatchHealthSuitabilityPrompt(...)
-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon()).append("\n\n")
             .append("반드시 다음 JSON 형태로만 응답해줘:\n")
@@ private String createBatchCombinedSuitabilityPrompt(...)
-        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon().replace("=== 🎯 적합도 평가 기준 (우선순위 순) ===", "평가 기준:")).append("\n\n")
+        promptBuilder.append(buildPriorityBasedSuitabilityPromptCommon()).append("\n\n")
             .append("반드시 다음 JSON 형태로만 응답해줘:\n")
@@ private String createBatchHealthGoalSuitabilityPrompt(...)
-        promptBuilder.append(buildHealthGoalSuitabilityPromptCommon().replace("위 정보를 바탕으로", "평가 기준:")).append("\n\n")
+        promptBuilder.append(buildHealthGoalSuitabilityPromptCommon()).append("\n\n")
             .append("반드시 다음 JSON 형태로만 응답해줘:\n")

그리고 단건 평가 프롬프트들(createIngredientSuitabilityPrompt / createHealthSuitabilityPrompt / createHealthGoalSuitabilityPrompt / createCombinedSuitabilityPrompt) 끝에만 다음 한 줄을 추가해 주세요(배치 프롬프트에는 추가 금지).

// 단건 적합도 프롬프트 끝에만 추가
promptBuilder.append("\n적합도 점수만 숫자로 응답하세요 (예: 8.5)");

Also applies to: 1636-1661, 1823-1849, 1927-1953, 2201-2215


744-779: 숫자 추출 로직이 부정확합니다(예: “9.7/10” → “9.710” 파싱 버그). JSON 스키마도 OpenAI 전용 필드에 의존

현재 replaceAll("[^0-9.]", "")는 "9.7/10"을 "9.710"으로 만들어 오탐합니다. 또한 { choices[0].message.content } 경로는 Gemini 응답 스키마와 맞지 않습니다. 첫 번째 실수(소수)만 안전하게 추출하거나 JSON 내 숫자 필드를 탐색하도록 수정해 주세요.

적용 예시(diff):

@@ private double callAIForSuitabilityScore(String prompt) {
-            String scoreStr = response.trim();
+            String scoreStr = response.trim();
@@
-            // JSON 응답인 경우 처리
-            if (scoreStr.startsWith("{")) {
-                try {
-                    JsonNode jsonNode = objectMapper.readTree(scoreStr);
-                    String content = jsonNode.path("choices").path(0).path("message").path("content").asText();
-                    if (!content.isEmpty()) {
-                        scoreStr = content.trim();
-                    }
-                } catch (Exception e) {
-                    log.warn("JSON 파싱 실패: {}", e.getMessage());
-                    return 5.0;
-                }
-            }
+            // JSON 응답일 가능성 우선 처리: {"score": 8.5} 또는 {"scores": {...}}
+            if (scoreStr.startsWith("{")) {
+                try {
+                    JsonNode json = objectMapper.readTree(scoreStr);
+                    if (json.has("score")) {
+                        double v = json.path("score").asDouble(Double.NaN);
+                        if (!Double.isNaN(v)) return clampScore(v);
+                    }
+                    if (json.has("value")) {
+                        double v = json.path("value").asDouble(Double.NaN);
+                        if (!Double.isNaN(v)) return clampScore(v);
+                    }
+                    JsonNode scoresNode = json.path("scores");
+                    if (scoresNode.isObject() && scoresNode.fieldNames().hasNext()) {
+                        String firstKey = scoresNode.fieldNames().next();
+                        double v = scoresNode.path(firstKey).asDouble(Double.NaN);
+                        if (!Double.isNaN(v)) return clampScore(v);
+                    }
+                } catch (Exception e) {
+                    log.warn("JSON 파싱 실패: {}", e.getMessage());
+                    // fall-through → 자유 텍스트에서 숫자 추출
+                }
+            }
@@
-            // 숫자만 추출 (소수점 포함)
-            scoreStr = scoreStr.replaceAll("[^0-9.]", "");
-
-            if (scoreStr.isEmpty()) {
+            // 자유 텍스트에서 '첫 번째 실수'만 추출
+            java.util.regex.Matcher m =
+                java.util.regex.Pattern.compile("([0-9]+(?:\\.[0-9]+)?)")
+                    .matcher(response);
+            if (!m.find()) {
                 log.warn("점수 추출 실패, 기본값 5.0 사용");
                 return 5.0;
             }
-
-            double score = Double.parseDouble(scoreStr);
-
-            // 점수 범위 검증 (0.0 ~ 10.0)
-            if (score < 0.0 || score > 10.0) {
-                log.warn("AI 응답의 점수가 범위를 벗어남: {}, 기본값 5.0 사용", score);
-                return 5.0;
-            }
-
-            return score;
+            double score = Double.parseDouble(m.group(1));
+            return clampScore(score);

그리고 아래 보조 메서드를 클래스 내부(가까운 유틸 영역)에 추가하세요.

private double clampScore(double v) {
    if (Double.isNaN(v)) return 5.0;
    if (v < 0.0 || v > 10.0) {
        log.warn("AI 응답 점수 범위 초과: {}, 기본값 5.0 사용", v);
        return 5.0;
    }
    return v;
}

추가로, java.util.regex 임포트가 필요합니다.

import java.util.regex.Matcher;
import java.util.regex.Pattern;

1450-1476: 건강 목표/건강 정보 및 사용자 데이터 로그 민감도 조정 필요(PII/PHI 최소화)

건강 목표/건강 상태, AI 원문 응답, 개별 점수 등 민감 정보가 info 레벨로 무조건 로그됩니다. 운영 환경에서는 PHI에 해당할 수 있어 노출 위험이 큽니다. ENABLE_VERBOSE_LOG 가드 또는 debug 레벨로 내리고, 상세 값은 마스킹/요약하세요.

예시(diff):

@@
-        log.info("HealthGoal 기반 배치 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강목표: {}", 
+        if (ENABLE_VERBOSE_LOG) log.debug("HealthGoal 기반 배치 처리 시작 - 사용자 재료: {}, 알레르기: {}, 건강목표: {}",
                 String.join(", ", availableIngredients), 
                 allergyTypes.isEmpty() ? "없음" : String.join(", ", allergyTypes),
                 String.join(", ", healthGoals));
@@
-            log.info("HealthGoal 기반 배치 프롬프트 생성 완료");
+            if (ENABLE_VERBOSE_LOG) log.debug("HealthGoal 기반 배치 프롬프트 생성 완료");
@@
-            log.info("AI 배치 응답: {}", batchResponse);
+            if (ENABLE_VERBOSE_LOG) log.debug("AI 배치 응답 수신 (length={} chars)", batchResponse != null ? batchResponse.length() : -1);
@@
-            log.info("파싱된 점수: {}", scores);
+            if (ENABLE_VERBOSE_LOG) log.debug("파싱된 점수 {}건", scores.size());
@@
-                log.info("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score);
+                if (ENABLE_VERBOSE_LOG) log.debug("레시피 '{}' 적합도 점수: {}", recipe.getTitle(), score);

동일 패턴을 processIndividualByHealthGoal, evaluateRecipeSuitabilityByHealthGoal 등 건강 관련 로깅 전반에 적용해 주세요.

Also applies to: 1495-1516, 1394-1409


2029-2055: AI 원격 호출을 트랜잭션 경계 안에서 수행 — 장기 트랜잭션/잠금 확대 리스크

@Transactional 메서드에서 네트워크 호출(30초 타임아웃) 후 DB 쓰기가 수행됩니다. 실패 시 롤백은 되지만, 트랜잭션 유지 시간이 불필요하게 길어지고 DB 잠금·풀 점유 위험이 큽니다. AI 호출을 트랜잭션 밖으로 분리하고, 저장 단계만 짧은 트랜잭션으로 묶어주세요.

권장 방향:

  • public API 메서드에서는 프롬프트 생성 → AI 호출(비트랜잭션) → 파싱/검증.
  • 저장 전용 메서드를 @Transactional(propagation = REQUIRES_NEW)로 분리하여 짧게 커밋.
  • 또는 TransactionTemplate로 DB 저장 구간만 감싸기.

스케치 코드:

public List<RecipeResponse.DetailRes> createAndSaveRandomRecipes(Long userId, String topic) {
    getUser(userId);
    String prompt = buildRecipePostPromptRandom(topic);
    // (비트랜잭션) AI 호출 및 파싱 → transient Recipe 리스트
    List<Recipe> transientRecipes = callAIAndBuildRecipesWithoutSaving(prompt);
    // (짧은 트랜잭션) 저장
    List<Recipe> saved = persistRecipes(transientRecipes); // @Transactional(REQUIRES_NEW)
    return saved.stream().map(RecipeConverter::toDetailRes).toList();
}

현재 구조에서는 callAIAndSaveRecipes 내부에서 저장까지 수행하므로, 저장 부분을 분리(callAIAndBuildRecipesWithoutSaving + persistRecipes)하는 리팩터가 필요합니다. 원하시면 제가 안전한 단계별 리팩터링 패치를 제안드리겠습니다.

Also applies to: 2069-2094, 2096-2124


925-953: 제목 기반 중복 체크의 정규화 미흡(대소문자/공백/유니코드 차이로 중복 누락 가능)

현재 Redis/DB 조회 모두 원문 제목 그대로 비교합니다. 대소문자/공백/全角·半角 등 미세 차이로 중복을 통과할 수 있습니다. 키/조회 모두 동일한 정규화 규칙을 적용하세요.

적용 예시(diff):

@@ private boolean isDuplicateRecipe(Recipe recipe) {
-        String title = recipe.getTitle();
+        String title = recipe.getTitle();
+        String normTitle = normalizeTitle(title);
@@
-            Double score = redisTemplate.opsForZSet().score(RECIPE_TITLES_KEY, title);
+            Double score = redisTemplate.opsForZSet().score(RECIPE_TITLES_KEY, normTitle);
@@
-        boolean isDuplicate = recipeRepository.existsByTitle(title);
+        boolean isDuplicate = recipeRepository.existsByTitle(title) || recipeRepository.existsByTitleIgnoreCase(title);
@@
-                redisTemplate.opsForZSet().add(RECIPE_TITLES_KEY, title, System.currentTimeMillis());
+                redisTemplate.opsForZSet().add(RECIPE_TITLES_KEY, normTitle, System.currentTimeMillis());
@@ private void cacheRecipeTitle(String title) {
-            redisTemplate.opsForZSet().add(RECIPE_TITLES_KEY, title, System.currentTimeMillis());
+            redisTemplate.opsForZSet().add(RECIPE_TITLES_KEY, normalizeTitle(title), System.currentTimeMillis());

그리고 아래 정규화 유틸을 클래스에 추가하세요.

private String normalizeTitle(String s) {
    if (s == null) return "";
    String t = s.trim().toLowerCase();
    // 연속 공백 단일화
    t = t.replaceAll("\\s+", " ");
    // NFC 정규화(필요 시)
    return java.text.Normalizer.normalize(t, java.text.Normalizer.Form.NFC);
}

Also applies to: 2130-2138


1218-1243: 배치 JSON 키로 '레시피 제목' 사용 → 매칭 취약(제목 변경/공백/따옴표 포함 시 파싱 실패)

AI가 제목 철자를 바꾸거나 공백/따옴표가 포함되면 JSON 키 매칭이 깨질 수 있습니다. 안정적인 식별자인 recipe.getId()를 JSON 키로 사용하고, 파싱도 ID 기준으로 수행하는 것을 권장합니다.

적용 방향(개략):

  • 프롬프트 JSON 스켈레톤: "scores": { "123": 8.5, "124": 7.0, ... } (키: 문자열화된 ID)
  • 파싱: scoresNode.path(String.valueOf(recipe.getId()))

필요시 제가 일괄 패치(diff) 제안드리겠습니다.

Also applies to: 1641-1661, 1828-1849, 1948-1953

♻️ Duplicate comments (1)
src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (1)

1037-1094: 프롬프트 문구 품질(오타·어색한 예시·용어 통일) 다듬기 제안

전반적으로 잘 구조화된 프롬프트입니다. 다만 다음 문구들은 모델 일관성과 가독성을 위해 손봐 주세요. 과거 코멘트에서도 유사 지적이 있었던 영역으로, 이번 라운드에서 함께 정리되면 좋겠습니다.

  • "코드 또는 햇살숭어"는 한국어 일반 표기와 예시로 어색합니다. "대구 또는 농어"처럼 흔한 식재료로 교체 권장.
  • "(선택)", "(옵션)" 등 용어 표기를 일관되게.
  • 일부 문장 간 군더더기 표현 정리.

적용 예시(diff):

@@
-               "* **'A 또는 B' 해석:** 입력에 '또는', '/' 등이 포함된 경우, 이는 **별개의 선택지**임을 의미한다. '코드 또는 햇살숭어'가 주어지면, '코드'를 사용한 요리 **혹은** '햇살숭어'를 사용한 요리를 각각 추천해야 한다. **두 재료를 한 요리에 섞는 것은 절대 금지된다.**\n" +
+               "* **'A 또는 B' 해석:** 입력에 '또는' 또는 '/'가 포함되면 **서로 다른 선택지**로 해석한다. 예: '대구 또는 농어'라면 '대구' 요리 또는 '농어' 요리를 각각 추천한다. **두 재료를 한 요리에 혼합하지 않는다.**\n" +
@@
-               "* **'(선택)' 재료 해석:** '(선택)', '(옵션)' 등으로 표시된 재료는 필수가 아니다. 해당 재료가 없어도 완성되는 레시피를 우선적으로 고려해야 한다.\n" +
+               "* **'(선택)' 재료 해석:** '(선택)' 또는 '(옵션)' 표시는 **비필수**를 의미한다. 해당 재료 없이도 완성되는 레시피를 우선 고려한다.\n" +
🧹 Nitpick comments (6)
src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (6)

231-233: 주석과 실제 샘플 크기 상이(48 → RANDOM_SAMPLE_SIZE=12)

"DB 레벨에서 랜덤 샘플링으로 48개 조회" 주석은 현재 상수값(12)과 불일치합니다. 주석을 상수 기반으로 갱신해 주세요.

-        // DB 레벨에서 랜덤 샘플링으로 48개 조회
+        // DB 레벨에서 랜덤 샘플링으로 RANDOM_SAMPLE_SIZE개 조회

2168-2195: 오타: "===최우선 고려사항 알러지 정보보 ===" → "=== 최우선 고려사항: 알레르기 정보 ==="

헤더 오타와 띄어쓰기를 수정해 주세요. 동일 메서드 내에서 '알러지/알레르기' 표기도 통일(표준: 알레르기) 권장합니다.

-        promptBuilder.append("===최우선 고려사항 알러지 정보보 ===\n");
+        promptBuilder.append("=== 최우선 고려사항: 알레르기 정보 ===\n");
-        promptBuilder.append("위 알러지 성분이 포함된 요리는 절대 추천하지 마세요. (0점 처리)\n\n");
+        promptBuilder.append("위 알레르기 성분이 포함된 요리는 절대 추천하지 마세요. (0점 처리)\n\n");

1999-2026: 카테고리 기반 추천에서 이중 제한 및 잠재 중복 제거 필요

getRecipesByCategories에서 이미 limit(MAX_RECOMMENDATIONS) 후, 호출부(recommendRecipesByCategories)에서도 다시 limit를 적용합니다. 불필요한 이중 제한이며, 다중 카테고리 교집합 시 중복 레시피가 섞일 수도 있습니다. 리턴 전 distinct 처리와 한 곳에서만 limit 적용을 권장합니다.

예시:

-            return recipes.stream()
-                .limit(MAX_RECOMMENDATIONS)
-                .collect(Collectors.toList());
+            return recipes.stream()
+                .distinct()
+                .collect(Collectors.toList());

그리고 호출부에서만 limit(MAX_RECOMMENDATIONS) 적용.

Also applies to: 141-172


786-848: AI 원문 응답 전체 로깅(ENABLE_VERBOSE_LOG)도 길이 제한/해시 요약으로 축소 권장

원문 응답은 길이·민감도 면에서 부담이 큽니다. 운영에서는 길이와 해시만 남기고 본문은 마스킹하는 편이 안전합니다.

-            if (ENABLE_VERBOSE_LOG) log.info("AI 원본 응답: {}", response);
+            if (ENABLE_VERBOSE_LOG) {
+                String digest = Integer.toHexString(response.hashCode());
+                log.info("AI 응답 수신(length={}, digest={})", response.length(), digest);
+            }

1099-1106: 알레르기 표기의 일관성(알러지 ↔ 알레르기) 통일

사용자-facing 프롬프트에서는 표준 표기인 "알레르기"로 통일하세요.

-        return "※ 사용자 알레르기 정보: " + String.join(", ", allergyTypes) + "\n" +
-               "※ 중요: 위 알레르기 성분이 포함된 요리는 절대 추천하지 마세요.\n";
+        return "※ 사용자 알레르기 정보: " + String.join(", ", allergyTypes) + "\n" +
+               "※ 중요: 위 알레르기 성분이 포함된 요리는 절대 추천하지 마세요.\n";

(메시지 상에는 이미 '알레르기' 표기를 사용 중이므로, 다른 영역의 '알러지' 표기들도 함께 정리 권장)


986-994: Gemini API 호출 로깅 및 예외 매핑 개선 제안

  • 대상 메서드

    • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.javacallAI(String prompt) (Lines 986–994)
    • 유사 구현부: RecipeServiceImpl.javacallAI (참고)
  • 제안 사항

    1. 상관관계 ID(correlation ID) 도입
      • 각 요청마다 UUID 등을 생성해 SLF4J MDC에 삽입한 뒤 로그 포맷에 포함하세요.
      • 예시:
        String correlationId = UUID.randomUUID().toString();
        MDC.put("correlationId", correlationId);
        try {
          //…  
        } finally {
          MDC.clear();
        }
      • 이를 통해 호출 응답 간 추적성이 확보됩니다.
    2. 프롬프트 본문 로깅 금지
      • AI 프롬프트에는 민감 정보가 포함될 수 있어, 로그에 남기지 않는 것을 권장합니다.
    3. 예외별 ErrorCode 매핑
      • 현재 catch (Exception e)로 모든 예외를 잡아 OPENAI_API_ERROR로 일괄 매핑하고 있어, 타임아웃(TimeoutException), 레이트 리밋(WebClientResponseException 상태 429) 등 주요 예외를 구분하여 적절한 ErrorCode로 던지면 문제 원인 파악에 도움이 됩니다.
      • 예시:
        - } catch (Exception e) {
        -   log.error("Gemini Pro API 호출 실패: {}", e.getMessage());
        -   throw new BusinessException(ErrorCode.OPENAI_API_ERROR);
        - }
        + } catch (TimeoutException te) {
        +   log.error("Gemini Pro API 타임아웃 (30s 초과)", te);
        +   throw new BusinessException(ErrorCode.OPENAI_TIMEOUT);
        + } catch (WebClientResponseException.TooManyRequests rte) {
        +   log.error("Gemini Pro 레이트 리밋(429) 응답", rte);
        +   throw new BusinessException(ErrorCode.OPENAI_RATE_LIMIT);
        + } catch (Exception e) {
        +   log.error("Gemini Pro API 호출 예외", e);
        +   throw new BusinessException(ErrorCode.OPENAI_API_ERROR);
        + }
    4. 로그 일관성 유지
      • RecipeServiceImpl.callAI에서는 스택트레이스 전체를 로깅하나(log.error("AI 호출 실패", e)), RecipeRecommendServiceImpl에서는 메시지만 남기고 있어 통일을 권장합니다.

위 개선은 운영 안정성 및 디버깅 생산성 향상을 위한 선택적 리팩터링입니다.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled
  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c3deb64 and b96dea2.

📒 Files selected for processing (1)
  • src/main/java/com/mumuk/domain/recipe/service/RecipeRecommendServiceImpl.java (3 hunks)

Copy link

@answjddn0607 answjddn0607 left a comment

Choose a reason for hiding this comment

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

수고하셨습니다!!

@beans3142 beans3142 merged commit 7c7dca0 into dev Aug 21, 2025
3 checks passed
@jaemin0413 jaemin0413 deleted the feat/#144_MatchingPrompt branch August 27, 2025 08:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

♻️ REFACTOR 리팩토링 관련 라벨

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants