Skip to content

Commit c7b7716

Browse files
authored
[Feat] 상품 정렬 인기순 추가 (#359)
* feat:상품 엔티티에 리뷰 필드 업데이트 * feat: WishlistController에 @operation 추가 * feat: 인기순 정렬 추가
1 parent c43e0d5 commit c7b7716

File tree

8 files changed

+188
-0
lines changed

8 files changed

+188
-0
lines changed

src/main/java/com/back/domain/order/orderItem/repository/OrderItemRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.back.domain.order.order.entity.Order;
44
import com.back.domain.order.orderItem.entity.OrderItem;
5+
import com.back.domain.product.product.entity.Product;
56
import org.springframework.data.jpa.repository.JpaRepository;
67
import org.springframework.data.jpa.repository.Query;
78
import org.springframework.data.repository.query.Param;
@@ -17,4 +18,7 @@ public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
1718
"JOIN FETCH oi.product " +
1819
"WHERE oi.order = :order")
1920
List<OrderItem> findByOrderWithProduct(@Param("order") Order order);
21+
22+
// 특정 상품의 총 누적 판매 수량
23+
long countByProduct(Product product);
2024
}

src/main/java/com/back/domain/product/product/entity/Product.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ public class Product extends BaseEntity {
104104

105105
private Integer reviewCount; // 리뷰 개수
106106

107+
private int popularityScore = 0; // 인기 점수 필드
108+
107109
@Column(nullable = false)
108110
private boolean isDeleted; // 논리 삭제 여부 (상품 삭제 시, 진짜 DB에서 삭제하냐 아니면 삭제 처리만 하냐 차이)
109111

@@ -128,4 +130,9 @@ public class Product extends BaseEntity {
128130
public int getDiscountPrice() {
129131
return price - (price * discountRate / 100);
130132
}
133+
134+
// 인기 점수 계산
135+
public void updatePopularityScore(int score) {
136+
this.popularityScore = score;
137+
}
131138
}

src/main/java/com/back/domain/product/product/repository/ProductCustomRepositoryImpl.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public ProductListResponse findProducts(
9090
// 정렬 처리
9191
if ("priceAsc".equals(sort)) query.orderBy(p.price.asc()); // 가격 낮은 순
9292
else if ("priceDesc".equals(sort)) query.orderBy(p.price.desc()); // 가격 높은 순
93+
else if ("popular".equals(sort)) query.orderBy(p.popularityScore.desc()); // 인기순
9394
else query.orderBy(p.createDate.desc()); // 일단 기본은 신상품순으로 함.
9495

9596
// 전체 건수 조회 (페이징용)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.back.domain.product.product.scheduler;
2+
3+
import com.back.domain.order.orderItem.repository.OrderItemRepository;
4+
import com.back.domain.product.product.entity.Product;
5+
import com.back.domain.product.product.repository.ProductRepository;
6+
import com.back.domain.wishlist.repository.WishlistRepository;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.scheduling.annotation.Scheduled;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
import java.util.List;
14+
15+
@Slf4j
16+
@Service
17+
@RequiredArgsConstructor
18+
public class ProductPopularityScheduler {
19+
20+
private final ProductRepository productRepository;
21+
private final OrderItemRepository orderItemRepository;
22+
private final WishlistRepository wishlistRepository;
23+
24+
// 매일 한국 시간 기준 오전 10시 30분에 실행 (이후 실제 서비스 운영한다면 매일 00:20시에 실행되도록 수정)
25+
@Scheduled(cron = "0 30 10 * * *", zone = "Asia/Seoul")
26+
@Transactional
27+
public void updatePopularityScores() {
28+
log.info("상품 인기 점수 계산 스케줄러 시작");
29+
List<Product> products = productRepository.findAll();
30+
31+
for (Product product : products) {
32+
// 판매 수 계산 (가중치 60%)
33+
long salesCount = orderItemRepository.countByProduct(product);
34+
35+
// 찜 수 계산 (가중치 20%)
36+
long wishlistCount = wishlistRepository.countByProduct(product);
37+
38+
// 리뷰 평점 (가중치 20%)
39+
Double averageRating = product.getAverageRating();
40+
if (averageRating == null) {
41+
averageRating = 0.0;
42+
}
43+
44+
// 최종 점수 계산
45+
int popularityScore = (int) (salesCount * 0.6 + wishlistCount * 0.2 + averageRating * 0.2);
46+
47+
// 상품에 점수 업데이트
48+
product.updatePopularityScore(popularityScore);
49+
}
50+
log.info("{}개 상품에 대한 인기 점수 업데이트 완료.", products.size());
51+
}
52+
}

src/main/java/com/back/domain/review/service/ReviewService.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ public ReviewResponseDto createReview(ReviewCreateRequestDto requestDto, User us
101101
Review savedReview = reviewRepository.save(review);
102102

103103
log.info("리뷰 작성 완료 - 리뷰ID: {}", savedReview.getId());
104+
105+
// 상품 평점 및 리뷰 개수 업데이트
106+
updateProductReviewStats(product);
107+
104108
return ReviewResponseDto.from(savedReview, false); // 작성자는 좋아요 안 누름
105109
}
106110

@@ -329,6 +333,9 @@ public ReviewResponseDto updateReview(Long reviewId, ReviewUpdateRequestDto requ
329333

330334
Review savedReview = reviewRepository.save(review);
331335

336+
// 상품 평점 및 리뷰 개수 업데이트
337+
updateProductReviewStats(savedReview.getProduct());
338+
332339
// 사용자가 좋아요 눌렀는지 확인
333340
boolean isLiked = reviewLikeRepository.findByReviewAndUserAndNotDeleted(savedReview, user).isPresent();
334341

@@ -358,6 +365,9 @@ public void deleteReview(Long reviewId, User user) {
358365
review.deleteReview();
359366
reviewRepository.save(review);
360367

368+
// 상품 평점 및 리뷰 개수 업데이트
369+
updateProductReviewStats(review.getProduct());
370+
361371
log.info("리뷰 삭제 완료 - 리뷰ID: {}", reviewId);
362372
}
363373

@@ -454,4 +464,18 @@ private Page<Review> getReviewsByType(Product product, ReviewListRequestDto.Revi
454464
return reviewRepository.findByProductAndNotDeleted(product, pageable);
455465
}
456466
}
467+
468+
/**
469+
* 상품의 평균 평점(averageRating)과 리뷰 개수(reviewCount) 업데이트
470+
*/
471+
private void updateProductReviewStats(Product product) {
472+
Long reviewCount = reviewRepository.countByProductAndNotDeleted(product);
473+
Double averageRating = reviewRepository.findAverageRatingByProduct(product).orElse(0.0);
474+
475+
product.setReviewCount(reviewCount.intValue()); // 상태 업데이트
476+
product.setAverageRating(averageRating); // 상태 업데이트
477+
productRepository.save(product); // DB 저장
478+
log.info("상품 리뷰 통계 업데이트 완료 - 상품ID: {}, 리뷰개수: {}, 평균평점: {}",
479+
product.getId(), reviewCount, averageRating);
480+
}
457481
}

src/main/java/com/back/domain/wishlist/controller/WishlistController.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import com.back.domain.wishlist.service.WishlistService;
44
import com.back.global.rsData.RsData;
55
import com.back.global.security.auth.CustomUserDetails;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.media.Content;
8+
import io.swagger.v3.oas.annotations.media.Schema;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
610
import io.swagger.v3.oas.annotations.tags.Tag;
711
import lombok.RequiredArgsConstructor;
812
import org.springframework.http.ResponseEntity;
@@ -21,6 +25,27 @@ public class WishlistController {
2125

2226
/** 찜 등록 */
2327
@PostMapping
28+
@Operation(
29+
summary = "찜 등록",
30+
responses = {
31+
@ApiResponse(
32+
responseCode = "200",
33+
description = "찜 등록 성공",
34+
content = @Content(
35+
mediaType = "application/json",
36+
schema = @Schema(
37+
example = """
38+
{
39+
"resultCode": "200",
40+
"msg": "상품이 위시리스트에 추가되었습니다.",
41+
"data": "a1b2c3d4-e5f6-7890-1234-567890abcdef"
42+
}
43+
"""
44+
)
45+
)
46+
)
47+
}
48+
)
2449
public ResponseEntity<RsData<UUID>> addWishlist(
2550
@PathVariable UUID productUuid,
2651
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
@@ -30,6 +55,27 @@ public ResponseEntity<RsData<UUID>> addWishlist(
3055

3156
/** 찜 삭제 */
3257
@DeleteMapping
58+
@Operation(
59+
summary = "찜 삭제",
60+
responses = {
61+
@ApiResponse(
62+
responseCode = "200",
63+
description = "찜 삭제 성공",
64+
content = @Content(
65+
mediaType = "application/json",
66+
schema = @Schema(
67+
example = """
68+
{
69+
"resultCode": "200",
70+
"msg": "상품이 위시리스트에서 제거되었습니다.",
71+
"data": "a1b2c3d4-e5f6-7890-1234-567890abcdef"
72+
}
73+
"""
74+
)
75+
)
76+
)
77+
}
78+
)
3379
public ResponseEntity<RsData<UUID>> removeWishlist(
3480
@PathVariable UUID productUuid,
3581
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
@@ -39,6 +85,27 @@ public ResponseEntity<RsData<UUID>> removeWishlist(
3985

4086
/** 상품별 찜 개수 조회 */
4187
@GetMapping("/count")
88+
@Operation(
89+
summary = "상품별 찜 개수 조회",
90+
responses = {
91+
@ApiResponse(
92+
responseCode = "200",
93+
description = "상품별 찜 개수 조회 성공",
94+
content = @Content(
95+
mediaType = "application/json",
96+
schema = @Schema(
97+
example = """
98+
{
99+
"resultCode": "200",
100+
"msg": "상품 찜 개수 조회 성공",
101+
"data": 10
102+
}
103+
"""
104+
)
105+
)
106+
)
107+
}
108+
)
42109
public ResponseEntity<RsData<Long>> getWishlistCount(
43110
@PathVariable UUID productUuid) {
44111
Long count = wishlistService.getWishlistCount(productUuid);

src/main/java/com/back/domain/wishlist/repository/WishlistRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.domain.wishlist.repository;
22

3+
import com.back.domain.product.product.entity.Product;
34
import com.back.domain.wishlist.entity.Wishlist;
45
import org.springframework.data.jpa.repository.JpaRepository;
56

@@ -11,4 +12,7 @@ public interface WishlistRepository extends JpaRepository<Wishlist, Long> {
1112
// 상품별 찜 개수 조회
1213
Long countByProductId(Long productId);
1314

15+
// 특정 상품 해당하는 Wishlist(찜) 개수
16+
long countByProduct(Product product);
17+
1418
}

src/test/java/com/back/domain/product/product/controller/ProductControllerTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,29 @@ void getProducts_Success_EmptyResult() throws Exception {
367367
.andExpect(jsonPath("$.data.products", hasSize(0)));
368368
}
369369

370+
@Test
371+
@DisplayName("인기순으로 상품 목록을 정렬하여 조회한다")
372+
void getProducts_Success_SortByPopular() throws Exception {
373+
// Given
374+
productRepository.save(createSampleProduct(artistUser, category, List.of(tag1), "인기상품A", 10000, 100));
375+
productRepository.save(createSampleProduct(artistUser, category, List.of(tag2), "인기상품C", 30000, 300));
376+
productRepository.save(createSampleProduct(artistUser, category, List.of(tag1, tag2), "인기상품B", 20000, 200));
377+
378+
// When
379+
ResultActions resultActions = mockMvc.perform(
380+
get("/api/products")
381+
.param("sort", "popular")
382+
).andDo(print());
383+
384+
// Then
385+
resultActions
386+
.andExpect(status().isOk())
387+
.andExpect(jsonPath("$.data.products", hasSize(3)))
388+
.andExpect(jsonPath("$.data.products[0].name").value("인기상품C"))
389+
.andExpect(jsonPath("$.data.products[1].name").value("인기상품B"))
390+
.andExpect(jsonPath("$.data.products[2].name").value("인기상품A"));
391+
}
392+
370393
// ==================== 상품 수정 (Update) ====================
371394

372395
@Test
@@ -572,4 +595,10 @@ private Product createSampleProduct(User artist, Category category, List<Tag> ta
572595
});
573596
return product;
574597
}
598+
599+
private Product createSampleProduct(User artist, Category category, List<Tag> tags, String name, int price, int popularityScore) {
600+
Product product = createSampleProduct(artist, category, tags, name, price);
601+
product.updatePopularityScore(popularityScore);
602+
return product;
603+
}
575604
}

0 commit comments

Comments
 (0)