Conversation
📝 WalkthroughWalkthroughAI 기반 노트 제목 자동 생성 API를 추가했습니다. 클라이언트가 첫 질문을 보내면 Gemini/OpenRouter를 호출해 생성된 제목을 노트에 저장합니다. 요청/응답 DTO, 서비스 메서드, Gemini 클라이언트, 설정 및 에러 코드가 함께 추가·수정되었습니다. 변경사항
시퀀스 다이어그램sequenceDiagram
participant Client
participant Controller as NoteController
participant Service as NoteServiceImpl
participant Gemini as GeminiClient
participant OpenRouter as OpenRouter
participant DB as Database
Client->>Controller: POST /api/notes/{noteId}/generate-title (GenerateTitleRequest)
activate Controller
Controller->>Service: generateNoteTitle(userId, noteId, request)
activate Service
Service->>DB: 조회 note by noteId
DB-->>Service: note 데이터
Service->>Service: 권한/소유자 검증
Service->>Gemini: generateNoteTitle(questionText)
activate Gemini
Gemini->>OpenRouter: POST /v1/chat/completions (model, messages, max_tokens, temperature)
activate OpenRouter
OpenRouter-->>Gemini: completion response
deactivate OpenRouter
Gemini-->>Service: generatedTitle (validated, ≤30 chars)
deactivate Gemini
Service->>DB: note.title 업데이트 및 저장
DB-->>Service: 업데이트 결과
Service-->>Controller: UpdateNoteTitleResponse
deactivate Service
Controller-->>Client: ApiResponse<UpdateNoteTitleResponse>
deactivate Controller
예상 코드 리뷰 노력🎯 4 (Complex) | ⏱️ ~45 minutes 관련 가능성 있는 PR
추천 검토자
개요AI 기반 노트 제목 자동 생성 기능을 위한 새로운 API 엔드포인트, 서비스 계층, 그리고 OpenRouter 연동 클라이언트를 추가했습니다. 사용자의 첫 질문을 기반으로 AI가 노트 제목을 생성하고 업데이트합니다. 변경사항 (요약 블록 — 업데이트된 내용)
시 🐰
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 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: 4
🧹 Nitpick comments (3)
src/main/java/com/proovy/global/infra/gemini/GeminiClient.java (2)
48-54: 사용자 입력이 프롬프트에 직접 삽입됩니다 (프롬프트 인젝션).
String.format을 통해 사용자 질문 텍스트가 프롬프트에 직접 삽입되고 있습니다. 제목 생성이라 실제 악용 위험은 낮지만, 사용자가 "제목만 출력" 규칙을 무시하도록 유도하는 입력을 보낼 수 있습니다. 현재로서는 심각한 문제는 아니지만 인지해 두시기 바랍니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines 48 - 54, The prompt currently inserts raw user input via String.format in generateNoteTitle using TITLE_PROMPT_TEMPLATE, risking prompt/format injection; sanitize and normalize questionText before templating: trim and truncate to 500, remove or replace newline/control sequences and common instruction tokens (e.g., lines starting with "system:", "assistant:", "###"), escape percent signs (%) to avoid String.format interpolation, and/or stop using String.format by using a safe placeholder replacement (e.g., replace("{user}", escapedText)) so TITLE_PROMPT_TEMPLATE receives an escaped, instruction-free questionText.
16-27:@Value필드를final+ 생성자 주입으로 변경하면 테스트가 용이해집니다.현재
apiKey와model은 필드 주입 방식이라 단위 테스트 시 리플렉션 없이 값을 설정하기 어렵습니다.♻️ 생성자 주입 방식으로 개선 제안
`@Slf4j` `@Component` -@RequiredArgsConstructor public class GeminiClient { private final WebClient webClient; - - `@Value`("${openrouter.api.key}") - private String apiKey; - - `@Value`("${openrouter.api.model:google/gemini-2.5-flash-lite-preview-09-2025}") - private String model; + private final String apiKey; + private final String model; + + public GeminiClient( + WebClient webClient, + `@Value`("${openrouter.api.key}") String apiKey, + `@Value`("${openrouter.api.model:google/gemini-2.5-flash-lite-preview-09-2025}") String model + ) { + this.webClient = webClient; + this.apiKey = apiKey; + this.model = model; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines 16 - 27, Convert the `@Value` field injections for apiKey and model in the GeminiClient class to constructor injection: make the fields final (private final String apiKey; private final String model;), remove the `@Value` annotations from the fields, and add a constructor that accepts WebClient, `@Value`("${openrouter.api.key}") String apiKey, and `@Value`("${openrouter.api.model:...}") String model (or annotate the constructor with `@Autowired` and annotate the apiKey/model parameters with `@Value`). Keep the existing final WebClient and remove field injection so GeminiClient has all three dependencies injected via the constructor (no reflective field access), which makes unit testing easier.src/main/resources/application.yaml (1)
142-148: 안정적인 GA 모델 버전으로 변경하세요.
google/gemini-2.5-flash-lite-preview-09-2025는 OpenRouter에서 유효하지만, 동일한 기능의 안정적인 GA 버전google/gemini-2.5-flash-lite가 2025년 7월부터 사용 가능합니다. 프로덕션 환경에서는 안정성을 위해 GA 버전으로 변경하는 것을 권장합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/resources/application.yaml` around lines 142 - 148, 현재 application.yaml의 openrouter.api.model 값이 불안정한 프리뷰 버전인 "google/gemini-2.5-flash-lite-preview-09-2025"로 설정되어 있습니다; 이를 프로덕션용 안정된 GA 버전인 "google/gemini-2.5-flash-lite"로 변경하세요 (참조: 설정 키 openrouter.api.model).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java`:
- Around line 308-315: The current catch for geminiClient.generateNoteTitle in
NoteServiceImpl incorrectly throws BusinessException(ErrorCode.NOTE5001); change
it so failures are non-fatal: log the warning (keep log.warn("Gemini 제목 생성 실패 -
noteId: {}, error: {}", noteId, e.getMessage())) but do NOT throw — instead
assign generatedTitle to the note's existing title (e.g., obtain current title
from the Note entity or request payload, e.g., note.getTitle() or
request.title()) and continue normal flow; if your intent was to expose errors,
update the controller/Swagger text instead of keeping the throw in
generateNoteTitle's catch.
- Around line 317-319: geminiClient.generateNoteTitle()의 반환값이 빈 문자열/공백일 수 있으므로
generatedTitle을 Note.updateTitle 호출 전에 검증하고 유효하지 않다면 대체 제목(예: 기존 제목 유지 또는 기본
제목)으로 처리하여 빈 제목이 저장되지 않도록 수정하세요; 구체적으로 NoteServiceImpl에서 generatedTitle 변수 검사(예:
null/trim().isEmpty())를 하고 유효하면 note.updateTitle(generatedTitle) 호출, 그렇지 않으면
note.updateTitle(...)를 건너뛰거나 기본 제목을 설정한 뒤 noteRepository.save(note)를 호출하도록
변경하세요.
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 76-83: The null-check currently verifies response,
response.choices(), choices().isEmpty(), and message() but not
message().content(), so calling
response.choices().get(0).message().content().trim() can NPE when content is
null; update the validation in GeminiClient to also check that
response.choices().get(0).message().content() is non-null (and non-blank if
desired) before assigning to title, and if null provide a safe fallback or throw
a clear RuntimeException indicating empty content instead of allowing a
NullPointerException.
- Around line 67-74: The current blocking call that retrieves OpenRouterResponse
via webClient.post() uses .bodyToMono(...).block() with no timeout, risking
indefinite thread blocking; change the reactive chain to enforce a bounded wait
(for example, apply .timeout(Duration.ofSeconds(X)) on the Mono returned by
bodyToMono(OpenRouterResponse.class) or configure a WebClient TCP response
timeout) and handle the resulting TimeoutException so servlet threads are
protected; update the call site around
webClient.post()/.bodyToMono(OpenRouterResponse.class)/.block() and add
appropriate logging/error handling when the timeout triggers.
---
Nitpick comments:
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 48-54: The prompt currently inserts raw user input via
String.format in generateNoteTitle using TITLE_PROMPT_TEMPLATE, risking
prompt/format injection; sanitize and normalize questionText before templating:
trim and truncate to 500, remove or replace newline/control sequences and common
instruction tokens (e.g., lines starting with "system:", "assistant:", "###"),
escape percent signs (%) to avoid String.format interpolation, and/or stop using
String.format by using a safe placeholder replacement (e.g., replace("{user}",
escapedText)) so TITLE_PROMPT_TEMPLATE receives an escaped, instruction-free
questionText.
- Around line 16-27: Convert the `@Value` field injections for apiKey and model in
the GeminiClient class to constructor injection: make the fields final (private
final String apiKey; private final String model;), remove the `@Value` annotations
from the fields, and add a constructor that accepts WebClient,
`@Value`("${openrouter.api.key}") String apiKey, and
`@Value`("${openrouter.api.model:...}") String model (or annotate the constructor
with `@Autowired` and annotate the apiKey/model parameters with `@Value`). Keep the
existing final WebClient and remove field injection so GeminiClient has all
three dependencies injected via the constructor (no reflective field access),
which makes unit testing easier.
In `@src/main/resources/application.yaml`:
- Around line 142-148: 현재 application.yaml의 openrouter.api.model 값이 불안정한 프리뷰 버전인
"google/gemini-2.5-flash-lite-preview-09-2025"로 설정되어 있습니다; 이를 프로덕션용 안정된 GA 버전인
"google/gemini-2.5-flash-lite"로 변경하세요 (참조: 설정 키 openrouter.api.model).
src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java
Outdated
Show resolved
Hide resolved
src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/main/java/com/proovy/global/infra/gemini/GeminiClient.java (1)
84-86:RuntimeException대신 커스텀 예외 사용을 고려해 주세요.프로젝트에 이미
BusinessException/ErrorCode패턴이 있으므로, 여기서도 해당 패턴을 사용하면 예외 처리 일관성이 향상됩니다. 현재 호출부(NoteServiceImpl)에서catch (Exception e)로 잡고 있어 동작상 문제는 없습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines 84 - 86, Replace the RuntimeException thrown in GeminiClient where content==null || content.isBlank() with the project's BusinessException/ErrorCode pattern: throw new BusinessException(ErrorCode.<NEW_OR_EXISTING_CODE>, "OpenRouter API 응답이 비어있습니다."); Add a new ErrorCode entry (e.g., EMPTY_OPENROUTER_RESPONSE or OPENROUTER_EMPTY_RESPONSE) if none exists, and ensure GeminiClient's method signature/behavior remains unchanged so callers like NoteServiceImpl continue to catch as before.src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java (1)
296-319: 외부 API 호출이 트랜잭션 내에서 수행되어 DB 커넥션을 불필요하게 점유합니다.
geminiClient.generateNoteTitle()호출(최대 10초 블로킹)이@Transactional범위 안에서 실행되므로, API 응답을 기다리는 동안 DB 커넥션이 점유됩니다. 동시 요청이 많아지면 커넥션 풀 고갈로 이어질 수 있습니다.외부 API 호출을 트랜잭션 밖으로 분리하는 것을 고려해 주세요:
♻️ 트랜잭션 분리 제안 (개념)
+ `@Override` + public UpdateNoteTitleResponse generateNoteTitle(Long userId, Long noteId, GenerateTitleRequest request) { + log.info("AI 노트 제목 생성 요청 - userId: {}, noteId: {}", userId, noteId); + + // 1. 트랜잭션 내: 노트 조회 및 권한 확인 + Note note = findNoteWithOwnerCheck(noteId, userId); + + // 2. 트랜잭션 밖: 외부 API 호출 + String generatedTitle; + try { + generatedTitle = geminiClient.generateNoteTitle(request.text()); + } catch (Exception e) { + log.warn("Gemini 제목 생성 실패 - noteId: {}, 기존 제목 유지, error: {}", noteId, e.getMessage()); + return UpdateNoteTitleResponse.builder() + .noteId(note.getId()) + .title(note.getTitle()) + .updatedAt(note.getUpdatedAt()) + .build(); + } + + if (generatedTitle == null || generatedTitle.isBlank()) { + // 기존 제목 유지 + ... + } + + // 3. 트랜잭션 내: 제목 업데이트 + return updateNoteTitleInternal(note.getId(), generatedTitle); + }이 방식은 메서드에
@Transactional을 제거하고, 조회/저장 부분만 별도의@Transactional메서드로 분리하는 구조입니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java` around lines 296 - 319, The generateNoteTitle method currently calls geminiClient.generateNoteTitle() while inside the transaction, which can hold DB connections during the blocking external call; fix this by splitting responsibilities: create a small `@Transactional` helper (e.g., fetchNoteForTitleUpdate or verifyOwnership) that uses noteRepository.findById(...) to load the Note and check ownership (using NoteServiceImpl::generateNoteTitle to call it), then return the Note DTO or id/title, call geminiClient.generateNoteTitle(request.text()) outside any transaction, and finally invoke another `@Transactional` helper (e.g., updateNoteTitle) that takes noteId and generatedTitle and performs the update + save and returns the UpdateNoteTitleResponse; remove long-running external call from the original transactional scope so only the short DB reads/writes run in transactions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 49-55: The current generateNoteTitle method can throw formatting
errors if the user text contains percent-signs that are interpreted by
String.format; before calling String.format(TITLE_PROMPT_TEMPLATE,
truncatedText) escape percent signs in the user content (e.g. replace "%" with
"%%") so the input cannot be treated as format specifiers, then pass the escaped
string into String.format; update generateNoteTitle to perform this escape on
truncatedText prior to formatting with TITLE_PROMPT_TEMPLATE.
---
Duplicate comments:
In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java`:
- Around line 296-342: The generateNoteTitle method correctly handles note
lookup and permission checks (noteRepository.findById, NOTE4041/NOTE4031),
returns the existing title on Gemini failures (catch block around
geminiClient.generateNoteTitle) and defends against empty titles (generatedTitle
null/blank check); no code changes required—leave
NoteServiceImpl.generateNoteTitle as implemented.
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 68-75: The .block(Duration.ofSeconds(10)) timeout fixed the
infinite-blocking issue, but replace the hard-coded Duration with a configurable
property and add null/timeout handling: make the timeout value configurable
(e.g., gemini.requestTimeout) and use it in the webClient call in GeminiClient
(the webClient.post()...bodyToMono(...).block(...)), and after the block call
check if response is null and throw or return a clear exception/error so callers
don't receive a NPE; also consider wrapping the call in try/catch to convert
WebClient exceptions into a meaningful GeminiClientException.
- Around line 77-86: The previous null-check issue around message().content() in
GeminiClient has been addressed but to make the code clearer and safer, extract
the first Choice's Message into a local variable (e.g., Message firstMessage =
response.choices().get(0).message()) and then set String content = firstMessage
== null ? null : firstMessage.content(); keep the existing content == null ||
content.isBlank() runtime check and throw; this avoids the complex ternary and
ensures message/content nullability is explicit in GeminiClient.
---
Nitpick comments:
In `@src/main/java/com/proovy/domain/note/service/NoteServiceImpl.java`:
- Around line 296-319: The generateNoteTitle method currently calls
geminiClient.generateNoteTitle() while inside the transaction, which can hold DB
connections during the blocking external call; fix this by splitting
responsibilities: create a small `@Transactional` helper (e.g.,
fetchNoteForTitleUpdate or verifyOwnership) that uses
noteRepository.findById(...) to load the Note and check ownership (using
NoteServiceImpl::generateNoteTitle to call it), then return the Note DTO or
id/title, call geminiClient.generateNoteTitle(request.text()) outside any
transaction, and finally invoke another `@Transactional` helper (e.g.,
updateNoteTitle) that takes noteId and generatedTitle and performs the update +
save and returns the UpdateNoteTitleResponse; remove long-running external call
from the original transactional scope so only the short DB reads/writes run in
transactions.
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java`:
- Around line 84-86: Replace the RuntimeException thrown in GeminiClient where
content==null || content.isBlank() with the project's
BusinessException/ErrorCode pattern: throw new
BusinessException(ErrorCode.<NEW_OR_EXISTING_CODE>, "OpenRouter API 응답이
비어있습니다."); Add a new ErrorCode entry (e.g., EMPTY_OPENROUTER_RESPONSE or
OPENROUTER_EMPTY_RESPONSE) if none exists, and ensure GeminiClient's method
signature/behavior remains unchanged so callers like NoteServiceImpl continue to
catch as before.
| public String generateNoteTitle(String questionText) { | ||
| // 질문이 너무 길면 앞부분만 사용 (토큰 절약) | ||
| String truncatedText = questionText.length() > 500 | ||
| ? questionText.substring(0, 500) | ||
| : questionText; | ||
|
|
||
| String prompt = String.format(TITLE_PROMPT_TEMPLATE, truncatedText); |
There was a problem hiding this comment.
String.format에 사용자 입력을 직접 전달하면 MissingFormatArgumentException이 발생할 수 있습니다.
questionText에 %s, %d, %n 등의 포맷 지정자가 포함되어 있으면 String.format이 이를 해석하려 시도하여 예외가 발생하거나 의도치 않은 결과가 반환됩니다. 사용자 입력은 포맷 인자로 전달하거나 문자열 치환 방식을 변경해야 합니다.
🐛 수정 제안
- private static final String TITLE_PROMPT_TEMPLATE = """
- 다음 사용자 질문을 읽고, 노트 제목을 30자 이내로 만들어주세요.
-
- 규칙:
- - 30자 이내
- - 질문의 핵심 주제를 간결하게 표현
- - 제목만 출력 (설명, 따옴표, 줄바꿈 없이)
-
- 질문: %s
- """;
+ private static final String TITLE_PROMPT_TEMPLATE = """
+ 다음 사용자 질문을 읽고, 노트 제목을 30자 이내로 만들어주세요.
+
+ 규칙:
+ - 30자 이내
+ - 질문의 핵심 주제를 간결하게 표현
+ - 제목만 출력 (설명, 따옴표, 줄바꿈 없이)
+
+ 질문: {{QUESTION}}
+ """;- String prompt = String.format(TITLE_PROMPT_TEMPLATE, truncatedText);
+ String prompt = TITLE_PROMPT_TEMPLATE.replace("{{QUESTION}}", truncatedText);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/com/proovy/global/infra/gemini/GeminiClient.java` around lines
49 - 55, The current generateNoteTitle method can throw formatting errors if the
user text contains percent-signs that are interpreted by String.format; before
calling String.format(TITLE_PROMPT_TEMPLATE, truncatedText) escape percent signs
in the user content (e.g. replace "%" with "%%") so the input cannot be treated
as format specifiers, then pass the escaped string into String.format; update
generateNoteTitle to perform this escape on truncatedText prior to formatting
with TITLE_PROMPT_TEMPLATE.
📌 관련 이슈
🏷️ PR 타입
📝 작업 내용
POST /api/notes/{noteId}/generate-title)GenerateTitleRequestDTO 추가GeminiClient를 사용하여 제목 생성 요청📸 스크린샷
✅ 체크리스트
📎 기타 참고사항
Summary by CodeRabbit