Review
@Entity
@Table(name = "reviews")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "review_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "reviewer_id")
private User reviewer; // 리뷰 작성자 (구매자)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "target_user_id")
private User targetUser; // 리뷰 대상자 (판매자)
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
@Column(nullable = false)
private Integer rating;
@Column(columnDefinition = "TEXT")
private String content;
@Builder
public Review(User reviewer, User targetUser, Product product, Integer rating, String content) {
this.reviewer = reviewer;
this.targetUser = targetUser;
this.product = product;
this.rating = rating;
this.content = content;
}
// 리뷰 내용 수정
public void update(Integer rating, String content) {
this.rating = rating;
this.content = content;
}
}
RatingCount
@Getter
@RequiredArgsConstructor
public class RatingCount {
private final Integer rating;
private final Long count;
}
특정 별점(rating)별 리뷰 개수(count)를 담기 위한 간단한 값 객체(Value Object)다.
RatingDistribution
@Getter
public class RatingDistribution {
private final Map<Integer, Long> distribution;
private final long totalCount;
public RatingDistribution(Map<Integer, Long> distribution) {
this.distribution = distribution;
this.totalCount = distribution.values().stream().mapToLong(Long::valueOf).sum();
}
public double getPercentage(int rating) {
if (totalCount == 0) return 0.0;
return (distribution.getOrDefault(rating, 0L) * 100.0 / totalCount);
}
}
리뷰 시스템에서 별점 분포와 관련된 계산 및 통계를 담당하는 값 객체(Value Object)다.
ReviewRepository
public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewRepositoryCustom {
Optional<Review> findByProduct(Product product);
// 특정 사용자(판매자)에 대한 리뷰 목록 페이징 조회
Page<Review> findByTargetUserOrderByCreatedDateDesc(User targetUser, Pageable pageable);
// 특정 사용자(구매자)가 작성한 리뷰 목록 페이징 조회
Page<Review> findByReviewerOrderByCreatedDateDesc(User reviewer, Pageable pageable);
// 특정 사용자가 받은 리뷰 수 조회
long countByTargetUserId(Long targetUserId);
}
ReviewRepositoryCustom
public interface ReviewRepositoryCustom {
// 특정 별점 이상의 리뷰만 조회
Page<Review> findByTargetUserIdAndMinimumRating(Long targetUserId, Integer minimumRating, Pageable pageable);
// 별점 분포 통계 조회
RatingDistribution getRatingDistributionByUserId(Long userId);
// 평균 평점 조회
Double getAverageRatingByUserId(Long userId);
}
ReviewRepositoryImpl
@RequiredArgsConstructor
public class ReviewRepositoryImpl implements ReviewRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<Review> findByTargetUserIdAndMinimumRating(Long targetUserId, Integer minimumRating, Pageable pageable) {
List<Review> content = queryFactory
.selectFrom(review)
.where(review.targetUser.id.eq(targetUserId)
.and(review.rating.goe(minimumRating)))
.orderBy(review.createdDate.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> count = queryFactory
.select(review.count())
.from(review)
.where(review.targetUser.id.eq(targetUserId)
.and(review.rating.goe(minimumRating)));
return PageableExecutionUtils.getPage(content, pageable, count::fetchOne);
}
@Override
public RatingDistribution getRatingDistributionByUserId(Long userId) {
List<RatingCount> ratingCount = queryFactory
.select(Projections.constructor(RatingCount.class,
review.rating,
review.count()))
.from(review)
.where(review.targetUser.id.eq(userId))
.groupBy(review.rating)
.fetch();
Map<Integer, Long> distribution = new HashMap<>();
for (int i = 1; i <= 5; i++) {
distribution.put(i, 0L);
}
// 조회 결과로 맵 업데이트
for (RatingCount count : ratingCount) {
distribution.put(count.getRating(), count.getCount());
}
return new RatingDistribution(distribution);
}
@Override
public Double getAverageRatingByUserId(Long userId) {
Double avgRating = queryFactory
.select(review.rating.avg())
.from(review)
.where(review.targetUser.id.eq(userId))
.fetchOne();
return avgRating != null ? avgRating : 0.0;
}
}
findByTargetUserIdAndMinimumRating
review.targetUser.id.eq(targetUserId): 특정 판매자에 대한 리뷰만 선택review.rating.goe(minimumRating): 최소 별점 이상의 리뷰만 선택 (goe = greater or equal)orderBy(review.createdDate.desc()): 최신 리뷰부터 정렬offset(pageable.getOffset()): 페이지 시작점 설정limit(pageable.getPageSize()): 한 페이지에 표시할 개수 설정PageableExecutionUtils.getPage(): 효율적인 페이지 객체 생성getRatingDistributionByUserId
Projections.constructor(RatingCount.class, ...): 쿼리 결과를 RatingCount 객체로 매핑review.rating.count(): 각 별점별 리뷰 개수 계산groupBy(review.rating): 별점별로 그룹화RatingDistribution 객체로 래핑하여 반환getAverageRatingByUserId
review.rating.avg(): SQL의 AVG 함수와 같은 역할QueryDSL이 자동으로 SELECT AVG(review.rating) FROM review WHERE ... SQL로 변환null을 반환할 수 있으므로, null 체크 후 기본값 0.0 반환public class ReviewDto {
@Schema(description = "리뷰 작성 요청")
public record ReviewCreateRequest(
@Schema(description = "상품 ID", example = "123")
@NotNull(message = "상품 ID는 필수 입력값입니다.")
Long productId,
@Schema(description = "별점 (1-5)", example = "4")
@NotNull(message = "별점은 필수 입력값입니다.")
@Min(value = 1, message = "별점은 1점 이상이어야 합니다.")
@Max(value = 5, message = "별점은 5점 이하여야 합니다.")
Integer rating,
@Schema(description = "리뷰 내용", example = "친절하고 빠른 거래였습니다.")
String content
) {}
@Schema(description = "리뷰 수정 요청")
public record ReviewUpdateRequest(
@Schema(description = "별점 (1-5)", example = "5")
@NotNull(message = "별점은 필수 입력값입니다.")
@Min(value = 1, message = "별점은 1점 이상이어야 합니다.")
@Max(value = 5, message = "별점은 5점 이하여야 합니다.")
Integer rating,
@Schema(description = "리뷰 내용", example = "정말 만족스러운 거래였습니다.")
String content
) {}
@Builder
@Schema(description = "리뷰 응답")
public record ReviewResponse(
@Schema(description = "리뷰 ID", example = "1")
Long id,
@Schema(description = "리뷰어 ID (구매자)", example = "100")
Long reviewerId,
@Schema(description = "리뷰어 이름", example = "홍길동")
String reviewerName,
@Schema(description = "대상자 ID (판매자)", example = "200")
Long targetUserId,
@Schema(description = "대상자 이름", example = "김판매")
String targetUserName,
@Schema(description = "상품 ID", example = "123")
Long productId,
@Schema(description = "상품명", example = "중고 노트북")
String productName,
@Schema(description = "별점 (1-5)", example = "4")
Integer rating,
@Schema(description = "리뷰 내용", example = "친절하고 빠른 거래였습니다.")
String content,
@Schema(description = "작성일", example = "2023-09-15T14:30:00")
LocalDateTime createdDate
) {
public static ReviewResponse from(Review review) {
return ReviewResponse.builder()
.id(review.getId())
.reviewerId(review.getReviewer().getId())
.reviewerName(review.getReviewer().getName())
.targetUserId(review.getTargetUser().getId())
.targetUserName(review.getTargetUser().getName())
.productId(review.getProduct().getId())
.productName(review.getProduct().getName())
.rating(review.getRating())
.content(review.getContent())
.createdDate(review.getCreatedDate())
.build();
}
}
@Builder
@Schema(description = "리뷰 통계 응답")
public record ReviewStatsResponse (
@Schema(description = "평균 평점", example = "4.3")
double averageRating,
@Schema(description = "총 리뷰 수", example = "27")
long totalReviews,
@Schema(description = "별점 분포 (각 별점별 개수)", example = "{\"1\": 2, \"2\": 3, \"3\": 5, \"4\": 10, \"5\": 7}")
Map<Integer, Long> ratingDistribution,
@Schema(description = "별점 분포 퍼센트", example = "{\"1\": 7.4, \"2\": 11.1, \"3\": 18.5, \"4\": 37.0, \"5\": 25.9}")
Map<Integer, Double> ratingPercentages
) {}
@Builder
@Schema(description = "리뷰 목록 응답")
public record ReviewListResponse(
@Schema(description = "리뷰 목록")
java.util.List<ReviewResponse> reviews,
@Schema(description = "리뷰 통계")
ReviewStatsResponse stats,
@Schema(description = "총 페이지 수", example = "3")
int totalPages,
@Schema(description = "총 리뷰 수", example = "27")
long totalElements
) {}
@Builder
@Schema(description = "내가 작성한 리뷰 목록 응답")
public record MyReviewListResponse(
@Schema(description = "리뷰 목록")
List<ReviewResponse> reviews,
@Schema(description = "총 페이지 수", example = "3")
int totalPages,
@Schema(description = "총 리뷰 수", example = "27")
long totalElements
) {}
}
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ReviewService {
private final ReviewRepository reviewRepository;
private final UserRepository userRepository;
private final ProductRepository productRepository;
// 리뷰 생성
@Transactional
public ReviewDto.ReviewResponse createReview(Long reviewerId, ReviewDto.ReviewCreateRequest request) {
User reviewer = getUser(reviewerId);
Product product = getProduct(request.productId());
// 판매자가 리뷰 작성자와 같으면 예외 발생
if (product.getSeller().getId().equals(reviewerId)) {
throw new ReviewException.SelfReviewException();
}
// 상품이 판매완료 상태가 아니면 예외 발생
if (product.getStatus() != ProductStatus.SOLD_OUT) {
throw new ReviewException.ProductNotSoldException();
}
// 구매자가 리뷰 작성자와 같은지 확인
if (product.getBuyer() == null || !product.getBuyer().getId().equals(reviewerId)) {
throw new ReviewException.NotBuyerException();
}
// 이미 리뷰가 있는지 확인
if (reviewRepository.findByProduct(product).isPresent()) {
throw new ReviewException.ReviewAlreadyExistsException();
}
User targetUser = product.getSeller();
Review review = Review.builder()
.reviewer(reviewer)
.targetUser(targetUser)
.product(product)
.rating(request.rating())
.content(request.content())
.build();
Review savedReview = reviewRepository.save(review);
log.info("리뷰 생성 완료: 리뷰어 ID {}, 판매자 ID {}, 상품 ID {}", reviewerId, targetUser.getId(), product.getId());
return ReviewDto.ReviewResponse.from(review);
}
// 리뷰 수정
@Transactional
public ReviewDto.ReviewResponse updateReview(Long reviewerId, Long reviewId, ReviewDto.ReviewUpdateRequest request) {
Review review = getReviewWithReviewerCheck(reviewId, reviewerId);
review.update(request.rating(), request.content());
log.info("리뷰 수정 완료: 리뷰 ID {}, 리뷰어 ID {}", reviewId, reviewerId);
return ReviewDto.ReviewResponse.from(review);
}
// 리뷰 삭제
@Transactional
public void deleteReview(Long reviewerId, Long reviewId) {
Review review = getReviewWithReviewerCheck(reviewId, reviewerId);
reviewRepository.delete(review);
log.info("리뷰 삭제 완료: 리뷰 ID {}, 리뷰어 ID {}", reviewId, reviewerId);
}
// 리뷰 조회
public ReviewDto.ReviewResponse findReview(Long reviewId) {
Review review = getReview(reviewId);
return ReviewDto.ReviewResponse.from(review);
}
// 특정 사용자에 대한 리뷰 목록 조회 (판매자 대상)
public ReviewDto.ReviewListResponse getUserReviews(Long targetUserId, Pageable pageable) {
User targetUser = getUser(targetUserId);
Page<Review> reviewsPage = reviewRepository.findByTargetUserOrderByCreatedDateDesc(targetUser, pageable);
List<ReviewDto.ReviewResponse> reviews = reviewsPage.getContent().stream()
.map(ReviewDto.ReviewResponse::from)
.collect(Collectors.toList());
// 평점 통계 조회
Double averageRating = reviewRepository.getAverageRatingByUserId(targetUserId);
if (averageRating == null) averageRating = 0.0;
long totalReviews = reviewRepository.countByTargetUserId(targetUserId);
// 별점 분포 조회
RatingDistribution ratingDistribution = reviewRepository.getRatingDistributionByUserId(targetUserId);
Map<Integer, Double> percentages = new HashMap<>();
for (int i = 1; i <= 5; i++) {
percentages.put(i, ratingDistribution.getPercentage(i));
}
ReviewDto.ReviewStatsResponse stats = ReviewDto.ReviewStatsResponse.builder()
.averageRating(averageRating)
.totalReviews(totalReviews)
.ratingDistribution(ratingDistribution.getDistribution())
.ratingPercentages(percentages)
.build();
return ReviewDto.ReviewListResponse.builder()
.reviews(reviews)
.stats(stats)
.totalPages(reviewsPage.getTotalPages())
.totalElements(reviewsPage.getTotalElements())
.build();
}
// 자신이 작성한 리뷰 목록 조회
public ReviewDto.MyReviewListResponse getMyReviews(Long reviewerId, Pageable pageable) {
User reviewer = getUser(reviewerId);
Page<Review> reviewsPage = reviewRepository.findByReviewerOrderByCreatedDateDesc(reviewer, pageable);
List<ReviewDto.ReviewResponse> reviews = reviewsPage.getContent().stream()
.map(ReviewDto.ReviewResponse::from)
.collect(Collectors.toList());
return ReviewDto.MyReviewListResponse.builder()
.reviews(reviews)
.totalPages(reviewsPage.getTotalPages())
.totalElements(reviewsPage.getTotalElements())
.build();
}
// 특정 사용자의 평균 평점 조회
public double getUserAverageRating(Long userId) {
Double averageRating = reviewRepository.getAverageRatingByUserId(userId);
return averageRating != null ? averageRating : 0.0;
}
// 권한 확인 후 리뷰 조회
private Review getReviewWithReviewerCheck(Long reviewId, Long reviewerId) {
Review review = getReview(reviewId);
if (!review.getReviewer().getId().equals(reviewerId)) {
throw new ReviewException.ReviewAccessDeniedException();
}
return review;
}
private Review getReview(Long reviewId) {
return reviewRepository.findById(reviewId)
.orElseThrow(() -> new ReviewException.ReviewNotFoundException(reviewId));
}
private User getUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new UserException.UserNotFoundException(userId));
}
private Product getProduct(Long productId) {
return productRepository.findById(productId)
.orElseThrow(() -> new ProductException.ProductNotFoundException(productId));
}
}
createReviewupdateReviewgetReviewWithReviewerCheck 헬퍼 메서드로 분리update 메서드를 활용한 객체 지향적 설계Dirty Checking)를 활용하여 명시적 save 없이 업데이트deleteReviewgetUserReviewstargetUser)가 받은 리뷰 목록과 통계 정보를 종합적으로 제공Stream API를 활용한 데이터 처리getMyReviewsISP)에 따라 MyReviewListResponse 사용@Slf4j
@RestController
@RequestMapping("/api/reviews")
@RequiredArgsConstructor
@Tag(name = "리뷰", description = "리뷰 관련 API")
public class ReviewController {
private final ReviewService reviewService;
@Operation(summary = "리뷰 작성", description = "구매한 상품에 대해 판매자에게 리뷰를 작성합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "리뷰 작성 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "403", description = "권한 없음"),
@ApiResponse(responseCode = "404", description = "상품 또는 사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "409", description = "이미 리뷰가 존재함")
})
@PostMapping
public ResponseEntity<ResponseDTO<ReviewDto.ReviewResponse>> createReview(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
@Parameter(description = "리뷰 작성 정보", required = true)
@Valid @RequestBody ReviewDto.ReviewCreateRequest request) {
log.info("리뷰 작성 요청: 사용자 ID {}, 상품 ID {}", userDetails.getUserId(), request.productId());
ReviewDto.ReviewResponse response = reviewService.createReview(userDetails.getUserId(), request);
return ResponseEntity.status(HttpStatus.CREATED).body(ResponseDTO.success(response, "리뷰가 성공적으로 작성되었습니다."));
}
@Operation(summary = "리뷰 수정", description = "자신이 작성한 리뷰를 수정합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 수정 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "403", description = "권한 없음"),
@ApiResponse(responseCode = "404", description = "리뷰를 찾을 수 없음")
})
@PatchMapping("/{reviewId}")
public ResponseEntity<ResponseDTO<ReviewDto.ReviewResponse>> updateReview(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
@Parameter(description = "수정할 리뷰 ID", required = true) @PathVariable Long reviewId,
@Parameter(description = "리뷰 수정 정보", required = true)
@Valid @RequestBody ReviewDto.ReviewUpdateRequest request) {
log.info("리뷰 수정 요청: 사용자 ID {}, 리뷰 ID {}", userDetails.getUserId(), reviewId);
ReviewDto.ReviewResponse response = reviewService.updateReview(userDetails.getUserId(), reviewId, request);
return ResponseEntity.ok(ResponseDTO.success(response, "리뷰가 성공적으로 수정되었습니다."));
}
@Operation(summary = "리뷰 삭제", description = "자신이 작성한 리뷰를 삭제합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 삭제 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패"),
@ApiResponse(responseCode = "403", description = "권한 없음"),
@ApiResponse(responseCode = "404", description = "리뷰를 찾을 수 없음")
})
@DeleteMapping("/{reviewId}")
public ResponseEntity<ResponseDTO<Void>> deleteReview(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
@Parameter(description = "삭제할 리뷰 ID", required = true) @PathVariable Long reviewId) {
log.info("리뷰 삭제 요청: 사용자 ID {}, 리뷰 ID {}", userDetails.getUserId(), reviewId);
reviewService.deleteReview(userDetails.getUserId(), reviewId);
return ResponseEntity.ok(ResponseDTO.success(null, "리뷰가 성공적으로 삭제되었습니다."));
}
@Operation(summary = "리뷰 상세 조회", description = "특정 리뷰의 상세 정보를 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 조회 성공"),
@ApiResponse(responseCode = "404", description = "리뷰를 찾을 수 없음")
})
@GetMapping("/{reviewId}")
public ResponseEntity<ResponseDTO<ReviewDto.ReviewResponse>> findReview(
@Parameter(description = "조회할 리뷰 ID", required = true) @PathVariable Long reviewId) {
log.info("리뷰 상세 조회 요청: 리뷰 ID {}", reviewId);
ReviewDto.ReviewResponse response = reviewService.findReview(reviewId);
return ResponseEntity.ok(ResponseDTO.success(response));
}
@Operation(summary = "사용자가 받은 리뷰 목록 조회", description = "특정 사용자가 판매자로서 받은 리뷰 목록을 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음")
})
@GetMapping("/users/{userId}")
public ResponseEntity<ResponseDTO<ReviewDto.ReviewListResponse>> getUserReviews(
@Parameter(description = "조회할 사용자 ID (판매자)", required = true) @PathVariable Long userId,
@Parameter(description = "페이지네이션 정보") @PageableDefault(size = 10) Pageable pageable) {
log.info("사용자가 받은 리뷰 목록 조회 요청: 사용자 ID {}", userId);
ReviewDto.ReviewListResponse response = reviewService.getUserReviews(userId, pageable);
return ResponseEntity.ok(ResponseDTO.success(response));
}
@Operation(summary = "내가 작성한 리뷰 목록 조회", description = "로그인한 사용자가 작성한 리뷰 목록을 조회합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "리뷰 목록 조회 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
@GetMapping("/me")
public ResponseEntity<ResponseDTO<ReviewDto.MyReviewListResponse>> getMyReviews(
@Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails,
@Parameter(description = "페이지네이션 정보") @PageableDefault(size = 10) Pageable pageable) {
log.info("내가 작성한 리뷰 목록 조회 요청: 사용자 ID {}", userDetails.getUserId());
ReviewDto.MyReviewListResponse response = reviewService.getMyReviews(userDetails.getUserId(), pageable);
return ResponseEntity.ok(ResponseDTO.success(response));
}
}
UserProfileService
private final ReviewService reviewService;
// 평균 평점 조회
double averageRating = reviewService.getUserAverageRating(userId);
TODO 표시 해뒀던 평균 평점 조회는 reviewService를 이용하여 조회한다.
ProductStatusService
private final ReviewRepository reviewRepository;
// 판매완료 취소 처리 메서드
@Transactional
public ProductDto.ProductBaseResponse cancelProductSold(Long sellerId, Long productId) {
Product product = productManagementService.getProductWithSellerCheck(productId, sellerId);
if (product.getStatus() != ProductStatus.SOLD_OUT) {
throw new ProductException.NotSoldProductException();
}
// 리뷰가 작성되었는지 확인
boolean reviewExists = reviewRepository.findByProduct(product).isPresent();
if (reviewExists) {
throw new ProductException.CannotCancelSoldProductException("이미 리뷰가 작성된 상품은 판매완료 취소가 불가능합니다.");
}
product.cancelSold();
log.info("판매완료 취소 처리: 상품 ID {}, 판매자 ID {}", productId, sellerId);
return ProductDto.ProductBaseResponse.from(product);
}
판매 취소 메서드에서 리뷰가 작성 되어 있으면 판매 취소 처리를 할 수 없게 수정한다.
ProductException
public static class CannotCancelSoldProductException extends ProductException {
public CannotCancelSoldProductException(String message) {
super(message, HttpStatus.BAD_REQUEST, "CANNOT_CANCEL_SOLD_PRODUCT");
}
}
판매자의 상품에 리뷰 작성

자신의 상품에 리뷰 작성

리뷰 수정

리뷰 상세 조회

사용자가 받은 리뷰 목록 조회


내가 작성한 리뷰 목록 조회

판매 완료 취소 불가능

리뷰 삭제

