Skip to content

[feat] #35 - 소요시간 ai 추천 - #41

Closed
aneykrap wants to merge 76 commits into
developfrom
feat/#35-recommend-duration
Closed

aneykrap wants to merge 76 commits into
developfrom
feat/#35-recommend-duration

Conversation

@aneykrap

@aneykrap aneykrap commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

관련 이슈 🛠

작업 내용 요약 ✏️

투두명과 사용자의 과거 실제 소요시간 기록을 기반으로 예상 소요 시간을 추천하는 AI API를 추가했습니다.
Gemini API 호출 전 Redis 기반 quota 제한을 적용해 RPM/RPD/TPM 초과 상황을 방어하도록 구성했습니다.

주요 변경 사항 🛠️

  • [AI 예상 소요시간 추천 API 추가]: POST /api/v1/ai/todos/recommend-duration 엔드포인트를 추가했습니다.
  • [타이머 기록 기반 추천]: timer_records.actual_seconds를 기준으로 비슷한 투두명 기록과 지정 태그의 최근 소요시간 경향을 Gemini 프롬프트에 전달합니다.
  • [Gemini 연동]: Spring Boot 서버에서 Gemini API를 직접 호출하고 추천 예상 소요시간을 JSON 응답으로 반환합니다.
  • [Redis 기반 quota 제한]: AI API 호출 전 RPM/RPD/TPM 제한을 검사해 과도한 호출 시 429 응답을 반환하도록 구현했습니다.
  • [Swagger 문서 추가]: AI 예상 소요시간 추천 API 요청/응답 및 실패 케이스를 문서화했습니다.
  • [환경변수 기반 설정]: Gemini API Key, 모델명, quota 제한값을 환경변수로 관리하도록 구성했습니다.

트러블 슈팅 ⚽️

  • Gemini API에서 단순 호출 구현만으로는 다중 사용자 요청 시 quota 초과 가능성이 있었습니다.
  • Redis 기반 rate limiter를 추가해 Gemini 호출 전 서버에서 quota를 먼저 검사하도록 처리했습니다.
  • 프롬프트에 전달되는 데이터가 많아질 경우 TPM 부담이 커질 수 있어 일단 비슷한 투두명 기록과 지정 태그 최근 기록을 각각 최대 5개로 제한했습니다. -> 해당 내용은 이후 조정하도록 하겠습니다.

테스트 결과 📄

./gradlew compileJava

스크린샷 📷

스크린샷 2026-07-08 오전 6 48 49

리뷰 요구사항 📢

  • Redis 기반 RPM/RPD/TPM 제한 방식이 현재 서비스 규모에 적절한지 검토 부탁드립니다.
  • AI 도메인을 별도 패키지로 분리한 현재 구조가 전체 아키텍처 관점에서 적절한지 확인 부탁드립니다.

📎 참고 자료 (선택)

없음

Summary by CodeRabbit

  • New Features
    • AI 투두 추천 API가 추가되어, 투두 예상 소요 시간을 추천받을 수 있습니다.
    • 제목과 태그 기반의 추천 입력/응답 형식이 새로 제공됩니다.
    • AI 추천을 위해 외부 모델 연동과 추천 프롬프트 생성 기능이 추가되었습니다.
  • Bug Fixes
    • AI 추천 요청이 과도할 경우 제한되는 보호가 추가되었습니다.
    • 관련 API에 대한 인증 및 문서 노출이 보강되었습니다.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@aneykrap, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b7bc3723-38e3-40aa-af91-7e7a9b477e97

📥 Commits

Reviewing files that changed from the base of the PR and between c536a24 and 2c47e1e.

