-
Notifications
You must be signed in to change notification settings - Fork 0
refactor : 수정사항 반영 #166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refactor : 수정사항 반영 #166
Conversation
Walkthrough아바타 닉네임 길이 검증(@SiZe(max=14)) 추가, Garden에 소유자 물주기 쿨다운 검사 및 남은 초 계산 추가, GardenService 물주기/해금 흐름 리팩터링, WishTree의 자동 진화·언락 카운트 도입, LevelService 삭제 및 레벨 API 전환, 피드/다이어리/아바타포스트의 좋아요·댓글 카운트 타입 확대( int→long ) 및 랜덤 피드·배치 집계 추가. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor 사용자 as User
participant HomeSvc as HomeService
participant Garden as Garden
User->>HomeSvc: 홈 데이터 요청
HomeSvc->>Garden: isWaterableByOwner()
HomeSvc->>Garden: getWaterableByOwnerInSeconds()
Garden-->>HomeSvc: 가능 여부 / 남은 초
HomeSvc-->>User: GardenSummaryInfo(nextWaterableAt, waterableInSeconds)
sequenceDiagram
autonumber
actor Client as 클라이언트
participant FeedCtrl as FeedController
participant FeedSvc as FeedService
participant DiaryRepo as DiaryRepository
participant AvatarRepo as AvatarPostRepository
participant LikeRepo as LikeRepository
participant CommentRepo as CommentRepository
Client->>FeedCtrl: POST /api/v1/feed/random (RandomFeedRequest)
FeedCtrl->>FeedSvc: getRandomFeed(excludeDiaryIds, excludeAvatarPostIds, size)
FeedSvc->>DiaryRepo: findRandomPublicDiaryIds(...) / findAllByIdIn(...)
FeedSvc->>AvatarRepo: findRandomPublicAvatarPostIds(...) / findAllByIdIn(...)
FeedSvc->>LikeRepo: countLikesByTargetIds(diaryIds, "DIARY") / (postIds, "AVATAR_POST")
FeedSvc->>CommentRepo: countCommentsByDiaryIds/AvatarPostIds(...)
FeedSvc-->>FeedCtrl: FeedScrollResponse(items, hasMore)
FeedCtrl-->>Client: 200 OK
sequenceDiagram
autonumber
actor User as 사용자
participant WTService as WishTreeService
participant WT as WishTree
participant UserEntity as User
User->>WTService: addPointsToWishTree(userId, points)
WTService->>WT: if stage == TREE or FINAL then return
WTService->>WT: addPoints(points)
WT->>WT: evolveStageIfNeeded() [while canEvolve()]
WT->>UserEntity: incrementUnlockableGardenCount() per evolution
WTService-->>User: 처리 완료
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests
Warning Review ran into problems🔥 ProblemsGit: Failed to clone repository. Please run the 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java (1)
167-243: 빈 정원 슬롯에서 NPE 발생잠금 해제된 정원이라도 아직 아바타가 배치되지 않은 경우가 많습니다. 그런데 현재 코드는
garden.getAvatar()가null일 때 바로.getId()등을 호출해NullPointerException이 납니다. 최소한garden.getAvatar()존재 여부를 확인하고, 없으면 아바타 정보를 비우거나 기본값으로 채우는 처리가 필요합니다.
🧹 Nitpick comments (12)
src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java (1)
73-85: Long 상수로 선언해 캐스팅을 없애주세요.지금처럼
int상수를long으로 매번 캐스팅하기보다, 상수를 처음부터long으로 선언하면 중복 캐스팅을 제거하고 타입 의도를 더 명확하게 전달할 수 있습니다.- final int PHOTO_MISSION_COMPLETE_POINT = 15; - final int QUIZ_MISSION_COMPLETE_POINT = 15; - final int DIARY_MISSION_COMPLETE_POINT = 15; + final long PHOTO_MISSION_COMPLETE_POINT = 15L; + final long QUIZ_MISSION_COMPLETE_POINT = 15L; + final long DIARY_MISSION_COMPLETE_POINT = 15L; @@ - userService.addExperience( - userService.getCurrentUser().getId(), (long) PHOTO_MISSION_COMPLETE_POINT); + userService.addExperience( + userService.getCurrentUser().getId(), PHOTO_MISSION_COMPLETE_POINT); @@ - userService.addExperience( - userService.getCurrentUser().getId(), (long) QUIZ_MISSION_COMPLETE_POINT); + userService.addExperience( + userService.getCurrentUser().getId(), QUIZ_MISSION_COMPLETE_POINT); @@ - userService.addExperience( - userService.getCurrentUser().getId(), (long) DIARY_MISSION_COMPLETE_POINT); + userService.addExperience( + userService.getCurrentUser().getId(), DIARY_MISSION_COMPLETE_POINT);src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java (1)
28-68: 컨트롤러에서 LikeRepository를 직접 다루지 않는 편이 계층 분리에 더 좋습니다.현재
DiaryController가LikeRepository에 직접 의존하면서 컨트롤러가 도메인 로직(좋아요 수 계산)을 일부 책임지고 있습니다. 기존 구조처럼 서비스 계층이 DTO 생성까지 책임지고, 컨트롤러는 결과만 반환하도록 되돌리면 테스트/재사용성이 올라가고 의존성도 줄어듭니다.DiaryService에서DiaryResponse.from(...)를 구성하도록 위임하는 방향을 고려해 주세요.src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/Avatar.java (1)
21-23: DB 스키마도 14자 제한으로 맞춰주세요Bean Validation만으로는 서비스 내부에서 직접 엔티티를 수정하거나 DB에서 업데이트할 때 길이 제한을 보장하기 어렵습니다.
@Column(length = 14)로 스키마까지 일관되게 제한해 두면 데이터 무결성을 확실히 지킬 수 있습니다.- @Column(nullable = false) + @Column(nullable = false, length = 14)src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java (1)
38-38: 사용하지 않는 GardenService 주입 제거 검토
gardenService가 이 클래스 어디에서도 사용되지 않습니다. 불필요한 의존성은 제거하거나 실제 사용하는 로직을 추가해 주세요.src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java (2)
99-101: 좋아요 수 집계 전달 방식 전환 LGTM + "DIARY" 매직 문자열 상수화 제안성능/쿼리 관점에서 깔끔합니다. 다만 "DIARY" 문자열을 상수나 Enum으로 치환해 오타/분기 누락 리스크를 줄이는 것을 권장합니다.
적용 예시(해당 범위 내 치환):
- return DiaryInfoResponse.from( - diary, isLiked, likeRepository.countByTargetIdAndTargetType(diaryId, "DIARY")); + return DiaryInfoResponse.from( + diary, isLiked, likeRepository.countByTargetIdAndTargetType(diaryId, TARGET_TYPE_DIARY));범위 외 보완(클래스 상단에 추가):
private static final String TARGET_TYPE_DIARY = "DIARY";같은 파일의 Line 94에서도 동일 상수로의 치환을 추천합니다.
109-134: countLikesByTargetIds 계약·정렬 확인
- countLikesByTargetIds는 내부적으로 likes가 있는 대상만 Map에 포함하고, 없는 ID는 getOrDefault로 0을 처리합니다. 유지보수를 위해 메서드 주석에 이 계약을 명시하세요.
- findMyDiaries가 반환하는 일기 목록의 정렬(예: 생성일 내림차순)이 클라이언트 기대와 일치하는지 확인하고, 필요 시 스트림 처리 전 정렬 로직을 추가하세요.
src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTree.java (1)
38-41: addPoints 입력 검증 보강 및 진화 루프 세이프가드 제안
- addPoints에 null/음수 입력이 들어오면 NPE/의도치 않은 감소가 발생할 수 있습니다. 간단한 가드를 추천합니다.
- while(canEvolve())는 현재 구현상 안전해 보이지만, 방어적으로도 충분합니다.
적용 예시:
public void addPoints(Long points) { - this.points += points; - evolveStageIfNeeded(); + if (points == null || points <= 0) { + return; // 필요 시 IllegalArgumentException으로 엄격화 가능 + } + this.points += points; + evolveStageIfNeeded(); }Also applies to: 61-67
src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java (3)
55-61: 경계값 방어: 음수 currentExp 방지데이터 불일치 시 expInCurrentLevel이 음수가 될 여지가 있습니다. 0 하한을 두는 것이 안전합니다.
- long expInCurrentLevel = totalPoints - expForCurrentLevelStart; + long expInCurrentLevel = Math.max(0L, totalPoints - expForCurrentLevelStart);
77-90: 다음 물주기 시간 계산을 도메인으로 위임 제안isOwnerWateringAble이 false인데 lastWateredByOwnerAt이 null인 비정상 상태에서 NPE 가능성이 있습니다. nextWaterableAt 계산을 Garden의 메서드로 캡슐화하면 안전합니다.
적용 방향:
- Garden에 getNextOwnerWaterableAt() 추가(내부에서 null 체크 및 정책 캡슐화).
- 여기서는 해당 메서드 호출로 대체.
119-124: 잠금 해제 가능 로직: garden null 케이스도 허용 권장현재는 garden != null이 전제되어 null 슬롯을 해제 대상으로 표기하지 않습니다. 슬롯 엔티티가 아직 생성되지 않은 구조라면 해제 표시가 누락됩니다.
- boolean isUnlockable = - garden != null - && garden.isLocked() - && slotNumber > unlockedGardenCount - && slotNumber <= unlockedGardenCount + user.getUnlockableGardenCount(); + boolean isUnlockable = + (garden == null || garden.isLocked()) + && slotNumber > unlockedGardenCount + && slotNumber <= unlockedGardenCount + user.getUnlockableGardenCount();src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java (1)
9-14: 스테이지 메타데이터 확장 LGTM + 보조 API 제안
- level/구간 시작/다음 구간 시작 포인트로 모델링이 명확합니다.
- FINAL의 requiredPointsForNextStage=Long.MAX_VALUE는 상위 로직에서 FINAL 특례 처리가 필요합니다(HomeService에 패치 제안 완료).
보조 메서드 추가 제안(범위 외):
- boolean hasNextStage() { return this != FINAL; }
- boolean isFinal() { return this == FINAL; }
Also applies to: 16-18
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java (1)
21-31: DiaryFeedItemResponse에 static from 팩토리 메서드 추가
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java에
public static DiaryFeedItemResponse from(Diary diary, long likeCount, long commentCount)를 추가해
FeedService의new DiaryFeedItemResponse(…)호출부를 대체하고 DiaryResponse.from과 일관성을 맞추세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (33)
src/main/java/com/example/cp_main_be/domain/avatar/avatar/domain/Avatar.java(2 hunks)src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java(2 hunks)src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java(6 hunks)src/main/java/com/example/cp_main_be/domain/home/HomeResponseDto.java(2 hunks)src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java(3 hunks)src/main/java/com/example/cp_main_be/domain/member/level/service/LevelService.java(0 hunks)src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java(5 hunks)src/main/java/com/example/cp_main_be/domain/member/user/dto/response/LevelStatusResponseDto.java(1 hunks)src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserGardenDetailResponse.java(1 hunks)src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java(0 hunks)src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java(2 hunks)src/main/java/com/example/cp_main_be/domain/member/user/service/UserService.java(6 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryInfoResponse.java(2 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryResponse.java(2 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/presentation/DiaryController.java(3 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/service/DiaryService.java(3 hunks)src/main/java/com/example/cp_main_be/domain/mission/user_daily_mission/service/UserDailyMissionService.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTree.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeService.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeStage.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/AvatarPost.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/AvatarPostFeedItemResponse.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/PostInfoResponse.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java(3 hunks)src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java(3 hunks)src/main/java/com/example/cp_main_be/global/dto/FeedItemResponse.java(1 hunks)src/main/java/com/example/cp_main_be/global/listener/NotificationEventListener.java(2 hunks)src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql(1 hunks)
💤 Files with no reviewable changes (2)
- src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserRegisterResponse.java
- src/main/java/com/example/cp_main_be/domain/member/level/service/LevelService.java
👮 Files not reviewed due to content moderation or server errors (6)
- src/main/java/com/example/cp_main_be/domain/member/user/presentation/UserController.java
- src/main/java/com/example/cp_main_be/domain/member/user/dto/response/LevelStatusResponseDto.java
- src/main/java/com/example/cp_main_be/domain/home/HomeResponseDto.java
- src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java
- src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryInfoResponse.java
- src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/example/cp_main_be/domain/member/user/dto/response/LevelStatusResponseDto.java (1)
src/main/java/com/example/cp_main_be/domain/home/HomeResponseDto.java (5)
Getter(8-71)Getter(20-29)Getter(32-46)Getter(48-54)Getter(56-64)
🔇 Additional comments (7)
src/main/java/com/example/cp_main_be/domain/member/user/dto/response/UserGardenDetailResponse.java (1)
19-21: 추가된 카운트 필드 확인 완료정원 상태 응답에 필요한 카운트 정보가 누락 없이 전달될 수 있게 잘 반영됐습니다.
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryFeedItemResponse.java (1)
17-19: 카운트 타입 long 전환 LGTMRepository와 타입 일치하며 오버플로 리스크 감소에 유효합니다.
src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/PostInfoResponse.java (1)
17-38: long 타입 전환 확인 완료likeCount 필드와 팩토리 메서드 모두 long으로 정렬되어 계층 간 일관성이 잘 맞춰졌습니다.
src/main/java/com/example/cp_main_be/domain/social/avatarpost/dto/AvatarPostFeedItemResponse.java (1)
15-24: Feed DTO 카운트 타입 정규화 👍외부에서 계산된 long 카운트를 주입하도록 리팩터링되어, 피드 병합 시 중복 조회를 줄이는 데 도움이 되겠습니다.
src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/AvatarPost.java (1)
43-58: like_count 컬럼 스키마도 BIGINT로 맞춰주세요엔티티 필드를 long으로 확장했으니 DB 컬럼 타입도 BIGINT로 변경됐는지 확인이 필요합니다. 기존 INT 그대로면 21억 건을 넘길 때 여전히 오버플로우가 발생합니다.
src/main/java/com/example/cp_main_be/global/dto/FeedItemResponse.java (1)
12-14: 인터페이스 시그니처 업데이트 확인long 반환형으로 선언해 구현체 전반의 스펙을 통일한 점 좋습니다.
src/main/java/com/example/cp_main_be/domain/mission/diary/dto/response/DiaryResponse.java (1)
14-47: Diary 응답도 long 카운트를 받도록 정리 완료팩토리 메서드가 주입된 likeCount를 그대로 활용하도록 바뀌어 일괄 카운트와 호환이 잘 되겠습니다.
src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java
Show resolved
Hide resolved
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
Show resolved
Hide resolved
| long progressPercent = | ||
| (expNeededForLevelUp > 0) | ||
| ? (long) (((double) expInCurrentLevel / expNeededForLevelUp) * 100) | ||
| : 100; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FINAL 단계 진행률 계산 오류: 최종 단계에서 0%로 보이는 문제
FINAL은 다음 단계가 없으므로 진행률은 100%가 되어야 합니다. 현재는 Long.MAX_VALUE를 분모로 계산되어 0%에 수렴합니다.
- long progressPercent =
- (expNeededForLevelUp > 0)
- ? (long) (((double) expInCurrentLevel / expNeededForLevelUp) * 100)
- : 100;
+ long progressPercent =
+ (nextStageEnum == null)
+ ? 100
+ : (expNeededForLevelUp > 0
+ ? Math.min(100, Math.max(0,
+ (long) (((double) expInCurrentLevel / expNeededForLevelUp) * 100)))
+ : 100);추가로 Line 199의 expInCurrentLevel도 0 하한 적용을 권장합니다(위 다른 코멘트 참고).
🤖 Prompt for AI Agents
In src/main/java/com/example/cp_main_be/domain/home/service/HomeService.java
around lines 201 to 205, the progressPercent calculation uses
expNeededForLevelUp that is Long.MAX_VALUE for FINAL, producing 0%; change logic
to explicitly detect FINAL/“no next level” (or expNeededForLevelUp <= 0 or
Long.MAX_VALUE) and set progressPercent = 100 in that case, otherwise compute
the percentage normally. Also ensure expInCurrentLevel (line 199) is clamped to
a non-negative lower bound (max(expInCurrentLevel, 0)) before calculating the
percentage to avoid negative values affecting the result.
src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java
Show resolved
Hide resolved
...main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java
Show resolved
Hide resolved
| // [추가] 나무 또는 최종 단계에 도달하면 더 이상 포인트를 추가하지 않음 | ||
| if (wishTree.getStage() == WishTreeStage.TREE || wishTree.getStage() == WishTreeStage.FINAL) { | ||
| return wishTree; // 아무 작업도 하지 않고 현재 상태 반환 | ||
| } | ||
|
|
||
| // 3. Stage 성장 및 이벤트 발행 로직 모두 제거 | ||
| wishTree.addPoints(points); | ||
| return wishTree; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TREE 단계에서 포인트 누적이 중단되어 최종 단계로 진입하지 못합니다.
WishTree가 TREE 단계에 도달한 순간부터 포인트 추가가 전면 차단되어 FINAL 단계로의 진화가 더 이상 일어나지 않습니다. WishTreeStage 정의상 TREE는 아직 최종 상태가 아니므로(예: SEED → BUD → FLOWER → TREE → FINAL), 현 로직은 레벨업 경로를 끊어버리는 치명적 회귀입니다. 최소한 FINAL 단계까지만 포인트 차단을 적용하고 TREE 단계에서는 기존대로 누적이 가능해야 합니다.
수정을 위해 다음과 같이 조건을 완화해 주세요:
- if (wishTree.getStage() == WishTreeStage.TREE || wishTree.getStage() == WishTreeStage.FINAL) {
+ if (wishTree.getStage() == WishTreeStage.FINAL) {
return wishTree;
}📝 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.
| // [추가] 나무 또는 최종 단계에 도달하면 더 이상 포인트를 추가하지 않음 | |
| if (wishTree.getStage() == WishTreeStage.TREE || wishTree.getStage() == WishTreeStage.FINAL) { | |
| return wishTree; // 아무 작업도 하지 않고 현재 상태 반환 | |
| } | |
| // 3. Stage 성장 및 이벤트 발행 로직 모두 제거 | |
| wishTree.addPoints(points); | |
| return wishTree; | |
| // [추가] 나무 또는 최종 단계에 도달하면 더 이상 포인트를 추가하지 않음 | |
| if (wishTree.getStage() == WishTreeStage.FINAL) { | |
| return wishTree; // 아무 작업도 하지 않고 현재 상태 반환 | |
| } | |
| wishTree.addPoints(points); | |
| return wishTree; |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/wishTree/WishTreeService.java
around lines 25 to 31, the current guard blocks point accumulation when stage ==
WishTreeStage.TREE which prevents progression to FINAL; change the condition to
only short-circuit for WishTreeStage.FINAL (i.e., allow points to be added when
stage == TREE), so update the if to check FINAL only and return the current
wishTree in that case so TREE continues to accumulate points toward FINAL.
src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java
Show resolved
Hide resolved
src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java
Show resolved
Hide resolved
| if ("feed".equalsIgnoreCase(targetType)) { | ||
| Feed feed = | ||
| feedRepository | ||
| .findById(targetId) | ||
| .orElseThrow(() -> new IllegalArgumentException("피드를 찾을 수 없습니다.")); | ||
| User receiver = feed.getUser(); | ||
|
|
||
| // 자기 자신에게는 알림을 보내지 않음 | ||
| if (!receiver.getId().equals(userId)) { | ||
| notificationService.send(receiver, user, NotificationType.FEED_LIKE, "/feeds/" + targetId); | ||
| } | ||
|
|
||
| // 일단 보류 | ||
| return 0; | ||
| } else if ("DIARY".equalsIgnoreCase(targetType)) { | ||
| Diary diary = | ||
| diaryRepository | ||
| .findById(targetId) | ||
| .orElseThrow(() -> new IllegalArgumentException("일기를 찾을 수 없습니다.")); | ||
| diary.increaseLikeCount(); | ||
| // [제거] diary.increaseLikeCount(); | ||
|
|
||
| User receiver = diary.getUser(); | ||
| if (!receiver.getId().equals(userId)) { | ||
| notificationService.send( | ||
| receiver, user, NotificationType.DIARY_LIKE, "/diaries/" + targetId); | ||
| } | ||
| return diary.getLikeCount(); | ||
| } else if ("AVATAR_POST".equalsIgnoreCase(targetType)) { | ||
| AvatarPost avatarPost = | ||
| avatarPostRepository | ||
| .findById(targetId) | ||
| .orElseThrow(() -> new IllegalArgumentException("포스트를 찾을 수 없습니다.")); | ||
| avatarPost.increaseLikeCount(); | ||
| // [제거] avatarPost.increaseLikeCount(); | ||
|
|
||
| User receiver = avatarPost.getUser(); | ||
| if (!receiver.getId().equals(userId)) { | ||
| notificationService.send( | ||
| receiver, user, NotificationType.AVATAR_POST_LIKE, "/avatar-posts/" + targetId); | ||
| } | ||
| return avatarPost.getLikeCount(); | ||
| } | ||
| return 0; | ||
|
|
||
| // [수정] 실제 Like 개수를 세어서 반환 | ||
| return likeRepository.countByTargetIdAndTargetType(targetId, targetType); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
중복 좋아요 race condition 방치됨
existsBy... → save() 순서는 동시에 요청이 들어오면 둘 다 exists 검사를 통과해 중복 레코드를 만들 수 있습니다. 기존 코드가 DataIntegrityViolationException을 캐치하던 이유가 바로 이 상황인데, 지금은 그 보호막이 사라져 DB 제약 위반 예외가 그대로 터지면서 500을 반환하게 됩니다. 중복을 막으려면 유니크 제약을 확실히 두고 save 시 발생하는 예외를 잡아 사용자 친화적으로 바꿔 주거나, exists 대신 DB 레벨에서 upsert/잠금으로 원자성을 보장하는 식으로 수정해 주세요.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java
around lines 45 to 83, the current exists-then-save flow allows a race where two
concurrent requests both pass the exists check and create duplicate likes (which
used to be masked by catching DataIntegrityViolationException but now bubbles up
as 500). Add/ensure a DB unique constraint on (targetId, targetType, userId) and
make save() call resilient: wrap the repository.save(...) (or the transaction
that inserts the Like) in a try-catch that catches
DataIntegrityViolationException (and any JPA-specific constraint exception),
translate it into a controlled response (e.g., return current like count or
throw a custom DuplicateLikeException mapped to 409/appropriate API error), or
alternatively implement an atomic upsert or pessimistic lock at repository/DB
level so insertion is atomic; ensure the method still returns the correct
counted like number after handling the duplicate case.
There was a problem hiding this comment.
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 (2)
src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java (1)
82-90: 주인 물주기 가능 여부 계산 로직 적절쿨다운 판단을 도메인으로 내린 것이 명확합니다. 다만 매직 넘버(4시간)는 상수로 추출을 권장합니다.
쿨다운 시간을 상수로 추출:
@Entity public class Garden { + private static final int OWNER_WATERING_COOLDOWN_HOURS = 4; @@ - LocalDateTime nextWaterableTime = this.lastWateredByOwnerAt.plusHours(4); + LocalDateTime nextWaterableTime = this.lastWateredByOwnerAt.plusHours(OWNER_WATERING_COOLDOWN_HOURS);src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (1)
211-213: 정리 배치의 시간대 일관성 권장다른 로직은 Asia/Seoul을 사용하지만, 여기서는 시스템 기본 시간대입니다. 운영 시간대 변경 시 예기치 못한 차이가 날 수 있습니다.
- LocalDateTime cutoffDate = LocalDateTime.now().minusDays(RETENTION_DAYS); + LocalDateTime cutoffDate = LocalDateTime.now(ZoneId.of("Asia/Seoul")).minusDays(RETENTION_DAYS);
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java(2 hunks)src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java(6 hunks)src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java(5 hunks)src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java(3 hunks)src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java(3 hunks)src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/main/java/com/example/cp_main_be/domain/social/like/domain/repository/LikeRepository.java
🔇 Additional comments (15)
src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java (5)
36-39: 역할 Enum을 STRING으로 매핑한 점 확인 완료DB 스키마 변경과의 호환성 측면에서 안전합니다.
49-51: 아바타 컬렉션 매핑 구성 적절cascade = ALL + orphanRemoval = true 조합과 addAvatar에서의 역참조 설정 흐름이 일관적입니다.
114-116: 증가 로직 OK단순 증가로직은 문제 없습니다. 현재 도메인에서 상한(cap)이 필요 없다면 그대로 유지해도 됩니다.
118-123: 이전 리뷰 반영: 음수/NULL 방지 가드 추가 확인0 이하에서 감소 차단하도록 수정되어 전체 Unlock 흐름 안정성이 확보되었습니다.
125-128: 양방향 연관관계 설정 적절컬렉션 추가와 함께 avatar.setUser(this) 역참조 설정이 일관적입니다.
src/main/java/com/example/cp_main_be/domain/garden/garden/domain/Garden.java (1)
92-101: 음수 초 클램프 적용 확인경계 조건에서 음수 노출 방지 로직(Math.max) 적용되었습니다. UX/계산 정합성 측면에서 적절합니다.
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java (4)
74-76: 도메인 메서드 위임으로 쿨다운 체크 단순화 — 좋습니다서비스 레이어에서 시간 계산 제거되어 가독성과 테스트 용이성이 좋아졌습니다.
111-117: 일일 친구 물주기 횟수 제한 체크 OKSeoul 자정 기준으로 카운트하는 의도가 명확합니다.
152-172: 해금 카운트 감소 순서 수정 — 상태 불일치 리스크 해소정원 조회/해금 성공 후에만 decrement하도록 바뀌어 이전 리뷰 이슈가 해소되었습니다.
195-198: 자정 기준 헬퍼 분리 좋습니다시간대 명시(Asia/Seoul)와 일관성 확보에 도움이 됩니다.
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java (2)
65-71: LGTM! 이전 리뷰 피드백이 올바르게 반영되었습니다.
excludePostId가null일 때 SQL NULL 비교 문제를 해결하기 위해(:excludePostId IS NULL OR d.diary_id <> :excludePostId)조건을 사용한 것이 완벽합니다. 이제excludePostId가null로 전달되어도 올바르게 공개 일기들을 조회할 수 있습니다.
74-74: LGTM! ID 목록 기반 배치 조회 메서드가 적절합니다.
findAllByIdIn메서드는 랜덤 피드 구현에서 ID 목록으로 Diary 엔티티들을 효율적으로 배치 조회하는 데 사용됩니다. Spring Data JPA의 표준 메서드 네이밍을 따르고 있어 적절합니다.src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java (3)
104-123: 이전 리뷰 피드백이 올바르게 반영되었습니다.
size <= 0일 때 조기 반환하고,diarySize > 0과avatarPostSize > 0조건을 통해PageRequest.of(page, 0)호출을 방지한 것이 좋습니다. 이제size=1이나 작은 값에서도 예외가 발생하지 않습니다.
168-168: Collections.shuffle() 사용이 적절합니다.랜덤 피드의 특성상 매번 다른 순서로 항목들을 섞는 것이 사용자 경험에 도움이 됩니다.
Collections.shuffle()을 사용한 것이 적절합니다.
130-131: countLikesByTargetIds 구현 및 반환 타입 정상 확인 default 메서드로 존재하며 Map<Long, Long>을 반환합니다.
| @Transactional | ||
| public void waterFriendGarden(Long actorId, Garden garden) { | ||
| User actor = | ||
| userRepository | ||
| .findById(actorId) | ||
| .orElseThrow(() -> new CustomApiException(ErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| // 낮 12시 이후 -> 그날의 12시 날짜 반환, 이전 -> 전날의 12시 날짜 반환 | ||
| LocalDateTime startOfWateringDay = getStartOfCurrentWateringDay(); | ||
|
|
||
| // 1. 하루에 3회 제한 체크 | ||
| checkFriendWateringLimit(actor, startOfWateringDay); | ||
| LocalDateTime startOfToday = getStartOfToday(); | ||
|
|
||
| // 2. 같은 정원에 하루 한 번 제한 체크 | ||
| checkAlreadyWateredToday(actor, garden, startOfWateringDay); | ||
| checkFriendWateringLimit(actor, startOfToday); | ||
| checkAlreadyWateredToday(actor, garden, startOfToday); | ||
|
|
||
| // 남한테 주는 경우에는 준 사람이 물 경험치를 받고 정원의 waterCount가 증가한다. | ||
| wishTreeService.addPointsToWishTree(actor.getId(), WATERING_POINTS); | ||
| garden.increaseWaterCount(); | ||
|
|
||
| // [수정] 시간대(Timezone) 문제를 방지하기 위해, 물 준 시간을 명시적으로 기록합니다. | ||
| LocalDateTime nowInSeoul = LocalDateTime.now(ZoneId.of("Asia/Seoul")); | ||
|
|
||
| // 물주기 활동 기록 | ||
| FriendWateringLog log = | ||
| FriendWateringLog.builder() | ||
| .waterGiver(actor) | ||
| .wateredGarden(garden) | ||
| .wateredAt(nowInSeoul) // @CreatedDate 대신 직접 시간을 설정합니다. | ||
| .wateredAt(nowInSeoul) | ||
| .build(); | ||
| friendWateringLogRepository.save(log); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
동시성: 친구 물주기 중복 처리 레이스 조건
두 요청이 동시에 들어오면 limit/exists 체크를 모두 통과한 뒤 로그가 2건 저장될 수 있고 포인트가 중복 적립될 수 있습니다. 애플리케이션 레벨 체크만으로는 원자성을 보장하기 어렵습니다.
대안:
- DB 고유 제약 도입(권장): (giver, garden, watering_day) 유니크. watering_day는 Asia/Seoul 기준의 날짜 컬럼(또는 함수 인덱스)로 설계. 충돌 시 DataIntegrityViolationException을 ALREADY_WATERED_GARDEN으로 변환.
- 또는 비관적 락: Garden 또는 (giver,garden) 키에 대해 PESSIMISTIC_WRITE로 단일화 처리.
선택지 확정 시 필요한 DDL/코드 수정 제안 드리겠습니다.
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/garden/garden/service/GardenService.java
around lines 85-109, concurrent requests can bypass the in-memory checks and
create duplicate FriendWateringLog rows and duplicate point awards; add a
DB-level uniqueness constraint on (water_giver_id, watered_garden_id,
watering_day) where watering_day is computed in Asia/Seoul (either a persisted
DATE column set from now(ZoneId.of("Asia/Seoul")) or a DB function-based index),
and update service code to handle DataIntegrityViolationException (or the
specific SQL constraint exception) by translating it to ALREADY_WATERED_GARDEN;
alternatively, if you prefer locks, obtain a PESSIMISTIC_WRITE lock on the
relevant Garden or a composite owner/garden lock within the same @Transactional
boundary before performing checks and inserts to serialize concurrent writers.
| @Builder.Default private Integer unlockableGardenCount = 0; | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
엔티티 기본값(0) ↔ DB 기본값(1) 불일치: 하나로 맞추세요
User.unlockableGardenCount가 엔티티 기본값 0인데, 마이그레이션은 DEFAULT 1입니다. JPA가 0을 명시적으로 INSERT하면 DB DEFAULT(1)이 적용되지 않아 생성 시점부터 불일치가 납니다. 비즈니스 정책에 맞춰 하나로 통일하세요.
두 가지 대안 중 하나를 적용하세요.
- 엔티티를 DB 기본값(1)에 맞춤
- @Builder.Default private Integer unlockableGardenCount = 0;
+ @Builder.Default private Integer unlockableGardenCount = 1;
@@
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
if (this.status == null) this.status = UserStatus.ACTIVE;
if (this.role == null) this.role = Role.USER;
+ if (this.unlockableGardenCount == null) this.unlockableGardenCount = 1;
}- DB 마이그레이션을 엔티티 기본값(0)에 맞춤
V3__refactor_garden_unlock_logic.sql에서 DEFAULT 1 → 0 변경:
-ALTER TABLE users ADD COLUMN unlockable_garden_count INT NOT NULL DEFAULT 1;
+ALTER TABLE users ADD COLUMN unlockable_garden_count INT NOT NULL DEFAULT 0;정책 확정이 필요하면 알려주세요. 둘 중 하나로 정리되면 관련 서비스/테스트도 함께 검증 권장합니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/main/java/com/example/cp_main_be/domain/member/user/domain/User.java
around lines 82-83, the entity default unlockableGardenCount is 0 while the DB
migration V3__refactor_garden_unlock_logic.sql sets DEFAULT 1, causing
inconsistency on insert; pick and apply one of two fixes: (A) align the entity
to DB by changing the @Builder.Default value to 1 (and update any tests/services
expecting 0), or (B) change the migration to DEFAULT 0 so the DB default matches
the entity (update the SQL file and re-run/verify migrations and tests); after
choosing, run unit/integration tests and adjust any service logic that assumes
the previous default.
src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java
Outdated
Show resolved
Hide resolved
src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java
Outdated
Show resolved
Hide resolved
src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java
Show resolved
Hide resolved
| @@ -0,0 +1,8 @@ | |||
| -- users 테이블에 정원 해금 카운트 컬럼 추가하고 기본값을 1로 설정 | |||
| ALTER TABLE users ADD COLUMN unlockable_garden_count INT NOT NULL DEFAULT 1; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
User.unlockable_garden_count 기본값, 엔티티와 불일치
엔티티는 0, 마이그레이션은 DEFAULT 1입니다. 신규 사용자 초기 상태가 어긋나니 반드시 통일하세요. (User.java 코멘트 참고)
정책이 1이라면 엔티티 기본값을 1로, 0이라면 이 DDL을 DEFAULT 0으로 바꾸세요.
🤖 Prompt for AI Agents
In src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql around
line 2, the migration sets DEFAULT 1 for unlockable_garden_count but the User
entity default is 0; make them consistent. Inspect User.java to confirm the
intended policy: if the policy is that new users start with 1, update the entity
default to 1; if new users should start with 0, change this DDL to use DEFAULT 0
and update any existing rows accordingly (e.g., backfill current users to the
intended value and set the column NOT NULL constraint and default to the agreed
value).
src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (8)
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java (3)
65-71: NULL 예외 처리 반영은 좋습니다. 다만 ORDER BY RAND()는 대용량에서 병목입니다.RAND() 정렬은 테이블 풀스캔+정렬로 고비용입니다. 트래픽/데이터 규모에 따라 캐싱, 샘플링, 또는 사전 랜덤키 컬럼(예: random_seed) 기반 접근을 고려해 주세요.
73-75: findAllByIdIn은 입력 순서를 보장하지 않습니다.랜덤 ID로 조회 후 응답 순서를 유지해야 한다면 서비스 레이어에서 입력 ID 순서대로 재정렬이 필요합니다. 또한 JpaRepository의 findAllById로 대체 가능해 보입니다.
77-78: 주석과 실제 쿼리가 불일치합니다.“comments까지 fetch”라고 주석에 있으나 실제로는 user만 fetch합니다. 주석을 정정하거나, 컬렉션 fetch-join은 페이지네이션과 상충할 수 있으니 의도적으로 제외했다는 설명으로 보완해 주세요.
src/main/java/com/example/cp_main_be/domain/social/comment/dto/CommentCountDto.java (1)
6-19: 불변 DTO로 단순화하세요 (record 권장).setter 불필요합니다. 불변으로 만들면 안전하고 의도가 분명해집니다. JDK 17+라면 record 사용을 권장합니다.
두 가지 옵션 중 하나를 적용하세요.
옵션 A: record로 전환
-package com.example.cp_main_be.domain.social.comment.dto; - -import lombok.Getter; -import lombok.Setter; - -@Setter -@Getter -public class CommentCountDto { - // Setters (필요한 경우) - // Getters - private Long targetId; - private Long count; - - // JPA 쿼리에서 사용할 생성자 - public CommentCountDto(Long targetId, Long count) { - this.targetId = targetId; - this.count = count; - } -} +package com.example.cp_main_be.domain.social.comment.dto; + +public record CommentCountDto(Long targetId, Long count) {}옵션 B: 불변 필드 + Getter만
-import lombok.Getter; -import lombok.Setter; - -@Setter -@Getter -public class CommentCountDto { - // Setters (필요한 경우) - // Getters - private Long targetId; - private Long count; +import lombok.Getter; + +@Getter +public class CommentCountDto { + private final Long targetId; + private final Long count;src/main/java/com/example/cp_main_be/domain/social/comment/domain/repository/CommentRepository.java (2)
12-19: 빈 ID 목록 처리와 정렬 일관성 확인 필요
- avatarPostIds가 빈 리스트면 구현체에 따라 예외가 될 수 있어 사전 단락을 권장합니다.
- 결과 정렬이 비결정적입니다. 필요 시 ID 기준 정렬을 추가하세요.
@Query( "SELECT new com.example.cp_main_be.domain.social.comment.dto.CommentCountDto(c.avatarPost.id, COUNT(c.id)) " + "FROM Comment c " + "WHERE c.avatarPost.id IN :avatarPostIds " - + "GROUP BY c.avatarPost.id") + + "GROUP BY c.avatarPost.id " + + "ORDER BY c.avatarPost.id")또한 댓글이 0개인 대상은 결과에 포함되지 않습니다. 0 포함이 필요하면 AvatarPost 기준 LEFT JOIN 쿼리를 별도 리포지토리에서 사용하는 방안을 검토해 주세요.
21-27: 동일 사항: 빈 리스트 가드 + 정렬위와 동일한 이유로 정렬 추가를 고려하고, 빈 diaryIds는 호출부에서 가드하세요.
@Query( "SELECT new com.example.cp_main_be.domain.social.comment.dto.CommentCountDto(c.diary.id, COUNT(c.id)) " + "FROM Comment c " + "WHERE c.diary.id IN :diaryIds " - + "GROUP BY c.diary.id") + + "GROUP BY c.diary.id " + + "ORDER BY c.diary.id")src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java (2)
41-42: 주석과 반환 타입이 불일치합니다.해당 주석은 FeedItemResponse를 언급하나 메서드는 List를 반환합니다. 혼동을 줄이도록 주석을 정리하세요.
47-54: 인증 사용자 미사용getRandomFeed에서 @AuthenticationPrincipal user를 주입하지만 사용하지 않습니다. 차단 사용자 필터링 등 정책 적용이 필요하다면 서비스로 전달하세요. 필요 없다면 파라미터 제거를 고려하세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/comment/domain/repository/CommentRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/comment/dto/CommentCountDto.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java(3 hunks)src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/main/resources/db/migration/V3__refactor_garden_unlock_logic.sql
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java (1)
src/main/java/com/example/cp_main_be/global/exception/UserNotFoundException.java (1)
UserNotFoundException(3-7)
🔇 Additional comments (4)
src/main/java/com/example/cp_main_be/domain/social/like/service/LikeService.java (3)
44-49: flush 추가로 중복 좋아요 예외를 즉시 감지할 수 있게 되었네요.
save 후 flush를 호출해주셔서 유니크 제약 위반이 트랜잭션 종료 시점이 아니라 여기서 바로 터지고, catch 블록에서 친절한 메시지로 래핑할 수 있게 됐습니다. 👍
88-90: 실제 카운트를 반환하도록 바뀐 부분이 명확합니다.
DB에서 직접 count를 읽어오므로 도메인 객체 내부 상태와 API 응답이 자연스럽게 일치하네요.
111-112: 삭제 후에도 동일한 카운트 계산 흐름을 맞춘 점이 좋습니다.
삭제 트랜잭션 내에서 count 쿼리가 자동으로 flush를 유도하기 때문에 최신 값이 반환됩니다.src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java (1)
34-36: cursor 미전달 시 초기 로드 동작 확인 필요현재 optional 파라미터인데 null이면 Repository 레벨에서 전체가 필터링될 수 있으므로(별도 수정 없으면), 서비스에서 now()로 대체하거나 쿼리 NULL‑세이프 처리 여부를 확인해 주세요.
| "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC") | ||
| List<Diary> findPublicDiariesWithCursor(@Param("cursor") LocalDateTime cursor, Pageable pageable); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
초기 호출 시 cursor=null이면 결과가 비어집니다. NULL‑세이프 조건 필요
cursor 미전달 시 WHERE d.createdAt < :cursor가 전부 걸러집니다. 다음과 같이 수정하세요.
- @Query(
- "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC")
+ @Query(
+ "SELECT d FROM Diary d JOIN FETCH d.user " +
+ "WHERE d.isPublic = true " +
+ "AND (:cursor IS NULL OR d.createdAt < :cursor) " +
+ "ORDER BY d.createdAt DESC")📝 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.
| "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC") | |
| List<Diary> findPublicDiariesWithCursor(@Param("cursor") LocalDateTime cursor, Pageable pageable); | |
| @Query( | |
| "SELECT d FROM Diary d JOIN FETCH d.user " + | |
| "WHERE d.isPublic = true " + | |
| "AND (:cursor IS NULL OR d.createdAt < :cursor) " + | |
| "ORDER BY d.createdAt DESC") | |
| List<Diary> findPublicDiariesWithCursor(@Param("cursor") LocalDateTime cursor, Pageable pageable); |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java
around lines 79-81, the JPQL query uses "d.createdAt < :cursor" which returns no
rows when cursor is null; make the condition null-safe by changing the WHERE
clause to allow all rows when :cursor is null (for example replace "d.createdAt
< :cursor" with "(:cursor IS NULL OR d.createdAt < :cursor)"), keep the JOIN
FETCH and pagination as-is, and ensure the method parameter remains
@Param("cursor") LocalDateTime cursor.
.../com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java
Show resolved
Hide resolved
| // 커서가 없으면(최초 요청) 현재 시간으로 설정 | ||
| if (cursor == null) { | ||
| cursor = LocalDateTime.now(); | ||
| } | ||
| Pageable pageable = PageRequest.of(0, size); // 각 소스에서 size만큼만 가져옴 | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
size 파라미터 검증 없이 PageRequest.of(...) 호출됨
size가 0 이하로 들어오면 PageRequest.of(0, size)에서 즉시 IllegalArgumentException("Page size must not be less than one!")가 발생해 API가 500을 반환합니다. 외부 요청 파라미터인 만큼 먼저 유효성을 검사해 예외를 방지해주세요.
if (cursor == null) {
cursor = LocalDateTime.now();
}
+
+ if (size <= 0) {
+ throw new IllegalArgumentException("size must be greater than zero");
+ }
+
Pageable pageable = PageRequest.of(0, size); // 각 소스에서 size만큼만 가져옴📝 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.
| // 커서가 없으면(최초 요청) 현재 시간으로 설정 | |
| if (cursor == null) { | |
| cursor = LocalDateTime.now(); | |
| } | |
| Pageable pageable = PageRequest.of(0, size); // 각 소스에서 size만큼만 가져옴 | |
| // 커서가 없으면(최초 요청) 현재 시간으로 설정 | |
| if (cursor == null) { | |
| cursor = LocalDateTime.now(); | |
| } | |
| if (size <= 0) { | |
| throw new IllegalArgumentException("size must be greater than zero"); | |
| } | |
| Pageable pageable = PageRequest.of(0, size); // 각 소스에서 size만큼만 가져옴 |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java
around lines 50 to 55, PageRequest.of(0, size) is called without validating the
incoming size parameter which causes IllegalArgumentException when size <= 0;
add validation before creating the Pageable: check that size is > 0 and either
replace invalid values with a sane default (e.g. 1 or a configured max) or throw
a controlled exception mapped to HTTP 400 (e.g.
InvalidArgumentException/BadRequest) with a clear message, then only call
PageRequest.of with the validated size.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java (2)
73-74: 연관 엔티티 페치 전략 고려 필요현재 메서드는 ID 목록으로 Diary만 조회합니다. 만약 호출 측에서
user,comments,diaryImage등의 연관 엔티티에 접근한다면 N+1 쿼리가 발생할 수 있습니다. 사용 패턴에 따라@EntityGraph또는 별도의 JOIN FETCH 쿼리 메서드 추가를 고려하세요.예시:
@Query("SELECT DISTINCT d FROM Diary d LEFT JOIN FETCH d.user LEFT JOIN FETCH d.diaryImage WHERE d.id IN :ids") List<Diary> findAllByIdInWithDetails(@Param("ids") List<Long> ids);
90-108: SpEL 표현식 일관성 확인 및 런타임 검증 요청
- AvatarPostRepository(src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java)에도 동일한 SpEL 패턴(
:#{#excludeIds == null} = true,:#{#excludeIds.isEmpty()} = true)이 사용되고 있습니다.- Spring Data JPA가 네이티브 쿼리 내 SpEL을 런타임에 올바르게 평가하는지 통합 테스트로 검증하세요.
- (선택) 가독성 개선을 위해 조건을
:#{#excludeIds == null or #excludeIds.isEmpty()} = true또는COALESCE(:#{#excludeIds.size()}, 0) = 0형태로 단순화할 수 있습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/dto/request/RandomFeedRequest.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/dto/response/FeedScrollResponse.java(1 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java(2 hunks)src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java (1)
src/main/java/com/example/cp_main_be/global/exception/UserNotFoundException.java (1)
UserNotFoundException(3-7)
🔇 Additional comments (11)
src/main/java/com/example/cp_main_be/domain/social/feed/dto/response/FeedScrollResponse.java (1)
8-13: LGTM! 스크롤 응답 DTO 구조가 적절합니다.랜덤 피드의 무한 스크롤을 위한 응답 구조가 올바르게 설계되었습니다.
items와hasMore플래그를 통해 클라이언트가 추가 데이터 존재 여부를 판단할 수 있습니다.src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java (1)
33-35: LGTM! 커서 기반 페이지네이션이 올바르게 구현되었습니다.
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)를 사용하여 ISO 8601 형식의 커서를 수신하고,size기본값 20으로 설정하여 합리적인 페이지 크기를 제공합니다.src/main/java/com/example/cp_main_be/domain/social/avatarpost/domain/repository/AvatarPostRepository.java (3)
43-52: LGTM!@EntityGraph를 활용한 N+1 문제 해결이 적절합니다.커서 기반 페이지네이션 메서드에서
@EntityGraph(attributePaths = {"user"})를 사용하여 연관된 User 엔티티를 즉시 로딩함으로써 N+1 문제를 효과적으로 방지하고 있습니다.
55-70: LGTM! null 처리가 올바르게 구현되었습니다.SpEL 표현식을 사용하여
excludeIds가 null이거나 비어있을 때 필터링을 건너뛰도록 구현되어, 이전 메서드의 null 처리 문제를 해결했습니다. 이 메서드가 30-36번 라인의 구 메서드를 대체해야 합니다.
30-36: 이전 리뷰에서 지적된 null 처리 문제가 여전히 존재합니다.
excludePostId가 null일 때ap.id != :excludePostId는 SQL에서UNKNOWN을 반환하여 모든 행을 필터링합니다. 이 문제는 이전 리뷰에서 이미 지적되었으며, 55-70번 라인의 새로운 메서드findRandomPublicAvatarPostIdsExcluding에서는 올바르게 처리되고 있습니다.다음 중 하나를 선택하세요:
옵션 1 (권장): 이 메서드를 deprecated 처리하고 새 메서드 사용
+ @Deprecated @Query( value = "SELECT ap.id FROM avatar_post ap WHERE ap.id != :excludePostId ORDER BY RAND()", nativeQuery = true) List<Long> findRandomPublicAvatarPostIds( @Param("excludePostId") Long excludePostId, Pageable pageable);옵션 2: null 처리 로직 추가
@Query( - value = "SELECT ap.id FROM avatar_post ap WHERE ap.id != :excludePostId ORDER BY RAND()", + value = "SELECT ap.id FROM avatar_post ap WHERE (:excludePostId IS NULL OR ap.id <> :excludePostId) ORDER BY RAND()", nativeQuery = true)src/main/java/com/example/cp_main_be/domain/social/feed/service/FeedService.java (4)
82-84: LGTM! 랜덤 피드에서 size 검증이 적절히 구현되었습니다.유효하지 않은
size값에 대해 조기 반환하여 불필요한 처리를 방지하고 있습니다.
180-224: LGTM! 배치 쿼리를 통한 N+1 문제 해결이 우수합니다.
fetchAndMapFeedItems메서드에서 좋아요 수와 댓글 수를 타입별로 배치 조회하여 이전 리뷰에서 지적된 N+1 문제를 효과적으로 해결했습니다.likeRepository.countLikesByTargetIds와commentRepository.countCommentsByDiaryIds/countCommentsByAvatarPostIds를 사용한 접근 방식이 적절합니다.
151-178: LGTM! 두 정렬된 리스트의 병합 로직이 효율적입니다.시간 순으로 정렬된 Diary와 AvatarPost 리스트를 투 포인터 방식으로 병합하여 O(n+m) 시간 복잡도로 처리하고 있습니다. 각 소스가 소진된 경우를 올바르게 처리하고 있습니다.
46-50: 이전 리뷰에서 지적된 size 검증 누락 문제가 여전히 존재합니다.
size가 0 이하로 전달되면PageRequest.of(0, size)에서IllegalArgumentException("Page size must not be less than one!")이 발생하여 API가 500 에러를 반환합니다.getRandomFeed에서는 82-84번 라인에서 검증하고 있지만,getFeed에서는 검증이 누락되었습니다.다음 diff를 적용하여 검증을 추가하세요:
if (cursor == null) { cursor = LocalDateTime.now(); } + + if (size <= 0) { + throw new IllegalArgumentException("size must be greater than zero"); + } Pageable pageable = PageRequest.of(0, size);src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java (2)
65-71: NULL 처리가 올바르게 적용되었습니다이전 리뷰에서 지적된
excludePostId가 NULL일 때의 문제가 정확히 수정되었습니다.(:excludePostId IS NULL OR d.diary_id <> :excludePostId)조건으로 NULL이 전달될 때 모든 공개 다이어리를 반환하고, 값이 있을 때는 해당 ID를 제외하는 로직이 올바르게 구현되어 있습니다.
78-80: cursor가 NULL일 때 빈 결과가 반환됩니다 (이전 리뷰 미해결)이전 리뷰에서도 지적되었지만, 현재 쿼리는
d.createdAt < :cursor조건만 사용하고 있어cursor가 NULL로 전달되면 (첫 페이지 조회 시) SQL에서< NULL비교가 항상 UNKNOWN이 되어 모든 레코드가 필터링됩니다.다음과 같이 NULL-safe 조건을 추가하세요:
@Query( - "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC") + "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.isPublic = true " + + "AND (:cursor IS NULL OR d.createdAt < :cursor) " + + "ORDER BY d.createdAt DESC")
| // 커서 기반 페이지네이션을 위한 메서드 (팔로잉 피드용) | ||
| @Query( | ||
| "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.user IN :followingUsers AND d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC") | ||
| List<Diary> findFollowingDiariesWithCursor( | ||
| @Param("followingUsers") List<User> followingUsers, | ||
| @Param("cursor") LocalDateTime cursor, | ||
| Pageable pageable); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cursor NULL 처리 및 빈 followingUsers 리스트 처리 필요 (이전 리뷰 미해결)
이전 리뷰에서 지적된 두 가지 문제가 여전히 남아있습니다:
- cursor NULL 문제:
d.createdAt < :cursor조건이 cursor가 NULL일 때 항상 false가 되어 빈 결과를 반환합니다. - 빈 리스트 문제:
followingUsers가 빈 리스트일 경우IN ()절이 DB에 따라 예외를 발생시키거나 잘못된 결과를 반환할 수 있습니다.
다음과 같이 수정하세요:
@Query(
- "SELECT d FROM Diary d JOIN FETCH d.user WHERE d.user IN :followingUsers AND d.isPublic = true AND d.createdAt < :cursor ORDER BY d.createdAt DESC")
+ "SELECT d FROM Diary d JOIN FETCH d.user " +
+ "WHERE d.user IN :followingUsers " +
+ "AND d.isPublic = true " +
+ "AND (:cursor IS NULL OR d.createdAt < :cursor) " +
+ "ORDER BY d.createdAt DESC")추가로, 서비스 레이어에서 followingUsers가 빈 리스트일 때 이 메서드를 호출하지 않도록 사전 체크를 추가하세요:
if (followingUsers.isEmpty()) {
return Collections.emptyList();
}🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/diary/domain/repository/DiaryRepository.java
around lines 82 to 88, the query fails to handle null cursor and empty
followingUsers: change the JPQL to guard cursor (use OR :cursor IS NULL or use a
conditional WHERE clause so rows are returned when cursor is null) and avoid
using IN with an empty list (either add a predicate like (:followingUsers IS
EMPTY OR d.user IN :followingUsers) or handle empty list before calling the
repository). Also add a service-layer precheck to return Collections.emptyList()
immediately when followingUsers.isEmpty() to prevent executing the query with an
empty collection.
| public class RandomFeedRequest { | ||
| private List<Long> excludeDiaryIds; | ||
| private List<Long> excludeAvatarPostIds; | ||
|
|
||
| @Min(1) | ||
| @Max(50) | ||
| private int size = 10; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
컨트롤러에서 @Valid 검증 누락 확인이 필요합니다.
size 필드에 @Min(1), @Max(50) 제약이 정의되어 있지만, FeedController.getRandomFeed에서 @Valid 어노테이션이 적용되지 않으면 검증이 실행되지 않습니다.
다음 스크립트로 컨트롤러에서 @Valid가 적용되었는지 확인하세요:
🏁 Script executed:
#!/bin/bash
# Description: FeedController의 getRandomFeed 메서드에서 @Valid 사용 여부 확인
rg -nP -A3 '@PostMapping\s*\(\s*"/random"' src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.javaLength of output: 237
FeedController.getRandomFeed 메서드에 @Valid 애노테이션 추가 필요
@RequestBody RandomFeedRequest request 파라미터에 @Valid를 적용해야 @Min·@Max 제약이 정상적으로 동작합니다.
예시:
public ResponseEntity<ApiResponse<FeedScrollResponse>> getRandomFeed(
@AuthenticationPrincipal User user,
@Valid @RequestBody RandomFeedRequest request) {
…
}🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/social/feed/dto/request/RandomFeedRequest.java
around lines 13-20, the request DTO uses @Min/@Max but validation won’t run
unless the controller parameter is annotated with @Valid; update
FeedController.getRandomFeed method signature to annotate the RandomFeedRequest
parameter with @Valid (e.g., public ResponseEntity<?>
getRandomFeed(@AuthenticationPrincipal User user, @Valid @RequestBody
RandomFeedRequest request)), and add the necessary import for
javax.validation.Valid in the controller so the size constraints are enforced.
| @Operation(summary = "랜덤 피드 조회 (무한 스크롤용)", description = "상세보기 화면에서 하단에 표시될 랜덤 피드를 불러옵니다") | ||
| @PostMapping("/random") // GET → POST로 변경 | ||
| public ResponseEntity<ApiResponse<FeedScrollResponse>> getRandomFeed( | ||
| @AuthenticationPrincipal User user, | ||
| @RequestBody RandomFeedRequest request) { // @RequestBody로 변경 | ||
|
|
||
| FeedScrollResponse response = | ||
| feedService.getRandomFeed( | ||
| request.getExcludeDiaryIds() != null | ||
| ? request.getExcludeDiaryIds() | ||
| : Collections.emptyList(), | ||
| request.getExcludeAvatarPostIds() != null | ||
| ? request.getExcludeAvatarPostIds() | ||
| : Collections.emptyList(), | ||
| request.getSize()); | ||
|
|
||
| return ResponseEntity.ok(ApiResponse.success(response)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Valid 어노테이션을 추가하여 요청 검증을 활성화하세요.
RandomFeedRequest에 @Min(1), @Max(50) 제약이 정의되어 있지만, 컨트롤러에서 @Valid 어노테이션이 누락되어 검증이 실행되지 않습니다. size가 0 이하 또는 50 초과 값으로 들어올 경우 런타임 오류나 비정상 동작을 유발할 수 있습니다.
다음 diff를 적용하여 검증을 활성화하세요:
+import jakarta.validation.Valid;
+
@PostMapping("/random")
public ResponseEntity<ApiResponse<FeedScrollResponse>> getRandomFeed(
@AuthenticationPrincipal User user,
- @RequestBody RandomFeedRequest request) {
+ @RequestBody @Valid RandomFeedRequest request) {🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/social/feed/presentation/FeedController.java
around lines 44 to 61, the @Valid annotation is missing on the @RequestBody
RandomFeedRequest so bean validation (e.g. @Min/@Max on size) won't run; add
@Valid before the @RequestBody parameter (and import javax.validation.Valid or
jakarta.validation.Valid as appropriate) to enable request validation, and
ensure the validation provider dependency (hibernate-validator) is on the
classpath.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
🧹 Nitpick comments (4)
src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java (1)
5-8: 불변성 고려 권장DTO는 일반적으로 불변 객체로 설계하는 것이 안전합니다.
@Getter와 함께 필드를private final로 선언하고 생성자를 통해 초기화하는 것을 권장합니다.+import lombok.AllArgsConstructor; + @Getter +@AllArgsConstructor public class AddKeywordRequest { - String keyword; + private final String keyword; }src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java (2)
13-13: 와일드카드 임포트 지양와일드카드 임포트(
import org.springframework.web.bind.annotation.*)는 코드 가독성을 떨어뜨리고 네임스페이스 충돌 가능성을 높입니다. 명시적 임포트를 사용하는 것이 권장됩니다.-import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController;
37-42: HTTP 상태 코드 개선 고려리소스 생성 엔드포인트는 200 OK 대신 201 Created를 반환하는 것이 REST 규칙에 더 적합합니다. 필요시 생성된 리소스의 위치를 Location 헤더로 제공할 수도 있습니다.
- public ResponseEntity<Void> addKeyword(@Valid @RequestBody AddKeywordRequest request) { - keywordService.addNewKeyword(request); - return ResponseEntity.ok().build(); + public ResponseEntity<Void> addKeyword(@Valid @RequestBody AddKeywordRequest request) { + keywordService.addNewKeyword(request); + return ResponseEntity.status(HttpStatus.CREATED).build(); }src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java (1)
31-60: 성능 최적화 고려
getTodayKeyword()가 호출될 때마다count()쿼리와 페이징 쿼리를 실행합니다. 키워드가 자주 변경되지 않는다면 결과를 캐싱하여 데이터베이스 부하를 줄이는 것을 고려하세요.+import org.springframework.cache.annotation.Cacheable; + +@Cacheable(value = "todayKeyword", key = "#root.method.name + T(java.time.LocalDate).now()") public TodayKeywordResponse getTodayKeyword() { // 기존 로직 }캐시 설정을 application.yml에 추가:
spring: cache: type: caffeine caffeine: spec: maximumSize=100,expireAfterWrite=1h
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java(1 hunks)src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java(4 hunks)src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java(2 hunks)
🔇 Additional comments (2)
src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java (1)
37-42: 인증 및 권한 검증 누락키워드 추가 엔드포인트에 인증 및 권한 검사가 없어 누구나 키워드를 추가할 수 있습니다. 관리자 권한 확인 등의 보안 조치가 필요합니다.
다음 스크립트를 실행하여 다른 관리 엔드포인트에서 사용하는 인증 패턴을 확인하세요:
src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java (1)
25-29: 중복 키워드 처리 누락동일한 키워드가 여러 번 추가될 수 있습니다. 중복 방지를 위해 저장 전에 존재 여부를 확인하거나 데이터베이스에 UNIQUE 제약 조건을 추가하는 것이 좋습니다.
다음 스크립트로 Keyword 엔티티의 제약 조건을 확인하세요:
중복 체크 로직을 추가하려면:
public void addNewKeyword(AddKeywordRequest request) { + if (keywordRepository.existsByKeyword(request.getKeyword())) { + throw new IllegalArgumentException("이미 존재하는 키워드입니다: " + request.getKeyword()); + } Keyword newKeyword = Keyword.builder().keyword(request.getKeyword()).build(); keywordRepository.save(newKeyword); }
|
|
||
| @Getter | ||
| public class AddKeywordRequest { | ||
| String keyword; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
필드 검증 추가 필요
keyword 필드에 유효성 검증이 없어 null, 빈 문자열, 또는 과도하게 긴 값이 전달될 수 있습니다. 데이터 무결성 및 보안을 위해 검증 애노테이션을 추가하세요.
다음 diff를 적용하여 검증을 추가하세요:
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
@Getter
public class AddKeywordRequest {
- String keyword;
+ @NotBlank(message = "키워드는 필수입니다")
+ @Size(max = 50, message = "키워드는 50자를 초과할 수 없습니다")
+ private String keyword;
}Controller에서도 @Valid 애노테이션을 추가해야 합니다:
-public ResponseEntity<Void> addKeyword(@RequestBody AddKeywordRequest request) {
+public ResponseEntity<Void> addKeyword(@Valid @RequestBody AddKeywordRequest request) {📝 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.
| String keyword; | |
| // At the top of src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java | |
| import jakarta.validation.constraints.NotBlank; | |
| import jakarta.validation.constraints.Size; | |
| import lombok.Getter; | |
| @Getter | |
| public class AddKeywordRequest { | |
| @NotBlank(message = "키워드는 필수입니다") | |
| @Size(max = 50, message = "키워드는 50자를 초과할 수 없습니다") | |
| private String keyword; | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/keyword/dto/request/AddKeywordRequest.java
around line 7, the keyword field lacks validation allowing null/empty/overly
long values; add Bean Validation annotations to the field (e.g., @NotNull or
@NotBlank and @Size(max = <reasonable_length>)) to enforce presence and length
constraints, import the corresponding javax.validation or jakarta.validation
annotations, and update any constructors/getters if needed; additionally, in the
controller method that accepts AddKeywordRequest, add the @Valid annotation to
the request parameter so validation is triggered and ensure the controller
handles MethodArgumentNotValidException or relies on a global exception handler.
|
|
||
| @Operation(summary = "키워드 추가", description = "db에 키워드를 추가합니다") | ||
| @PostMapping | ||
| public ResponseEntity<Void> addKeyword(@RequestBody AddKeywordRequest request) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
요청 검증 누락
@RequestBody에 @Valid 애노테이션이 없어 DTO의 검증 제약 조건이 실행되지 않습니다. AddKeywordRequest에 검증 애노테이션을 추가한 경우 반드시 @Valid를 추가해야 합니다.
-public ResponseEntity<Void> addKeyword(@RequestBody AddKeywordRequest request) {
+public ResponseEntity<Void> addKeyword(@Valid @RequestBody AddKeywordRequest request) {📝 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.
| public ResponseEntity<Void> addKeyword(@RequestBody AddKeywordRequest request) { | |
| public ResponseEntity<Void> addKeyword(@Valid @RequestBody AddKeywordRequest request) { |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/keyword/presentation/KeywordController.java
at line 39, the controller method parameter is missing @Valid so
AddKeywordRequest validation constraints are not executed; add the @Valid
annotation to the @RequestBody parameter (and import the correct
javax.validation.Valid or jakarta.validation.Valid) so Spring will trigger DTO
validation, and if you rely on programmatic handling ensure you handle
MethodArgumentNotValidException or include a BindingResult parameter as needed.
| } | ||
|
|
||
| // 2. 기준이 될 시작 날짜를 정합니다. (서비스 시작일 등) | ||
| LocalDate startDate = LocalDate.of(2025, 10, 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
시작 날짜가 과거로 설정됨
startDate가 2025-10-01로 설정되어 있지만, 현재는 2025년 9월입니다. 서비스 시작일이 미래 날짜인지 확인하고, 실제 서비스 시작일로 수정하거나 동적으로 처리하는 것을 고려하세요.
서비스 시작일을 설정값으로 관리하는 것을 권장합니다:
+// application.yml에 추가:
+// keyword:
+// service-start-date: 2025-09-26
+
+@Value("${keyword.service-start-date}")
+private LocalDate serviceStartDate;
+
public TodayKeywordResponse getTodayKeyword() {
// ...
- LocalDate startDate = LocalDate.of(2025, 10, 1);
+ LocalDate startDate = serviceStartDate;🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java
around line 41, the startDate is hardcoded to LocalDate.of(2025, 10, 1) which is
no longer correct; replace the hardcoded date with a configurable value (e.g.,
read from application properties or an environment variable) or compute it
dynamically (e.g., LocalDate.now() or a next-valid start date calculation) and
update usages to read that configuration; ensure a sensible default and
validation (must be today or in the future) so the service behaves correctly
without code changes when the start date needs to change.
| // 3. 시작 날짜로부터 오늘까지 며칠이 지났는지 계산합니다. | ||
| long daysSinceStart = ChronoUnit.DAYS.between(startDate, today); | ||
|
|
||
| // 4. (지난 날짜 % 전체 키워드 개수)로 오늘 보여줄 키워드의 순번(index)을 구합니다. | ||
| int keywordIndex = (int) (daysSinceStart % totalKeywords); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
음수 일수 처리 누락
시스템 날짜가 startDate보다 이전인 경우 daysSinceStart가 음수가 되어 modulo 연산 결과가 예상과 다를 수 있습니다. Java의 모듈로 연산은 음수에 대해 음수 결과를 반환할 수 있습니다.
long daysSinceStart = ChronoUnit.DAYS.between(startDate, today);
+
+// 시작일 이전인 경우 처리
+if (daysSinceStart < 0) {
+ return new TodayKeywordResponse("서비스 시작 전입니다.");
+}
+
int keywordIndex = (int) (daysSinceStart % totalKeywords);📝 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.
| // 3. 시작 날짜로부터 오늘까지 며칠이 지났는지 계산합니다. | |
| long daysSinceStart = ChronoUnit.DAYS.between(startDate, today); | |
| // 4. (지난 날짜 % 전체 키워드 개수)로 오늘 보여줄 키워드의 순번(index)을 구합니다. | |
| int keywordIndex = (int) (daysSinceStart % totalKeywords); | |
| // 3. 시작 날짜로부터 오늘까지 며칠이 지났는지 계산합니다. | |
| long daysSinceStart = ChronoUnit.DAYS.between(startDate, today); | |
| // 시작일 이전인 경우 처리 | |
| if (daysSinceStart < 0) { | |
| return new TodayKeywordResponse("서비스 시작 전입니다."); | |
| } | |
| // 4. (지난 날짜 % 전체 키워드 개수)로 오늘 보여줄 키워드의 순번(index)을 구합니다. | |
| int keywordIndex = (int) (daysSinceStart % totalKeywords); |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java
around lines 44 to 48, daysSinceStart can be negative when system date is before
startDate which makes the modulo result negative and yields an incorrect
keywordIndex; fix by normalizing the computed index to a non-negative value
before casting to int — compute the remainder of daysSinceStart modulo
totalKeywords and if that remainder is negative add totalKeywords (or repeatedly
add totalKeywords until non-negative) and then use that normalized value as the
keywordIndex.
| Page<Keyword> keywordPage = keywordRepository.findAll(PageRequest.of(keywordIndex, 1)); | ||
|
|
||
| if (keywordPage.hasContent()) { | ||
| String todayKeyword = keywordPage.getContent().get(0).getKeyword(); | ||
| return new TodayKeywordResponse(todayKeyword); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
정렬 기준 없는 페이징 - 순서 보장 불가
PageRequest.of(keywordIndex, 1)에 정렬 기준이 없어 데이터베이스 반환 순서가 예측 불가능합니다. ID나 생성일시 기준 정렬을 명시해야 매일 일관된 키워드를 반환할 수 있습니다.
+import org.springframework.data.domain.Sort;
+
-Page<Keyword> keywordPage = keywordRepository.findAll(PageRequest.of(keywordIndex, 1));
+Page<Keyword> keywordPage = keywordRepository.findAll(
+ PageRequest.of(keywordIndex, 1, Sort.by("id").ascending())
+);또는 생성일시 기준:
+Page<Keyword> keywordPage = keywordRepository.findAll(
+ PageRequest.of(keywordIndex, 1, Sort.by("createdAt").ascending())
+);📝 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.
| Page<Keyword> keywordPage = keywordRepository.findAll(PageRequest.of(keywordIndex, 1)); | |
| if (keywordPage.hasContent()) { | |
| String todayKeyword = keywordPage.getContent().get(0).getKeyword(); | |
| return new TodayKeywordResponse(todayKeyword); | |
| } | |
| import org.springframework.data.domain.Sort; | |
| Page<Keyword> keywordPage = keywordRepository.findAll( | |
| PageRequest.of(keywordIndex, 1, Sort.by("id").ascending()) | |
| ); | |
| if (keywordPage.hasContent()) { | |
| String todayKeyword = keywordPage.getContent().get(0).getKeyword(); | |
| return new TodayKeywordResponse(todayKeyword); | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/com/example/cp_main_be/domain/mission/keyword/service/KeywordService.java
around lines 51 to 56, the PageRequest.of(keywordIndex, 1) call has no sort so
returned rows are not deterministic; update the PageRequest to include an
explicit Sort (for example Sort.by(Sort.Direction.ASC, "id") or
Sort.by(Sort.Direction.DESC, "createdAt") depending on desired semantics) so the
same keyword is returned predictably each day, i.e. replace the
PageRequest.of(...) call with one that includes Sort.by(...) targeting the
appropriate column name and direction.
#132
Summary by CodeRabbit
New Features
Improvements
Chores