[Fix/#222] 일정추천기능수정 - #223
Conversation
Walkthrough추천 배치 유형을 추가하고, 기존 추천과 중복되지 않는 최대 3개의 일정을 재생성하는 서비스를 구현했습니다. 인증된 사용자를 위한 Changes추천 일정 재생성
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 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: 2
🧹 Nitpick comments (1)
src/migrations/1785487545211-AddRecommendType.ts (1)
12-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
down()에서 불필요한COMMENT ON COLUMN문을 제거하세요.
down()은type컬럼을 곧바로 삭제합니다(Line 14). 삭제 직전에 컬럼 코멘트를 다시 설정하는 문장(Line 13)은 효과가 없습니다. 이 문장을 제거하면 롤백 로직이 더 명확해집니다.이 파일은 정적 분석 도구가 지적한 Prettier 포맷팅 이슈도 다수 포함합니다. 통합 코멘트에서 함께 다룹니다.
♻️ 제안하는 수정
public async down(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query(`COMMENT ON COLUMN "Recommend"."type" IS '추천 배치 종류 (일기 고정 / 통계 최신 재생성 / 보관된 예전 재생성)'`); await queryRunner.query(`ALTER TABLE "Recommend" DROP COLUMN "type"`); await queryRunner.query(`DROP TYPE "public"."recommend_type_enum"`); }🤖 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/migrations/1785487545211-AddRecommendType.ts` around lines 12 - 16, Remove the redundant COMMENT ON COLUMN query from the down() method in the 1785487545211 migration, leaving the column drop and enum type removal operations unchanged.
🤖 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/ai/services/ai-gemini.service.ts`:
- Around line 53-55: 배포 구성에 GEMINI_REGENERATE_RECOMMEND_PROMPT 환경 변수 항목을 추가하세요.
ai-gemini.service.ts의 regenerateRecommendPrompt 설정 키와 일치하는 예시 값 또는 설명을
.env.example이나 기존 구성 등록 문서에 포함해 누락 시 undefined가 사용되지 않도록 하세요.
In `@src/ai/services/ai-recommend.service.ts`:
- Around line 284-360: Update regenerateRecommendations so archiving the
existing ADDITIONAL records and creating the replacement batch execute
atomically within a database transaction. Acquire a pessimistic lock for the
diary identified by diary.diaryId before these writes to serialize concurrent
regenerations, and ensure saveRecommendation uses the same transaction manager;
preserve the existing validation and no-candidate behavior outside the write
transaction.
---
Nitpick comments:
In `@src/migrations/1785487545211-AddRecommendType.ts`:
- Around line 12-16: Remove the redundant COMMENT ON COLUMN query from the
down() method in the 1785487545211 migration, leaving the column drop and enum
type removal operations unchanged.
🪄 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: abcbfcda-ca2d-452f-b301-0dfb20712324
📒 Files selected for processing (7)
src/ai/ai.controller.tssrc/ai/ai.swagger.tssrc/ai/entities/ai-recommend.entity.tssrc/ai/services/ai-gemini.service.tssrc/ai/services/ai-recommend.service.tssrc/migrations/1785487545211-AddRecommendType.tssrc/stats/stats.service.ts
| this.regenerateRecommendPrompt = this.configService.get<string>( | ||
| 'GEMINI_REGENERATE_RECOMMEND_PROMPT', | ||
| )!; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Verify GEMINI_REGENERATE_RECOMMEND_PROMPT is registered in env config files.
set -euo pipefail
fd -HI '\.env' -e example
rg -n 'GEMINI_REGENERATE_RECOMMEND_PROMPT|GEMINI_INITIAL_RECOMMEND_PROMPT' --hidden -g '!node_modules'Repository: TEAM-DAILOG/BE
Length of output: 152
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo 'Candidate config/env files:'
git ls-files | grep -Ei '(^|/)(\.env.*|.*config.*|^docker-compose.*|.*deployment.*|.*helm.*|.*k8s.*)' || true
echo
echo 'Search for GEMINI_* and similar prompt keys:'
rg -n --hidden -g '!node_modules' 'GEMINI_|REGENERATE_RECOMMEND|RECOMMEND_PROMPT|configService\.get' . || true
echo
echo 'Service file outline and relevant lines:'
sed -n '1,150p' src/ai/services/ai-gemini.service.ts 2>/dev/null || trueRepository: TEAM-DAILOG/BE
Length of output: 7468
GEMINI_REGENERATE_RECOMMEND_PROMPT 설정을 배포 환경에 추가하세요.
src/ai/services/ai-gemini.service.ts가 새 설정 키를 직접 읽고 ! 단언을 사용하므로, 환경 변수 없으면 런타임에 프롬프트에 "undefined"가 포함됩니다. .env.example이나 구성 등록 문서에 GEMINI_REGENERATE_RECOMMEND_PROMPT를 포함하세요.
[ensure]
🤖 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/ai/services/ai-gemini.service.ts` around lines 53 - 55, 배포 구성에
GEMINI_REGENERATE_RECOMMEND_PROMPT 환경 변수 항목을 추가하세요. ai-gemini.service.ts의
regenerateRecommendPrompt 설정 키와 일치하는 예시 값 또는 설명을 .env.example이나 기존 구성 등록 문서에 포함해
누락 시 undefined가 사용되지 않도록 하세요.
| // 통계에서 "다른 일정 추천받기" 호출. 지금까지 나온(타입 무관) 모든 제목을 제외 목록으로 | ||
| // 넘겨서 최대 3개(부족하면 그보다 적게)를 새로 만든다. 기존 ADDITIONAL 배치는 삭제하지 않고 | ||
| // ARCHIVED로 상태만 바꿔 보존하며, 새 배치만 ADDITIONAL로 저장해 통계에 우선 노출시킨다. | ||
| async regenerateRecommendations(userId: number): Promise<RecommendListDTO> { | ||
| const diary = await this.findTodayDiary(userId); | ||
|
|
||
| if (!diary) { | ||
| throw new ConflictException('오늘 작성된 일기가 없습니다.'); | ||
| } | ||
|
|
||
| const ownedCategories = await this.categoryRepository.find({ | ||
| where: { userId }, | ||
| }); | ||
|
|
||
| if (ownedCategories.length === 0) { | ||
| throw new ConflictException( | ||
| '일정 추천을 받으려면 먼저 카테고리를 생성해야 합니다.', | ||
| 'NO_CATEGORY', | ||
| ); | ||
| } | ||
|
|
||
| const allExistingRecommends = await this.recommendRepository.find({ | ||
| where: { diary: { diaryId: diary.diaryId } }, | ||
| }); | ||
|
|
||
| const items = await this.geminiService.generateRegeneratedRecommendations( | ||
| diary.content, | ||
| ownedCategories.map((category) => ({ | ||
| categoryId: category.categoryId, | ||
| categoryName: category.categoryName, | ||
| })), | ||
| allExistingRecommends.map((r) => r.title), | ||
| ); | ||
|
|
||
| // 유효한(카테고리까지 매칭되는) 후보가 하나도 없으면 기존 ADDITIONAL 배치를 건드리지 않는다 — | ||
| // 재생성 실패가 "직전에 보여주던 배치가 사라지는" 부작용을 남기면 안 되기 때문. | ||
| const candidates = items | ||
| .slice(0, MAX_RECOMMENDATION_BATCH_COUNT) | ||
| .filter( | ||
| (item): item is RecommendationItem & { scheduleTitle: string } => | ||
| !!item.scheduleTitle && | ||
| ownedCategories.some((category) => category.categoryId === item.categoryId), | ||
| ); | ||
|
|
||
| if (candidates.length === 0) { | ||
| throw new ConflictException( | ||
| '더 이상 추천할 수 있는 일정이 없습니다.', | ||
| 'NO_MORE_RECOMMENDATIONS', | ||
| ); | ||
| } | ||
|
|
||
| await this.recommendRepository.update( | ||
| { | ||
| diary: { diaryId: diary.diaryId }, | ||
| type: RecommendType.ADDITIONAL, | ||
| }, | ||
| { type: RecommendType.ARCHIVED }, | ||
| ); | ||
|
|
||
| const created: RecommendEntity[] = []; | ||
|
|
||
| for (const item of candidates) { | ||
| // candidates는 이미 카테고리 매칭까지 검증됐으므로 saveRecommendation은 항상 성공한다. | ||
| const recommend = await this.saveRecommendation( | ||
| diary, | ||
| item, | ||
| ownedCategories, | ||
| RecommendType.ADDITIONAL, | ||
| ); | ||
|
|
||
| if (recommend) { | ||
| created.push(recommend); | ||
| } | ||
| } | ||
|
|
||
| return new RecommendListDTO(created); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
아카이브·저장 시퀀스에 트랜잭션과 동시성 보호가 없습니다.
regenerateRecommendations는 기존 ADDITIONAL 배치를 ARCHIVED로 변경하는 작업(Line 335-341)과 새 ADDITIONAL 배치를 저장하는 작업(Line 345-357)을 트랜잭션 없이 순차적으로 실행합니다. 이로 인해 두 가지 문제가 발생합니다.
- 동시성 경쟁 조건: 이 API를 짧은 간격으로 두 번 호출하면(예: 사용자의 중복 클릭), 두 요청 모두 같은 기존 상태를 읽고 각각 아카이브·저장을 수행할 수 있습니다. 그 결과 "diary당 최대 1세트만 존재"라는 불변식(Line 227-228, 254 주석)이 깨지고,
getTodayRecommendationsForStats가 3개를 초과하는 ADDITIONAL 레코드를 반환할 수 있습니다. - 부분 실패: 아카이브 업데이트가 성공한 뒤 저장 루프 중간에 오류가 발생하면, 기존 배치는 이미 사라졌는데 새 배치는 불완전하게 남을 수 있습니다.
트랜잭션으로 두 단계를 묶고, 동시 호출을 막기 위한 잠금(예: diary_id에 대한 비관적 잠금) 또는 (diary_id) 대상 부분 유니크 제약(type = 'ADDITIONAL'인 행에 대해)을 추가하는 방안을 검토하세요.
// 개념적 예시: DataSource를 주입해 트랜잭션으로 묶는 방법
async regenerateRecommendations(userId: number): Promise<RecommendListDTO> {
// ...검증 로직...
return this.dataSource.transaction(async (manager) => {
// 동시 호출을 막기 위해 diary 행에 비관적 잠금을 건 뒤 아카이브·저장을 수행
await manager.update(
RecommendEntity,
{ diary: { diaryId: diary.diaryId }, type: RecommendType.ADDITIONAL },
{ type: RecommendType.ARCHIVED },
);
// ...saveRecommendation 호출들을 같은 manager로 수행...
});
}🧰 Tools
🪛 ESLint
[error] 325-325: Replace (category)·=>·category.categoryId·===·item.categoryId with ⏎············(category)·=>·category.categoryId·===·item.categoryId,⏎··········
(prettier/prettier)
🤖 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/ai/services/ai-recommend.service.ts` around lines 284 - 360, Update
regenerateRecommendations so archiving the existing ADDITIONAL records and
creating the replacement batch execute atomically within a database transaction.
Acquire a pessimistic lock for the diary identified by diary.diaryId before
these writes to serialize concurrent regenerations, and ensure
saveRecommendation uses the same transaction manager; preserve the existing
validation and no-candidate behavior outside the write transaction.
📌 관련 이슈번호
📌 PR 유형
어떤 변경 사항이 있나요?
📌 PR 요약
해당 PR을 간단하게 요약해 주세요
📌 작업 세부 내용
📸 스크린샷 (선택)
🔗 참고 자료