[FEAT] 일반 유저 - 푸드트럭 저장 상태 변경 및 평점 등록 api 구현#17
Conversation
Walkthrough회원 전용 기능(푸드트럭 저장/해제, 저장 목록 조회(커서 페이징), 평점 등록 및 평점 대상 예약 조회)과 이를 뒷받침하는 도메인·레포지토리·서비스·컨트롤러·DTO·검증기, FoodTruck의 RatingInfo 임베디드 도입 및 에러/스웨거·페이징 DTO 갱신을 추가했다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Member
participant Controller
participant MemberService
participant MemberValidator
participant SavedService
participant SavedRepo
participant FoodTruckRepo
Member->>Controller: PATCH /members/me/food-trucks/{id} (isSavedRequest)
Controller->>MemberService: updateFoodTruckSaveStatus(req, id, memberId)
MemberService->>MemberValidator: validateAndGetMember(memberId)
MemberValidator-->>MemberService: User
MemberService->>SavedService: updateFoodTruckSaveStatus(...)
SavedService->>FoodTruckRepo: findById(id)
SavedService->>SavedRepo: existsByMemberAndFoodTruck(...) / findByMemberAndFoodTruck(...)
SavedRepo-->>SavedService: entity / exists
SavedService->>SavedRepo: save(...) / delete(...)
SavedService-->>MemberService: SavedFoodTruckStatusResponse
MemberService-->>Controller: BaseResponse(...)
Controller-->>Member: 200 OK
sequenceDiagram
autonumber
actor Member
participant Controller
participant MemberService
participant MemberValidator
participant RatingService
participant RatingRepo
participant FoodTruckRepo
participant ReservationRepo
Member->>Controller: POST /members/me/ratings (reservationId, foodTruckId, rating)
Controller->>MemberService: registerRatings(req, memberId)
MemberService->>MemberValidator: validateAndGetMember(memberId)
MemberValidator-->>MemberService: User
MemberService->>RatingService: registerRatings(req, user)
RatingService->>ReservationRepo: findById(reservationId)
ReservationRepo-->>RatingService: Reservation?
RatingService->>Reservation: validateCanBeRatedBy(user)
RatingService->>FoodTruckRepo: findById(foodTruckId)
RatingService->>RatingRepo: findByMemberAndFoodTruckAndIsRatedFalse(...)
RatingRepo-->>RatingService: Rating?
alt rating found
RatingService->>Rating: registerRating(rate)
RatingService->>FoodTruck: updateAverageRating(rate)
else not found
RatingService-->>MemberService: throw RATING_NOT_FOUND
end
MemberService-->>Controller: BaseResponse<Void>
Controller-->>Member: 200 OK
sequenceDiagram
autonumber
actor Member
participant Controller
participant MemberService
participant MemberValidator
participant SavedService
participant SavedRepo
Member->>Controller: GET /members/me/food-trucks?cursor=&size=
Controller->>MemberService: getSavedFoodTrucks(cursorPagingRequest, memberId)
MemberService->>MemberValidator: validateAndGetMember(memberId)
MemberValidator-->>MemberService: User
MemberService->>SavedService: getSavedFoodTrucks(...)
SavedService->>SavedRepo: findMemberSavedFoodTruckWithCursor(member, lastCursor, pageable)
SavedRepo-->>SavedService: Slice<SavedFoodTruck>
SavedService-->>MemberService: CursorPagingResponse<SavedFoodTruckResponse>
MemberService-->>Controller: BaseResponse(...)
Controller-->>Member: 200 OK (items, nextCursor)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (3 warnings)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
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: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
src/main/java/konkuk/chacall/domain/member/domain/Rating.java (1)
20-26: 낙관적 락 버전 필드 추가로 동시성 안전성 확보.동일 평점에 대한 중복 등록(동시 요청) 시 마지막 커밋 승리로 이중 집계가 발생할 수 있습니다.
@Version을 추가하여 경쟁 상황을 감지하고 재시도/에러로 처리하세요.@Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "rating_id", nullable = false) private Long ratingId; +@Version +private Long version;src/main/java/konkuk/chacall/global/common/security/oauth2/CustomOAuth2UserService.java (2)
32-32: OAuth2 프로필 전체 로그는 PII 과다 노출email, profileImage 등 민감정보가 info 레벨로 남습니다. 최소 필드만 debug로 로그하거나 완전 제거하세요.
- log.info("KakaoUser: {}", oAuth2User.getAttributes()); + log.debug("Kakao login: provider={}, providerId={}", + oAuth2UserDetails.getProvider(), oAuth2UserDetails.getProviderId());
49-57: 중요: kakaoId 유니크 제약으로 인한 신규 생성 충돌 위험 — 반드시 수정 필요검증 결과: User 엔티티에 kakaoId에 대한 unique 제약(@column(nullable = false, unique = true))이 존재(src/main/java/konkuk/chacall/domain/user/domain/model/User.java ~26-27), UserRepository에는 상태 기준 조회만 정의되어 있음(Optional findByKakaoIdAndStatus(...), src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java:11). CustomOAuth2UserService의 현재 로직(src/main/java/konkuk/chacall/global/common/security/oauth2/CustomOAuth2UserService.java:49-57)은 ACTIVE만 조회 후 없으면 신규 생성하므로 INACTIVE/DELETED 레코드 존재 시 save에서 DataIntegrityViolationException(유니크 충돌)이 발생합니다.
권장 조치(짧게):
- 옵션 A(권장): 상태 무관 조회(findByKakaoId) 추가 → 존재 시 재활성화 또는 로그인 차단 흐름으로 변경.
- 옵션 B(대안): 현 로직 유지하되 save 시 DataIntegrityViolationException을 캐치해 상태 무관 재조회 후 재활성화 처리.
제안된 코드(원본과 동일):
- CustomOAuth2User customOAuth2User = userRepository.findByKakaoIdAndStatus(kakaoId, BaseStatus.ACTIVE) - .map(existingUser -> new CustomOAuth2User( - LoginUser.createLoginUser(kakaoId, existingUser.getUserId(), existingUser.getRole() - ) - )) - .orElseGet(() -> { - User newUser = userRepository.save(User.createNewUser(nickname, profileImage, kakaoId, email)); - return new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, newUser.getUserId(), newUser.getRole())); - }); + CustomOAuth2User customOAuth2User = userRepository.findByKakaoId(kakaoId) + .map(u -> { + if (u.getStatus() != BaseStatus.ACTIVE) { + u.activate(); // or u.setStatus(BaseStatus.ACTIVE) + } + return new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, u.getUserId(), u.getRole())); + }) + .orElseGet(() -> { + User newUser = userRepository.save(User.createNewUser(nickname, profileImage, kakaoId, email)); + return new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, newUser.getUserId(), newUser.getRole())); + });+ try { + return userRepository.findByKakaoIdAndStatus(kakaoId, BaseStatus.ACTIVE) + .map(u -> new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, u.getUserId(), u.getRole()))) + .orElseGet(() -> { + User newUser = userRepository.save(User.createNewUser(nickname, profileImage, kakaoId, email)); + return new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, newUser.getUserId(), newUser.getRole())); + }); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + User u = userRepository.findByKakaoId(kakaoId).orElseThrow(() -> e); + if (u.getStatus() != BaseStatus.ACTIVE) { + u.activate(); + } + return new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, u.getUserId(), u.getRole())); + }수정 필요 파일(참고):
- src/main/java/konkuk/chacall/domain/user/domain/model/User.java (~26-27)
- src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java (findByKakaoIdAndStatus at line 11)
- src/main/java/konkuk/chacall/global/common/security/oauth2/CustomOAuth2UserService.java (현재 로직 49-57)
🧹 Nitpick comments (15)
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (1)
58-64: 에러 메시지를 더 구체적으로 수정하는 것을 고려해보세요.Rating 관련 에러 코드들의 전반적인 구조는 좋지만,
RATING_NOT_FOUND의 메시지가 다소 애매합니다. "평점을 매길 수 없는 푸드트럭입니다"보다는 "평점을 등록할 수 있는 예약을 찾을 수 없습니다" 등 더 구체적인 메시지가 사용자에게 도움이 될 것 같습니다.-RATING_NOT_FOUND(HttpStatus.NOT_FOUND, 100001, "평점을 매길 수 없는 푸드트럭입니다."), +RATING_NOT_FOUND(HttpStatus.NOT_FOUND, 100001, "평점을 등록할 수 있는 예약을 찾을 수 없습니다."),src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (1)
69-74: USER_NOT_FOUND 에러 코드 누락을 확인해주세요.평점 등록 API에서는 로그인된 사용자 정보가 필요한데,
MEMBER_RATING에서USER_NOT_FOUND에러 코드가 빠져있습니다. 평점 등록 시 사용자 조회 실패 가능성도 고려해야 합니다.MEMBER_RATING(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, FOOD_TRUCK_NOT_FOUND, RATING_NOT_FOUND, RATING_INVALID_SCORE, RATING_ALREADY_REGISTERED )))src/main/java/konkuk/chacall/domain/member/domain/repository/RatingRepository.java (1)
3-3: 사용하지 않는 임포트 제거 필요
@NotNull임포트가 사용되지 않고 있습니다.-import jakarta.validation.constraints.NotNull;src/main/java/konkuk/chacall/domain/foodtruck/domain/value/RatingInfo.java (1)
5-5: 사용하지 않는 임포트 제거 필요
Rating클래스를 임포트했지만 현재 파일에서 사용되지 않고 있습니다.-import konkuk.chacall.domain.member.domain.Rating;src/main/java/konkuk/chacall/domain/member/domain/Rating.java (2)
42-48: 널 방지 및 방어적 프로그래밍 추가(평점 null 입력 방지).
registerRating(Double rating)에null전달 시 NPE 위험은 없지만 데이터 무결성에 악영향입니다. 즉시 검증을 추가하세요.public void registerRating(Double rating) { - if (isRated) { + if (isRated) { throw new DomainRuleException(ErrorCode.RATING_ALREADY_REGISTERED); - } - this.rating = rating; + } + if (rating == null) { + throw new DomainRuleException(ErrorCode.RATING_INVALID_SCORE); + } + this.rating = rating; this.isRated = true; }
25-29: 불리언 필드 네이밍 정리 제안(isRated→rated).Lombok의 boolean 게터 생성 규칙 때문에 프로퍼티 명/게터 명이 혼동될 수 있습니다. 직관성과 직렬화 호환성을 위해
private boolean rated;로 교체하는 것을 권장합니다. 마이그레이션 비용이 크면 유지하되 DTO/JSON 바인딩 시 프로퍼티 명을 명시하세요.src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java (3)
46-51: 삭제 경량화 및 멱등성 강화.현재는 엔티티를 로딩 후 삭제합니다. 직접 삭제 쿼리를 사용하면 왕복 1회로 줄고 경합 상황에서 더 견고합니다. 레포지토리에
int deleteByMemberUserIdAndFoodTruckFoodTruckId(...)를 추가하고, 반환값으로 존재 여부를 판단하세요.예시:
- SavedFoodTruck savedFoodTruck = savedFoodTruckRepository.findByMemberIdAndFoodTruckId(memberId, foodTruckId) - .orElseThrow(() -> new BusinessException(ErrorCode.FOOD_TRUCK_NOT_SAVED)); - savedFoodTruckRepository.delete(savedFoodTruck); + int deleted = savedFoodTruckRepository.deleteByMemberUserIdAndFoodTruckFoodTruckId(memberId, foodTruckId); + if (deleted == 0) { + throw new BusinessException(ErrorCode.FOOD_TRUCK_NOT_SAVED); + }
53-54: 응답은 실제 저장 상태를 조회해 리턴하는 것이 안전합니다.현재는 요청 의도값을 그대로 반환합니다. 트랜잭션 내 예외 상황/경합 처리 후 실제 상태(
existsBy...)를 조회해 응답하면 일관성이 보장됩니다.
11-13: 에러코드 도메인 소속에 대한 의견(질문에 대한 피드백).
FOOD_TRUCK_ALREADY_SAVED,FOOD_TRUCK_NOT_SAVED는Member와FoodTruck사이 연관(집합체)인SavedFoodTruck컨텍스트에 속하는 것이 가장 명확합니다. 에러코드 네이밍/그룹을SAVED_FOOD_TRUCK_*로 통일하거나, 최소한 도메인 섹션을 SavedFoodTruck로 분류하는 것을 권장합니다.src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java (2)
10-17: 파생 메서드로 단순화 및 삭제 전용 메서드 추가 제안.현재 JPQL은 동작에 문제 없으나, 스프링 데이터 파생 메서드가 가독성과 유지보수에 유리합니다. 또한 서비스 최적화를 위해 직접 삭제 메서드를 추가하세요.
-public interface SavedFoodTruckRepository extends JpaRepository<SavedFoodTruck, Long> { - @Query("SELECT CASE WHEN COUNT(s) > 0 THEN true ELSE false END " + - "FROM SavedFoodTruck s " + - "WHERE s.member.userId = :memberId AND s.foodTruck.foodTruckId = :foodTruckId") - boolean existsByMemberIdAndFoodTruckId(Long memberId, Long foodTruckId); - - @Query("SELECT s FROM SavedFoodTruck s " + - "WHERE s.member.userId = :memberId AND s.foodTruck.foodTruckId = :foodTruckId") - Optional<SavedFoodTruck> findByMemberIdAndFoodTruckId(Long memberId, Long foodTruckId); -} +public interface SavedFoodTruckRepository extends JpaRepository<SavedFoodTruck, Long> { + boolean existsByMemberUserIdAndFoodTruckFoodTruckId(Long memberId, Long foodTruckId); + Optional<SavedFoodTruck> findByMemberUserIdAndFoodTruckFoodTruckId(Long memberId, Long foodTruckId); + int deleteByMemberUserIdAndFoodTruckFoodTruckId(Long memberId, Long foodTruckId); +}
9-18: 인덱스/유니크 키 운영 조언.대량 조회/중복 방지 위해 아래 인덱스를 권장합니다.
unique(member_id, food_truck_id)index(member_id)— 내 저장 목록 조회용index(food_truck_id)— 역참조 통계/정리 작업용DDL 예시:
create unique index uk_saved_food_truck_member_foodtruck on saved_food_truck(member_id, food_truck_id);src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java (1)
39-44: 미구현 GET 엔드포인트는 플래그/문서로 가리거나 501로 명시 고려.현재
null응답은 API 소비자 혼란을 야기할 수 있습니다. 배포 전까지
- 비활성화(라우팅 주석/feature flag),
- 또는 501 Not Implemented/204 No Content로 명시적 처리
를 권장합니다.src/main/java/konkuk/chacall/global/common/security/oauth2/CustomOAuth2UserService.java (2)
27-31: 외부 호출(super.loadUser)까지 트랜잭션 경계에 포함됨loadUser 전체에 @transactional을 걸면 외부 I/O(소셜 프로바이더 API)가 트랜잭션 안에서 실행됩니다. 커넥션 점유·타임아웃·락 경합 리스크가 불필요하게 커집니다. DB 작업 구간만 트랜잭션으로 좁혀주세요.
권장 리팩터(요지: 트랜잭션 제거, DB 처리만 분리):
- @Override - @Transactional - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); ... - CustomOAuth2User customOAuth2User = userRepository.findByKakaoIdAndStatus(kakaoId, BaseStatus.ACTIVE) + CustomOAuth2User customOAuth2User = persistOrLoadUser(kakaoId, email, nickname, profileImage); return customOAuth2User; } + + @Transactional + protected CustomOAuth2User persistOrLoadUser(String kakaoId, String email, String nickname, String profileImage) { + return userRepository.findByKakaoIdAndStatus(kakaoId, BaseStatus.ACTIVE) + .map(u -> new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, u.getUserId(), u.getRole()))) + .orElseGet(() -> { + User newUser = userRepository.save(User.createNewUser(nickname, profileImage, kakaoId, email)); + return new CustomOAuth2User(LoginUser.createLoginUser(kakaoId, newUser.getUserId(), newUser.getRole())); + }); + }
45-47: 프로필 필드 null 가능성 방어이메일/닉네임/프로필이미지 null 가능. 생성 시 기본값 또는 trim/length 제한 필요. NPE·제약조건 위반 방지.
src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java (1)
11-11: 상태 필터 조회 메서드 추가는 적절합니다.로그인 시 ACTIVE 한정 조회 용도로 맞습니다. 다만 재활성화/중복 방지 플로우를 위해 상태 무관 조회도 함께 두는 것을 권장합니다.
public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByKakaoIdAndStatus(String kakaoId, BaseStatus status); + Optional<User> findByKakaoId(String kakaoId);
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (21)
src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruck.java(2 hunks)src/main/java/konkuk/chacall/domain/foodtruck/domain/repository/FoodTruckRepository.java(1 hunks)src/main/java/konkuk/chacall/domain/foodtruck/domain/value/RatingInfo.java(1 hunks)src/main/java/konkuk/chacall/domain/member/application/MemberService.java(1 hunks)src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java(1 hunks)src/main/java/konkuk/chacall/domain/member/application/rating/RatingScore.java(1 hunks)src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java(1 hunks)src/main/java/konkuk/chacall/domain/member/domain/Rating.java(2 hunks)src/main/java/konkuk/chacall/domain/member/domain/SavedFoodTruck.java(2 hunks)src/main/java/konkuk/chacall/domain/member/domain/repository/RatingRepository.java(1 hunks)src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/request/RegisterRatingRequest.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/request/UpdateFoodTruckSaveStatusRequest.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/response/RatingResponse.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckResponse.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckStatusResponse.java(1 hunks)src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java(1 hunks)src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java(1 hunks)src/main/java/konkuk/chacall/global/common/security/oauth2/CustomOAuth2UserService.java(2 hunks)src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java (2)
src/main/java/konkuk/chacall/domain/owner/presentation/OwnerController.java (1)
Tag(23-143)src/main/java/konkuk/chacall/domain/owner/application/OwnerService.java (1)
RequiredArgsConstructor(18-92)
src/main/java/konkuk/chacall/domain/member/application/MemberService.java (2)
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (1)
Service(13-42)src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java (1)
Service(18-55)
src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java (2)
src/main/java/konkuk/chacall/global/common/exception/EntityNotFoundException.java (1)
EntityNotFoundException(6-11)src/main/java/konkuk/chacall/domain/member/application/MemberService.java (1)
Service(12-26)
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (1)
src/main/java/konkuk/chacall/domain/member/application/MemberService.java (1)
Service(12-26)
src/main/java/konkuk/chacall/domain/member/domain/SavedFoodTruck.java (1)
src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruck.java (1)
Builder(11-76)
src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruck.java (2)
src/main/java/konkuk/chacall/domain/member/domain/SavedFoodTruck.java (1)
Builder(12-38)src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruckServiceArea.java (1)
Entity(10-28)
🔇 Additional comments (25)
src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckStatusResponse.java (1)
3-6: LGTM! 간결하고 명확한 응답 DTO입니다.저장 상태 변경 API의 응답으로
boolean isSaved필드만을 가진 간단한 record로 적절하게 구현되었습니다.src/main/java/konkuk/chacall/domain/member/presentation/dto/request/UpdateFoodTruckSaveStatusRequest.java (1)
5-10: LGTM! 적절한 Swagger 문서화와 명명입니다.필드명
isSavedRequest와 Swagger 문서화가 명확하게 작성되었습니다. 저장(true)/저장 취소(false) 동작을 하나의 API로 처리하는 설계도 RESTful하고 직관적입니다.src/main/java/konkuk/chacall/domain/member/presentation/dto/request/RegisterRatingRequest.java (1)
6-20: LGTM! 완전한 검증과 문서화가 포함된 요청 DTO입니다.각 필드에 대한 적절한
@NotNull검증과 명확한 Swagger 문서화가 포함되어 있습니다. 특히 평점 필드의 설명("0~5 범위 내에 0.5 단위")이 명확하게 작성되어 비즈니스 규칙을 잘 표현하고 있습니다.src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (2)
48-49: LGTM! 세미콜론에서 쉼표로 변경이 적절합니다.새로운 에러 코드를 추가하기 위해
CHAT_TEMPLATE_NOT_FOUND의 세미콜론을 쉼표로 변경한 것이 적절합니다.
51-57: LGTM! FoodTruck 관련 에러 코드가 적절합니다.푸드트럭 저장/해제 기능에 필요한 에러 코드들이 적절하게 정의되어 있습니다:
FOOD_TRUCK_NOT_FOUND: 404 상태로 적절FOOD_TRUCK_ALREADY_SAVED,FOOD_TRUCK_NOT_SAVED: 409 상태로 중복/상태 충돌을 나타내는 것이 적절에러 코드 번호 체계(90001-90003)도 일관성 있게 할당되었습니다.
src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (1)
59-68: LGTM! 적절한 에러 코드 매핑입니다.
MEMBER_SAVED_FOOD_TRUCK에 대한 에러 코드 매핑이 저장/해제 기능의 모든 예외 상황을 적절하게 커버하고 있습니다:
USER_NOT_FOUND: 사용자 인증/조회 실패FOOD_TRUCK_NOT_FOUND: 대상 푸드트럭 없음FOOD_TRUCK_ALREADY_SAVED,FOOD_TRUCK_NOT_SAVED: 저장 상태 충돌src/main/java/konkuk/chacall/domain/foodtruck/domain/repository/FoodTruckRepository.java (1)
6-7: LGTM! 표준적인 JPA Repository 정의입니다.
JpaRepository<FoodTruck, Long>를 상속받은 표준적인 Repository 인터페이스로 적절합니다. 현재 요구사항에는 기본 CRUD 작업만 필요해 보이므로 커스텀 메서드 없이 정의한 것이 적절합니다.src/main/java/konkuk/chacall/domain/member/domain/SavedFoodTruck.java (2)
8-12: Lombok 애노테이션으로 코드 간소화Builder 패턴과 AllArgsConstructor 추가로 객체 생성이 간편해졌습니다.
32-37: 정적 팩토리 메서드가 깔끔하게 구현됨
SavedFoodTruck.of()메서드가 Builder 패턴을 활용해 명확하고 간결하게 구현되었습니다. 도메인 객체 생성의 의도가 명확히 드러납니다.src/main/java/konkuk/chacall/domain/member/domain/repository/RatingRepository.java (1)
11-13: JPQL 쿼리와 메서드명이 적절함
isRated = false조건을 통해 아직 평점을 남기지 않은 레코드만 조회하는 로직이 명확합니다. JPQL에서 중첩 프로퍼티 접근도 올바르게 구현되었습니다.src/main/java/konkuk/chacall/domain/member/application/MemberService.java (2)
16-17: 의존성 주입이 올바르게 구현됨Lombok의
@RequiredArgsConstructor를 사용한 의존성 주입이 적절합니다.
19-21: 서비스 레이어의 위임 패턴이 적절함MemberService가 각 세부 서비스로 적절히 위임하고 있어 관심사 분리가 잘 되어 있습니다.
Also applies to: 23-25
src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruck.java (3)
9-11: Lombok 애노테이션 추가가 적절함Builder 패턴과 AllArgsConstructor 추가로 객체 생성과 테스트 용이성이 향상되었습니다.
Also applies to: 14-14
65-67: RatingInfo 임베딩과 기본값 설정이 우수함
@Builder.Default를 사용하여 Builder 패턴에서도 기본값이 적절히 설정되도록 했습니다. RatingInfo를 임베딩하여 평점 관련 로직을 캡슐화한 설계가 좋습니다.
73-75: 평점 업데이트 메서드가 적절함
updateAverageRating메서드가 RatingInfo 객체에 위임하여 책임을 명확히 분리했습니다.src/main/java/konkuk/chacall/domain/member/application/rating/RatingScore.java (2)
8-10: enum을 활용한 평점 검증이 우수함0.5 단위로 0.0~5.0 범위의 평점을 enum으로 관리하는 것은 타입 안전성과 유효성 검증 측면에서 매우 좋은 접근입니다.
Also applies to: 12-22
26-33: fromValue 메서드 구현이 안전함
Double.compare를 사용한 부동소수점 비교와 적절한 예외 처리가 구현되어 있습니다.src/main/java/konkuk/chacall/domain/foodtruck/domain/value/RatingInfo.java (2)
23-28: 초기값 생성 팩토리 메서드가 적절함
createInitial()메서드로 초기 상태를 명확히 표현하고 있습니다.
31-36: 평균 평점 업데이트 로직이 정확함누적 평점 계산과 평균 업데이트 로직이 수학적으로 올바르게 구현되었습니다.
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (2)
23-25: 평점 유효성 검증 방식에 대한 피드백(정규식 vs 상수).현재
RatingScore.fromValue(상수/열거 기반) 접근은 가독성과 유지보수 관점에서 적절합니다. 정규식은 숫자 부동소수 처리에서 경계가 모호해질 수 있어 비권장입니다. 그대로 가시죠.
21-22: 트랜잭션 범위 적절.클래스 레벨 readOnly에 메서드 레벨 쓰기 트랜잭션 오버라이드 구성이 적절합니다.
src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java (2)
28-37: 엔드포인트/레이어링은 적절합니다.경로/HTTP 메서드 선택(PATCH for save toggle, POST for rating)과 파라미터 바인딩 구성이 명확합니다. 서비스 위임도 깔끔합니다.
46-55: 예외 스펙 매핑은 좋습니다. 다만 예약 식별자 사용 여부 점검 필요.
RegisterRatingRequest가reservationId를 포함한다면 컨트롤러→서비스→레포지토리까지 전달/사용이 일관적인지 확인해주세요. 현재 서비스 구현은reservationId를 사용하지 않습니다(별도 코멘트 참조).src/main/java/konkuk/chacall/global/common/security/oauth2/CustomOAuth2UserService.java (1)
5-5: 상태 필터 도입 자체는 타당합니다.Active 사용자만 인증 컨텍스트에 올리는 방향성은 OK. 아래 코멘트의 중복 계정/재활성화 플로우만 정리되면 좋겠습니다.
src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java (1)
15-15: existsBy 추가는 OK — findByKakaoId 호출 흔적 없음 확인*
레포 전체 검색 결과 UserRepository.java 외에 findByKakaoId 호출은 없습니다. 변경 승인. 성능(잠재적 N+1) 주의.
| if (request.isSavedRequest()) { // 저장 요청 | ||
| // 이미 저장된 푸드트럭인지 확인 | ||
| if(savedFoodTruckRepository.existsByMemberIdAndFoodTruckId(memberId, foodTruckId)) { | ||
| throw new BusinessException(ErrorCode.FOOD_TRUCK_ALREADY_SAVED); | ||
| } | ||
|
|
||
| SavedFoodTruck savedFoodTruck = SavedFoodTruck.of(member, foodTruck); | ||
| savedFoodTruckRepository.save(savedFoodTruck); | ||
| } else { // 저장 취소 요청 |
There was a problem hiding this comment.
저장 동시요청 경쟁 조건: 존재 여부 체크 후 저장 사이 레이스 발생 가능.
동시에 2개 저장 요청이 들어오면 둘 다 existsBy... == false로 통과해 중복 레코드가 생성될 수 있습니다. DB에 유니크 제약(회원ID+푸드트럭ID)을 두고, 서비스 레이어에서 DataIntegrityViolationException을 처리하세요.
서비스 예시(예외 처리):
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.dao.DataIntegrityViolationException;
...
if (request.isSavedRequest()) { // 저장 요청
// 이미 저장된 푸드트럭인지 확인
if(savedFoodTruckRepository.existsByMemberIdAndFoodTruckId(memberId, foodTruckId)) {
throw new BusinessException(ErrorCode.FOOD_TRUCK_ALREADY_SAVED);
}
- SavedFoodTruck savedFoodTruck = SavedFoodTruck.of(member, foodTruck);
- savedFoodTruckRepository.save(savedFoodTruck);
+ SavedFoodTruck savedFoodTruck = SavedFoodTruck.of(member, foodTruck);
+ try {
+ savedFoodTruckRepository.save(savedFoodTruck);
+ } catch (DataIntegrityViolationException e) {
+ // 유니크 제약 위반 시 멱등 응답
+ throw new BusinessException(ErrorCode.FOOD_TRUCK_ALREADY_SAVED);
+ }
}엔티티/DDL 권장:
@Table(uniqueConstraints = @UniqueConstraint(name = "uk_saved_food_truck_member_foodtruck", columnNames = {"member_id","food_truck_id"}))
📝 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 (request.isSavedRequest()) { // 저장 요청 | |
| // 이미 저장된 푸드트럭인지 확인 | |
| if(savedFoodTruckRepository.existsByMemberIdAndFoodTruckId(memberId, foodTruckId)) { | |
| throw new BusinessException(ErrorCode.FOOD_TRUCK_ALREADY_SAVED); | |
| } | |
| SavedFoodTruck savedFoodTruck = SavedFoodTruck.of(member, foodTruck); | |
| savedFoodTruckRepository.save(savedFoodTruck); | |
| } else { // 저장 취소 요청 | |
| if (request.isSavedRequest()) { // 저장 요청 | |
| // 이미 저장된 푸드트럭인지 확인 | |
| if(savedFoodTruckRepository.existsByMemberIdAndFoodTruckId(memberId, foodTruckId)) { | |
| throw new BusinessException(ErrorCode.FOOD_TRUCK_ALREADY_SAVED); | |
| } | |
| SavedFoodTruck savedFoodTruck = SavedFoodTruck.of(member, foodTruck); | |
| try { | |
| savedFoodTruckRepository.save(savedFoodTruck); | |
| } catch (DataIntegrityViolationException e) { | |
| // 유니크 제약 위반 시 멱등 응답 | |
| throw new BusinessException(ErrorCode.FOOD_TRUCK_ALREADY_SAVED); | |
| } | |
| } else { // 저장 취소 요청 |
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java
around lines 38-46, the current existsBy... check can race under concurrent save
requests; add a DB-level unique constraint on the SavedFoodTruck entity (e.g.,
@Table(uniqueConstraints = @UniqueConstraint(name =
"uk_saved_food_truck_member_foodtruck", columnNames =
{"member_id","food_truck_id"}))) and wrap savedFoodTruckRepository.save(...) in
a try/catch that catches DataIntegrityViolationException (or the specific
JPA/SQL constraint exception) and translates it into the
BusinessException(ErrorCode.FOOD_TRUCK_ALREADY_SAVED); keep the optimistic
exists check if desired but rely on the DB constraint + exception handling to
prevent duplicate records.
| ratingRepository.findByMemberIdAndFoodTruckIdAndIsRatedFalse(memberId, request.foodTruckId()) | ||
| .ifPresentOrElse(rating -> { | ||
| // 평점 등록 | ||
| rating.registerRating(ratingScore.getValue()); | ||
|
|
||
| // 푸드트럭의 평균 평점 업데이트 | ||
| foodTruck.updateAverageRating(ratingScore.getValue()); | ||
| }, () -> { | ||
| throw new BusinessException(ErrorCode.RATING_NOT_FOUND); | ||
| }); |
There was a problem hiding this comment.
🛠️ Refactor suggestion
예약 식별자 미사용으로 잘못된 평점이 등록될 수 있습니다.
RegisterRatingRequest가 reservationId를 가진 설계인데, 여기서는 memberId + foodTruckId + isRated=false만으로 조회합니다. 동일 푸드트럭에 대한 복수 예약이 존재하면 다른 예약의 평점이 갱신/소모될 수 있습니다. reservationId로 특정 레이팅을 조회/잠금하세요.
적용 예시:
- ratingRepository.findByMemberIdAndFoodTruckIdAndIsRatedFalse(memberId, request.foodTruckId())
+ ratingRepository.findByMemberIdAndReservationIdAndIsRatedFalse(memberId, request.reservationId())
.ifPresentOrElse(rating -> {
// 평점 등록
rating.registerRating(ratingScore.getValue());
// 푸드트럭의 평균 평점 업데이트
foodTruck.updateAverageRating(ratingScore.getValue());
}, () -> {
throw new BusinessException(ErrorCode.RATING_NOT_FOUND);
});레포지토리에 해당 메서드 추가가 필요합니다:
Optional<Rating> findByMemberIdAndReservationIdAndIsRatedFalse(Long memberId, Long reservationId);
📝 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.
| ratingRepository.findByMemberIdAndFoodTruckIdAndIsRatedFalse(memberId, request.foodTruckId()) | |
| .ifPresentOrElse(rating -> { | |
| // 평점 등록 | |
| rating.registerRating(ratingScore.getValue()); | |
| // 푸드트럭의 평균 평점 업데이트 | |
| foodTruck.updateAverageRating(ratingScore.getValue()); | |
| }, () -> { | |
| throw new BusinessException(ErrorCode.RATING_NOT_FOUND); | |
| }); | |
| ratingRepository.findByMemberIdAndReservationIdAndIsRatedFalse(memberId, request.reservationId()) | |
| .ifPresentOrElse(rating -> { | |
| // 평점 등록 | |
| rating.registerRating(ratingScore.getValue()); | |
| // 푸드트럭의 평균 평점 업데이트 | |
| foodTruck.updateAverageRating(ratingScore.getValue()); | |
| }, () -> { | |
| throw new BusinessException(ErrorCode.RATING_NOT_FOUND); | |
| }); |
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java
around lines 31-40, the current lookup uses memberId+foodTruckId+isRated=false
which can match the wrong reservation; change the lookup to use
memberId+reservationId+isRated=false and ensure the rating row is locked for
update. Add a repository method like Optional<Rating>
findByMemberIdAndReservationIdAndIsRatedFalse(Long memberId, Long reservationId)
(with a pessimistic write lock or equivalent) and replace the existing
findByMemberIdAndFoodTruckIdAndIsRatedFalse call with this new method, keeping
the same ifPresentOrElse flow and throwing
BusinessException(ErrorCode.RATING_NOT_FOUND) when absent.
| // 푸드트럭의 평균 평점 업데이트 | ||
| foodTruck.updateAverageRating(ratingScore.getValue()); | ||
| }, () -> { |
There was a problem hiding this comment.
푸드트럭 평균 평점 업데이트의 동시성(잃어버린 업데이트) 위험.
여러 사용자가 동시에 같은 트럭에 평점을 등록하면 RatingInfo의 read-modify-write가 충돌할 수 있습니다. 대안:
FoodTruck에@Version추가(권장) 후 충돌 시 재시도.- 또는 DB 단에서 원자적 증가 연산(카운트/합계 컬럼에
update ... set cnt = cnt + 1, sum = sum + ?)을 사용.
단순 평균만 저장한다면 합계/건수 누적 모델로 전환하고, 평균은 조회 시 계산하는 접근도 고려하세요.
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java
around lines 36 to 38, the current read-modify-write that updates FoodTruck
average risks lost updates under concurrent ratings; fix by either (1) adding an
optimistic locking @Version field to the FoodTruck entity and catching
OptimisticLockException to retry the update a few times, or (2) performing an
atomic DB update to maintain sum and count columns (e.g., UPDATE ... SET sum =
sum + ?, cnt = cnt + 1) and compute average on read; if you prefer minimal
schema change convert to sum/count persisted fields and calculate average on
retrieval.
| @ManyToOne(fetch = FetchType.LAZY) | ||
| @JoinColumn(name = "reservation_id", nullable = false) | ||
| private Reservation reservation; | ||
|
|
There was a problem hiding this comment.
예약과 평점의 관계를 1:1로 강제하거나, 최소한 DB 유니크 제약을 추가하세요.
현재 @ManyToOne Reservation은 하나의 예약에 여러 Rating이 매핑될 수 있는 모델입니다. 동일 예약에 중복 평점 생성/등록 위험이 있습니다. 운영 중 데이터 정합성 보장을 위해 아래 중 하나는 필수입니다.
- 선호: 예약 단건당 평점 1건이라면
@OneToOne(optional = false)+ DB 유니크(또는 유니크 FK) 제약. - 대안: 현 구조 유지 시
reservation_id에 유니크 제약.
적용 예시(테이블 유니크 제약 추가):
-@Table(name = "ratings")
+@Table(
+ name = "ratings",
+ uniqueConstraints = {
+ @UniqueConstraint(name = "uk_ratings_reservation", columnNames = {"reservation_id"})
+ }
+)또는 매핑을 1:1로 변경:
-@ManyToOne(fetch = FetchType.LAZY)
-@JoinColumn(name = "reservation_id", nullable = false)
-private Reservation reservation;
+@OneToOne(fetch = FetchType.LAZY, optional = false)
+@JoinColumn(name = "reservation_id", nullable = false, unique = true)
+private Reservation reservation;📝 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.
| @ManyToOne(fetch = FetchType.LAZY) | |
| @JoinColumn(name = "reservation_id", nullable = false) | |
| private Reservation reservation; | |
| @OneToOne(fetch = FetchType.LAZY, optional = false) | |
| @JoinColumn(name = "reservation_id", nullable = false, unique = true) | |
| private Reservation reservation; |
🤖 Prompt for AI Agents
In src/main/java/konkuk/chacall/domain/member/domain/Rating.java around lines
38-41, the Reservation mapping is currently @ManyToOne which allows multiple
Ratings per reservation; enforce a 1:1 relationship or add a DB unique
constraint to prevent duplicate ratings: either change the mapping to
@OneToOne(optional = false) with a unique foreign key (update the annotation and
adjust the owning side accordingly) or keep @ManyToOne but add a unique
constraint on reservation_id at the entity/table level (or the migration) so
reservation_id is unique in the ratings table; ensure JPA annotations and DB
schema (DDL/migration) are consistent and update any service/repository logic
that assumes the previous multiplicity.
| public record RatingResponse( | ||
|
|
||
| ) { | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
빈 응답 DTO의 구조를 검토해주세요.
평점 관련 예약 조회 API(getReservationsForRating)에서 사용되는 응답 DTO인데, 빈 record로 정의되어 있습니다. 평점을 등록할 수 있는 예약 목록을 반환하는 API라면 최소한 예약 ID, 푸드트럭 정보, 예약 날짜 등의 필드가 필요할 것 같습니다.
다음 스크립트로 RatingResponse와 관련 API의 사용 맥락을 확인해보겠습니다:
🏁 Script executed:
#!/bin/bash
# 설명: RatingResponse 사용처 및 평점 관련 API 확인
# RatingResponse 사용처 검색
echo "=== RatingResponse 사용처 ==="
rg -nP "RatingResponse" --type=java
# 평점 관련 예약 조회 API 확인
echo "=== 평점 관련 예약 API ==="
rg -nP "getReservationsForRating|rating.*reservation" --type=java -A5 -B5Length of output: 1917
RatingResponse 빈 DTO 및 getReservationsForRating 반환형/로직을 완성하세요
- src/main/java/konkuk/chacall/domain/member/presentation/dto/response/RatingResponse.java가 빈 record입니다. 최소 필드로 reservationId(Long), foodTruck(푸드트럭 요약 DTO — 예: id, name), reservationDate(LocalDate/LocalDateTime) 등을 추가하세요.
- src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java의 getReservationsForRating(...)는 현재 BaseResponse 및 TODO로 null을 반환합니다. 반환형을 BaseResponse<List>로 변경하고, Reservation 조회 및 RatingResponse로의 매핑 로직을 구현하세요.
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/presentation/dto/response/RatingResponse.java
around lines 3-6, the record is empty; define a minimal DTO with fields
reservationId (Long), foodTruck (a nested/summary DTO containing at least id and
name), and reservationDate (LocalDateTime or LocalDate) and provide a static
factory or constructor to build from a Reservation entity; then update
src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java
method getReservationsForRating(...) to change its return type from
BaseResponse<RatingResponse> to BaseResponse<List<RatingResponse>>, query the
reservations eligible for rating (via the service/repository call used
elsewhere), map each Reservation to RatingResponse (including constructing the
foodTruck summary DTO), and return BaseResponse.success(listOfResponses) instead
of null, ensuring imports and null checks are handled.
| public record SavedFoodTruckResponse() { | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
빈 응답 DTO의 목적과 향후 확장 계획을 명확히 해주세요.
현재 빈 record로 정의되어 있는데, 저장된 푸드트럭 목록 조회 API에서 실제로 반환해야 할 데이터가 무엇인지 명확하지 않습니다. 페이징 처리가 다른 담당자에 의해 구현될 예정이라고 하더라도, 최소한 푸드트럭 기본 정보(ID, 이름 등)는 포함되어야 할 것 같습니다.
다음 스크립트로 SavedFoodTruckResponse가 어떻게 사용되는지 확인해보겠습니다:
🏁 Script executed:
#!/bin/bash
# 설명: SavedFoodTruckResponse 사용처 및 관련 컨트롤러 메서드 확인
# SavedFoodTruckResponse 사용처 검색
echo "=== SavedFoodTruckResponse 사용처 ==="
rg -nP "SavedFoodTruckResponse" --type=java
# 저장된 푸드트럭 관련 컨트롤러 메서드 확인
echo "=== 저장된 푸드트럭 관련 컨트롤러 메서드 ==="
rg -nP "getSavedFoodTrucks|saved.*food.*truck" --type=java -A3 -B3Length of output: 2753
SavedFoodTruckResponse가 빈 DTO입니다 — 반환 스펙(최소 id, name 등)을 정의하고 DTO를 구현하세요.
MemberController#getSavedFoodTrucks가 BaseResponse를 반환하도록 선언되어 있으나 현재 BaseResponse.ok(null)을 반환하고 있으며, SavedFoodTruckResponse는 빈 record입니다. 커서 페이징을 후속으로 구현할 예정이라면 DTO를 리스트/페이징 메타 구조로 설계하거나 최소한 푸드트럭 기본 정보(예: id, name, 썸네일 등)를 포함하도록 변경해야 합니다.
위치: src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java (약 38–44행), src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckResponse.java (선언부). 참고: src/main/java/konkuk/chacall/domain/member/domain/SavedFoodTruck.java.
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckResponse.java
(around lines 3–4), SavedFoodTruckResponse is currently an empty record;
implement it to represent the saved food trucks response shape expected by
MemberController. Define a minimal response containing a List of item DTOs (each
with at least id, name, thumbnailUrl) plus simple paging metadata (e.g.,
nextCursor and hasNext) to support future cursor paging. Also create an
inner/static SavedFoodTruckItem DTO with the required fields and map
SavedFoodTruck entities to these DTOs in the controller/service so
BaseResponse.ok(...) returns a non-null, populated SavedFoodTruckResponse
instead of BaseResponse.ok(null).
There was a problem hiding this comment.
확인했습니다! 전체적으로 코드가 깔끔한 것 같아서 크게 건드릴 부분은 없을 것 같습니다.
추가로 리뷰 요청 사항에 대해 제 의견을 답하자면
- 저장 상태 요청에서 FOOD_TRUCK_ALREADY_SAVED, FOOD_TRUCK_NOT_SAVED 에러는 FoodTruck 도메인에 해당할지, User 도메인에 해당할지 아니면 아예 SavedFoodTruck라는 다른 도메인에 해당할지?
-> 제 생각에는 SavedFoodTruck 도메인에 할당하는 것이 맞는 것 같습니다. 각각의 도메인에서 생성하는 에러코드는 명확하게 해당 도메인에 종속되어야한다고 생각하는데, FOOD_TRUCK_ALREADY_EXIST 가 FoodTruck 하위에 들어간다면, 푸드트럭 자체가 이미 등록되어있다는 의미로 받아들여질 것 같습니다. 반면에 저희가 생각하는 의미는, 이 특정 유저가 이미 해당 푸드트럭을 관심 푸드트럭으로 등록했는가? 를 의미하는 것이기 때문에 SavedFoodTruck 으로 들어가는 것이 좀 더 적합하겠다는 생각이 드네요
FOOD_TRUCK_NOT_SAVED 도 동일한 입장입니다!!
- 평점의 유효성(0.5 단위로 0~5 범위)을 정규식으로 검증할지, 상수로 검증할지 고민이 되었는데 가독성 + 유지보수 면에서 상수로 검증하는게 더 낫다고 판단해서 일단 이렇게 구현했습니다. 이에 대해서 어떻게 생각하시는지 궁금합니다!
-> 일단 저는 확장(평점 단위가 변경되는 것)을 고려하지 않은 상황에서는 정규식 검증이 더 나을 것 같다고 생각합니다. 물론 현재와 같이 상수로 관리하는 것도 매우 좋습니다.
다만 정규식을 활용하면
@Pattern(
regexp = "^(?:[0-4](?:\\.0|\\.5)|5(?:\\.0)?)$",
message = "평점은 0.0부터 5.0까지 0.5 단위로 입력해야 합니다."
)
String rating와 같이 Bean Validation 만 적용해두면 그 이외의 영역에서는 평점 검증에 신경을 쓸 필요가 사라지게 될 것 같습니다.
만약 평점 단위가 변경될 여지가 존재한다면 현재와 같은 구조도 좋다고 생각하지만 현재로써는 그러한 상황을 상상하기 어렵고, 서비스 로직까지도 이러한 파싱에 신경을 써야한다는 부분에서 효율성을 따져보자면 정규표현식이 더 낫다고 생각하긴 합니다.
하지만 앞서 말했다시피 상수로 관리도 좋다고 생각하고, 이미 구현도 해두셨기 때문에 그대로 가져가도 문제 없을 것 같습니다!
나머지 로직 추가되면 그 때 추가로 리뷰하고 approve 하겠습니당
생각할 게 많았을텐데 수고 많으셨슴다👍🏻
| // 유저 존재 여부 확인 | ||
| User member = userRepository.findById(memberId) | ||
| .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); |
There was a problem hiding this comment.
이 부분은 MemberService(파사드) 쪽에서 맡아도 될 것 같습니다!
그리고 이 부분은 굳이 일 수도 있긴 한데, 이 부분에서도 요청한 유저가 Member 인지, status=ACTIVE 인지를 검증할 필요성은 없는가? 에 대해 이야기해보면 좋을 것 같습니다.
우선 기존에 사장님 관련 로직을 구현할 때는, 모든 기능에 대해서 사장님인지 여부를 확인하긴 했어서 통일성을 위해서는 멤버인지 여부를 확인하는 것이 좋을 것 같다고 생각하긴 합니다.
There was a problem hiding this comment.
아하 그러면 파사드에서 User 조회를 맡고 내부 서비스 클래스에는 User 객체 자체를 파라미터로 전달하는 방식을 말씀하시는거 맞을까요?
There was a problem hiding this comment.
@ksg1227 넵 알겠습니다! 엇 그리고 현재 OwnerValidator 쪽 코드를 보다가 문득 든 생각인데 findByUserIdAndRoleAndStatus로 조회해서 만약 일반 유저가 사장님 api를 호출할 경우 현재 USER_NOT_FOUND 에러가 발생하고 있는데, findByUserIdAndStatus로만 조회해서 존재하지 않을경우 USER_NOT_FOUND 에러를 터뜨리고, 그 이후에 User의 Role을 검증해서 403 에러를 발생시키는거 어떨까요?? 승인하시면 제가 수정해보겠습니다
| public static RatingScore fromValue(double value) { | ||
| for (RatingScore score : values()) { | ||
| if (Double.compare(score.value, value) == 0) { | ||
| return score; | ||
| } | ||
| } | ||
| throw new BusinessException(ErrorCode.RATING_INVALID_SCORE); | ||
| } |
There was a problem hiding this comment.
enum 개수가 적어서 전체 순회를 거쳐도 상관 없을 것 같긴 한데, 좀 더 코드 효율을 높이려면 Map 을 만들어두고 사용해도 좋을 것 같아용
private static final Map<Double, RatingScore> VALUE_MAP =
Arrays.stream(values()).collect(Collectors.toMap(RatingScore::getValue, e -> e));
public static RatingScore fromValue(double value) {
RatingScore score = VALUE_MAP.get(value);
if (score == null) {
throw new BusinessException(ErrorCode.RATING_INVALID_SCORE);
}
return score;
}=> 추가로 이 부분에서 터지는 예외는 DomainRuleException 에 가깝지 않나 생각하는데 어떻게 생각하시나용
There was a problem hiding this comment.
이 부분은 리뷰에서 작성해주신 대로 정규식으로 처리하도록 하겠습니다!!
…ve-and-get-foodtruck # Conflicts: # src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java # src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (3)
src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java (2)
12-12: ACTIVE 전용 조회 헬퍼 추가 + null 상태 방지 제안
findByKakaoIdAndStatus도입은 타당합니다. 다만 호출부가 대부분ACTIVE일 가능성이 높아, 파라미터 누락/null전달로 인한status IS NULL조회를 예방하고, 호출부 가독성을 높이기 위해 기본 헬퍼를 추가하는 것을 권장합니다.아래처럼 default 메서드를 추가해 주세요:
public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByKakaoIdAndStatus(String kakaoId, BaseStatus status); + + default Optional<User> findActiveByKakaoId(String kakaoId) { + return findByKakaoIdAndStatus(kakaoId, BaseStatus.ACTIVE); + } }
11-19: PR 전반 질의에 대한 간단 피드백
- 오류 코드 소속: 저장/해제는 User가 아닌 SavedFoodTruck(= 북마크) 서브도메인에 귀속시키는 것을 권장합니다. 전역 코드가 필요하면
SAVED_FOOD_TRUCK_*네임스페이스로 그룹화하세요.- 평점 검증: 정규식보다 상수 기반(하한/상한 + step) 검증이 바람직합니다. 내부 표현을 정수 스케일(예: 0~10, 0.5단위 → 값×2) 또는 BigDecimal(scale=1)로 두면 오차/표현 이슈를 줄일 수 있습니다.
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (1)
66-71: SavedFoodTruck(회원‑푸드트럭 관계)로 상수 분리·리네이밍 권장 — 전역 치환 필요FOOD_TRUCK_ALREADY_SAVED / FOOD_TRUCK_NOT_SAVED는 SavedFoodTruck(회원‑푸드트럭 관계) 도메인에서 사용되고 있습니다. 명확화 위해 리네이밍을 권장하며, 변경 시 모든 사용처를 치환해야 합니다.
사용처(검색 결과):
- src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java — FOOD_TRUCK_ALREADY_SAVED(라인 41), FOOD_TRUCK_NOT_SAVED(라인 48)
- src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java — 참조(라인 80–81)
- src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java — 정의(라인 69–71)
권장 diff 예시:
- /** - * FoodTruck - */ - FOOD_TRUCK_NOT_FOUND(HttpStatus.NOT_FOUND, 110001, "푸드트럭을 찾을 수 없습니다."), - FOOD_TRUCK_ALREADY_SAVED(HttpStatus.CONFLICT, 110002, "이미 저장된 푸드트럭입니다."), - FOOD_TRUCK_NOT_SAVED(HttpStatus.CONFLICT, 110003, "저장되지 않은 푸드트럭입니다."), + /** + * SavedFoodTruck (Member-FoodTruck relation) + */ + FOOD_TRUCK_NOT_FOUND(HttpStatus.NOT_FOUND, 110001, "푸드트럭을 찾을 수 없습니다."), + SAVED_FOOD_TRUCK_ALREADY_EXISTS(HttpStatus.CONFLICT, 110002, "이미 저장된 푸드트럭입니다."), + SAVED_FOOD_TRUCK_NOT_EXISTS(HttpStatus.CONFLICT, 110003, "저장되지 않은 푸드트럭입니다."),치환 시 서비스·컨트롤러·예외 발생 지점·Swagger 응답 매핑 등 전역 치환 및 빌드/테스트 확인 필요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java(1 hunks)src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java(1 hunks)src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java
🔇 Additional comments (7)
src/main/java/konkuk/chacall/domain/user/domain/repository/UserRepository.java (4)
11-19: kakaoId는 엔티티에 unique 선언 존재 — DB 마이그레이션(및 복합 인덱스) 추가 권장
- src/main/java/konkuk/chacall/domain/user/domain/model/User.java (라인 26–27): kakaoId가 @column(nullable = false, unique = true)로 선언되어 있음.
- 제공된 스크립트 결과, 마이그레이션(V*.sql/yml/xml 등)에서 UNIQUE/INDEX 선언은 발견되지 않음. DB 레벨 유니크(전역 or 상태별 정책 결정) 및 인덱스 (kakao_id, status)와 (user_id, role, status)를 명시적 마이그레이션으로 추가하세요.
- 로그인 흐름에서 중복 레코드 시 IncorrectResultSize 예외가 발생할 수 있으니 중복 방지/예외 처리도 검증하세요.
11-11: @repository 제거는 문제 없습니다 — 패키지 스캔 포함 여부만 확인하세요.Spring Data JPA는 리포지토리 인터페이스를 자동 스캔하므로 @repository 제거는 괜찮습니다. 다만 애플리케이션의 컴포넌트/리포지토리 스캔 범위에 해당 패키지가 포함되어 있는지 확인해야 합니다. 제공된 자동 검색에서는 관련 설정이 발견되지 않았습니다. 로컬에서 아래 명령으로 확인하세요:
rg -n '@EnableJpaRepositories|@SpringBootApplication|spring.data.jpa' -C3 || true rg -n 'spring.data.jpa' -g 'application.*' -C3 || true
14-18: 결론: userId는 PK(@id) — 메서드명 유지, 별도 인덱스 불필요User 엔티티의 userId 필드에 @id 및 @GeneratedValue가 선언되어 있어 PK입니다 (src/main/java/konkuk/chacall/domain/user/domain/model/User.java:15–18). PK는 자동 인덱스가 있으므로 별도 인덱스 추가 불필요하며, 현재의 findByUserIdAndRoleAndStatus / existsByUserIdAndRoleAndStatus / findAllByUserIdInAndRoleAndStatus 서명은 올바릅니다. 'findById…'로 변경할 필요 없음(엔티티에 'id' 프로퍼티가 없어 파생 쿼리에서 'id' 사용 불가).
Likely an incorrect or invalid review comment.
12-12: 시그니처 변경 후 잔존 호출 검증 필요자동 검색이 빈 결과를 반환했습니다. 이전 findByKakaoId(String) 호출이 남아 있으면 컴파일/런타임 오류가 발생하므로 리포지토리 전체에서 호출 잔존 여부를 수동 확인하거나 아래 스크립트를 실행해 재검증하세요.
#!/bin/bash # 변경 전 메서드 호출 잔존 여부 확인 (rg 권장, 없으면 grep 대체) if command -v rg >/dev/null 2>&1; then rg -nP '\.findByKakaoId\s*\(' -C2 else grep -R --line-number --exclude-dir={.git,build,target} -n "findByKakaoId" . || true fisrc/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (3)
57-58: enum 상수 뒤 구분자 수정은 적절합니다
RESERVATION_NOT_OWNED뒤를 세미콜론에서 콤마로 바꿔 후속 상수 선언을 허용한 변경이 맞습니다.
59-71: 에러 코드 자릿수 혼재 확인 — 5자리 vs 6자리; 문서·클라이언트 영향 검토 필요현황: 리포지토리에서 5자리(예: 60001~90004)와 6자리(예: 100001, 110001)가 혼재함을 확인했음. OpenApiConfig가 ErrorCode 값을 스웨거 예시로 생성하므로 6자리 코드가 API 문서에 노출됨. ResponseCode/ErrorResponse 등에는 자릿수 제약이 없음.
근거(주요 파일)
- src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java — RATING(100001~), FOOD_TRUCK(110001~) 항목.
- src/main/java/konkuk/chacall/global/config/OpenApiConfig.java — generateErrorCodeResponseExample가 ErrorResponse.of(errorCode)를 사용하여 스웨거 예시 생성.
- src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java — ErrorCode 집합 사용.
- src/main/java/konkuk/chacall/global/common/dto/ResponseCode.java, src/main/java/konkuk/chacall/global/common/dto/ErrorResponse.java — 코드 자릿수/패턴 제약 없음.
권장 조치
- ErrorCode.java 상단에 도메인별 코드 범위 및 자릿수 컨벤션 주석 추가하거나, 전체 표준(모두 5자리 또는 모두 6자리)으로 통일.
- 스웨거/문서(OpenApi) 예시와 명세에 자릿수 변경을 명시하여 클라이언트 혼선 방지.
- 모바일/웹/SDK 등 외부 소비자가 ‘5자리 가정’ 여부를 확인하고 필요 시 마이그레이션 계획 수립(당장 변경 불가 시 문서화+점진적 마이그레이션).
59-65: RATING 에러코드: NOT_FOUND는 유지 권고 — 메시지·상태만 개선 권장검증 결과: 현재 코드에서 RATING_NOT_FOUND는 Rating 레코드 부재( RatingService의 repository 조회 실패 )를 나타내므로 HttpStatus.NOT_FOUND 유지가 구현과 일치합니다. 권한(403) 의미로 바꾸려면 의도 확인 후 모든 참조를 함께 변경하세요. (참조: src/main/java/.../ErrorCode.java ~라인62, RatingService.java ~라인37-39, SwaggerResponseDescription.java ~라인83-87, Rating.java ~라인42-44)
- 권장: RATING_ALREADY_REGISTERED — HttpStatus.BAD_REQUEST → HttpStatus.CONFLICT(409).
- 권장: RATING_INVALID_SCORE — 메시지에 허용 범위 명시: (허용: 0~5, 0.5 단위).
- 검증 로직: RatingScore.fromValue는 현재 double 비교로 0.5 단위 값들에는 정상 작동함. 소수점 오차 우려 시 BigDecimal 전환 고려.
제안 변경(diff):
- RATING_INVALID_SCORE(HttpStatus.BAD_REQUEST, 100002, "유효하지 않은 평점 값입니다."), + RATING_INVALID_SCORE(HttpStatus.BAD_REQUEST, 100002, "유효하지 않은 평점 값입니다. (허용: 0~5, 0.5 단위)"), - RATING_ALREADY_REGISTERED(HttpStatus.BAD_REQUEST, 100003, "이미 평점이 등록된 상태입니다."), + RATING_ALREADY_REGISTERED(HttpStatus.CONFLICT, 100003, "이미 평점이 등록된 상태입니다."),Likely an incorrect or invalid review comment.
|
@ksg1227 나머지 api 2개 구현 완료했습니다. 저장된 푸드트럭 목록 조회 (무한 스크롤)
평점 등록이 필요한 예약 목록 조회
평점 등록이 필요한 예약 목록 조회의 경우는 20개가 넘어가는 평점이 나오는 것은 요구사항 자체가 잘못된 것이라고 판단해서 무한스크롤을 넣지 않았습니다. 그러다보니 List로 감싸지만 CursorBaseResponse를 사용하지 않는 특수한 케이스였는데, 일단 임의로 dto에 inner class 도입해서 선언하긴 하였습니다. 이 부분에 대해서 한번 컨벤션을 정해보면 좋을 것 같습니다. |
There was a problem hiding this comment.
Actionable comments posted: 8
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/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (1)
109-123: Getter로 노출되는 Set 가변성 제거(불변화 권장)
errorCodeList를 생성자에서 변경하고, @Getter로 그대로 노출하여 외부에서 수정될 수 있습니다. 문서 정의가 런타임에 변질되지 않도록 불변 컬렉션으로 감싸서 보관하세요.적용 예시:
@@ -import java.util.LinkedHashSet; +import java.util.LinkedHashSet; +import java.util.Collections; @@ - SwaggerResponseDescription(Set<ErrorCode> errorCodeList) { - // 공통 에러 - errorCodeList.addAll(new LinkedHashSet<>(Set.of( - API_NOT_FOUND, - API_METHOD_NOT_ALLOWED, - API_SERVER_ERROR, - - API_MISSING_PARAM, - API_INVALID_PARAM, - API_INVALID_TYPE - ))); - - this.errorCodeList = errorCodeList; - } + SwaggerResponseDescription(Set<ErrorCode> errorCodeList) { + Set<ErrorCode> combined = new LinkedHashSet<>(errorCodeList); + // 공통 에러 + combined.addAll(Set.of( + API_NOT_FOUND, + API_METHOD_NOT_ALLOWED, + API_SERVER_ERROR, + API_MISSING_PARAM, + API_INVALID_PARAM, + API_INVALID_TYPE + )); + this.errorCodeList = Collections.unmodifiableSet(combined); + }
♻️ Duplicate comments (6)
src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (1)
98-105: 스웨거-런타임 불일치 가능성: RATING_INVALID_SCORE / RATING_ALREADY_REGISTERED이 두 코드가 실제 서비스에서 발생하지 않는다는 기존 리뷰가 있었습니다. 서비스 로직을 정합화하거나(권장) 여기서 제거해 문서를 동기화해 주세요. 선택지는 두 가지입니다.
- 서비스 수정: 파싱/범위/스텝(0~5, 0.5 스텝) 위반 시 RATING_INVALID_SCORE, 이미 평점 완료 시 RATING_ALREADY_REGISTERED를 던지기.
- 문서 수정: 현 행위에 맞춰 위 두 응답을 제거.
평점 유효성 검증은 정규식보다 상수 기반 수치 검증(BigDecimal) 사용을 권장합니다.
필요 시 서비스 레벨 구현 예시:
private static final BigDecimal MIN = BigDecimal.ZERO; private static final BigDecimal MAX = new BigDecimal("5.0"); private static final BigDecimal STEP = new BigDecimal("0.5"); static boolean isValidScore(String s) { try { BigDecimal v = new BigDecimal(s).setScale(1, RoundingMode.UNNECESSARY); return v.compareTo(MIN) >= 0 && v.compareTo(MAX) <= 0 && v.remainder(STEP).compareTo(BigDecimal.ZERO) == 0; } catch (ArithmeticException | NumberFormatException e) { return false; } }src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckResponse.java (1)
6-19: 이전 빈 DTO 이슈가 해소되었습니다. 매핑 스펙 적절합니다.src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (3)
33-44: 예약 기준 조회로 변경하지 않으면 잘못된 레이팅이 적용될 수 있습니다.
member+foodTruck+isRated=false는 동일 트럭 다중 예약 시 오매칭 위험이 큽니다.member+reservationId+isRated=false로 교체하세요.- ratingRepository.findByMemberAndFoodTruckAndIsRatedFalse(member, foodTruck) + ratingRepository.findPendingByMemberAndReservation(member, request.reservationId()) .ifPresentOrElse(rating -> { - double rate = Double.parseDouble(request.rating()); + // 아래 검증 로직과 함께 rate 계산 // 평점 등록 - rating.registerRating(rate); + rating.registerRating(rate);
25-45: 평점 값 검증 누락(NumberFormatException, 범위/증분) — 명세(0~5, 0.5 단위) 충족 필요.현재 파싱 예외가 500으로 전파되고 잘못된 값이 허용될 수 있습니다. BigDecimal 기반 검증을 추가하세요.
@@ - // 유효한 평점 값인지 확인 + // 유효한 평점 값인지 확인 (0.5 단위, 0~5) + java.math.BigDecimal bd; + try { + bd = new java.math.BigDecimal(request.rating()).setScale(1, java.math.RoundingMode.UNNECESSARY); + } catch (NumberFormatException | java.math.ArithmeticException e) { + throw new BusinessException(ErrorCode.RATING_INVALID_SCORE); + } + if (bd.compareTo(new java.math.BigDecimal("0.0")) < 0 || bd.compareTo(new java.math.BigDecimal("5.0")) > 0) { + throw new BusinessException(ErrorCode.RATING_INVALID_SCORE); + } + // 0.5 단위 확인: x*2가 정수 + java.math.BigDecimal doubled = bd.multiply(new java.math.BigDecimal("2")); + if (doubled.stripTrailingZeros().scale() > 0) { + throw new BusinessException(ErrorCode.RATING_INVALID_SCORE); + } + final double rate = bd.doubleValue(); @@ - double rate = Double.parseDouble(request.rating()); - // 평점 등록 - rating.registerRating(rate); + // 평점 등록 + rating.registerRating(rate);
40-41: 푸드트럭 평균 평점 갱신의 동시성(잃어버린 업데이트) 위험 — 잠금 또는 원자적 갱신 필요.동시 다발적 등록 시 read-modify-write가 충돌할 수 있습니다.
FoodTruck에@Version(낙관적 락) 추가 후 재시도, 또는 합계/건수 컬럼을 원자적으로 갱신하는 DB UPDATE로 전환을 권장합니다.예시(FoodTruck 일부):
@Version private Long version;또는 서비스 단 재시도 루프를 짧게 도입하세요(최대 3회 등).
src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java (1)
39-47: 저장 동시요청 레이스: exists 체크 후 save 사이 중복 생성 가능동시에 저장 요청이 들어오면 둘 다 existsBy... == false로 통과해 중복 레코드가 생성될 수 있습니다. DB 유니크 제약(회원ID+푸드트럭ID) + save 시 제약 위반 예외 변환이 필요합니다.
- 엔티티에 유니크 제약 추가(@table(uniqueConstraints=...))
- 서비스에서 save를 try/catch로 감싸고 DataIntegrityViolationException을 BusinessException(ErrorCode.SAVED_FOOD_TRUCK_ALREADY_EXIST)로 변환
서비스 수정 예:
import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DataIntegrityViolationException; ... if (request.isSavedRequest()) { if(savedFoodTruckRepository.existsByMemberAndFoodTruck(member, foodTruck)) { throw new BusinessException(ErrorCode.SAVED_FOOD_TRUCK_ALREADY_EXIST); } SavedFoodTruck savedFoodTruck = SavedFoodTruck.of(member, foodTruck); - savedFoodTruckRepository.save(savedFoodTruck); + try { + savedFoodTruckRepository.save(savedFoodTruck); + } catch (DataIntegrityViolationException e) { + throw new BusinessException(ErrorCode.SAVED_FOOD_TRUCK_ALREADY_EXIST); + } }엔티티 예(참고):
@Table( name = "saved_food_truck", uniqueConstraints = @UniqueConstraint( name = "uk_saved_food_truck_member_foodtruck", columnNames = {"member_id","food_truck_id"} ))
🧹 Nitpick comments (21)
src/main/java/konkuk/chacall/domain/user/presentation/dto/response/UserResponse.java (1)
7-12: 스키마 포맷 지정 및 예시 개선 제안(URI/Email 포맷, HTTPS 예시)문서 정확도를 높이기 위해 format 메타데이터를 추가하고 URL 예시는 https로 교체를 권장합니다.
- @Schema(description = "프로필 이미지 URL", example = "http://image.png", nullable = true) + @Schema(description = "프로필 이미지 URL", example = "https://example.com/image.png", nullable = true, format = "uri") ... - @Schema(description = "이메일", example = "email@email.com") + @Schema(description = "이메일", example = "user@example.com", format = "email")src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (2)
13-13: 불필요한 주석 라인 제거의미 없는
//단독 라인입니다. 제거해 주세요.
87-93: SavedFoodTruck 오류 세트 구성 OK — 도메인 소속·네이밍 정리 제안
- 도메인 소속: 저장/해제는 Member-FoodTruck 관계 애그리게잇(예: SavedFoodTruck) 관점이므로, ErrorCode 정의도 해당 컨텍스트(예: saved-food-truck/bookmark)로 묶는 것을 권장합니다. 전역 Enum을 쓰더라도 접두어로 도메인 경계를 명확히 유지하세요.
- 네이밍:
SAVED_FOOD_TRUCK_ALREADY_EXIST는 문법상...ALREADY_EXISTS가 자연스럽습니다(팀 합의 시 추후 일괄 변경).src/main/java/konkuk/chacall/domain/owner/application/OwnerService.java (1)
28-31: PR 요청사항 답변: 오류 코드 소속과 평점 검증 방식
- 저장/해제 오류 코드는 Member가 주체인 관계 리소스이므로
member도메인(예:member.saved하위) 쪽 에러 코드로 분류하는 것을 추천합니다.- 평점 검증은 정규식보다 수치 검증이 명확합니다: 범위(0~5) + 스텝(0.5). 구현은 값×2가 정수인지 체크(
value*2 % 1 == 0) 또는 Bean Validation 커스텀 어노테이션@Rating사용을 권장합니다.src/main/java/konkuk/chacall/global/common/dto/CursorPagingRequest.java (1)
7-7: size 상한 및 방어적 클램프 추가 권장클라이언트가 과도한
size를 넣어도 방어되도록 상한을 두고 생성자에서 클램프하면 안전합니다. 또한 스키마에 상한을 명시하면 API 사용성도 좋아집니다.import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Max; @Schema(description = "커서 기반 페이징 요청 DTO") public record CursorPagingRequest( @Schema(description = "마지막으로 조회된 데이터의 ID (다음 페이지 요청 시 사용)", example = "120", nullable = true) Long cursor, @Schema(description = "한 페이지에 조회할 개수", - defaultValue = "20", + defaultValue = "20", minimum = "1") @Min(value = 1, message = "size 는 1 이상이어야 합니다.") + @Max(value = 50, message = "size 는 50 이하여야 합니다.") Integer size ) { private static final int DEFAULT_SIZE = 20; + private static final int MAX_SIZE = 50; private static final long DEFAULT_CURSOR = Long.MAX_VALUE; public CursorPagingRequest { if (cursor == null) { cursor = DEFAULT_CURSOR; } - if (size == null) { - size = DEFAULT_SIZE; - } + // null/음수 방어 및 상한 클램프 + size = (size == null) ? DEFAULT_SIZE : Math.max(1, Math.min(size, MAX_SIZE)); } }Also applies to: 22-29
src/main/java/konkuk/chacall/domain/foodtruck/domain/value/PhotoUrlList.java (1)
22-27: 대표 이미지 부재 시 null 반환 — 계약 명시 또는 대안 제안
null반환은 호출부에서 NPE 리스크가 있으니 계약을 명확히 하거나Optional<String>/hasMainPhoto()보조 API를 추가하는 것을 권장합니다. 현재of()에서 내부 리스트는 절대 null이 아니므로isEmpty()구현도 단순화 가능합니다.가능한 보완안:
public boolean hasMainPhoto() { return !isEmpty(); }- Javadoc에 “이미지 없으면 null” 명시 또는
Optional<String> getMainPhotoUrlOptional()제공src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java (1)
20-28: 성능 메모: 인덱스와 조건 정합성쿼리 패턴(
member+id DESC+id < :cursor)에 맞춰(member_id, saved_food_truck_id DESC)복합 인덱스를 추천합니다. 초기 요청에서cursor=Long.MAX_VALUE를 쓰는 설계는 OK이며 인덱스 범위 스캔과도 잘 맞습니다.src/main/java/konkuk/chacall/domain/member/domain/repository/RatingRepository.java (1)
16-21: 중복 제거와 널 허용 관계 고려 — DISTINCT/LEFT JOIN FETCH 적용 제안.현재 fetch join이 중복 Row를 유발할 소지가 있습니다. 또한 예약 연결이 선택적이면 LEFT JOIN이 안전합니다.
- @Query("SELECT r FROM Rating r " + - "JOIN FETCH r.foodTruck f " + - "JOIN FETCH r.reservation re " + - "WHERE r.member = :member " + - "AND r.isRated = false") + @Query("SELECT DISTINCT r FROM Rating r " + + "LEFT JOIN FETCH r.foodTruck f " + + "LEFT JOIN FETCH r.reservation re " + + "WHERE r.member = :member " + + "AND r.isRated = false") List<Rating> findAllByMemberAndIsRatedFalse(User member);src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckResponse.java (2)
20-28: 널 안정성 보강: 사진/평점 정보가 없을 때 NPE 및 null 응답 방지.
getFoodTruckPhotoList()/getRatingInfo()가 null일 경우를 방어하세요. 기본값(평균 0.0, 건수 0) 적용을 권장합니다.- return new SavedFoodTruckResponse( - foodTruck.getFoodTruckId(), - foodTruck.getName(), - foodTruck.getFoodTruckPhotoList().getMainPhotoUrl(), // 대표 사진 (첫 번째 사진) - foodTruck.getDescription(), - foodTruck.getRatingInfo().getAverageRating(), - foodTruck.getRatingInfo().getRatingCount() - ); + var photoList = foodTruck.getFoodTruckPhotoList(); + String mainPhotoUrl = (photoList != null) ? photoList.getMainPhotoUrl() : null; + var ratingInfo = foodTruck.getRatingInfo(); + Double avg = (ratingInfo != null && ratingInfo.getAverageRating() != null) ? ratingInfo.getAverageRating() : 0.0; + Integer cnt = (ratingInfo != null && ratingInfo.getRatingCount() != null) ? ratingInfo.getRatingCount() : 0; + return new SavedFoodTruckResponse( + foodTruck.getFoodTruckId(), + foodTruck.getName(), + mainPhotoUrl, + foodTruck.getDescription(), + avg, + cnt + );
6-19: primitive 타입 고려(선택).API 일관성을 위해 평균/카운트를 primitive(double/int)로 노출해 null을 피하는 것도 검토해주세요.
src/main/java/konkuk/chacall/domain/member/application/MemberService.java (2)
17-19: 오류 코드 도메인 소속 가이드(리뷰 요청 답변).
- 저장 상태: FoodTruck 자체가 아니라 “회원-푸드트럭 관계” 애그리게이트(예: SavedFoodTruck) 소속 코드로 두는 것이 응집도와 책임 분리가 좋습니다. 예) SAVED_FOOD_TRUCK_ALREADY_EXIST, SAVED_FOOD_TRUCK_NOT_FOUND → member.savedtruck 도메인.
- 평점: Rating(및 예약 연결) 도메인에 소속. 예) RATING_NOT_FOUND, RATING_ALREADY_REGISTERED, RATING_INVALID_SCORE.
- 기술적 매핑(HTTP 상태/전역 에러 응답 포맷)은 global 레이어에서 처리하되, 에러 코드는 도메인별로 정의/소유하세요.
41-47: registerRatings → registerRating로 네이밍 단수화 제안동작이 단건 등록이므로 단수형이 의도 전달에 더 명확합니다. 리네임 시 다음 정의/호출부를 함께 변경하세요:
- src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java — 선언 55행, 호출 59행
- src/main/java/konkuk/chacall/domain/member/application/MemberService.java — 정의 41행, ratingService 호출 46행
- src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java — 정의 26행
IDE 자동 리네임으로 호출부 일괄 수정 권장.
src/main/java/konkuk/chacall/domain/member/presentation/dto/response/ReservationForRatingResponse.java (2)
26-29: 스키마의 “최대 2개” 문구와 실제 반환 불일치 — 상위 2개로 제한하세요.현재 전체 리스트를 그대로 반환합니다.
- List<String> dateTimeList = reservation.getReservationInfo().getFormattedDateTimeInfos(); + List<String> dateTimeList = reservation.getReservationInfo().getFormattedDateTimeInfos(); + if (dateTimeList.size() > 2) { + dateTimeList = dateTimeList.subList(0, 2); + }
30-39: 널 안정성 보강(선택): 사진/주소 정보 null 방어.
foodTruck.getFoodTruckPhotoList()또는reservation.getReservationInfo()가 null이면 NPE가 납니다. 간단한 가드/기본값을 권장합니다.- foodTruck.getFoodTruckPhotoList().getMainPhotoUrl(), // 대표 사진 (첫 번째 사진) - reservation.getReservationInfo().getReservationAddress(), + (foodTruck.getFoodTruckPhotoList() != null + ? foodTruck.getFoodTruckPhotoList().getMainPhotoUrl() : null), + (reservation.getReservationInfo() != null + ? reservation.getReservationInfo().getReservationAddress() : null),src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (1)
25-45: 검증 책임의 위치: 정규식 vs 상수(리뷰 요청 답변).정규식보다는 숫자 도메인 규칙을 보존하는 값 객체/검증 유틸을 권장합니다. 예)
RatingScoreVO가 생성자에서 (0~5, 0.5단위) 보장 → 서비스는 VO만 받도록. 간단히는 BigDecimal 검증(위 diff)로 대체 가능합니다.src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java (3)
63-69: getReservationsForRating에 @ExceptionDescription 누락다른 엔드포인트들과 통일성을 위해 예외 스펙을 스웨거에 노출하세요.
@Operation(summary = "평점을 등록할 예약 조회", description = "평점을 등록할 수 있는 지난 예약 목록을 조회합니다.") + @ExceptionDescription(SwaggerResponseDescription.MEMBER_GET_RESERVATIONS_FOR_RATING) @GetMapping("/me/ratings/reservations") public BaseResponse<ReservationForRatingResponse> getReservationsForRating( @Parameter(hidden = true) @UserId final Long memberId ) { return BaseResponse.ok(memberService.getReservationsForRating(memberId)); }위 상수명이 실제로 존재하는지 확인 부탁드립니다. 없다면 OWNER 쪽 네이밍 컨벤션을 따라 새 항목을 추가해 주세요.
52-61: 평점 값 검증: 정규식 대신 수치 기반 Bean Validation 권장숫자 필드에 정규식을 쓰면 파싱/에러 메시지 모두 비직관적입니다. 0.0~5.0 범위 및 0.5 step 검증은 Bean Validation으로 수치 기반 제약을 적용하세요.
- DTO 필드에 @DecimalMin("0.0") @DecimalMax("5.0") 부여
- 0.5 배수 검증은 커스텀 제약(예: @halfstep)으로 구현
예시(검증기):
@Target({ FIELD, PARAMETER }) @Retention(RUNTIME) @Constraint(validatedBy = HalfStepValidator.class) public @interface HalfStep { String message() default "평점은 0.5 단위여야 합니다."; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } public class HalfStepValidator implements ConstraintValidator<HalfStep, BigDecimal> { public boolean isValid(BigDecimal v, ConstraintValidatorContext c) { if (v == null) return true; return v.multiply(BigDecimal.valueOf(2)).stripTrailingZeros().scale() <= 0; } }DTO 필드:
@DecimalMin("0.0") @DecimalMax("5.0") @HalfStep private BigDecimal rating;이는 정규식보다 유지보수성과 오류 메시지 품질이 높습니다.
55-61: 생성 작업의 응답 코드평점 등록이 새로운 리소스를 생성한다면 200 OK 대신 201 Created(+ 선택적 Location 헤더)를 고려해보세요. 현재 공통 응답 스펙(BaseResponse) 정책에 맞춘 결정이라면 유지해도 무방합니다.
src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java (3)
35-38: 삭제/비활성 상태 푸드트럭 처리 정책 확인FoodTruck 존재만 확인합니다. 도메인 정책상 비활성(예: CLOSED/DELISTED) 푸드트럭은 저장 불가라면 상태 필터를 추가하세요.
54-55: 응답 값 신뢰성현재 응답 boolean은 요청 플래그를 그대로 반환합니다. save/delete 모두 성공 시엔 문제 없지만, 추후 정책 변경(멱등 처리 등) 시 실제 저장 상태를 조회해 반환하는 편이 안전합니다.
13-16: 에러코드 소속(도메인) 정리 제안저장 상태 관련 에러코드(SAVED_FOOD_TRUCK_ALREADY_EXIST/NOT_FOUND)는 Member-저장 도메인에 네임스페이스를 부여하는 편이 가독성과 유지보수에 유리합니다. 예: MEMBER_SAVED_FOOD_TRUCK_ALREADY_EXISTS, MEMBER_SAVED_FOOD_TRUCK_NOT_FOUND 또는 하위 enum/클래스 분리.
필요하시면 ErrorCode 리팩터링 초안 드리겠습니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (18)
src/main/java/konkuk/chacall/domain/foodtruck/domain/value/MenuCategoryList.java(0 hunks)src/main/java/konkuk/chacall/domain/foodtruck/domain/value/PhotoUrlList.java(1 hunks)src/main/java/konkuk/chacall/domain/foodtruck/domain/value/RatingInfo.java(1 hunks)src/main/java/konkuk/chacall/domain/member/application/MemberService.java(1 hunks)src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java(1 hunks)src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java(1 hunks)src/main/java/konkuk/chacall/domain/member/domain/SavedFoodTruck.java(2 hunks)src/main/java/konkuk/chacall/domain/member/domain/repository/RatingRepository.java(1 hunks)src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/response/ReservationForRatingResponse.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckResponse.java(1 hunks)src/main/java/konkuk/chacall/domain/owner/application/OwnerService.java(2 hunks)src/main/java/konkuk/chacall/domain/owner/presentation/dto/request/GetReservationHistoryRequest.java(2 hunks)src/main/java/konkuk/chacall/domain/user/presentation/dto/response/UserResponse.java(1 hunks)src/main/java/konkuk/chacall/global/common/dto/CursorPagingRequest.java(2 hunks)src/main/java/konkuk/chacall/global/common/dto/HasPaging.java(1 hunks)src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java(2 hunks)
💤 Files with no reviewable changes (1)
- src/main/java/konkuk/chacall/domain/foodtruck/domain/value/MenuCategoryList.java
🚧 Files skipped from review as they are similar to previous changes (2)
- src/main/java/konkuk/chacall/domain/foodtruck/domain/value/RatingInfo.java
- src/main/java/konkuk/chacall/domain/member/domain/SavedFoodTruck.java
🧰 Additional context used
🧬 Code graph analysis (4)
src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java (2)
src/main/java/konkuk/chacall/global/common/exception/EntityNotFoundException.java (1)
EntityNotFoundException(6-11)src/main/java/konkuk/chacall/domain/member/application/MemberService.java (1)
Service(17-56)
src/main/java/konkuk/chacall/domain/member/application/MemberService.java (2)
src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java (1)
Service(24-71)src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (1)
Service(17-56)
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (1)
src/main/java/konkuk/chacall/domain/member/application/MemberService.java (1)
Service(17-56)
src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java (1)
src/main/java/konkuk/chacall/domain/owner/presentation/OwnerController.java (1)
Tag(25-173)
🪛 GitHub Actions: CI with Gradle
src/main/java/konkuk/chacall/domain/owner/presentation/dto/request/GetReservationHistoryRequest.java
[error] 10-10: GetReservationHistoryRequest is not abstract and does not override abstract method pagingRequest() in HasPaging. Command: './gradlew clean test --stacktrace'
🔇 Additional comments (6)
src/main/java/konkuk/chacall/domain/user/presentation/dto/response/UserResponse.java (1)
3-3: LGTM: Swagger 스키마 임포트 추가 적절의존성/네임스페이스도 일치합니다.
src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (2)
94-97: 목록 조회 에러 세트 적절인증/인가 실패만 기술한 구성이 합리적입니다. 파라미터 오류는 공통(API_INVALID_PARAM 등)으로 커버됩니다.
36-39: 확인 완료 — OWNER_ 블록에 USER_FORBIDDEN 누락 없음*스크립트 검사 결과, src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java의 모든 OWNER_* 블록에 USER_FORBIDDEN이 포함되어 있습니다.
src/main/java/konkuk/chacall/global/common/dto/HasPaging.java (1)
4-8: 타입 일원화 LGTM
CursorPagingRequest로의 일원화 적절합니다. 기본 구현(pagingOrDefault)이 널 처리까지 포함해 호출부 단순화에 기여합니다.src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java (1)
20-28: 페치 조인 주의사항 점검현재
sft.foodTruck가 to-one이면FETCH JOIN+Slice조합은 안전합니다. 만약 to-many로 변경될 여지가 있으면 중복/페이징 왜곡이 생기므로DISTINCT또는 두 단계 조회로 분리해야 합니다.src/main/java/konkuk/chacall/domain/owner/application/OwnerService.java (1)
101-103: CursorPagingRequest 적용 LGTM — 시그니처 확인 완료OwnerReservationService#getOwnerReservations 시그니처는 (Long ownerId, ReservationStatus status, Long lastCursor, int pageSize)입니다 — 호출부( ownerId, request.status(), cursorPagingRequest.cursor(), cursorPagingRequest.size() )의 인자 순서와 호출 지점은 적절합니다.
| public CursorPagingResponse<SavedFoodTruckResponse> getSavedFoodTrucks(CursorPagingRequest cursorPagingRequest, User member) { | ||
| // 저장된 푸드트럭 목록 조회 | ||
| Slice<SavedFoodTruck> savedFoodTruckSlice = savedFoodTruckRepository | ||
| .findMemberSavedFoodTruckWithCursor(member, cursorPagingRequest.cursor(), PageRequest.of(0, cursorPagingRequest.size())); | ||
| List<SavedFoodTruck> savedFoodTrucks = savedFoodTruckSlice.getContent(); | ||
|
|
||
| // 응답 DTO로 변환 | ||
| List<SavedFoodTruckResponse> responses = savedFoodTrucks.stream() | ||
| .map(SavedFoodTruck::getFoodTruck) | ||
| .map(SavedFoodTruckResponse::of) | ||
| .toList(); | ||
|
|
||
| return CursorPagingResponse.of(responses, SavedFoodTruckResponse::foodTruckId, savedFoodTruckSlice.hasNext()); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
저장목록 조회: N+1 및 커서 정렬 키 일치 여부 확인
- N+1 위험: stream에서 getFoodTruck 접근 시 LAZY면 추가 쿼리가 발생합니다. 리포지토리에서 fetch join 또는 @EntityGraph로 FoodTruck을 함께 조회하는지 확인하세요.
- 커서 정렬 일치: CursorPagingResponse.of(..., SavedFoodTruckResponse::foodTruckId, ...)는 커서 키가 FoodTruck ID임을 전제합니다. 리포지토리 정렬/필터가 SavedFoodTruck ID나 createdAt 기준이면 페이지 누락/중복이 발생합니다. 정렬 기준과 커서 키를 일치시켜 주세요.
검증용 스크립트:
권장:
- 리포지토리: order by food_truck_id desc (또는 선택한 커서 키) + where food_truck_id < :cursor 형태로 통일
- DTO: Cursor key provider가 리포지토리 정렬 키와 동일하도록 유지
🏁 Script executed:
#!/bin/bash
# 리포지토리 메서드 정의 및 JPQL/EntityGraph 확인
rg -n -C3 "interface\s+SavedFoodTruckRepository|class\s+SavedFoodTruckRepository" --type=java
rg -n -C3 "findMemberSavedFoodTruckWithCursor" --type=java
rg -n -C3 "@EntityGraph|fetch join" --type=java
# 응답 DTO의 커서 키 구현 확인
fd "SavedFoodTruckResponse.java" -t f
rg -n -C2 "foodTruckId\s*\(" $(fd "SavedFoodTruckResponse.java" -t f)Length of output: 3085
긴급: 커서 정렬 키 불일치 및 N+1 위험 — 즉시 수정 필요
- 검증 결과: SavedFoodTruckRepository.findMemberSavedFoodTruckWithCursor(...) JPQL이 "WHERE sft.savedFoodTruckId < :lastCursor ORDER BY sft.savedFoodTruckId DESC"로 savedFoodTruckId 기준 페이징을 수행합니다. (파일: src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java)
- 커서 불일치(심각): 서비스는 CursorPagingResponse.of(..., SavedFoodTruckResponse::foodTruckId, ...)로 foodTruckId를 커서로 가정합니다(파일: src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java, DTO: src/main/java/konkuk/chacall/domain/member/presentation/dto/response/SavedFoodTruckResponse.java). 둘 중 하나로 통일하세요 — DTO의 커서 제공자를 savedFoodTruckId로 변경하거나, 리포지토리의 정렬/필터를 foodTruckId 기준으로 변경.
- N+1 위험: savedFoodTruck.getFoodTruck() 접근 시 LAZY 연관이면 추가 쿼리 발생. 해결책: findMemberSavedFoodTruckWithCursor 쿼리에 JOIN FETCH sft.foodTruck 또는 @EntityGraph 적용하거나, 필요한 필드만 조회하는 프로젝션으로 변경. (수정 위치: 위 Repository 메서드 및 Service/Response 변환 로직)
| Optional<Rating> findByMemberAndFoodTruckAndIsRatedFalse(User member, FoodTruck foodTruck); | ||
|
|
There was a problem hiding this comment.
예약 기준으로 단일 레이팅을 잠그고 조회하세요 (중복/오매칭 방지).
동일 푸드트럭에 대한 복수 예약이 가능한 모델이면 member + foodTruck + isRated=false 조건은 잘못된 레이팅을 집어올 수 있습니다. member + reservationId + isRated=false로 범위를 좁히고 쓰기 잠금을 적용해 동시 등록을 막아주세요.
다음 메서드를 추가하는 것을 권장합니다(쓰기 잠금 포함):
import org.springframework.data.jpa.repository.Lock;
import jakarta.persistence.LockModeType;
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select r
from Rating r
join fetch r.foodTruck f
join fetch r.reservation re
where r.member = :member
and re.reservationId = :reservationId
and r.isRated = false
""")
Optional<Rating> findPendingByMemberAndReservation(User member, Long reservationId);🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/domain/repository/RatingRepository.java
around lines 14-15, the current finder uses member+foodTruck+isRated=false which
can return wrong Rating when a member has multiple reservations; replace or add
a method that narrows by reservationId and applies a pessimistic write lock to
prevent concurrent writes: add a repository method annotated with
@Lock(LockModeType.PESSIMISTIC_WRITE) and a JPQL @Query that selects Rating r
join fetch r.foodTruck join fetch r.reservation where r.member = :member and
r.reservation.reservationId = :reservationId and r.isRated = false, returning
Optional<Rating>; also import org.springframework.data.jpa.repository.Lock and
jakarta.persistence.LockModeType so the locking annotation compiles.
| boolean existsByMemberAndFoodTruck(User member, FoodTruck foodTruck); | ||
|
|
||
| Optional<SavedFoodTruck> findByMemberAndFoodTruck(User member, FoodTruck foodTruck); |
There was a problem hiding this comment.
중복 저장 레이스 컨디션 방지: DB 유니크 제약 필수
existsBy... → insert 패턴만으로는 동시 요청에서 중복 레코드가 발생할 수 있습니다. 엔티티 테이블에 (member_id, food_truck_id) 유니크 제약을 두고, 서비스에서 DataIntegrityViolationException을 잡아 도메인 예외로 변환하세요.
예시(JPA 엔티티):
@Table(
name = "saved_food_truck",
uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "food_truck_id"})
)
public class SavedFoodTruck { ... }🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java
lines 16-18: the existsBy+insert pattern can produce duplicate rows under
concurrent requests—add a DB-level unique constraint on (member_id,
food_truck_id) in the SavedFoodTruck JPA entity (annotate the entity's @Table
with a UniqueConstraint for those two columns and ensure column names match the
FK column names), add a corresponding DB migration/index so the constraint
exists in the database, and in the service method that creates/saves
SavedFoodTruck wrap the save in a try/catch that catches
DataIntegrityViolationException (or the underlying constraint exception) and
converts it into a domain-level DuplicateSavedFoodTruckException (or appropriate
domain error) so concurrent inserts are handled gracefully.
| @Query("SELECT sft FROM SavedFoodTruck sft " + | ||
| "JOIN FETCH sft.foodTruck ft " + | ||
| "WHERE sft.member = :member " + | ||
| "AND sft.savedFoodTruckId < :lastCursor " + | ||
| "ORDER BY sft.savedFoodTruckId DESC") | ||
| Slice<SavedFoodTruck> findMemberSavedFoodTruckWithCursor( | ||
| @Param("member") User member, | ||
| @Param("lastCursor") Long lastCursor, | ||
| Pageable pageable); |
There was a problem hiding this comment.
🧩 Analysis chain
커서 페이징 완결성 점검(이탈 여부 계산용 size+1 패턴)
Slice의 hasNext 판정을 위해 서비스 단에서 PageRequest.of(0, size + 1)로 한 건 더 조회하는지 확인이 필요합니다. 미적용 시 hasNext/다음 커서 계산이 부정확할 수 있습니다.
다음으로 서비스 구현을 확인하세요.
🏁 Script executed:
#!/bin/bash
rg -nP -C3 '\bfindMemberSavedFoodTruckWithCursor\s*\(' src/main/java
rg -nP -C2 'PageRequest\.of\(\s*0\s*,\s*size\s*\+\s*1\s*\)' src/main/javaLength of output: 2137
커서 페이징: 조회를 size+1로 늘려야 함
SavedFoodTruckService#getSavedFoodTrucks가 PageRequest.of(0, cursorPagingRequest.size())를 사용 중이라 Slice.hasNext/다음 커서 계산이 부정확합니다.
위치: src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java:60 — PageRequest.of(0, cursorPagingRequest.size() + 1)로 변경하고, 응답으로는 최초 size개만 반환(예: subList(0, min(size, list.size())))하세요. hasNext = fetchedCount > requestedSize, nextCursor = 반환된 리스트 마지막 항목의 savedFoodTruckId.
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/domain/repository/SavedFoodTruckRepository.java
around lines 20-28 and in
src/main/java/konkuk/chacall/domain/member/application/foodtruck/SavedFoodTruckService.java
around line 60: the cursor paging logic fetches exactly size items which
prevents correctly detecting hasNext and computing nextCursor; change the
PageRequest creation in SavedFoodTruckService from PageRequest.of(0,
cursorPagingRequest.size()) to PageRequest.of(0, cursorPagingRequest.size() +
1), then after fetching trim the returned list to at most the requested size
(e.g., subList(0, min(size, list.size()))), set hasNext = fetchedCount >
requestedSize, and set nextCursor to the savedFoodTruckId of the last element in
the trimmed returned list; ensure you only expose the trimmed list in the
response.
| @Operation(summary = "푸드트럭 저장상태 변경", description = "푸드트럭 저장상태를 변경합니다. (저장/저장취소)") | ||
| @ExceptionDescription(SwaggerResponseDescription.MEMBER_SAVED_FOOD_TRUCK) | ||
| @PatchMapping("/me/food-trucks/{foodTruckId}") | ||
| public BaseResponse<SavedFoodTruckStatusResponse> updateFoodTruckSaveStatus( | ||
| @Parameter(description = "푸드트럭 식별자", example = "1") final @PathVariable Long foodTruckId, | ||
| @RequestBody @Valid final UpdateFoodTruckSaveStatusRequest request, | ||
| @Parameter(hidden = true) @UserId final Long memberId | ||
| ) { | ||
| return BaseResponse.ok(memberService.updateFoodTruckSaveStatus(request, foodTruckId, memberId)); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
권한/상태 검증 위치 일관성 확인 요청
컨트롤러 레벨에 권한 어노테이션이 없고, MemberService에서 memberValidator.validateAndGetMember(memberId)를 호출하는 구조입니다. 기존 Owner API와의 일관성을 위해 validateAndGetMember가 (1) ROLE=MEMBER, (2) STATUS=ACTIVE를 모두 보장하는지 확인해주세요.
필요 시 MemberController에 @PreAuthorize("hasRole('MEMBER')")를 추가하는 것도 고려 가능합니다(전역 필터로 대체 중이면 패스).
🏁 Script executed:
#!/bin/bash
# MemberValidator의 검증 범위를 확인
rg -n -C3 "class\s+MemberValidator|validateAndGetMember" --type=java
rg -n -C2 "ROLE.*MEMBER|Status.*ACTIVE" --type=javaLength of output: 8549
권한(Role) 검증 누락 — MemberValidator에서 ROLE=MEMBER 확인 필요
MemberValidator.validateAndGetMember은 userRepository.findByUserIdAndStatus(memberId, ACTIVE)로 ACTIVE 상태만 확인합니다(파일: src/main/java/konkuk/chacall/domain/member/application/validator/MemberValidator.java:19-21). Role 검증이 없어 권한 분리 일관성이 깨집니다. 조치 옵션(간단히 선택):
- 권장(보안): MemberValidator.validateAndGetMember에서 Role.MEMBER도 함께 확인하도록 변경 — userRepository.findByUserIdAndRoleAndStatus(memberId, Role.MEMBER, BaseStatus.ACTIVE) 등으로 조회하고 없으면 예외 처리. (수정 위치: src/main/java/konkuk/chacall/domain/member/application/validator/MemberValidator.java)
- 대안: 컨트롤러 레벨에 @PreAuthorize("hasRole('MEMBER')") 추가하여 진입 권한을 강제(전역 필터로 이미 처리 중이면 불필요).
- 이미 전역 보안에서 역할 검증 중이면 그 사실을 문서/주석으로 명시하고 validate 메서드에 주석 추가.
🤖 Prompt for AI Agents
In src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java
around lines 31-40 the endpoint lacks explicit role validation; fix it by
updating the validator: modify
src/main/java/konkuk/chacall/domain/member/application/validator/MemberValidator.java
(around lines 19-21) so validateAndGetMember queries by userId, Role.MEMBER and
BaseStatus.ACTIVE (e.g., userRepository.findByUserIdAndRoleAndStatus(memberId,
Role.MEMBER, BaseStatus.ACTIVE)) and throw the existing/not-found exception if
absent; alternatively, if you prefer controller-level enforcement, add
@PreAuthorize("hasRole('MEMBER')") to this controller method and/or document
that global security already enforces the role.
| @Schema(description = "성별", example = "남성") | ||
| String gender, | ||
| @Schema(description = "약관 동의 여부", example = "true") | ||
| boolean termAgreed |
There was a problem hiding this comment.
API 스키마와 실제값 불일치: gender는 null 가능인데 문서에 nullable 표시 누락
from(User)에서 gender를 null로 내려줄 수 있습니다(Line 23). 하지만 @Schema(nullable=true)가 없어 클라이언트에 비필수/nullable임이 문서화되지 않습니다. 스키마 불일치로 클라 코드 생성 시 런타임 NPE/검증 오류를 유발할 수 있습니다. 아래처럼 nullable=true를 추가해 주세요.
- @Schema(description = "성별", example = "남성")
+ @Schema(description = "성별", example = "남성", nullable = true)Also applies to: 19-25
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/user/presentation/dto/response/UserResponse.java
around lines 13-16 (also apply same change to lines 19-25), the OpenAPI @Schema
for gender omits nullable=true despite the from(User) mapping allowing gender to
be null; update the @Schema annotation on the gender field to include
nullable=true so the generated API docs and client code reflect that gender is
optional/nullable, ensuring consistency and preventing client-side
NPE/validation errors.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (1)
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (1)
72-76: 상수명 복수형으로 정정 권장: SAVED_FOOD_TRUCK_ALREADY_EXISTS기존 리뷰와 동일 코멘트입니다. 복수형 일관성 유지 차원에서 변경을 권장합니다.
- SAVED_FOOD_TRUCK_ALREADY_EXIST(HttpStatus.CONFLICT, 120002, "이미 저장된 푸드트럭입니다."), + SAVED_FOOD_TRUCK_ALREADY_EXISTS(HttpStatus.CONFLICT, 120002, "이미 저장된 푸드트럭입니다."),아래 사용처도 함께 치환해 주세요(예: SwaggerResponseDescription, SavedFoodTruckService 등).
#!/bin/bash set -euo pipefail rg -n --hidden --no-ignore '\bSAVED_FOOD_TRUCK_ALREADY_EXIST\b' -C2
🧹 Nitpick comments (2)
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (1)
60-65: 평점 검증 전용 에러코드 신설 제안 (RATING_INVALID_SCORE)0~5 범위/0.5 스텝 위반을 도메인 에러로 분리하면 클라이언트 처리/문서화가 선명해집니다. 아래 상수 추가를 권장합니다.
/** * Rating */ RATING_NOT_FOUND(HttpStatus.NOT_FOUND, 100001, "평점을 매길 수 없는 푸드트럭입니다."), RATING_ALREADY_REGISTERED(HttpStatus.BAD_REQUEST, 100002, "이미 평점이 등록된 상태입니다."), + RATING_INVALID_SCORE(HttpStatus.BAD_REQUEST, 100003, "평점은 0~5 범위의 0.5 단위만 허용됩니다."),추가로, 서비스 로직에서 파싱/범위/스텝 위반 시 BusinessException(RATING_INVALID_SCORE)로 매핑해 주세요. (정규식보다는 BigDecimal 기반 검증을 추천: 범위 비교 + 0.5 배수 여부 remainder 체크.)
src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (1)
86-109: 상수명 수정 및 평점 유효성 에러 추가 제안
- 상수명 정정(사소한 리팩터) — SwaggerResponseDescription 사용처 수정 (src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java).
- SAVED_FOOD_TRUCK_ALREADY_EXIST, + SAVED_FOOD_TRUCK_ALREADY_EXISTS,
- 평점 유효성 에러 추가(권장) — 저장소에서 RATING_INVALID_SCORE는 존재하지 않습니다. ErrorCode에 RATING_INVALID_SCORE를 추가하고 MEMBER_RATING에 포함하세요. 현재 RATING_ALREADY_REGISTERED는 ErrorCode.java(src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java:64) 및 Rating.registerRating(src/main/java/konkuk/chacall/domain/member/domain/Rating.java:44)에서 사용 중입니다.
MEMBER_RATING(new LinkedHashSet<>(Set.of( USER_NOT_FOUND, USER_FORBIDDEN, FOOD_TRUCK_NOT_FOUND, RATING_NOT_FOUND, - RATING_ALREADY_REGISTERED + RATING_ALREADY_REGISTERED, + RATING_INVALID_SCORE ))),
- 검증 구현 권장: 정규식 대신 BigDecimal로 0 ≤ score ≤ 5, 0.5 단위(예: score.multiply(2)의 정수성 검사)로 처리해 부동소수점 오차를 회피하세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java(1 hunks)src/main/java/konkuk/chacall/domain/member/presentation/dto/response/ReservationForRatingResponse.java(1 hunks)src/main/java/konkuk/chacall/global/common/dto/HasPaging.java(1 hunks)src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java(2 hunks)src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
- src/main/java/konkuk/chacall/domain/member/presentation/dto/response/ReservationForRatingResponse.java
- src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java
- src/main/java/konkuk/chacall/global/common/dto/HasPaging.java
🔇 Additional comments (4)
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (3)
37-37: USER_FORBIDDEN 추가 적절권한 오류를 403으로 명확히 구분한 선택, 좋습니다. 컨트롤러/핸들러에서 일관되게 매핑되는지만 확인해 주세요.
58-59: 콤마 추가 OKenum 항목 확장 대비한 쉼표 추가 문제 없습니다.
66-70: FOOD_TRUCK_NOT_FOUND 추가 적절도메인 경계가 명확해져 사용성 좋습니다.
src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java (1)
34-84: OWNER 섹션에 USER_FORBIDDEN 포함 — 컨트롤러/예외 매핑 확인 필요src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java의 OWNER_* 항목들이 USER_FORBIDDEN을 포함합니다. 레포 검색으로 ErrorCode.USER_FORBIDDEN을 직접 throw(예: throw new BusinessException(ErrorCode.USER_FORBIDDEN))하거나 권한 어노테이션(@PreAuthorize 등) 또는 @ControllerAdvice/전역 필터에서 해당 에러로 변환하는 구현을 찾지 못했습니다. 컨트롤러/서비스에서 USER_FORBIDDEN을 실제로 발생시키는지 또는 전역 핸들러/필터가 403으로 매핑하는지 확인하고, 불일치하면 Swagger 또는 에러 처리 로직을 수정하세요.
ksg1227
left a comment
There was a problem hiding this comment.
전체적으로 기존 코드 구조를 유지하면서 효율적으로 잘 구현해주신 것 같습니다!! 흠 잡을 곳 없네요 역시 띱으로 단련된 사람 굿굿 👍🏻👍🏻
| @Query("SELECT sft FROM SavedFoodTruck sft " + | ||
| "JOIN FETCH sft.foodTruck ft " + | ||
| "WHERE sft.member = :member " + | ||
| "AND sft.savedFoodTruckId < :lastCursor " + | ||
| "ORDER BY sft.savedFoodTruckId DESC") | ||
| Slice<SavedFoodTruck> findMemberSavedFoodTruckWithCursor( | ||
| @Param("member") User member, | ||
| @Param("lastCursor") Long lastCursor, | ||
| Pageable pageable); |
There was a problem hiding this comment.
적절한 fetch join 좋습니다!
원래 페이징 로직에서 fetch join 을 하면 문제가 터지는 케이스가 존재하는 것으로 아는데, 그러한 문제는 oneToMany 관계에서 fetch join 을 거는 경우에 문제가 발생하고, 현재와 같은 ManyToOne 관계에서는 문제가 터지지 않는 것 같네요! 굿
| List<String> dateTimeInfos | ||
| ) { | ||
| public static ReservationForRating of(FoodTruck foodTruck, Reservation reservation) { | ||
| List<String> dateTimeList = reservation.getReservationInfo().getFormattedDateTimeInfos(); |
There was a problem hiding this comment.
오호 메서드 확인하고 적절히 가져다가 사용하셨군요 아주 좋습니다 👍🏻 편안 -
|
|
||
| @Schema(description = "평점 (0~5 범위 내에 0.5 단위)", example = "4.5") | ||
| @Pattern( | ||
| regexp = "^(?:[0-4](?:\\.0|\\.5)|5(?:\\.0)?)$", |
| public void validateOwner() { | ||
| if (this.role != Role.OWNER) { | ||
| throw new DomainRuleException(ErrorCode.USER_FORBIDDEN, | ||
| new IllegalArgumentException("사용자는 사장님 권한이 없습니다. " + this.role)); | ||
| } | ||
| } | ||
|
|
||
| public void validateMember() { | ||
| if (this.role == Role.NON_SELECTED) { | ||
| throw new DomainRuleException(ErrorCode.USER_FORBIDDEN, | ||
| new IllegalArgumentException("사용자는 일반 유저 권한이 없습니다. " + this.role)); | ||
| } | ||
| } |
There was a problem hiding this comment.
오호 디버깅을 위해서 Exception 도 담으셨군요! 좋은 습관 같습니다.
카톡에서도 이미 이야기하긴 했지만, 이렇게 Role 검증 분리한 것 좋네요 굿
| ratingRepository.findByMemberAndFoodTruckAndIsRatedFalse(member, foodTruck) | ||
| .ifPresentOrElse(rating -> { | ||
| double rate = Double.parseDouble(request.rating()); | ||
| // 평점 등록 | ||
| rating.registerRating(rate); | ||
|
|
||
| // 푸드트럭의 평균 평점 업데이트 | ||
| foodTruck.updateAverageRating(rate); | ||
| }, () -> { | ||
| throw new BusinessException(ErrorCode.RATING_NOT_FOUND); | ||
| }); |
There was a problem hiding this comment.
이 부분에서 코드 래빗 리뷰처럼 사실상 RegisterRatingRequest 내의 reservationId 를 활용하고있지 않은 것 같습니다.
애초에 평점을 남겨야하는지 여부는 reservationId 로만은 판단이 불가하다고 생각하긴 합니다.
특정 푸드트럭을 예약 하긴 했지만 아직 평점을 남기지 않은 경우, 추후에 또 다시 동일한 푸드트럭을 예약하게되면 과거의 예약은 평점을 남기지 않기 때문이죠.
그래서 어차피 Member랑 foodTruck 으로 평점 등록 여부를 확인할거라면 RegisterRatingRequest 에서 reservationId 를 안받아도 될 것 같습니다! 다만 좀 더 확실하게 검증하고싶다하면 reservation 자체가 존재하는지 여부를 확인하는데 써도 될 것 같긴 한데, 현준님 생각이 궁금합니다!
There was a problem hiding this comment.
오호 그렇네요. 음 그냥 엔티티가 존재하는지는 확인할 필요가 없어보이긴 한데 해당 예약이 예약 확정 상태인지를 검증할 필요는 있어보입니다! 그러면 reservationId는 예약을 검증하는 용도로 적용해보겠습니다!
There was a problem hiding this comment.
오 예약 확정 상태인지 검증은 필요할 것 같네요! 좋습니다~
| public record ReservationForRating( | ||
| @Schema(description = "예약 식별자", example = "1") | ||
| Long reservationId, | ||
|
|
||
| @Schema(description = "푸드트럭 식별자", example = "1") | ||
| Long foodTruckId, | ||
|
|
||
| @Schema(description = "푸드트럭 이름", example = "푸드트럭") | ||
| String name, | ||
|
|
||
| @Schema(description = "푸드트럭 대표 사진 URL", example = "http://image.png") | ||
| String photoUrl, | ||
|
|
||
| @Schema(description = "예약 주소", example = "서울 광진구 화양동") | ||
| String address, | ||
|
|
||
| @Schema(description = "예약 날짜 및 운영 시간 정보 리스트 (최대 2개)", | ||
| example = "[\"2025-09-20 13시~19시\", \"2025-09-21 13시~19시\"]") | ||
| List<String> dateTimeInfos | ||
| ) { |
There was a problem hiding this comment.
일단 평점을 남겨야하는 목록이 20개가 넘어가는 상황은 저도 문제가 있는 상황이 맞다고 생각합니다.
사용하는 사용자 입장에서 평점을 남기기 싫은데 자꾸 평점을 남기라고 강요를 받는 느낌을 받을수도 있을 것 같아서 일단 페이징은 적용안하고 놔둬도 될 것 같습니당.
그리고 페이징을 적용하지 않는다면 현재와 같이 inner class 를 통한 구현 좋은 것 같습니다!
There was a problem hiding this comment.
넵넵 20개가 넘어가면 바로 사이트를 닫아버릴 것 같습니다 허헣,,
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (1)
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (1)
75-78: 상수명 오타: SAVED_FOOD_TRUCK_ALREADY_EXIST → SAVED_FOOD_TRUCK_ALREADY_EXISTS이전 코멘트와 동일 사안입니다. 복수형으로 정정하고 전역 참조를 함께 변경하세요.
- SAVED_FOOD_TRUCK_ALREADY_EXIST(HttpStatus.CONFLICT, 120002, "이미 저장된 푸드트럭입니다."), + SAVED_FOOD_TRUCK_ALREADY_EXISTS(HttpStatus.CONFLICT, 120002, "이미 저장된 푸드트럭입니다."),전역 사용처 점검 스크립트:
#!/bin/bash rg -n "SAVED_FOOD_TRUCK_ALREADY_EXIST\b" -C2덧붙여, “저장 상태 관련 오류 코드의 도메인 소속”은 현재처럼 ErrorCode 내 섹션(SavedFoodTruck) 분리로도 충분히 명확합니다. 장기적으로 도메인별 에러코드 Enum을 분리(예: member, reservation, foodtruck)하고 글로벌 인터페이스(ResponseCode)를 구현하는 구조도 고려해 볼 만합니다.
🧹 Nitpick comments (4)
src/main/java/konkuk/chacall/domain/reservation/domain/model/Reservation.java (1)
11-15: 불필요/중복 import 제거
BusinessException,ErrorCode일반 import는 사용되지 않고 있으며, static import만으로 충분합니다. 정리해 주세요.src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (2)
53-61: 평점 대상 예약 목록: 정렬/상한 제어 제안비즈니스상 20개 초과가 적절치 않다면 레포지토리 메서드에 정렬(예: createdAt DESC)과 limit(20)를 직접 반영해 불필요한 전량 조회를 피하세요.
28-33: 메서드명 단수화 검토 — registerRatings → registerRating단건 평점 등록이라면 단수형이 더 명확합니다. 리네이밍 시 아래 호출부도 함께 변경하세요.
- src/main/java/konkuk/chacall/domain/member/presentation/MemberController.java — @PostMapping("/me/ratings")(라인 54), registerRatings 선언(라인 55), memberService.registerRatings 호출(라인 59)
- src/main/java/konkuk/chacall/domain/member/application/MemberService.java — registerRatings 선언(라인 41), ratingService.registerRatings 호출(라인 46)
- src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java — registerRatings 정의(라인 29)
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (1)
60-67: RATING_NOT_FOUND 메시지 수정 및 에러코드 대역 통일 권고
- 메시지 불일치: 현재 메시지 "평점을 매길 수 없는 푸드트럭입니다."는 의미상 부적절합니다. 해당 에러는 RatingService에서 예외로 던져지고 Swagger에 노출되어 있으므로(아래 파일 참조) 메시지 변경 또는 코드명(PENDING_RATING_NOT_FOUND) 변경 시 Swagger·사용처도 함께 수정해야 합니다.
- 권장 변경(메시지 교정 예시):
- RATING_NOT_FOUND(HttpStatus.NOT_FOUND, 100001, "평점을 매길 수 없는 푸드트럭입니다."), + RATING_NOT_FOUND(HttpStatus.NOT_FOUND, 100001, "등록 가능한 평점 항목을 찾을 수 없습니다."),
영향 범위(수정 필요 파일):
- src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java:63
- src/main/java/konkuk/chacall/global/common/swagger/SwaggerResponseDescription.java:102
- src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java:49
에러코드 자리수: 현재 100001(6자리) 사용 중 — 프로젝트의 에러코드 자리수/도메인 대역 표준과 일치시키세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java(1 hunks)src/main/java/konkuk/chacall/domain/reservation/domain/model/Reservation.java(2 hunks)src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java (1)
src/main/java/konkuk/chacall/domain/member/application/MemberService.java (1)
Service(17-56)
🔇 Additional comments (3)
src/main/java/konkuk/chacall/global/common/exception/code/ErrorCode.java (3)
37-37: USER_FORBIDDEN 추가 건은 적절합니다권한 부족 상황을 403으로 매핑한 선택이 도메인 의미에 부합합니다.
58-58: 예약 소유자 오류코드 콤마 처리 OK이후 상수 추가를 위한 트레일링 콤마 반영 적절합니다.
71-71: FOOD_TRUCK_NOT_FOUND 정의 적절조회 실패를 404로 매핑한 선택이 일관적입니다.
| // 푸드트럭이 존재하는지 확인 | ||
| FoodTruck foodTruck = foodTruckRepository.findById(request.foodTruckId()) | ||
| .orElseThrow(() -> new BusinessException(ErrorCode.FOOD_TRUCK_NOT_FOUND)); | ||
|
|
There was a problem hiding this comment.
요청 파라미터 foodTruckId 신뢰는 위변조/불일치 위험 — 예약에서 직접 참조하세요
검증을 reservationId로 했는데 트럭은 request.foodTruckId로 재조회합니다. 서로 다른 값 전달 시, 잘못된 트럭의 평균이 갱신될 수 있습니다. 예약으로부터 트럭을 일관되게 가져오세요.
- // 푸드트럭이 존재하는지 확인
- FoodTruck foodTruck = foodTruckRepository.findById(request.foodTruckId())
- .orElseThrow(() -> new BusinessException(ErrorCode.FOOD_TRUCK_NOT_FOUND));
+ // 예약으로부터 일관된 푸드트럭 획득(파라미터 위변조 방지)
+ FoodTruck foodTruck = reservation.getFoodTruck();📝 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.
| // 푸드트럭이 존재하는지 확인 | |
| FoodTruck foodTruck = foodTruckRepository.findById(request.foodTruckId()) | |
| .orElseThrow(() -> new BusinessException(ErrorCode.FOOD_TRUCK_NOT_FOUND)); | |
| // 예약으로부터 일관된 푸드트럭 획득(파라미터 위변조 방지) | |
| FoodTruck foodTruck = reservation.getFoodTruck(); |
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java
around lines 35-38, the code re-fetches the FoodTruck using request.foodTruckId
which allows mismatch between the reservation and the supplied truck id;
instead, load the Reservation by reservationId, obtain the associated FoodTruck
from the reservation (or its foodTruckId) and use that entity for existence
checks and rating updates so the truck is derived from the trusted reservation
record rather than the external request parameter.
| // 로그인한 사용자가 해당 푸드트럭에 대해 아직 평점을 등록하지 않은 경우에만 평점 등록 | ||
| ratingRepository.findByMemberAndFoodTruckAndIsRatedFalse(member, foodTruck) | ||
| .ifPresentOrElse(rating -> { | ||
| double rate = Double.parseDouble(request.rating()); | ||
| // 평점 등록 | ||
| rating.registerRating(rate); | ||
|
|
||
| // 푸드트럭의 평균 평점 업데이트 | ||
| foodTruck.updateAverageRating(rate); | ||
| }, () -> { | ||
| throw new BusinessException(ErrorCode.RATING_NOT_FOUND); | ||
| }); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
레이팅 조회 키가 부정확 + 이중 제출 동시성 경쟁(중복 등록) 가능
- 동일 회원이 동일 트럭을 여러 번 예약했다면
member+foodTruck+isRated=false는 다른 예약의 레이팅을 매칭할 수 있습니다. - 동시에 두 요청이 들어오면 둘 다
isRated=false를 읽고 중복 등록될 수 있습니다.
해결:
- 조회 키를
member+reservation+isRated=false로 좁혀 지정성/정합성을 확보. - 해당 행에 PESSIMISTIC_WRITE 또는 낙관적 락을 적용해 이중 제출을 차단.
Service 수정 예:
- // 로그인한 사용자가 해당 푸드트럭에 대해 아직 평점을 등록하지 않은 경우에만 평점 등록
- ratingRepository.findByMemberAndFoodTruckAndIsRatedFalse(member, foodTruck)
+ // 예약 단위로 미등록 평점 조회(동시성 안전을 위해 잠금 적용된 쿼리 사용 권장)
+ ratingRepository.findByMemberAndReservationAndIsRatedFalseForUpdate(member, reservation)
.ifPresentOrElse(rating -> {
- double rate = Double.parseDouble(request.rating());
+ double rate = Double.parseDouble(request.rating()); // 아래 코멘트의 검증 로직 적용 권장
// 평점 등록
rating.registerRating(rate);
// 푸드트럭의 평균 평점 업데이트
foodTruck.updateAverageRating(rate);
}, () -> {
throw new BusinessException(ErrorCode.RATING_NOT_FOUND);
});Repository 추가 예(참고용):
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Rating> findByMemberAndReservationAndIsRatedFalse(User member, Reservation reservation);잠금 적용 메서드 존재 여부/사용처 점검:
🏁 Script executed:
#!/bin/bash
rg -nP 'findByMemberAndReservationAndIsRatedFalse' -C2Length of output: 0
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "1) 찾기: RatingService 클래스 위치"
rg -n -S 'class RatingService' || true
echo
echo "2) RatingService 파일 내용 출력(있다면)"
for f in $(rg -l -S 'class RatingService' || true); do
echo "=== $f ==="
sed -n '1,260p' "$f"
done
echo
echo "3) RatingRepository 인터페이스/클래스 찾기"
rg -n -S 'interface .*RatingRepository|class .*RatingRepository' || true
for f in $(rg -l -S 'interface .*RatingRepository|class .*RatingRepository' || true); do
echo "=== $f ==="
sed -n '1,240p' "$f"
done
echo
echo "4) 관련 메서드·사용처 검색"
rg -n -S -C3 'findByMemberAndReservation|findByMemberAndFoodTruckAndIsRatedFalse|findByMemberAndReservationAndIsRatedFalse|findByMemberAnd' || true
echo
echo "5) isRated 필드/사용처 검색"
rg -n -S -C3 'isRated' || true
echo
echo "6) ratingRepository 사용처 전반 검색"
rg -n -S -C3 'ratingRepository' || true
echo
echo "7) 락/버전 관련 어노테이션 검색"
rg -n -S -C3 '@Lock|LockModeType|PESSIMISTIC_WRITE|OPTIMISTIC|@Version' || trueLength of output: 13158
수정 필요 — 조회 키를 예약 단위로 좁히고 동시성 잠금 적용
확인: RatingService가 member+foodTruck+isRated=false로 조회하고 있으며(src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java 약 37–51행), RatingRepository에는 reservation 기반 잠금 메서드가 없습니다(src/main/java/konkuk/chacall/domain/member/domain/repository/RatingRepository.java).
- 문제: 동일 회원이 같은 푸드트럭을 여러 예약한 경우 다른 예약의 Rating을 잘못 매칭할 수 있음. 동시 요청 시 두 트랜잭션이 isRated=false를 읽어 중복 등록 및 평균값 계산 경쟁 발생.
- 권장 조치:
- 서비스: 조회를 member + reservation + isRated=false로 변경(Reservation을 키로 사용).
- 예시 diff:
- ratingRepository.findByMemberAndFoodTruckAndIsRatedFalse(member, foodTruck)
- ratingRepository.findByMemberAndReservationAndIsRatedFalse(member, reservation)
- 예시 diff:
- 레포지토리: PESSIMISTIC_WRITE 잠금 적용 메서드 추가.
- 예시:
@lock(LockModeType.PESSIMISTIC_WRITE)
Optional findByMemberAndReservationAndIsRatedFalse(User member, Reservation reservation);
- 예시:
- 대체 옵션: Rating 엔티티에 @Version 필드 추가(낙관적 락) 후 OptimisticLockException 처리로 재시도/에러 응답.
- FoodTruck 평균 업데이트도 동시성 영향 받으므로 동일 트랜잭션에서 잠금 또는 @Version 기반 원자 업데이트 적용 권장.
- 서비스: 조회를 member + reservation + isRated=false로 변경(Reservation을 키로 사용).
- 참고: Rating.registerRating()가 isRated 중복 체크를 던지긴 하나, 별도 잠금/버전 없이는 레이스 컨디션을 방지할 수 없음.
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java
around lines 39–51, the service currently queries by
member+foodTruck+isRated=false which can match the wrong reservation and allows
race conditions; change the lookup to use member + reservation + isRated=false
(use the reservation as the lookup key), add a repository method that applies a
PESSIMISTIC_WRITE lock (e.g., a findByMemberAndReservationAndIsRatedFalse with
@Lock(LockModeType.PESSIMISTIC_WRITE)) so the selected Rating row is locked
during the transaction, and ensure the FoodTruck average rating update is done
under the same transactional lock or by adding a @Version field to entities
(optimistic locking) and handling OptimisticLockException with retry/appropriate
error flow as an alternative.
| double rate = Double.parseDouble(request.rating()); | ||
| // 평점 등록 | ||
| rating.registerRating(rate); | ||
|
|
||
| // 푸드트럭의 평균 평점 업데이트 | ||
| foodTruck.updateAverageRating(rate); |
There was a problem hiding this comment.
평점 값 파싱/검증 누락 — 범위(0~5)와 0.5 단위 강제
현재 Double.parseDouble만 수행하여 잘못된 입력이 500으로 전파될 수 있습니다. DTO 단계에서 타입을 BigDecimal/double로 바꾸거나, 값 객체를 도입해 검증을 캡슐화하세요.
Service 내 적용 예:
- double rate = Double.parseDouble(request.rating());
+ double rate = RatingScore.parseAndValidate(request.rating()); // 0~5, 0.5 step간단 값객체(예시):
public final class RatingScore {
public static double parseAndValidate(String raw) {
double v;
try { v = Double.parseDouble(raw); } catch (NumberFormatException e) {
throw new BusinessException(ErrorCode.API_INVALID_PARAM);
}
if (v < 0.0 || v > 5.0) throw new BusinessException(ErrorCode.API_INVALID_PARAM);
if (Math.round(v * 2) != v * 2) throw new BusinessException(ErrorCode.API_INVALID_PARAM); // 0.5 step
return v;
}
}🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java
around lines 42–47, the code uses Double.parseDouble(request.rating()) without
validation; change this to parse-and-validate the incoming rating (either via a
small RatingScore value object or a static helper) that: 1) catches
NumberFormatException and throws BusinessException(ErrorCode.API_INVALID_PARAM),
2) enforces bounds 0.0 ≤ rating ≤ 5.0, and 3) enforces 0.5 increments (e.g.,
round-check v*2 equals integer), then pass the validated double to
rating.registerRating(...) and foodTruck.updateAverageRating(...).
| // 푸드트럭의 평균 평점 업데이트 | ||
| foodTruck.updateAverageRating(rate); |
There was a problem hiding this comment.
🧩 Analysis chain
푸드트럭 평균 평점 갱신의 경쟁 상태(잃어버린 업데이트) 위험
다중 요청이 동시에 평균(합/건수)을 갱신하면 read-modify-write 충돌이 납니다. 대안:
- FoodTruck에
@Version추가(권장) + 재시도 정책. - 또는 합계/카운트를 원자적 SQL로 갱신하고 평균은 조회 시 계산.
운영 측면에서 모니터링(OptimisticLockException 비율)도 함께 확보하세요.
FoodTruck에 버전 필드 존재 여부 점검:
🏁 Script executed:
#!/bin/bash
rg -nP 'class\s+FoodTruck\b.*|@Version' -n -C3 src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruck.javaLength of output: 208
조치 필요 — FoodTruck 엔티티에 낙관적 락(@Version) 추가 또는 원자적 DB 업데이트 도입
검증: src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruck.java에 @Version 필드 없음(클래스 선언 확인).
- 문제: src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java 라인 46–47의 foodTruck.updateAverageRating(rate)는 read→modify→write 구조로 동시성에서 잃어버린 업데이트 발생 가능.
- 권장: FoodTruck 엔티티에 @Version(Long version) 추가하고 저장 시 OptimisticLockException 발생하면 재시도(예: 최대 3회) 로직 구현.
- 대안: totalRating/ ratingCount를 DB 레벨에서 원자적 UPDATE(예: UPDATE ... SET total = total + ?, count = count + ?)로 갱신하고 평균은 조회 시 계산.
- 운영: OptimisticLockException 비율 모니터링 및 알림 설정.
🤖 Prompt for AI Agents
In
src/main/java/konkuk/chacall/domain/member/application/rating/RatingService.java
around lines 46–47, the call foodTruck.updateAverageRating(rate) does a
read→modify→write and can lose updates under concurrency; fix by adding
optimistic locking or an atomic DB update: add a @Version private Long version
field to src/main/java/konkuk/chacall/domain/foodtruck/domain/FoodTruck.java and
persist changes so OptimisticLockException is thrown on conflict, then wrap the
update in a retry loop (e.g., up to 3 attempts) that reloads the entity and
reapplies the rating on OptimisticLockException; alternatively implement a
repository @Modifying query to atomically update totalRating and ratingCount in
the database (UPDATE ... SET total_rating = total_rating + ?, rating_count =
rating_count + ?), compute the average on read, and remove the in-memory
read→modify→write path; also ensure you surface/metric OptimisticLockException
rates for monitoring.
| public void validateCanBeRatedBy(User member) { | ||
| if (!this.member.equals(member)) { | ||
| throw new DomainRuleException(CANNOT_RATE_RESERVATION_NOT_OWNED); | ||
| } | ||
| if (this.reservationStatus != ReservationStatus.CONFIRMED) { | ||
| throw new DomainRuleException(CANNOT_RATE_UNCONFIRMED_RESERVATION); | ||
| } | ||
| } |
There was a problem hiding this comment.
엔티티 동등성 비교 equals 의존은 위험 — ID 기반 비교로 교체 권장
this.member.equals(member)는 영속성 컨텍스트/프록시/분리 상태에 따라 오동작할 수 있습니다. 사용자 동일성은 PK 비교가 안전합니다.
- public void validateCanBeRatedBy(User member) {
- if (!this.member.equals(member)) {
+ public void validateCanBeRatedBy(User member) {
+ if (member == null || this.member == null
+ || !java.util.Objects.equals(this.member.getUserId(), member.getUserId())) {
throw new DomainRuleException(CANNOT_RATE_RESERVATION_NOT_OWNED);
}
- if (this.reservationStatus != ReservationStatus.CONFIRMED) {
+ if (this.reservationStatus == null || this.reservationStatus != ReservationStatus.CONFIRMED) {
throw new DomainRuleException(CANNOT_RATE_UNCONFIRMED_RESERVATION);
}
}📝 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 void validateCanBeRatedBy(User member) { | |
| if (!this.member.equals(member)) { | |
| throw new DomainRuleException(CANNOT_RATE_RESERVATION_NOT_OWNED); | |
| } | |
| if (this.reservationStatus != ReservationStatus.CONFIRMED) { | |
| throw new DomainRuleException(CANNOT_RATE_UNCONFIRMED_RESERVATION); | |
| } | |
| } | |
| public void validateCanBeRatedBy(User member) { | |
| if (member == null || this.member == null | |
| || !java.util.Objects.equals(this.member.getUserId(), member.getUserId())) { | |
| throw new DomainRuleException(CANNOT_RATE_RESERVATION_NOT_OWNED); | |
| } | |
| if (this.reservationStatus == null || this.reservationStatus != ReservationStatus.CONFIRMED) { | |
| throw new DomainRuleException(CANNOT_RATE_UNCONFIRMED_RESERVATION); | |
| } | |
| } |
🤖 Prompt for AI Agents
In src/main/java/konkuk/chacall/domain/reservation/domain/model/Reservation.java
around lines 52 to 59, replace the entity equality check
this.member.equals(member) with a safe ID-based comparison: obtain the member
IDs (e.g., this.member.getId() and member.getId()), null-safe compare them
(Objects.equals or explicit null checks) and throw the same DomainRuleException
when IDs differ; ensure you handle possible null member or id cases to avoid
NPEs and avoid relying on JPA proxy equals.


#️⃣연관된 이슈
closes #14
📝작업 내용
푸드트럭 저장 상태 변경
평점 등록
4번에서 평균 평점 업데이트를 위해서 FoodTruck의 RateInfo를 Embedabble 객체로 도입하여 내부에서 평균 평점 업데이트가 이루어지도록 하였습니다!
스크린샷 (선택)
💬리뷰 요구사항
두가지 고민 점이 있었는데 이에 대해 리뷰에서 답변해주시면 감사하겠습니다!
Summary by CodeRabbit
신기능
변경
문서