⛔ Files ignored due to path filters (5)
  • src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java is excluded by !**/docs/**
  • src/main/java/com/Timo/Timo/domain/user/docs/OnboardingControllerDocs.java is excluded by !**/docs/**
  • src/main/java/com/Timo/Timo/domain/user/docs/UserLanguageDocs.java is excluded by !**/docs/**
  • src/main/java/com/Timo/Timo/domain/user/docs/UserProfileDocs.java is excluded by !**/docs/**
  • src/main/java/com/Timo/Timo/global/auth/docs/AuthControllerDocs.java is excluded by !**/docs/**
📒 Files selected for processing (65)
  • src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java
  • src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistories.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiTodoHistoryService.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java
  • src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java
  • src/main/java/com/Timo/Timo/domain/tag/entity/Tag.java
  • src/main/java/com/Timo/Timo/domain/tag/exception/TagErrorCode.java
  • src/main/java/com/Timo/Timo/domain/tag/repository/TagRepository.java
  • src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java
  • src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoCreateRequest.java
  • src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoCreateResponse.java
  • src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java
  • src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java
  • src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java
  • src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java
  • src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java
  • src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java
  • src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java
  • src/main/java/com/Timo/Timo/domain/todo/service/TodoCapacityChecker.java
  • src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java
  • src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java
  • src/main/java/com/Timo/Timo/domain/todo/validation/SubtaskContentValidator.java
  • src/main/java/com/Timo/Timo/domain/todo/validation/TodoTitleValidator.java
  • src/main/java/com/Timo/Timo/domain/todo/validation/ValidSubtaskContent.java
  • src/main/java/com/Timo/Timo/domain/todo/validation/ValidTodoTitle.java
  • src/main/java/com/Timo/Timo/domain/todo/vo/Duration.java
  • src/main/java/com/Timo/Timo/domain/user/controller/OnboardingController.java
  • src/main/java/com/Timo/Timo/domain/user/controller/UserController.java
  • src/main/java/com/Timo/Timo/domain/user/dto/request/OnboardingRequest.java
  • src/main/java/com/Timo/Timo/domain/user/dto/request/UpdateLanguageRequest.java
  • src/main/java/com/Timo/Timo/domain/user/dto/response/OnboardingResponse.java
  • src/main/java/com/Timo/Timo/domain/user/dto/response/UpdateLanguageResponse.java
  • src/main/java/com/Timo/Timo/domain/user/entity/User.java
  • src/main/java/com/Timo/Timo/domain/user/enums/Language.java
  • src/main/java/com/Timo/Timo/domain/user/exception/UserErrorCode.java
  • src/main/java/com/Timo/Timo/domain/user/exception/UserSuccessCode.java
  • src/main/java/com/Timo/Timo/domain/user/factory/OnboardingResponseFactory.java
  • src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java
  • src/main/java/com/Timo/Timo/domain/user/service/OnboardingService.java
  • src/main/java/com/Timo/Timo/domain/user/service/UserService.java
  • src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java
  • src/main/java/com/Timo/Timo/global/auth/dto/ReissueResult.java
  • src/main/java/com/Timo/Timo/global/auth/dto/request/AuthTokenRequest.java
  • src/main/java/com/Timo/Timo/global/auth/dto/response/AuthReissueResponse.java
  • src/main/java/com/Timo/Timo/global/auth/dto/response/AuthTokenResponse.java
  • src/main/java/com/Timo/Timo/global/auth/exception/AuthErrorCode.java
  • src/main/java/com/Timo/Timo/global/auth/exception/AuthSuccessCode.java
  • src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java
  • src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java
  • src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java
  • src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java
  • src/main/java/com/Timo/Timo/global/auth/principal/CustomUserDetails.java
  • src/main/java/com/Timo/Timo/global/auth/service/AuthService.java
  • src/main/java/com/Timo/Timo/global/auth/service/BlackListService.java
  • src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java
  • src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java
  • src/main/java/com/Timo/Timo/global/auth/utils/TokenExtractor.java
  • src/main/java/com/Timo/Timo/global/common/BaseTimeEntity.java
  • src/main/java/com/Timo/Timo/global/exception/GlobalExceptionHandler.java
  • src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java
  • src/main/java/com/Timo/Timo/global/exception/dto/ErrorDto.java
  • src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java
  • src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java
  • src/main/resources/application-local.yml

Walkthrough

투두 제목/태그 기반으로 Gemini AI를 통해 예상 소요 시간을 추천하는 신규 API(/api/v1/ai/todos/recommend-duration)가 추가되었다. 컨트롤러, 서비스, 프롬프트 빌더, 히스토리 조회 리포지토리, Redis 기반 레이트 리미터, Gemini 연동 서비스, 관련 DTO/에러/성공 코드가 포함된다. 부가적으로 예외 핸들러 적용 범위, 세션 정책, Swagger 보안 설정, application-local.yml이 조정되었다.

Changes

AI 소요시간 추천 기능

Layer / File(s) Summary
요청/응답 DTO 및 코드 정의
domain/ai/dto/request/RecommendDurationRequest.java, domain/ai/dto/response/RecommendDurationResponse.java, domain/ai/dto/response/GeminiDurationRecommendation.java, domain/ai/exception/AiErrorCode.java, domain/ai/exception/AiSuccessCode.java
검증 제약이 있는 요청 레코드, 응답 레코드, Gemini 응답 매핑 레코드, 레이트리밋 초과 에러코드, 추천 성공코드를 정의.
컨트롤러 엔드포인트
domain/ai/controller/AiTodoController.java
/recommend-duration POST 엔드포인트가 인증 사용자와 요청을 받아 서비스를 호출하고 BaseResponse로 반환.
히스토리 조회 리포지토리
domain/ai/repository/AiTodoQueryRepository.java, domain/ai/repository/TodoDurationHistory.java
네이티브 쿼리로 제목 유사/태그 기반 실제 소요시간 이력을 조회하고 타입 변환 후 매핑.
프롬프트 빌더
domain/ai/prompt/TodoDurationPromptBuilder.java
요청 정보와 이력 데이터를 조합해 판단 규칙 및 JSON 응답 형식을 포함한 프롬프트 문자열을 생성.
Gemini 호출 및 레이트 리미터
domain/ai/service/GeminiService.java, domain/ai/service/AiRequestRateLimiter.java
Gemini API 호출/텍스트 추출과 Redis Lua 스크립트 기반 RPM/RPD/TPM 검증 및 TTL 관리를 구현.
서비스 오케스트레이션
domain/ai/service/AiTodoService.java
히스토리 조회, 프롬프트 생성, 레이트리밋 검증, Gemini 호출, 응답 파싱/검증을 통합한 recommendDuration 흐름을 구현.

보안/문서/설정 조정

Layer / File(s) Summary
설정 및 예외 핸들러 범위 변경
domain/todo/exception/TodoExceptionHandler.java, global/config/SecurityConfig.java, global/config/SwaggerConfig.java, application-local.yml
예외 핸들러 적용 범위를 패키지 기준으로 확장, 세션 정책을 IF_REQUIRED로 변경, Swagger에 bearerAuth 보안 요구사항 추가, ai(gemini/rate-limit) 설정 블록 추가.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AiTodoController
  participant AiTodoService
  participant AiTodoQueryRepository
  participant TodoDurationPromptBuilder
  participant AiRequestRateLimiter
  participant GeminiService

  Client->>AiTodoController: POST /recommend-duration
  AiTodoController->>AiTodoService: recommendDuration(userId, request)
  AiTodoService->>AiTodoQueryRepository: findActualDurationHistories...
  AiTodoQueryRepository-->>AiTodoService: TodoDurationHistory 목록
  AiTodoService->>TodoDurationPromptBuilder: build(request, histories)
  TodoDurationPromptBuilder-->>AiTodoService: 프롬프트 문자열
  AiTodoService->>AiRequestRateLimiter: validate(userId, estimatedTokenCost)
  AiTodoService->>GeminiService: generateJson(prompt)
  GeminiService-->>AiTodoService: JSON 응답
  AiTodoService-->>AiTodoController: RecommendDurationResponse
  AiTodoController-->>Client: BaseResponse(DURATION_RECOMMENDED)
Loading

Possibly related PRs

  • Team-Timo/Timo-Server#6: SecurityConfig, SwaggerConfig의 세션/보안 요구사항 설정 변경이 동일한 보안/문서 설정 구성 요소를 다룸.

Suggested reviewers: laura-jung, Jy000n

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning 세션 정책을 STATELESS에서 IF_REQUIRED로 바꾸는 변경은 AI 소요시간 추천 요구사항과 직접 관련이 없습니다. 해당 보안 설정 변경은 별도 PR로 분리하고, AI 추천 API에 필요한 변경만 남기세요.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 소요시간 AI 추천이라는 핵심 변경을 간결하게 요약합니다.
Linked Issues check ✅ Passed 투두 제목으로 AI 소요시간을 추천하는 API와 관련 DTO/서비스/레이트리밋 로직이 추가되어 요구사항을 충족합니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#35-recommend-duration

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (6)
src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java (3)

97-99: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

추천 소요시간 상한 클램핑 부재

normalizeMinutes는 하한(1분)만 보장하고 상한이 없어, Gemini가 비정상적으로 큰 값(예: 999999)을 반환하면 그대로 클라이언트에 노출됩니다. 상한 클램핑을 추가하면 안전합니다.

🤖 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/com/Timo/Timo/domain/ai/service/AiTodoService.java` around
lines 97 - 99, normalizeMinutes in AiTodoService only clamps the lower bound, so
very large Gemini values can pass through unchanged. Update this method to clamp
both ends by introducing a reasonable maximum minutes limit and returning a
value bounded within that range. Keep the fix localized to normalizeMinutes so
all callers automatically receive safe, normalized durations.

74-83: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

마크다운 펜스 제거 로직이 비정상 응답에 취약

stripMarkdownFence는 시작 펜스만 있고 종료 펜스가 없는 등 예상과 다른 형태의 응답이 오면 substring 인덱스 계산이 잘못되어 StringIndexOutOfBoundsException이 발생할 수 있습니다. parseRecommendation의 try-catch로 잡히기는 하지만 근본적으로는 정규식 기반 추출 등 더 견고한 방식으로 개선하는 것을 권장합니다.

🔧 제안
-	private String stripMarkdownFence(String value) {
-		String trimmed = value.trim();
-		if (trimmed.startsWith("```json")) {
-			return trimmed.substring(7, trimmed.length() - 3).trim();
-		}
-		if (trimmed.startsWith("```")) {
-			return trimmed.substring(3, trimmed.length() - 3).trim();
-		}
-		return trimmed;
-	}
+	private String stripMarkdownFence(String value) {
+		String trimmed = value.trim();
+		if (trimmed.startsWith("```")) {
+			int firstNewline = trimmed.indexOf('\n');
+			int closingFence = trimmed.lastIndexOf("```");
+			if (firstNewline > 0 && closingFence > firstNewline) {
+				return trimmed.substring(firstNewline + 1, closingFence).trim();
+			}
+		}
+		return trimmed;
+	}

Also applies to: 105-114

🤖 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/com/Timo/Timo/domain/ai/service/AiTodoService.java` around
lines 74 - 83, `parseRecommendation` currently relies on `stripMarkdownFence`,
which can break on malformed Gemini replies where a code fence is incomplete or
missing its closing marker. Update `stripMarkdownFence` in `AiTodoService` to
extract fenced content more defensively, using the fence boundaries only when
both an opening fence and a valid closing fence are present, and otherwise
return the trimmed input unchanged. Keep the existing `parseRecommendation`
error handling, but make the fence-stripping logic resilient so it cannot throw
`StringIndexOutOfBoundsException` on unexpected responses.

40-61: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Rate limit 검증 전 DB 조회 수행

similarTitleHistories/recentTagHistories 조회가 먼저 실행된 뒤 rateLimiter.validate가 호출됩니다. 한도 초과 사용자의 경우에도 매번 2건의 DB 쿼리가 낭비됩니다. 토큰 추정치가 프롬프트(히스토리 포함) 길이에 의존하는 구조상 완전한 재정렬은 어렵지만, 최소한 RPM/RPD 같은 기본 한도는 히스토리 조회 전에 먼저 체크하는 방식을 고려해볼 수 있습니다.

🤖 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/com/Timo/Timo/domain/ai/service/AiTodoService.java` around
lines 40 - 61, Rate limit validation is happening after the history lookups in
AiTodoService, so over-limit requests still pay for the similarTitleHistories
and recentTagHistories queries. Reorder the logic in AiTodoService so a
lightweight pre-check for basic limits (before the repository calls) runs ahead
of aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle and
findActualDurationHistoriesByTagId, then keep the existing prompt-based
estimateTokenCost(prompt) validation after promptBuilder.build for the final
token check.
src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java (1)

45-79: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Redis 장애 시 동작 방식이 불명확

redisTemplate.execute(...) 호출 중 Redis 연결 장애(예: RedisConnectionFailureException)가 발생하면 예외가 그대로 전파되어 AI 추천 기능 전체가 실패합니다. Rate limiter는 부가 기능인데 Redis 장애가 핵심 기능 장애로 전이되는 구조입니다. Redis 예외를 잡아 fail-open(제한 없이 통과)할지, fail-closed(요청 거부)할지 명시적으로 정책을 정하는 것을 권장합니다.

🤖 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/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java`
around lines 45 - 79, The Redis call in AiRequestRateLimiter.validate currently
lets connection failures propagate, so decide an explicit policy for Redis
outages and implement it around redisTemplate.execute with the existing
LIMIT_SCRIPT. If choosing fail-open, catch RedisConnectionFailureException and
other Redis access errors, log the issue, and allow the request to continue; if
choosing fail-closed, catch them and translate to the same
AI_RATE_LIMIT_EXCEEDED path. Keep the behavior localized to validate and
preserve the current bucket/key logic.
src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java (1)

13-14: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

tagId에 대한 값 검증 부재

tagId가 음수나 0으로 들어와도 그대로 서비스 계층까지 전달됩니다. DB 조회 시 단순히 결과가 없게 되어 기능적으로는 크게 문제없지만, 명시적으로 @Positive를 추가하면 잘못된 입력을 조기에 차단할 수 있습니다.

🔧 제안
+import jakarta.validation.constraints.Positive;
+
 	`@Schema`(description = "투두 태그 ID", example = "1", nullable = true)
+	`@Positive`
 	Long tagId
🤖 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/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java`
around lines 13 - 14, `RecommendDurationRequest`의 `tagId`에는 현재 양수 검증이 없어 0이나 음수가
그대로 전달됩니다. `tagId` 필드에 입력 검증 애노테이션을 추가해 서비스 계층에 들어가기 전에 잘못된 값을 차단하도록 수정하세요. 이 요청
DTO의 `tagId` 선언 위치를 찾아 `RecommendDurationRequest`에서 명시적으로 양수만 허용되도록 적용하면 됩니다.
src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java (1)

38-41: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

양방향 LIKE %...% 패턴으로 인해 인덱스를 활용할 수 없습니다.

lower(t.title) like lower(concat('%', :title, '%'))와 그 역방향 조건 모두 선행 와일드카드(%)를 사용하므로 title 컬럼에 인덱스가 있더라도 활용되지 못하고 풀 스캔이 발생합니다. 사용자별 타이머 기록이 많아질수록 이 쿼리의 비용이 선형으로 증가합니다. 유사도 매칭에 트라이그램 인덱스(pg_trgm) 또는 별도의 검색 인덱스(예: Elasticsearch) 도입을 검토해 보시기 바랍니다.

도메인 리포지토리 경로 지침 "쿼리 성능과 N+1 문제가 없는지 확인", "필요한 경우 인덱스 고려가 되어 있는지 확인"에 따라 지적합니다.

🤖 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/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java`
around lines 38 - 41, The title matching in AiTodoQueryRepository uses
leading-wildcard LIKE on both sides, so the query cannot use a title index and
will scale poorly. Update the query logic in the repository method to avoid the
bilateral lower(... ) like concat('%', ... , '%') pattern; if fuzzy matching is
required, switch to a pg_trgm-based search or another indexed search approach
and align the repository query with that index strategy.

Source: Path instructions

🤖 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/com/Timo/Timo/domain/ai/controller/AiTodoController.java`:
- Around line 38-43: The INFO log in AiTodoController currently prints the
user-provided todo title via request.title(), which may expose sensitive data in
logs. Update the log statement in the AI duration recommendation endpoint to
omit the title entirely or replace it with a masked/placeholder value, while
keeping non-sensitive context like userId and tagId. Use the AiTodoController
logging call as the target for the change and ensure no plaintext PII is emitted
at INFO level.

In `@src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java`:
- Around line 82-89: The escape() helper in TodoDurationPromptBuilder only
escapes backslashes and quotes, leaving newline/control characters intact and
allowing prompt-injection text to break the prompt structure. Update escape() to
also neutralize line breaks and other control characters before values like
title are inserted into the prompt, so user input cannot escape the intended
field context. Keep the fix localized to escape() and ensure the prompt-building
flow that uses it continues to receive safely sanitized text.

In `@src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java`:
- Around line 24-25: `AiTodoService` is keeping the whole `recommendDuration()`
flow inside the class-level read-only transaction, which leaves the DB
transaction open during the external Gemini HTTP call. Move the transaction
boundary so only the database lookup work stays inside a transactional method,
and invoke `geminiService.generateJson(prompt)` outside that scope; use the
`AiTodoService` and `recommendDuration()` symbols to separate the
query-building/lookup logic from the Gemini request.

In `@src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java`:
- Around line 20-21: The Gemini request is currently embedding the API key in
the GENERATE_CONTENT_URL query string, which can leak secrets through logs and
intermediaries. Update GeminiService so the URI only includes the model path and
the API key is sent via the x-goog-api-key header on the request builder. Keep
the model substitution in the URI and move apiKey handling out of the URL
entirely.
- Around line 20-24: The GeminiService RestClient is created without explicit
connect/read timeouts, so update the GeminService initialization to use a
request factory with timeout settings instead of plain RestClient.create().
Adjust the RestClient field setup in GeminiService so the client is built with
configured timeouts, and keep the change localized to the RestClient creation
path used by the Gemini 호출 logic.

In `@src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java`:
- Line 15: The RestControllerAdvice on TodoExceptionHandler is pointing at a
controller package that does not exist, so the advice is never applied. Update
the basePackages value to match the actual package where the todo controllers
live, or switch to assignableTypes if this handler should only target specific
controller classes. Keep the existing TodoExceptionHandler class and adjust the
`@RestControllerAdvice` configuration so it binds to the intended controllers.

In `@src/main/resources/application-local.yml`:
- Around line 40-50: Fix the typo in the ai.rate-limit configuration so it
matches the property expected by AiRequestRateLimiter; the current user-지rpd key
contains an accidental Korean character and does not match the
`@Value`("${ai.rate-limit.user-rpd}") injection. Update the application-local.yml
entry to use the exact user-rpd symbol alongside the other rate-limit keys so
Spring can resolve it during context initialization.

---

Nitpick comments:
In
`@src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java`:
- Around line 13-14: `RecommendDurationRequest`의 `tagId`에는 현재 양수 검증이 없어 0이나 음수가
그대로 전달됩니다. `tagId` 필드에 입력 검증 애노테이션을 추가해 서비스 계층에 들어가기 전에 잘못된 값을 차단하도록 수정하세요. 이 요청
DTO의 `tagId` 선언 위치를 찾아 `RecommendDurationRequest`에서 명시적으로 양수만 허용되도록 적용하면 됩니다.

In `@src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java`:
- Around line 38-41: The title matching in AiTodoQueryRepository uses
leading-wildcard LIKE on both sides, so the query cannot use a title index and
will scale poorly. Update the query logic in the repository method to avoid the
bilateral lower(... ) like concat('%', ... , '%') pattern; if fuzzy matching is
required, switch to a pg_trgm-based search or another indexed search approach
and align the repository query with that index strategy.

In `@src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java`:
- Around line 45-79: The Redis call in AiRequestRateLimiter.validate currently
lets connection failures propagate, so decide an explicit policy for Redis
outages and implement it around redisTemplate.execute with the existing
LIMIT_SCRIPT. If choosing fail-open, catch RedisConnectionFailureException and
other Redis access errors, log the issue, and allow the request to continue; if
choosing fail-closed, catch them and translate to the same
AI_RATE_LIMIT_EXCEEDED path. Keep the behavior localized to validate and
preserve the current bucket/key logic.

In `@src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java`:
- Around line 97-99: normalizeMinutes in AiTodoService only clamps the lower
bound, so very large Gemini values can pass through unchanged. Update this
method to clamp both ends by introducing a reasonable maximum minutes limit and
returning a value bounded within that range. Keep the fix localized to
normalizeMinutes so all callers automatically receive safe, normalized
durations.
- Around line 74-83: `parseRecommendation` currently relies on
`stripMarkdownFence`, which can break on malformed Gemini replies where a code
fence is incomplete or missing its closing marker. Update `stripMarkdownFence`
in `AiTodoService` to extract fenced content more defensively, using the fence
boundaries only when both an opening fence and a valid closing fence are
present, and otherwise return the trimmed input unchanged. Keep the existing
`parseRecommendation` error handling, but make the fence-stripping logic
resilient so it cannot throw `StringIndexOutOfBoundsException` on unexpected
responses.
- Around line 40-61: Rate limit validation is happening after the history
lookups in AiTodoService, so over-limit requests still pay for the
similarTitleHistories and recentTagHistories queries. Reorder the logic in
AiTodoService so a lightweight pre-check for basic limits (before the repository
calls) runs ahead of
aiTodoQueryRepository.findActualDurationHistoriesBySimilarTitle and
findActualDurationHistoriesByTagId, then keep the existing prompt-based
estimateTokenCost(prompt) validation after promptBuilder.build for the final
token check.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fead1de8-6797-4a84-b7a5-4ad091e46ab2

📥 Commits

Reviewing files that changed from the base of the PR and between 77fee04 and c536a24.

⛔ Files ignored due to path filters (1)
  • src/main/java/com/Timo/Timo/domain/ai/docs/AiTodoDocs.java is excluded by !**/docs/**
📒 Files selected for processing (16)
  • src/main/java/com/Timo/Timo/domain/ai/controller/AiTodoController.java
  • src/main/java/com/Timo/Timo/domain/ai/dto/request/RecommendDurationRequest.java
  • src/main/java/com/Timo/Timo/domain/ai/dto/response/GeminiDurationRecommendation.java
  • src/main/java/com/Timo/Timo/domain/ai/dto/response/RecommendDurationResponse.java
  • src/main/java/com/Timo/Timo/domain/ai/exception/AiErrorCode.java
  • src/main/java/com/Timo/Timo/domain/ai/exception/AiSuccessCode.java
  • src/main/java/com/Timo/Timo/domain/ai/prompt/TodoDurationPromptBuilder.java
  • src/main/java/com/Timo/Timo/domain/ai/repository/AiTodoQueryRepository.java
  • src/main/java/com/Timo/Timo/domain/ai/repository/TodoDurationHistory.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiRequestRateLimiter.java
  • src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java
  • src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java
  • src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java
  • src/main/java/com/Timo/Timo/global/config/SecurityConfig.java
  • src/main/java/com/Timo/Timo/global/config/SwaggerConfig.java
  • src/main/resources/application-local.yml

Comment thread src/main/java/com/Timo/Timo/domain/ai/service/AiTodoService.java Outdated
Comment thread src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java Outdated
Comment thread src/main/java/com/Timo/Timo/domain/ai/service/GeminiService.java Outdated
Comment thread src/main/java/com/Timo/Timo/domain/todo/exception/TodoExceptionHandler.java Outdated
Comment thread src/main/resources/application-local.yml
laura-jung and others added 25 commits July 8, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] ai 소요시간 추천

3 participants