Skip to content

Commit 7339613

Browse files
committed
feat: 상품 조회 기능 전체 개선 - Redis 캐시, 커서 페이징, 인기 상품 API
상품 목록/상세 조회 성능 향상 및 무한 스크롤, 인기 상품 기능 추가 - ProductDetailCache: 상품 상세 정보 Redis 캐싱 - ProductListCache: 상품 목록 조회 결과 캐싱 (LIKES_DESC 정렬만) - 커서 기반 페이징 구현으로 무한 스크롤 지원 - CursorPageInfo, ProductCursorSearchCommand 추가 - /products/cursor 엔드포인트 추가 - 인기 상품 조회 API 추가 (좋아요 수 기준 Top 10) - /products/popular 엔드포인트 추가 - 브랜드 필터링 기능 추가 - ProductRepository에 커서 및 인기 상품 조회 메서드 추가
1 parent 4003796 commit 7339613

16 files changed

Lines changed: 698 additions & 40 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.loopers.application.product;
2+
3+
import lombok.Getter;
4+
5+
import java.util.List;
6+
7+
@Getter
8+
public class CursorPageInfo<T> {
9+
10+
private final List<T> content;
11+
private final String nextCursor;
12+
private final boolean hasNext;
13+
14+
private CursorPageInfo(List<T> content, String nextCursor, boolean hasNext) {
15+
this.content = content;
16+
this.nextCursor = nextCursor;
17+
this.hasNext = hasNext;
18+
}
19+
20+
public static <T> CursorPageInfo<T> of(List<T> content, String nextCursor, boolean hasNext) {
21+
return new CursorPageInfo<>(content, nextCursor, hasNext);
22+
}
23+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.loopers.application.product;
2+
3+
import com.loopers.domain.product.enums.ProductSortCondition;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
@Getter
8+
@Builder
9+
public class ProductCursorSearchCommand {
10+
11+
private final Long brandId;
12+
private final String keyword;
13+
private final ProductSortCondition sort;
14+
private final String cursor;
15+
private final int size;
16+
private final Long memberIdOrNull;
17+
18+
public static ProductCursorSearchCommand of(Long brandId, String keyword, ProductSortCondition sort, String cursor, int size, Long memberIdOrNull) {
19+
return ProductCursorSearchCommand.builder()
20+
.brandId(brandId)
21+
.keyword(keyword)
22+
.sort(sort)
23+
.cursor(cursor)
24+
.size(size)
25+
.memberIdOrNull(memberIdOrNull)
26+
.build();
27+
}
28+
}
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package com.loopers.application.product;
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
5+
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
46
import com.loopers.domain.common.vo.Money;
57
import com.loopers.domain.product.vo.Stock;
68
import lombok.Builder;
79

810
@Builder
11+
@JsonDeserialize(builder = ProductDetailInfo.ProductDetailInfoBuilder.class)
912
public class ProductDetailInfo {
10-
13+
1114
private final Long id;
1215
private final String name;
1316
private final String description;
@@ -17,7 +20,7 @@ public class ProductDetailInfo {
1720
private final Stock stock;
1821
private final int likeCount;
1922
private final boolean isLikedByMember;
20-
23+
2124
public Long getId() { return id; }
2225
public String getName() { return name; }
2326
public String getDescription() { return description; }
@@ -26,7 +29,11 @@ public class ProductDetailInfo {
2629
public Money getPrice() { return price; }
2730
public Stock getStock() { return stock; }
2831
public int getLikeCount() { return likeCount; }
29-
32+
3033
@JsonProperty("likedByMember")
3134
public boolean isLikedByMember() { return isLikedByMember; }
35+
36+
@JsonPOJOBuilder(withPrefix = "")
37+
public static class ProductDetailInfoBuilder {
38+
}
3239
}
Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,129 @@
11
package com.loopers.application.product;
22

3+
import com.loopers.domain.like.service.LikeReadService;
34
import com.loopers.domain.product.service.ProductReadService;
45
import com.loopers.domain.product.command.ProductSearchFilter;
6+
import com.loopers.domain.product.enums.ProductSortCondition;
7+
import com.loopers.infrastructure.cache.ProductDetailCache;
8+
import com.loopers.infrastructure.cache.ProductListCache;
59
import lombok.RequiredArgsConstructor;
610
import org.springframework.data.domain.Page;
711
import org.springframework.data.domain.PageRequest;
812
import org.springframework.data.domain.Pageable;
913
import org.springframework.stereotype.Component;
1014
import org.springframework.transaction.annotation.Transactional;
1115

16+
import java.util.List;
17+
1218
@RequiredArgsConstructor
1319
@Component
1420
@Transactional
1521
public class ProductFacade {
1622

1723
private final ProductReadService productReadService;
24+
private final LikeReadService likeReadService;
25+
private final ProductDetailCache productDetailCache;
26+
private final ProductListCache productListCache;
1827

1928
@Transactional(readOnly = true)
2029
public Page<ProductSummaryInfo> getProducts(ProductSearchCommand command) {
30+
// Cache only for LIKES_DESC sort
31+
if (command.getSort() == ProductSortCondition.LIKES_DESC) {
32+
return productListCache.get(
33+
command.getBrandId(),
34+
command.getSort(),
35+
command.getPage(),
36+
command.getSize()
37+
).orElseGet(() -> {
38+
Page<ProductSummaryInfo> result = fetchProducts(command);
39+
productListCache.set(
40+
command.getBrandId(),
41+
command.getSort(),
42+
command.getPage(),
43+
command.getSize(),
44+
result
45+
);
46+
return result;
47+
});
48+
}
49+
50+
return fetchProducts(command);
51+
}
52+
53+
private Page<ProductSummaryInfo> fetchProducts(ProductSearchCommand command) {
2154
ProductSearchFilter filter = ProductSearchFilter.of(
55+
command.getBrandId(),
2256
command.getKeyword(),
2357
command.getSort()
2458
);
2559

2660
Pageable pageable = PageRequest.of(command.getPage(), command.getSize());
2761

2862
return productReadService.getProducts(
29-
filter,
30-
pageable,
63+
filter,
64+
pageable,
3165
command.getMemberIdOrNull()
3266
);
3367
}
3468

3569
@Transactional(readOnly = true)
36-
public ProductDetailInfo getProductDetail(Long productId, String memberIdOrNull) {
37-
return productReadService.getProductDetail(productId, memberIdOrNull);
70+
public CursorPageInfo<ProductSummaryInfo> getProductsByCursor(ProductCursorSearchCommand command) {
71+
ProductSearchFilter filter = ProductSearchFilter.of(
72+
command.getBrandId(),
73+
command.getKeyword(),
74+
command.getSort()
75+
);
76+
77+
return productReadService.getProductsByCursor(
78+
filter,
79+
command.getCursor(),
80+
command.getSize(),
81+
command.getMemberIdOrNull()
82+
);
83+
}
84+
85+
@Transactional(readOnly = true)
86+
public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
87+
// 1. 캐시에서 상품 정보 조회 (isLikedByMember=false인 상태)
88+
ProductDetailInfo cachedInfo = productDetailCache.get(productId)
89+
.orElseGet(() -> {
90+
// 캐시 miss: DB 조회 (isLikedByMember는 나중에 계산)
91+
ProductDetailInfo result = productReadService.getProductDetail(productId, null);
92+
productDetailCache.set(productId, result);
93+
return result;
94+
});
95+
96+
// 2. 로그인하지 않은 경우 바로 반환
97+
if (memberIdOrNull == null) {
98+
return cachedInfo; // isLikedByMember=false 그대로
99+
}
100+
101+
// 3. isLikedByMember만 동적 계산
102+
boolean isLiked = likeReadService.isLikedBy(memberIdOrNull, productId);
103+
104+
// 4. isLikedByMember 필드만 교체해서 반환
105+
return ProductDetailInfo.builder()
106+
.id(cachedInfo.getId())
107+
.name(cachedInfo.getName())
108+
.description(cachedInfo.getDescription())
109+
.brandName(cachedInfo.getBrandName())
110+
.brandDescription(cachedInfo.getBrandDescription())
111+
.price(cachedInfo.getPrice())
112+
.stock(cachedInfo.getStock())
113+
.likeCount(cachedInfo.getLikeCount())
114+
.isLikedByMember(isLiked) // ⭐ 동적 계산
115+
.build();
116+
}
117+
118+
@Transactional(readOnly = true)
119+
public List<ProductSummaryInfo> getPopularProducts(Long memberIdOrNull) {
120+
// 캐시 제거 - 순수 DB 조회
121+
return productReadService.getPopularProducts(memberIdOrNull);
122+
}
123+
124+
@Transactional(readOnly = true)
125+
public List<ProductSummaryInfo> getBrandPopularProducts(Long brandId, int limit, Long memberIdOrNull) {
126+
// 캐시 제거 - 순수 DB 조회
127+
return productReadService.getBrandPopularProducts(brandId, limit, memberIdOrNull);
38128
}
39129
}

apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
@Getter
88
@Builder
99
public class ProductSearchCommand {
10-
10+
11+
private final Long brandId;
1112
private final String keyword;
1213
private final ProductSortCondition sort;
1314
private final int page;
1415
private final int size;
15-
private final String memberIdOrNull;
16-
17-
public static ProductSearchCommand of(String keyword, ProductSortCondition sort, int page, int size, String memberIdOrNull) {
16+
private final Long memberIdOrNull;
17+
18+
public static ProductSearchCommand of(Long brandId, String keyword, ProductSortCondition sort, int page, int size, Long memberIdOrNull) {
1819
return ProductSearchCommand.builder()
20+
.brandId(brandId)
1921
.keyword(keyword)
2022
.sort(sort)
2123
.page(page)
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,32 @@
11
package com.loopers.application.product;
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
5+
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
46
import com.loopers.domain.common.vo.Money;
57
import lombok.Builder;
68

79
@Builder
10+
@JsonDeserialize(builder = ProductSummaryInfo.ProductSummaryInfoBuilder.class)
811
public class ProductSummaryInfo {
9-
12+
1013
private final Long id;
1114
private final String name;
1215
private final String brandName;
1316
private final Money price;
1417
private final int likeCount;
1518
private final boolean isLikedByMember;
16-
19+
1720
public Long getId() { return id; }
1821
public String getName() { return name; }
1922
public String getBrandName() { return brandName; }
2023
public Money getPrice() { return price; }
2124
public int getLikeCount() { return likeCount; }
22-
25+
2326
@JsonProperty("likedByMember")
2427
public boolean isLikedByMember() { return isLikedByMember; }
28+
29+
@JsonPOJOBuilder(withPrefix = "")
30+
public static class ProductSummaryInfoBuilder {
31+
}
2532
}

apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ public void decreaseLikeCount() {
6767
}
6868
}
6969

70+
public void setLikeCount(int likeCount) {
71+
this.likeCount = likeCount;
72+
}
73+
7074
public boolean isStockAvailable(int requiredQuantity) {
7175
return this.stock.isAvailable(requiredQuantity);
7276
}

apps/commerce-api/src/main/java/com/loopers/domain/product/command/ProductSearchFilter.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
@Getter
88
@Builder
99
public class ProductSearchFilter {
10-
10+
11+
private final Long brandId;
1112
private final String keyword;
1213
private final ProductSortCondition sortCondition;
13-
14-
public static ProductSearchFilter of(String keyword, ProductSortCondition sortCondition) {
14+
15+
public static ProductSearchFilter of(Long brandId, String keyword, ProductSortCondition sortCondition) {
1516
return ProductSearchFilter.builder()
17+
.brandId(brandId)
1618
.keyword(keyword)
1719
.sortCondition(sortCondition)
1820
.build();

apps/commerce-api/src/main/java/com/loopers/domain/product/repository/ProductRepository.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@ public interface ProductRepository {
1616

1717
Page<Product> findAll(ProductSearchFilter filter, Pageable pageable);
1818

19+
List<Product> findAllByCursor(ProductSearchFilter filter, String cursor, int size);
20+
1921
Product save(Product product);
2022

2123
int decreaseStock(Long productId, int quantity);
2224

2325
int incrementLikeCount(Long productId);
2426

2527
int decrementLikeCount(Long productId);
28+
29+
int updateLikeCount(Long productId, long count);
30+
31+
List<Product> findTopByLikeCount(int limit);
32+
33+
List<Product> findTopByBrandIdAndLikeCount(Long brandId, int limit);
2634
}

0 commit comments

Comments
 (0)