Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fe081d9
refactor: 피드백 반영, 레디스 pipeline 적용
ImSejin Sep 8, 2025
6d8ed98
docs: 카프카 설정에 주석 추가
ImSejin Sep 8, 2025
43d189a
chore: cleanup code
ImSejin Sep 8, 2025
a2eae10
feat: 상품 랭킹 집계
ImSejin Sep 8, 2025
1e0d48d
feat: 랭킹 캐시 수정
ImSejin Sep 9, 2025
2d02575
feat: 상품 랭킹 구현
ImSejin Sep 9, 2025
e58c0df
feat: 상품 랭킹 TOP 1000 검색 API 추가
ImSejin Sep 10, 2025
f273c2d
conf: JpaRepository의 기본 @Transactional 비활성화 (명시적 트랜잭션 의도)
ImSejin Sep 10, 2025
bf60749
refactor: 상품 랭킹 집계 로직 수정
ImSejin Sep 10, 2025
cb93541
refactor: 상품 랭킹 조회 API 리팩터링
ImSejin Sep 10, 2025
fbbdae5
feat: jackson 모듈에 WrapperJsonMapper 빈 추가
ImSejin Sep 10, 2025
595dc1f
docs: rank HTTP 템플릿 추가
ImSejin Sep 10, 2025
70a2afd
feat: 가중치를 enum으로 분리
ImSejin Sep 11, 2025
960fe06
docs: README 수정
ImSejin Sep 11, 2025
6b2b272
feat: 상품 상세 조회 시 랭크를 포함
ImSejin Sep 11, 2025
c050b75
chore: cleanup code
ImSejin Sep 11, 2025
f3e5dce
fix: 카프카 리스너 payload 타입 오류
ImSejin Sep 11, 2025
e069460
fix: WrappedJsonMapper currently drops all Boot Jackson modules and c…
ImSejin Sep 11, 2025
e95ade6
docs: 실시간 랭킹 문서 추가
ImSejin Sep 12, 2025
801df03
fix: 상품 상세에서 랭크를 조회할 때 잘못된 랭크를 반환한 점
ImSejin Sep 12, 2025
56aa885
test: ConcurrentAssertion 수정
ImSejin Sep 15, 2025
9cc890d
test: ConcurrentAssertion 리팩터링
ImSejin Sep 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ docker-compose -f ./docker/monitoring-compose.yml up
```
Root
├── apps ( spring-applications )
│ └── 📦 commerce-api
│ ├── 📦 commerce-api
│ ├── 📦 commerce-streamer
│ └── 📦 pg-simulator
├── modules ( reusable-configurations )
│ ├── 📦 jpa
│ ├── 📦 redis
│ ├── 📦 kafka
│ ├── 📦 feign
│ └── 📦 resilience4j
└── supports ( add-ons )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.product.ProductResult;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.ranking.RankingCommand;
import com.loopers.domain.ranking.RankingService;
import com.loopers.domain.user.UserService;
import com.loopers.support.error.BusinessException;
import com.loopers.support.error.CommonErrorType;
Expand All @@ -16,6 +18,7 @@
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.util.Optional;

@Component
Expand All @@ -24,6 +27,7 @@ public class ProductFacade {

private final ProductService productService;
private final BrandService brandService;
private final RankingService rankingService;
private final UserService userService;

@Qualifier(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
Expand All @@ -38,6 +42,9 @@ public ProductOutput.GetProductDetail getProductDetail(ProductInput.GetProductDe

BrandResult.GetBrand brand = brandService.getBrand(detail.brandId()).orElse(null);

RankingCommand.FindRank rankCommand = new RankingCommand.FindRank(LocalDate.now(), productId);
Long rank = rankingService.findRank(rankCommand).orElse(null);

Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid system-default time; inject Clock to fix date-boundary bugs.

Using LocalDate.now() ties behavior to the host JVM timezone and can mis-rank around midnight or on misconfigured servers. Inject a Clock and use LocalDate.now(clock).

Apply:

@@
-import java.time.LocalDate;
+import java.time.Clock;
+import java.time.LocalDate;
@@
-    private final RankingService rankingService;
+    private final RankingService rankingService;
+    private final Clock clock;
@@
-        RankingCommand.FindRank rankCommand = new RankingCommand.FindRank(LocalDate.now(), productId);
+        RankingCommand.FindRank rankCommand = new RankingCommand.FindRank(LocalDate.now(clock), productId);

If the API later accepts a date parameter, thread it into this facade and prefer the provided date over now(clock).

Committable suggestion skipped: line range outside the PR's diff.

// 회원이면 상품 조회 이벤트를 발행한다.
Optional.ofNullable(input.getUserName())
.filter(StringUtils::hasText)
Expand All @@ -48,7 +55,7 @@ public ProductOutput.GetProductDetail getProductDetail(ProductInput.GetProductDe
))
));

return ProductOutput.GetProductDetail.from(detail, brand);
return ProductOutput.GetProductDetail.from(detail, brand, rank);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ public static class GetProductDetail {
private final String brandName;
@Nullable
private final String brandDescription;
@Nullable
private final Long rank;

public static GetProductDetail from(
ProductResult.GetProductDetail product,
@Nullable
BrandResult.GetBrand brand
BrandResult.GetBrand brand,
@Nullable
Long rank
) {
return GetProductDetail.builder()
.productId(product.productId())
Expand All @@ -40,6 +44,7 @@ public static GetProductDetail from(
.brandId(brand == null ? null : brand.getBrandId())
.brandName(brand == null ? null : brand.getBrandName())
.brandDescription(brand == null ? null : brand.getBrandDescription())
.rank(rank)
.build();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.loopers.application.ranking;

import com.loopers.domain.product.ProductResult;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.ranking.RankingCommand;
import com.loopers.domain.ranking.RankingResult;
import com.loopers.domain.ranking.RankingService;
import com.loopers.support.error.BusinessException;
import com.loopers.support.error.CommonErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.util.List;

@Service
@RequiredArgsConstructor
public class RankingFacade {

private final RankingService rankingService;
private final ProductService productService;

public RankingOutput.SearchRankings searchRankings(RankingInput.SearchRankings input) {
RankingCommand.SearchRanks rankCommand = new RankingCommand.SearchRanks(input.date(), input.page(), input.size());
RankingResult.SearchRanks ranks = rankingService.searchRanks(rankCommand);

if (CollectionUtils.isEmpty(ranks.items())) {
return RankingOutput.SearchRankings.empty(ranks);
}

List<ProductResult.GetProductDetail> details = ranks.items()
.stream()
.map(rank -> productService.getProductDetail(rank.productId())
.orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND))
)
.toList();

return RankingOutput.SearchRankings.from(ranks, details);
Comment on lines +31 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Eliminate N+1 detail fetches (Top 1000 = 1000 calls).

Batch-fetch product details to reduce latency and load. Also decide behavior on missing details (skip vs. fail-all).

-        List<ProductResult.GetProductDetail> details = ranks.items()
-                .stream()
-                .map(rank -> productService.getProductDetail(rank.productId())
-                        .orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND))
-                )
-                .toList();
+        List<Long> productIds = ranks.items().stream().map(RankingResult.SearchRanks.Item::productId).toList();
+        // Prefer batch API to avoid N calls
+        List<ProductResult.GetProductDetail> details = productService.getProductDetails(productIds);
+        // Option A (strict): fail if any missing
+        if (details.size() != productIds.size()) {
+            throw new BusinessException(CommonErrorType.NOT_FOUND);
+        }
+        // Option B (lenient): filter missing and still return (requires output mapping to handle gaps)

Outside-this-file support (add to ProductService):

public List<ProductResult.GetProductDetail> getProductDetails(List<Long> productIds) { /* batch cache + repo */ }

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- rg: getProductDetail occurrences ----"
rg -n --hidden --no-ignore -S "getProductDetail\(" -C3 || true

echo
echo "---- rg: getProductDetails occurrences ----"
rg -n --hidden --no-ignore -S "getProductDetails\(" -C3 || true

echo
echo "---- rg: ProductService definition (interface/class) ----"
rg -n --hidden --no-ignore -S "interface\s+ProductService\b|class\s+ProductService\b" -C3 || true

echo
echo "---- rg: productService variable usages ----"
rg -n --hidden --no-ignore -S "\bproductService\b" -C3 || true

echo
echo "---- Show RankingFacade.java (first 300 lines) ----"
if [ -f "apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java" ]; then
  sed -n '1,300p' apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java || true
else
  echo "File not found: apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java"
fi

echo
echo "---- Show ProductService files (if found) ----"
rg -n --hidden --no-ignore -S "ProductService" -g '!**/target/**' -C2 || true

Length of output: 43208


Eliminate N+1 product-detail fetch — use a batch API

ProductService currently only exposes getProductDetail(Optional); add a batch getProductDetails(List) and update RankingFacade to use it. Decide strict (fail-all) vs lenient (skip-missing) behavior.

Location: apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java (stream mapping)

-        List<ProductResult.GetProductDetail> details = ranks.items()
-                .stream()
-                .map(rank -> productService.getProductDetail(rank.productId())
-                        .orElseThrow(() -> new BusinessException(CommonErrorType.NOT_FOUND))
-                )
-                .toList();
+        List<Long> productIds = ranks.items().stream().map(RankingResult.SearchRanks.Item::productId).toList();
+        // Prefer batch API to avoid N calls
+        List<ProductResult.GetProductDetail> details = productService.getProductDetails(productIds);
+        // Option A (strict): fail if any missing
+        if (details.size() != productIds.size()) {
+            throw new BusinessException(CommonErrorType.NOT_FOUND);
+        }
+        // Option B (lenient): filter missing and still return (requires output mapping to handle gaps)

Outside-this-file support (add to ProductService):

public List<ProductResult.GetProductDetail> getProductDetails(List<Long> productIds) { /* batch cache + repo */ }
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 31–38, the current stream does an N+1 call to
productService.getProductDetail; replace it with a single batch call to a new
ProductService.getProductDetails(List<Long>) and then map results back to the
original ranking order. Implementation steps: collect productIds from ranks into
a List in the same order, call productService.getProductDetails(productIds),
build a Map<Long, ProductResult.GetProductDetail> from the returned list, then
produce details by iterating the original productIds and looking up each id in
the map; choose strict behavior: if any id is missing from the batch result,
throw new BusinessException(CommonErrorType.NOT_FOUND). Also add the
getProductDetails(List<Long>) signature to ProductService and implement batch
caching/repo lookup as needed.

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.loopers.application.ranking;

import java.time.LocalDate;

public record RankingInput() {

public record SearchRankings(
LocalDate date,
Integer page,
Integer size
) {
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.loopers.application.ranking;

import com.loopers.domain.product.ProductResult;
import com.loopers.domain.ranking.RankingResult;

import java.util.List;

public record RankingOutput() {

public record SearchRankings(
Integer totalPages,
Long totalItems,
Integer page,
Integer size,
List<Item> items
) {
public static SearchRankings empty(RankingResult.SearchRanks result) {
return new SearchRankings(result.totalPages(), result.totalItems(), result.page(), result.size(), List.of());
}

public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
return new SearchRankings(
ranks.totalPages(),
ranks.totalItems(),
ranks.page(),
ranks.size(),
details.stream()
.map(detail -> new Item(
detail.productId(),
detail.productName(),
detail.basePrice(),
detail.likeCount(),
detail.brandId(),
detail.brandName()
))
.toList()
);
}
Comment on lines +21 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

Preserve ranking order when joining details (essential)

from(...) ignores the rank order contained in ranks and just streams details, which risks returning a misordered “ranking” if details isn’t already sorted. Build a map by productId and emit items in ranks order.

Apply:

@@
-        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
-            return new SearchRankings(
-                    ranks.totalPages(),
-                    ranks.totalItems(),
-                    ranks.page(),
-                    ranks.size(),
-                    details.stream()
-                            .map(detail -> new Item(
-                                    detail.productId(),
-                                    detail.productName(),
-                                    detail.basePrice(),
-                                    detail.likeCount(),
-                                    detail.brandId(),
-                                    detail.brandName()
-                            ))
-                            .toList()
-            );
-        }
+        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
+            var safeDetails = details == null ? List.<ProductResult.GetProductDetail>of() : details;
+            var byId = safeDetails.stream()
+                    .collect(java.util.stream.Collectors.toMap(
+                            ProductResult.GetProductDetail::productId,
+                            java.util.function.Function.identity(),
+                            (a, b) -> a,
+                            java.util.LinkedHashMap::new));
+
+            var items = ranks.productIds().stream() // assume this exposes ranked product IDs
+                    .map(byId::get)
+                    .filter(java.util.Objects::nonNull)
+                    .map(detail -> new Item(
+                            detail.productId(),
+                            detail.productName(),
+                            detail.basePrice(),
+                            detail.likeCount(),
+                            detail.brandId(),
+                            detail.brandName()
+                    ))
+                    .toList();
+
+            return new SearchRankings(
+                    ranks.totalPages(),
+                    ranks.totalItems(),
+                    ranks.page(),
+                    ranks.size(),
+                    items
+            );
+        }

Additional imports needed:

+import java.util.LinkedHashMap;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;

If SearchRanks uses a different accessor than productIds(), swap accordingly.


Preserve ranking order when joining details (essential)

from(...) currently streams details directly and ignores the rank order in ranks — map details by productId and emit items in ranks order.

File: apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java
Lines: 21-38

Apply:

@@
-        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
-            return new SearchRankings(
-                    ranks.totalPages(),
-                    ranks.totalItems(),
-                    ranks.page(),
-                    ranks.size(),
-                    details.stream()
-                            .map(detail -> new Item(
-                                    detail.productId(),
-                                    detail.productName(),
-                                    detail.basePrice(),
-                                    detail.likeCount(),
-                                    detail.brandId(),
-                                    detail.brandName()
-                            ))
-                            .toList()
-            );
-        }
+        public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
+            var safeDetails = details == null ? List.<ProductResult.GetProductDetail>of() : details;
+            var byId = safeDetails.stream()
+                    .collect(java.util.stream.Collectors.toMap(
+                            ProductResult.GetProductDetail::productId,
+                            java.util.function.Function.identity(),
+                            (a, b) -> a,
+                            java.util.LinkedHashMap::new));
+
+            var items = ranks.productIds().stream() // assume this exposes ranked product IDs
+                    .map(byId::get)
+                    .filter(java.util.Objects::nonNull)
+                    .map(detail -> new Item(
+                            detail.productId(),
+                            detail.productName(),
+                            detail.basePrice(),
+                            detail.likeCount(),
+                            detail.brandId(),
+                            detail.brandName()
+                    ))
+                    .toList();
+
+            return new SearchRankings(
+                    ranks.totalPages(),
+                    ranks.totalItems(),
+                    ranks.page(),
+                    ranks.size(),
+                    items
+            );
+        }

Additional imports needed:

+import java.util.LinkedHashMap;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;

If SearchRanks uses a different accessor than productIds(), swap accordingly.

📝 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.

Suggested change
public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
return new SearchRankings(
ranks.totalPages(),
ranks.totalItems(),
ranks.page(),
ranks.size(),
details.stream()
.map(detail -> new Item(
detail.productId(),
detail.productName(),
detail.basePrice(),
detail.likeCount(),
detail.brandId(),
detail.brandName()
))
.toList()
);
}
public static SearchRankings from(RankingResult.SearchRanks ranks, List<ProductResult.GetProductDetail> details) {
var safeDetails = details == null ? List.<ProductResult.GetProductDetail>of() : details;
var byId = safeDetails.stream()
.collect(java.util.stream.Collectors.toMap(
ProductResult.GetProductDetail::productId,
java.util.function.Function.identity(),
(a, b) -> a,
java.util.LinkedHashMap::new));
var items = ranks.productIds().stream() // assume this exposes ranked product IDs
.map(byId::get)
.filter(java.util.Objects::nonNull)
.map(detail -> new Item(
detail.productId(),
detail.productName(),
detail.basePrice(),
detail.likeCount(),
detail.brandId(),
detail.brandName()
))
.toList();
return new SearchRankings(
ranks.totalPages(),
ranks.totalItems(),
ranks.page(),
ranks.size(),
items
);
}
🤖 Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingOutput.java
around lines 21 to 38, the from(...) method ignores the ordering in ranks and
streams details directly; instead build a Map from productId to
ProductResult.GetProductDetail (e.g., Map<String,
ProductResult.GetProductDetail> detailsById =
details.stream().collect(Collectors.toMap(...))) and then iterate the ranks'
product id sequence (e.g., ranks.productIds() or the correct accessor) to
produce the Item list in that exact order, mapping each id to its detail and
creating Item instances (handle missing details gracefully or skip/throw as
appropriate); add imports for java.util.Map and java.util.stream.Collectors (or
java.util.stream.*) as needed.


public record Item(
Long productId,
String productName,
Integer basePrice,
Long likeCount,
Long brandId,
String brandName
) {
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ void saveProducts(
Page<ProductQueryResult.Products> page
);

Optional<ProductQueryResult.ProductDetail> findProductDetailById(Long productId);
Optional<ProductQueryResult.ProductDetail> findDetail(Long productId);

void saveProductDetail(
void saveDetail(
Long productId,
ProductQueryResult.ProductDetail productDetail
ProductQueryResult.ProductDetail detail
);

}
Original file line number Diff line number Diff line change
@@ -1,70 +1,56 @@
package com.loopers.domain.product;

import lombok.*;

import java.util.List;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class ProductQueryResult {
public record ProductQueryResult() {

@Getter
@Builder
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class Products {
private final Long productId;
private final String productName;
private final Integer basePrice;
private final Long likeCount;
private final Long brandId;
private final String brandName;
public record Products(
Long productId,
String productName,
Integer basePrice,
Long likeCount,
Long brandId,
String brandName
) {
}

// -------------------------------------------------------------------------------------------------

@Getter
@Builder
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class ProductDetail {
public record ProductDetail(
Long productId,
String productName,
Integer basePrice,
Long likeCount,
Long brandId,
String brandName,
List<Option> options
) {
/**
* For preventing cache penetration.
*/
public static final ProductDetail EMPTY = ProductDetail.builder().build();

private final Long productId;
private final String productName;
private final Integer basePrice;
private final Long likeCount;
private final Long brandId;
private final List<Option> options;

@Getter
@Builder
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class Option {
private final Long productOptionId;
private final String productOptionName;
private final Integer additionalPrice;
private final Long productId;
private final Integer stockQuantity;
public static final ProductDetail EMPTY = new ProductDetail(null, null, null, null, null, null, null);

public record Option(
Long productOptionId,
String productOptionName,
Integer additionalPrice,
Long productId,
Integer stockQuantity
) {
}
}

// -------------------------------------------------------------------------------------------------

@Getter
@Builder
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class ProductOptions {
private final List<Item> items;

@Getter
@Builder
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class Item {
private final Long productOptionId;
private final Integer salePrice;
private final Integer stockQuantity;
private final Long productId;
public record ProductOptions(
List<Item> items
) {
public record Item(
Long productOptionId,
Integer salePrice,
Integer stockQuantity,
Long productId
) {
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ public interface ProductRepository {

Page<ProductQueryResult.Products> searchProducts(ProductQueryCommand.SearchProducts command);

Optional<ProductQueryResult.ProductDetail> findProductDetailById(Long productId);
Optional<ProductQueryResult.ProductDetail> findDetail(Long productId);

Optional<ProductQueryResult.ProductOptions> findProductOptionsByIds(List<Long> productOptionIds);
Optional<ProductQueryResult.ProductOptions> findOptions(List<Long> productOptionIds);

Optional<Product> findProductForUpdate(Long productId);

Expand Down
Loading