Feat: 책모 챗봇 구현 - #299
Conversation
chat_session/chat_message 테이블과 ChatSession/ChatMessage 엔티티를 추가한다. 마스킹된 텍스트만 저장하도록 ChatMessage에는 원문 컬럼을 두지 않는다. Tested: ./gradlew compileJava, ./gradlew test --tests checkmo.CheckmoApplicationTests Not-tested: 실제 MySQL 대상 Flyway 마이그레이션 적용(로컬 QA 필요)
주민등록번호/전화번호/이메일/비밀번호 값과 로그인한 본인 닉네임을 정규식 기반으로 마스킹하는 PiiMaskingService를 추가한다. LLM 전송 및 DB/로그 저장 전 공통으로 거치도록 설계했다. Tested: ./gradlew test --tests "checkmo.chatbot.internal.service.PiiMaskingServiceTest", ./gradlew test --tests checkmo.CheckmoApplicationTests
RestTemplate 기반 GeminiApiService, 요청/응답 DTO, 시스템 프롬프트 상수, 프로퍼티/예외 클래스를 추가한다. 기본 모델은 gemini-3.1-flash-lite, 에스컬레이션은 gemini-3.6-flash로 설정했다(실제 계정에서 호출 가능한 모델로 재확인 후 최종 반영). 인증은 x-goog-api-key 헤더 방식을 쓰고, Content-Type을 application/json으로 명시했다. book 모듈의 Aladin 연동용 jackson-dataformat-xml이 classpath에 있어 Content-Type을 명시하지 않으면 RestTemplate이 요청 바디를 XML로 직렬화해버리는 문제가 있어 이를 방지했다. Tested: ./gradlew test --tests "checkmo.chatbot.*" --tests checkmo.CheckmoApplicationTests Tested: 로컬 bootRun + 실제 GEMINI_API_KEY로 curl 호출(단일 질문/멀티턴/에스컬레이션) 확인
세션 조회/생성(비로그인은 UUID 세션 토큰 발급) → PII 마스킹 → 최근 20개 대화 이력 조립 → 규칙 기반 에스컬레이션 판단 → Gemini 호출 → 미해결 태깅 → 저장 순서로 처리하는 ChatOrchestrationService를 추가한다. EscalationDecider/UnresolvedSessionTagger는 추가 LLM 호출 없이 키워드 매칭만으로 판단한다. 봇 응답에는 마스킹을 적용하지 않는다(모델은 이미 마스킹된 사용자 입력만 보므로 실제 PII를 답할 방법이 없어 구조적으로 안전하고, 반대로 정규식이 정상 안내 문장을 오탐해 훼손하는 위험이 더 크다). Gemini 호출(네트워크 I/O)이 DB 트랜잭션을 길게 물지 않도록 전체를 @transactional로 감싸지 않고, 짧은 저장 호출들로 나눴다. Tested: ./gradlew test --tests "checkmo.chatbot.*" --tests checkmo.CheckmoApplicationTests Tested: 로컬 bootRun + 실제 Gemini API로 멀티턴/에스컬레이션 확인
POST /api/v1/chatbot/messages를 추가한다. ReportController/ReportConverter 패턴을 그대로 따랐다. 비로그인 사용자도 이용할 수 있도록 SecurityConfig에 permitAll을 추가했다(그 전에는 anyRequest().authenticated()에 걸려 401). 응답 DTO에 handoffSuggested/supportUrl/inquiryFormUrl을 포함해 Swagger UI에서 바로 확인 가능하다. sessionToken Schema 예시값을 "null"(문자열)로 잘못 적어뒀던 것을, Swagger 사용자가 그대로 보내 404가 나는 문제가 있어 수정했다. Tested: ./gradlew test --tests "checkmo.chatbot.*" --tests checkmo.CheckmoApplicationTests Tested: Swagger UI 노출 확인, 로컬 bootRun + 실제 Gemini API로 비로그인/로그인/멀티턴 curl 검증
"비밀번호는 abc1234!입니다" 같은 실제 값뿐 아니라 "비밀번호가 기억이 안나요", "비밀번호 변경은 어디서 해요?" 같은 정상 질문/설명 문장까지 키워드 뒤 아무 단어나 마스킹해버리던 문제를 실제 QA로 발견했다. 값 부분을 영문/숫자/기호 조합(4자 이상)으로 한정해, 한글 뒤따름 단어는 매칭 대상에서 제외했다. Tested: ./gradlew test --tests "checkmo.chatbot.internal.service.PiiMaskingServiceTest" Tested: 로컬 bootRun + 실제 Gemini API로 "비밀번호는 6~12자..." 안내가 더 이상 훼손되지 않음을 확인
시스템 프롬프트 구조(섹션 헤더) 유출을 탐지해 안전한 대체 응답으로 교체하는 PromptLeakGuard를 추가하고, 모델 호출/미해결 세션 전환/ 프롬프트 유출 탐지에 대한 Micrometer 카운터를 CheckmoMetrics에 추가한다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
📝 WalkthroughWalkthroughAdded a public chatbot messaging endpoint backed by Gemini. The feature includes validated API contracts, session and message persistence, PII masking, escalation and handoff detection, prompt-leak protection, metrics, configuration, and tests. ChangesChatbot messaging
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant SecurityFilterChain
participant ChatbotController
participant ChatOrchestrationService
participant GeminiApiService
participant ChatSessionRepository
participant ChatMessageRepository
Client->>SecurityFilterChain: POST /api/v1/chatbot/messages
SecurityFilterChain->>ChatbotController: Permit request
ChatbotController->>ChatOrchestrationService: respond(sessionToken, memberId, message)
ChatOrchestrationService->>ChatSessionRepository: Find or create session
ChatOrchestrationService->>GeminiApiService: Generate reply
GeminiApiService-->>ChatOrchestrationService: Reply text
ChatOrchestrationService->>ChatMessageRepository: Persist masked messages
ChatOrchestrationService-->>ChatbotController: ChatReply
ChatbotController-->>Client: ChatbotResponseDTO.Reply
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (4)
src/main/java/checkmo/chatbot/internal/config/ChatbotRestTemplateConfig.java (1)
24-31: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winConsider a pooled HTTP client for the Gemini RestTemplate.
SimpleClientHttpRequestFactoryopens a new connection per request with no pooling. Under concurrent chatbot traffic, this adds TCP/TLS handshake overhead on every Gemini call. ConsiderHttpComponentsClientHttpRequestFactorybacked by aPoolingHttpClientConnectionManagerto reuse connections to the Gemini endpoint.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/checkmo/chatbot/internal/config/ChatbotRestTemplateConfig.java` around lines 24 - 31, Update chatbotRestTemplate() to use HttpComponentsClientHttpRequestFactory backed by a PoolingHttpClientConnectionManager instead of SimpleClientHttpRequestFactory, configuring the existing timeout values on the pooled client while preserving the RestTemplate bean and Gemini request behavior.src/main/resources/db/migration/V20260727__create_chatbot_tables.sql (1)
14-27: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low valueConsider an in-module foreign key for
chat_session_id.
chat_message.chat_session_idhas no foreign key tochat_session.id. Both tables live in the same module, so this does not conflict with the retrieved learning against cross-module foreign keys. Add a FK constraint here to enforce referential integrity between session and message rows.Based on learnings, "avoid foreign key constraints that cross module boundaries in SQL migrations... only place constraints where both tables reside in the same module," which supports adding this FK since both tables are in the chatbot module.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/resources/db/migration/V20260727__create_chatbot_tables.sql` around lines 14 - 27, Add an in-module foreign key for chat_message.chat_session_id referencing chat_session.id in the CREATE TABLE definition, using the migration’s existing constraint style and preserving the current index. Ensure message rows cannot reference nonexistent chat sessions.Source: Learnings
src/main/java/checkmo/authentication/internal/config/SecurityConfig.java (1)
86-86: 🩺 Stability & Availability | 🔵 TrivialConsider rate limiting for the new public chatbot route.
permitAll()is correct for this route per the PR intent. This route is unauthenticated and each call triggers a Gemini API request, with possible escalation to a more costly model. No throttling is visible in the provided files for this route.If no rate limiter exists upstream (gateway, WAF, CDN), add one for
/api/v1/chatbot/messagesto bound cost and protect availability from anonymous traffic spikes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/checkmo/authentication/internal/config/SecurityConfig.java` at line 86, Keep the permitAll configuration for /api/v1/chatbot/messages, but add rate limiting for this unauthenticated route using the project’s existing limiter mechanism, or configure the upstream gateway/WAF/CDN if that is where throttling is managed. Ensure anonymous requests are bounded before they can trigger Gemini calls, including costly model escalation.src/main/java/checkmo/chatbot/internal/service/UnresolvedSessionTagger.java (1)
13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winHedge phrase duplicated across two files with no shared source of truth.
UnresolvedSessionTagger.BOT_HEDGE_PHRASEand the hedge phrase insideChatbotSystemPrompt.SYSTEM_PROMPT(line 29) must stay byte-for-byte identical, but nothing enforces this. An independent edit to either file silently breaks unresolved-session detection.
src/main/java/checkmo/chatbot/internal/service/UnresolvedSessionTagger.java#L13-L13: MakeBOT_HEDGE_PHRASEpackage-visible (or move it intoChatbotSystemPrompt) and reference it from a single source, or add a test assertingChatbotSystemPrompt.SYSTEM_PROMPT.contains(BOT_HEDGE_PHRASE).src/main/java/checkmo/chatbot/internal/prompt/ChatbotSystemPrompt.java#L15-L199: Keep the hedge phrase at line 29 as the single source of truth thatUnresolvedSessionTaggerreferences or is tested against, so future prompt edits fail a test instead of silently breaking detection.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/checkmo/chatbot/internal/service/UnresolvedSessionTagger.java` at line 13, Use the hedge phrase in ChatbotSystemPrompt.SYSTEM_PROMPT as the single source of truth and update UnresolvedSessionTagger.BOT_HEDGE_PHRASE to reference it, or add a test enforcing that SYSTEM_PROMPT contains the detector phrase. Apply the corresponding consistency change in src/main/java/checkmo/chatbot/internal/service/UnresolvedSessionTagger.java:13-13 and src/main/java/checkmo/chatbot/internal/prompt/ChatbotSystemPrompt.java:15-199, preserving the existing byte-for-byte phrase.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@src/main/java/checkmo/chatbot/internal/config/properties/ChatbotProperties.java`:
- Around line 11-15: Update the ChatbotProperties class by adding Spring’s
`@Validated` annotation alongside `@ConfigurationProperties` so validation
constraints on apiKey, baseUrl, apiVersion, timeoutMs, and nested `@Valid` fields
execute during configuration binding.
In
`@src/main/java/checkmo/chatbot/internal/service/ChatOrchestrationService.java`:
- Around line 47-100: Update respond so the session mutations and user turn are
persisted before invoking geminiApiService.generateReply, while preserving the
existing non-transactional orchestration. Add a failure path around the Gemini
call that records a consistent failed assistant turn (or equivalent established
failure representation) and persists the updated chatSession, preventing
orphaned user history and lost activity/unresolved state.
In `@src/main/java/checkmo/chatbot/internal/service/PiiMaskingService.java`:
- Around line 65-76: Update maskOwnNickname to skip nickname replacement when
the fetched nickname is below the required minimum length, while preserving the
existing null, blank, and valid-length replacement behavior.
In `@src/main/java/checkmo/chatbot/web/dto/ChatbotRequestDTO.java`:
- Around line 23-25: Update the message field in ChatbotRequestDTO by adding a
`@Size` constraint with an appropriate maximum length, while preserving the
existing `@NotBlank` validation and schema metadata. Ensure the bound applies
before ChatOrchestrationService.respond forwards the request to external models.
In
`@src/test/java/checkmo/chatbot/internal/service/ChatOrchestrationServiceTest.java`:
- Around line 87-97: Update ChatOrchestrationService.resolveSession to validate
the loaded ChatSession.memberId against the supplied memberId, rejecting
mismatched or null ownership instead of reusing the session. Add the appropriate
ChatbotErrorStatus for unauthorized session-token reuse and propagate it through
the existing service error flow. Extend ChatOrchestrationServiceTest with
coverage for mismatched and null member IDs while preserving successful reuse
for the owning member.
---
Nitpick comments:
In `@src/main/java/checkmo/authentication/internal/config/SecurityConfig.java`:
- Line 86: Keep the permitAll configuration for /api/v1/chatbot/messages, but
add rate limiting for this unauthenticated route using the project’s existing
limiter mechanism, or configure the upstream gateway/WAF/CDN if that is where
throttling is managed. Ensure anonymous requests are bounded before they can
trigger Gemini calls, including costly model escalation.
In
`@src/main/java/checkmo/chatbot/internal/config/ChatbotRestTemplateConfig.java`:
- Around line 24-31: Update chatbotRestTemplate() to use
HttpComponentsClientHttpRequestFactory backed by a
PoolingHttpClientConnectionManager instead of SimpleClientHttpRequestFactory,
configuring the existing timeout values on the pooled client while preserving
the RestTemplate bean and Gemini request behavior.
In `@src/main/java/checkmo/chatbot/internal/service/UnresolvedSessionTagger.java`:
- Line 13: Use the hedge phrase in ChatbotSystemPrompt.SYSTEM_PROMPT as the
single source of truth and update UnresolvedSessionTagger.BOT_HEDGE_PHRASE to
reference it, or add a test enforcing that SYSTEM_PROMPT contains the detector
phrase. Apply the corresponding consistency change in
src/main/java/checkmo/chatbot/internal/service/UnresolvedSessionTagger.java:13-13
and
src/main/java/checkmo/chatbot/internal/prompt/ChatbotSystemPrompt.java:15-199,
preserving the existing byte-for-byte phrase.
In `@src/main/resources/db/migration/V20260727__create_chatbot_tables.sql`:
- Around line 14-27: Add an in-module foreign key for
chat_message.chat_session_id referencing chat_session.id in the CREATE TABLE
definition, using the migration’s existing constraint style and preserving the
current index. Ensure message rows cannot reference nonexistent chat sessions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 6cd8d469-317d-4905-bc9e-27a6461071a4
📒 Files selected for processing (35)
src/main/java/checkmo/authentication/internal/config/SecurityConfig.javasrc/main/java/checkmo/chatbot/internal/config/ChatbotRestTemplateConfig.javasrc/main/java/checkmo/chatbot/internal/config/properties/ChatbotProperties.javasrc/main/java/checkmo/chatbot/internal/converter/ChatbotConverter.javasrc/main/java/checkmo/chatbot/internal/entity/ChatMessage.javasrc/main/java/checkmo/chatbot/internal/entity/ChatRole.javasrc/main/java/checkmo/chatbot/internal/entity/ChatSession.javasrc/main/java/checkmo/chatbot/internal/exception/ChatbotErrorStatus.javasrc/main/java/checkmo/chatbot/internal/exception/ChatbotException.javasrc/main/java/checkmo/chatbot/internal/prompt/ChatbotSystemPrompt.javasrc/main/java/checkmo/chatbot/internal/repository/ChatMessageRepository.javasrc/main/java/checkmo/chatbot/internal/repository/ChatSessionRepository.javasrc/main/java/checkmo/chatbot/internal/service/ChatOrchestrationService.javasrc/main/java/checkmo/chatbot/internal/service/ChatReply.javasrc/main/java/checkmo/chatbot/internal/service/EscalationDecider.javasrc/main/java/checkmo/chatbot/internal/service/GeminiApiService.javasrc/main/java/checkmo/chatbot/internal/service/PiiMaskingService.javasrc/main/java/checkmo/chatbot/internal/service/PromptLeakGuard.javasrc/main/java/checkmo/chatbot/internal/service/UnresolvedSessionTagger.javasrc/main/java/checkmo/chatbot/internal/service/dto/GeminiApiDTO.javasrc/main/java/checkmo/chatbot/package-info.javasrc/main/java/checkmo/chatbot/web/controller/ChatbotController.javasrc/main/java/checkmo/chatbot/web/dto/ChatbotRequestDTO.javasrc/main/java/checkmo/chatbot/web/dto/ChatbotResponseDTO.javasrc/main/java/checkmo/common/monitoring/CheckmoMetrics.javasrc/main/resources/application-chatbot.ymlsrc/main/resources/db/migration/V20260727__create_chatbot_tables.sqlsrc/test/java/checkmo/chatbot/ChatbotApiTest.javasrc/test/java/checkmo/chatbot/internal/service/ChatOrchestrationServiceTest.javasrc/test/java/checkmo/chatbot/internal/service/EscalationDeciderTest.javasrc/test/java/checkmo/chatbot/internal/service/GeminiApiServiceTest.javasrc/test/java/checkmo/chatbot/internal/service/PiiMaskingServiceTest.javasrc/test/java/checkmo/chatbot/internal/service/PromptLeakGuardTest.javasrc/test/java/checkmo/chatbot/internal/service/UnresolvedSessionTaggerTest.javasrc/test/resources/application-test.yml
| @Getter | ||
| @Setter | ||
| @Component | ||
| @ConfigurationProperties(prefix = "chatbot.gemini") | ||
| public class ChatbotProperties { |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Locate ChatbotProperties =="
fd -a 'ChatbotProperties\.java' . || true
echo "== Inspect ChatbotProperties =="
file="$(fd 'ChatbotProperties\.java' . | head -n 1 || true)"
if [ -n "$file" ]; then
wc -l "$file"
sed -n '1,220p' "$file" | cat -n
fi
echo "== Search for validateConfigurationProperties / ValidationAutoConfiguration references =="
rg -n "validateConfigurationProperties|validation\.configurationProperties|`@ConfigurationProperties`|`@Validated`" src/main/java -S || true
echo "== Search for ChatbotProperties consumers =="
rg -n "ChatbotProperties|chatbotProperties|getApiKey\(|getBaseUrl\(|getApiVersion\()" src/main/java -S || true
echo "== Spring Boot version declarations =="
fd -a 'pom.xml|build.gradle|build.gradle.kts' . | sed 's#^\./##' | while read -r f; do
echo "--- $f"
rg -n "spring-boot|version>" "$f" -S || true
doneRepository: checkmo2025/BE
Length of output: 5862
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== ChatbotProperties consumers =="
rg -n "ChatbotProperties|chatbotProperties|getApiKey\\(|getBaseUrl\\(|getApiVersion\\(|GeminiApiService" src/main/java -S || true
echo "== Build files =="
fd -a '^(build.gradle\.kts|build.gradle|gradle.properties|pom.xml)$' . | sed 's#^\./##' | sort | while read -r f; do
echo "--- $f"
sed -n '1,140p' "$f" | cat -n
done
echo "== Check Boot property for validation of configuration properties =="
rg -n "validateConfigurationProperties|ConfigurationProperties:|enable.*validation|validation\\.configurationProperties" . -S --glob 'src/main/java' --glob 'src/main/resources' --glob '*.gradle' --glob '*.properties' --glob '*.yml' --glob '*.yaml' || trueRepository: checkmo2025/BE
Length of output: 8390
🌐 Web query:
Spring Boot 3.5 @ConfigurationPropertiesBean Validation requires@validated documentation
💡 Result:
In Spring Boot 3.5, using Bean Validation (JSR 380) annotations (such as @NotBlank, @NotNull, @Size) on a @ConfigurationProperties bean requires the class to be annotated with @Validated to trigger the validation process [1]. Key requirements for validating @ConfigurationProperties beans: 1. Dependency: You must have a JSR-303/JSR-380 implementation, such as Hibernate Validator, on your classpath. This is typically provided by including the spring-boot-starter-validation dependency [2][1][3]. 2. Enabling Validation: Adding @Validated to your @ConfigurationProperties class is mandatory [1]. Without this annotation, the constraint annotations placed on the fields will be ignored by the binding process [1]. 3. Nested Objects: To cascade validation to nested objects, you must annotate the nested field with @Valid [1][3]. Note that in some versions of Spring Boot, validation of nested objects might occur even without @Valid due to specific implementation details in ConfigurationPropertiesBinder, though using @Valid is considered the standard practice to align with the Bean Validation specification [4][3]. This approach allows your application to fail fast at startup if configuration properties do not meet the defined constraints, preventing runtime errors later [1][3]. Ensure that your @ConfigurationProperties bean is correctly registered as a bean, for example, by using @EnableConfigurationProperties or component scanning [1][5].
Citations:
- 1: https://springboot-123.mizucoffee.com/en/blog/spring-boot-configuration-properties-validation-guide/
- 2: https://docs.spring.io/spring-boot/3.5/reference/io/validation.html
- 3: https://wimdetroyer.com/blog/the-proper-way-of-using-configuration-properties-in-spring
- 4: Align cascade behavior of @Validated @ConfigurationProperties with the bean validation spec spring-projects/spring-boot#40345
- 5: https://docs.spring.io/spring-boot/reference/features/external-config.html
Add @Validated to ChatbotProperties so constraints run during binding.
Spring Boot validates a @ConfigurationProperties class only when it is annotated with @Validated, and spring-boot-starter-validation provides the implementation. Without it, @NotBlank on apiKey, baseUrl, apiVersion, timeoutMs, and nested @Valid fields are skipped, so invalid Gemini config can bind successfully and fail later in GeminiApiService.
🛡️ Proposed fix
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
+import org.springframework.validation.annotation.Validated;
`@Getter`
`@Setter`
`@Component`
+@Validated
`@ConfigurationProperties`(prefix = "chatbot.gemini")
public class ChatbotProperties {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Getter | |
| @Setter | |
| @Component | |
| @ConfigurationProperties(prefix = "chatbot.gemini") | |
| public class ChatbotProperties { | |
| `@Getter` | |
| `@Setter` | |
| `@Component` | |
| `@Validated` | |
| `@ConfigurationProperties`(prefix = "chatbot.gemini") | |
| public class ChatbotProperties { |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/checkmo/chatbot/internal/config/properties/ChatbotProperties.java`
around lines 11 - 15, Update the ChatbotProperties class by adding Spring’s
`@Validated` annotation alongside `@ConfigurationProperties` so validation
constraints on apiKey, baseUrl, apiVersion, timeoutMs, and nested `@Valid` fields
execute during configuration binding.
| public ChatReply respond(String sessionToken, Long memberId, String rawUserMessage) { | ||
| ChatSession chatSession = resolveSession(sessionToken, memberId); | ||
| boolean wasUnresolved = chatSession.isUnresolved(); | ||
| chatSession.recordActivity(); | ||
|
|
||
| String maskedUserMessage = piiMaskingService.mask(rawUserMessage, memberId); | ||
| boolean userNegativeReaction = unresolvedSessionTagger.isUserNegativeReaction(maskedUserMessage); | ||
| if (userNegativeReaction) { | ||
| chatSession.flagUnresolved(); | ||
| } | ||
|
|
||
| List<Content> contents = appendUserTurn(buildHistory(chatSession.getId()), maskedUserMessage); | ||
| chatMessageRepository.save( | ||
| ChatMessage.userMessage(chatSession.getId(), maskedUserMessage, userNegativeReaction)); | ||
|
|
||
| boolean escalated = escalationDecider.shouldEscalate(maskedUserMessage, userNegativeReaction); | ||
| String modelName = escalated | ||
| ? chatbotProperties.getEscalationModel().getName() | ||
| : chatbotProperties.getDefaultModel().getName(); | ||
| checkmoMetrics.incrementChatbotModelCall(modelName, escalated); | ||
|
|
||
| // 봇 응답에는 마스킹을 적용하지 않는다. 모델은 이미 마스킹된 사용자 입력만 봤으므로 실제 PII를 | ||
| // 답변에 포함시킬 방법이 없고(구조적으로 안전), 반대로 "비밀번호는 6~12자..." 같은 정상 안내 | ||
| // 문장을 정규식이 PII로 오탐해 훼손하는 위험이 더 크다(실제 QA에서 확인됨). | ||
| String reply = geminiApiService.generateReply(ChatbotSystemPrompt.SYSTEM_PROMPT, contents, modelName); | ||
|
|
||
| if (promptLeakGuard.isLeaked(reply)) { | ||
| checkmoMetrics.incrementChatbotPromptLeakDetected(); | ||
| reply = PromptLeakGuard.SAFE_FALLBACK_REPLY; | ||
| } | ||
|
|
||
| boolean botUncertain = unresolvedSessionTagger.isBotUncertain(reply); | ||
| if (botUncertain) { | ||
| chatSession.flagUnresolved(); | ||
| } | ||
|
|
||
| chatMessageRepository.save( | ||
| ChatMessage.assistantMessage(chatSession.getId(), reply, modelName, escalated, botUncertain)); | ||
| chatSessionRepository.save(chatSession); | ||
|
|
||
| if (!wasUnresolved && chatSession.isUnresolved()) { | ||
| checkmoMetrics.incrementChatbotUnresolvedSession(); | ||
| } | ||
|
|
||
| return new ChatReply( | ||
| chatSession.getSessionToken(), | ||
| reply, | ||
| escalated, | ||
| modelName, | ||
| chatSession.isUnresolved(), | ||
| chatbotProperties.getHandoff().getSupportUrl(), | ||
| chatbotProperties.getHandoff().getInquiryFormUrl() | ||
| ); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Gemini failures can leave an orphaned user message and lose session state.
The user ChatMessage is saved and committed at line 59-60, independently of the Gemini call at line 71. If geminiApiService.generateReply throws (network failure, Gemini 5xx, timeout), the method exits before the assistant ChatMessage save (line 83-84) and before chatSessionRepository.save(chatSession) (line 85).
This means, on a Gemini failure:
chatSession.recordActivity()(line 50) and anyflagUnresolved()call from this turn (line 55) are never persisted.- The user's message is left in history with no paired assistant reply.
- The next turn's
buildHistory()replays this orphaned user message as unanswered context.
The method intentionally avoids one large @Transactional boundary around the Gemini call (see the comment at line 28-29), but that design choice creates this specific gap. Persist the session-state mutation for this turn (recordActivity/flagUnresolved) before calling Gemini, or catch the failure and record a consistent state (e.g., a failed-turn assistant message) so the conversation history stays coherent after an external call failure.
As per coding guidelines, "Keep services focused on orchestration: loading, saving, deleting, event publication, logging, and transaction boundaries."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/checkmo/chatbot/internal/service/ChatOrchestrationService.java`
around lines 47 - 100, Update respond so the session mutations and user turn are
persisted before invoking geminiApiService.generateReply, while preserving the
existing non-transactional orchestration. Add a failure path around the Gemini
call that records a consistent failed assistant turn (or equivalent established
failure representation) and persists the updated chatSession, preventing
orphaned user history and lost activity/unresolved state.
Source: Coding guidelines
| private String maskOwnNickname(String text, Long memberId) { | ||
| if (memberId == null) { | ||
| return text; | ||
| } | ||
|
|
||
| String nickname = memberAPI.fetchNickname(memberId); | ||
| if (!StringUtils.hasText(nickname)) { | ||
| return text; | ||
| } | ||
|
|
||
| return text.replace(nickname, "[닉네임]"); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Short nicknames can over-mask unrelated text.
text.replace(nickname, "[닉네임]") replaces every literal occurrence of the nickname. If the nickname is short or a common word, this replaces unrelated substrings in the user's message before it is sent to Gemini and stored, corrupting conversation content.
Add a minimum-length guard before replacing, so very short nicknames are skipped.
🛡️ Proposed guard for short nicknames
String nickname = memberAPI.fetchNickname(memberId);
- if (!StringUtils.hasText(nickname)) {
+ if (!StringUtils.hasText(nickname) || nickname.length() < 2) {
return text;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private String maskOwnNickname(String text, Long memberId) { | |
| if (memberId == null) { | |
| return text; | |
| } | |
| String nickname = memberAPI.fetchNickname(memberId); | |
| if (!StringUtils.hasText(nickname)) { | |
| return text; | |
| } | |
| return text.replace(nickname, "[닉네임]"); | |
| } | |
| private String maskOwnNickname(String text, Long memberId) { | |
| if (memberId == null) { | |
| return text; | |
| } | |
| String nickname = memberAPI.fetchNickname(memberId); | |
| if (!StringUtils.hasText(nickname) || nickname.length() < 2) { | |
| return text; | |
| } | |
| return text.replace(nickname, "[닉네임]"); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/checkmo/chatbot/internal/service/PiiMaskingService.java` around
lines 65 - 76, Update maskOwnNickname to skip nickname replacement when the
fetched nickname is below the required minimum length, while preserving the
existing null, blank, and valid-length replacement behavior.
| @NotBlank(message = "메시지는 필수입니다.") | ||
| @Schema(description = "사용자가 입력한 질문", example = "책이야기 쓰려면?") | ||
| private String message; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Add a maximum length to message.
message only has @NotBlank. No upper bound exists. POST /api/v1/chatbot/messages is unauthenticated (see SecurityConfig.java line 86), so any anonymous caller can submit an arbitrarily large message. ChatOrchestrationService.respond forwards this text to the Gemini API after masking, and can also route it to the escalation model. An unbounded, unauthenticated input to a paid external API is a cost and availability risk.
Add a @Size constraint to cap the request payload.
🛡️ Proposed fix to bound message length
+import jakarta.validation.constraints.Size;
...
`@NotBlank`(message = "메시지는 필수입니다.")
+ `@Size`(max = 2000, message = "메시지가 너무 깁니다.")
`@Schema`(description = "사용자가 입력한 질문", example = "책이야기 쓰려면?")
private String message;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @NotBlank(message = "메시지는 필수입니다.") | |
| @Schema(description = "사용자가 입력한 질문", example = "책이야기 쓰려면?") | |
| private String message; | |
| `@NotBlank`(message = "메시지는 필수입니다.") | |
| `@Size`(max = 2000, message = "메시지가 너무 깁니다.") | |
| `@Schema`(description = "사용자가 입력한 질문", example = "책이야기 쓰려면?") | |
| private String message; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/checkmo/chatbot/web/dto/ChatbotRequestDTO.java` around lines 23
- 25, Update the message field in ChatbotRequestDTO by adding a `@Size` constraint
with an appropriate maximum length, while preserving the existing `@NotBlank`
validation and schema metadata. Ensure the bound applies before
ChatOrchestrationService.respond forwards the request to external models.
| @Test | ||
| void reusesExistingSessionWhenTokenProvided() { | ||
| ChatSession existing = ChatSession.start(5L, "existing-token"); | ||
| ReflectionTestUtils.setField(existing, "id", 10L); | ||
| when(chatSessionRepository.findBySessionToken("existing-token")).thenReturn(Optional.of(existing)); | ||
|
|
||
| ChatReply reply = chatOrchestrationService.respond("existing-token", 5L, "모임 가입하려면?"); | ||
|
|
||
| assertThat(reply.sessionToken()).isEqualTo("existing-token"); | ||
| verify(chatSessionRepository).findBySessionToken("existing-token"); | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Inspect ChatOrchestrationService.respond to check session-ownership handling.
fd ChatOrchestrationService.java src/main/java/checkmo/chatbot --exec cat -n {}Repository: checkmo2025/BE
Length of output: 7018
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Locate relevant files"
fd -a 'ChatOrchestrationController|ChatOrchestrationService|ChatSession|ChatbotErrorStatus|ChatbotException' src/main/java src/test/java | sed 's#^\./##'
echo
echo "Inspect ChatOrchestrationController:"
fd ChatOrchestrationController.java src/test/java/checkmo/chatbot src/main/java/checkmo/chatbot --exec sh -c 'echo "--- $1"; cat -n "$1"' sh {}
echo
echo "Inspect ChatSession.resolveSession usages and tests:"
rg -n "resolveSession|respond\\(|memberId|sessionToken|findBySessionToken|SETTLE|SESSION_NOT_FOUND|ACCESS_DENIED|FORBIDDEN" src/test/java src/main/java -SRepository: checkmo2025/BE
Length of output: 50371
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "ChatSession.java:"
cat -n src/main/java/checkmo/chatbot/internal/entity/ChatSession.java
echo
echo "Chatbot controller candidates:"
fd -i 'chatbot.*(controller|web)|controller.*(chatbot)|Chatbot.*' src/main/java | while read -r f; do
echo "--- $f"
cat -n "$f"
done
echo
echo "ChatbotApiTest relevant sections:"
cat -n src/test/java/checkmo/chatbot/ChatbotApiTest.java | sed -n '1,90p'
echo
echo "Targeted exception/status symbols:"
cat -n src/main/java/checkmo/chatbot/internal/exception/ChatbotErrorStatus.java
echo "---"
cat -n src/main/java/checkmo/chatbot/internal/exception/ChatbotException.java | sed -n '1,180p'
echo "---"
cat -n src/main/java/checkmo/common/exception/GlobalExceptionHandler.java | sed -n '1,220p' 2>/dev/null || trueRepository: checkmo2025/BE
Length of output: 2058
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Find ChatbotApiTest and controller files:"
fd -i 'ChatbotApiTest|.*Chatbot.*Controller|.*Controller.*Chatbot|chatbot' src/main/java src/test/java | sed 's#^\./##' | sed -n '1,120p'
echo
echo "ChatbotApiTest relevant sections:"
for f in $(fd -i 'ChatbotApiTest' src/test/java); do
echo "--- $f"
sed -n '1,120p' "$f" | cat -n
done
echo
echo "Targeted files:"
for f in \
src/main/java/checkmo/chatbot/internal/entity/ChatSession.java \
src/main/java/checkmo/chatbot/internal/exception/ChatbotErrorStatus.java \
src/main/java/checkmo/chatbot/internal/exception/ChatbotException.java \
src/main/java/checkmo/chatbot/internal/service/ChatOrchestrationService.java \
src/test/java/checkmo/chatbot/internal/service/ChatOrchestrationServiceTest.java
do
echo "### $f"
case "$(basename "$f")" in
ChatbotErrorStatus.java|ChatbotException.java|ChatSession.java|ChatOrchestrationService.java) sed -n '1,180p' "$f" | cat -n ;;
ChatOrchestrationServiceTest.java) sed -n '1,180p' "$f" | cat -n ;;
esac
done
echo
echo "Find ChatbotController exactly:"
rg -l "class .*Chatbot|`@GetMapping`\".*chat|chatbot" src/main/java src/test/java | sed 's#^\./##' | sed -n '1,60p'Repository: checkmo2025/BE
Length of output: 27739
Protect existing chatbot sessions against owner mismatch.
ChatOrchestrationService.resolveSession loads sessions only by sessionToken, so a caller can reuse another member’s token while supplying a different or null memberId and continue that member’s history. Add ownership checks for ChatSession.memberId, a ChatbotErrorStatus for this case, and service coverage for mismatched tokens.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/test/java/checkmo/chatbot/internal/service/ChatOrchestrationServiceTest.java`
around lines 87 - 97, Update ChatOrchestrationService.resolveSession to validate
the loaded ChatSession.memberId against the supplied memberId, rejecting
mismatched or null ownership instead of reusing the session. Add the appropriate
ChatbotErrorStatus for unauthorized session-token reuse and propagate it through
the existing service error flow. Extend ChatOrchestrationServiceTest with
coverage for mismatched and null member IDs while preserving successful reuse
for the owning member.
🚀 변경사항
책모 앱의 사용법 안내 챗봇 기능을 신규
chatbot모듈로 추가합니다.사용자가 로그인 여부와 무관하게 텍스트로 질문하면 Gemini API를 통해 답변하고, 필요 시 상담사 연결(핸드오프)을 안내합니다.
Spring Modulith 컨벤션에 따라
internal/web으로 분리했고, 기존report/book모듈의 구조·예외·모니터링 패턴을 그대로 따랐습니다.1. DB 스키마 & 모듈 뼈대
chat_session(세션,session_tokenUUID로 비로그인 사용자도 이어서 대화 가능),chat_message(턴 단위 메시지, 마스킹된 텍스트만 저장) 2개 테이블 추가 (V20260727__create_chatbot_tables.sql)chat_message.masked_content에는 아래 3번 PII 마스킹을 거친 텍스트만 들어갑니다.chatbot모듈은authentication,member,common에만 의존하도록@ApplicationModule(allowedDependencies=...)로 경계를 명시했습니다(닉네임 마스킹 위해member의 공개 API(MemberAPI)만 사용,internal미접근).2. PII 마스킹 (
PiiMaskingService)3. Gemini API 클라이언트 (
GeminiApiService)RestTemplate**로 GeminigenerateContentREST 엔드포인트를 직접 호출합니다(book모듈AladinApiService패턴과 동일하게 자체RestTemplate빈 구성).x-goog-api-key헤더 방식(쿼리 파라미터 방식보다 안전), 요청은Content-Type: application/json을 명시적으로 지정합니다 — 이 프로젝트에 이미jackson-dataformat-xml(Aladin XML 파싱용)이 있어서, Content-Type을 명시하지 않으면 RestTemplate이 XML 컨버터를 먼저 골라 요청 바디가 XML로 직렬화되는 문제가 있었습니다. 실제로 겪은 이슈라 리뷰 시 참고해주세요.GEMINI_API_KEY환경변수로 주입(application-chatbot.yml), 코드/설정 파일 어디에도 실제 키 값은 없습니다.4. 대화 오케스트레이션 · 모델 라우팅 · 미해결 태깅
ChatOrchestrationService: 세션 조회/생성 → PII 마스킹 → 최근 대화 이력(최대 20턴) 조립 → 에스컬레이션 판단 → Gemini 호출 → 프롬프트 유출 검사 → 미해결 태깅 → 저장 순서로 처리합니다.gemini-3.1-flash-lite, 아래 규칙에 해당하면gemini-3.6-flash로 에스컬레이션합니다(EscalationDecider).UnresolvedSessionTagger): 봇 응답에 헤지 문구("현재 확인된 사용법 기준으로는")가 있거나, 사용자가 부정 반응 키워드를 반복하면 세션을unresolved로 플래그합니다. 역시 규칙 기반, 추가 LLM 호출 없음.5. 컨트롤러 & 핸드오프 (Swagger)
POST /api/v1/chatbot/messages단일 엔드포인트,@CurrentId는 nullable이라 비로그인 사용자도 이용 가능합니다(SecurityConfig에 permitAll 추가).handoffSuggested,supportUrl,inquiryFormUrl필드를 포함합니다 — 이번 범위는 FE 버튼 없이 API 응답 필드로만 인간 상담사 연결(핸드오프)을 제공하는 것으로 사용자와 합의된 범위입니다. 값은 기존 FEEXTERNAL_LINKS와 동일한 값을 BE 프로퍼티로 관리합니다.sessionToken없이(또는null) 보내면 서버가 새로 발급해 응답에 담아줍니다. 이후 요청에 그 값을 그대로 실어 보내면 같은 대화로 이어집니다.6. 인젝션 방어 하드닝 & 모니터링 지표
PromptLeakGuard: 모델 응답에 시스템 프롬프트 "구조"(섹션 헤더 문구, 예:답변 원칙:,보안 지침 (매우 중요, 예외 없이 지킵니다))가 그대로 노출되면 유출로 판단해 안전한 대체 응답으로 교체합니다. 프롬프트 "내용"은 정상 답변과 자연스럽게 겹칠 수 있어 검사 대상에서 의도적으로 제외했습니다(오탐 방지).CheckmoMetrics에 챗봇 전용 Micrometer 카운터 3종 추가 — 모델별 호출 수(checkmo.chatbot.model.calls,model/escalated태그), 미해결 세션 전환 수(checkmo.chatbot.session.unresolved, 세션당 최초 전환 시 1회만 증가), 프롬프트 유출 탐지 수(checkmo.chatbot.prompt_leak.detected). 기존 Prometheus/Grafana 파이프라인(/actuator/prometheus)에 그대로 노출됩니다. Grafana 대시보드 패널 추가는 이번 PR 범위 밖으로 후속 작업입니다.🔗 관련 이슈
✅ 체크리스트
./gradlew test --tests "checkmo.chatbot.*" --tests checkmo.CheckmoApplicationTests전체 통과(챗봇 단위/API 테스트 39개 + Modulith 모듈 경계 검증). 실제 Gemini API 키로 로컬 Swagger 통해 멀티턴 대화, 에스컬레이션 전환, 인젝션 시도 시나리오까지 라이브 확인 완료.📝 특이사항
GEMINI_API_KEY가 없으면 앱 기동 시 프로퍼티 바인딩에서 실패합니다. 배포 환경 시크릿에 반드시 추가해주세요. (AI Studio에서 발급,x-goog-api-key헤더로 전송)chatbot모듈이 본인 닉네임 마스킹을 위해member모듈의 공개 API에 의존합니다(allowedDependencies에 추가,internal패키지 접근 없음). Modulith 경계 테스트로 검증됩니다.gemini-2.5-flash계열은 이 프로젝트가 쓰는 API 키/프로젝트 기준으로 신규 사용자에게 더 이상 제공되지 않아(404)gemini-3.1-flash-lite/gemini-3.6-flash로 확정했습니다. AI Studio 플레이그라운드 UI에 모델이 보이는 것과, 실제 REST 키로 호출 가능한 것은 별개였습니다 — 나중에 모델을 바꿀 일이 있으면 curl로 실제 호출 가능 여부부터 확인해주세요.LEAK_MARKERS목록은 현재 시스템 프롬프트 문구에 하드코딩되어 있어, 프롬프트를 수정할 때 같이 갱신해야 합니다.compose-dev.yml,application.yml,application-redis.yml,application-db.yml)은 이 PR에 포함하지 않았습니다(로컬 MySQL 포트 충돌 우회용 개인 설정).ChatOrchestrationService.MAX_HISTORY_MESSAGES).Summary by CodeRabbit
New Features
Tests