Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
61b0df6
docs: 9์ฃผ์ฐจ ์š”๊ตฌ์‚ฌํ•ญ ๋ถ„์„ ๋ฌธ์„œ ์ž‘์„ฑ
jeonga1022 Dec 21, 2025
77225a4
docs: 9์ฃผ์ฐจ ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ ์ž‘์„ฑ
jeonga1022 Dec 21, 2025
bca67df
feat: ๋žญํ‚น Redis ์„œ๋น„์Šค ๊ธฐ๋ณธ ๊ตฌํ˜„ (getTopProducts, incrementScoreForView)
jeonga1022 Dec 22, 2025
7275f5b
feat: ์ข‹์•„์š” ์‹œ ๋žญํ‚น ์ ์ˆ˜ ์ฆ๊ฐ€ ๊ธฐ๋Šฅ ๊ตฌํ˜„
jeonga1022 Dec 23, 2025
bc18cc4
feat: ์ฃผ๋ฌธ ์‹œ ๋žญํ‚น ์ ์ˆ˜ ์ฆ๊ฐ€ ๊ธฐ๋Šฅ ๊ตฌํ˜„
jeonga1022 Dec 23, 2025
d47efeb
feat: ์ƒํ’ˆ ์กฐํšŒ ๋กœ๊ทธ ์—”ํ‹ฐํ‹ฐ ๋ฐ Repository ์ƒ์„ฑ
jeonga1022 Dec 23, 2025
e38d21b
feat: ๋žญํ‚น ์‹œ์Šคํ…œ์„ ์œ„ํ•œ ์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ ์ถ”๊ฐ€
jeonga1022 Dec 23, 2025
3cf0dd6
feat: ์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ Consumer ์ถ”๊ฐ€
jeonga1022 Dec 23, 2025
dc137cf
feat: ZSET TTL 2์ผ ์„ค์ •
jeonga1022 Dec 23, 2025
82bd67b
feat: ์ƒํ’ˆ ์กฐํšŒ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ๊ตฌํ˜„
jeonga1022 Dec 23, 2025
e4a6149
feat: ์ข‹์•„์š” ์ด๋ฒคํŠธ ์‹œ ๋žญํ‚น ์ ์ˆ˜ ๋ฐ˜์˜
jeonga1022 Dec 25, 2025
569b5ae
refactor: ์ด๋ฒคํŠธ๋ณ„ Consumer ๋ถ„๋ฆฌ ๋ฐ ํ† ํ”ฝ ๋ณ€๊ฒฝ
jeonga1022 Dec 25, 2025
045e6cb
feat: Ranking API ๊ตฌํ˜„
jeonga1022 Dec 25, 2025
c5a89f6
feat: ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ ๋žญํ‚น ์ˆœ์œ„ ๋ฐ˜ํ™˜
jeonga1022 Dec 25, 2025
f994a91
fix: ProductViewedEvent Jackson ์—ญ์ง๋ ฌํ™” ํ˜ธํ™˜์„ฑ ์ˆ˜์ •
jeonga1022 Dec 26, 2025
7c253b2
refactor: EXPIRE๋ฅผ ํ‚ค ์ƒ์„ฑ ์‹œ์—๋งŒ ํ˜ธ์ถœํ•˜๋„๋ก ๋ณ€๊ฒฝ
jeonga1022 Dec 30, 2025
ed9adee
docs: 10์ฃผ์ฐจ ์š”๊ตฌ์‚ฌํ•ญ ๋ฐ ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ ๋ฌธ์„œ ์ถ”๊ฐ€
jeonga1022 Dec 30, 2025
b88de75
refactor: ProductMetrics์— date, viewCount ์ปฌ๋Ÿผ ์ถ”๊ฐ€
jeonga1022 Dec 31, 2025
b1e72df
feat: Consumer์—์„œ ProductMetrics ์ผ๋ณ„ ์ €์žฅ ์ ์šฉ
jeonga1022 Dec 31, 2025
cdd6f76
feat: Spring Batch ์„ค์ • ์ถ”๊ฐ€
jeonga1022 Dec 31, 2025
3dc3c47
feat: ์ฃผ๊ฐ„ ๋žญํ‚น MV ์—”ํ‹ฐํ‹ฐ ์ถ”๊ฐ€
jeonga1022 Dec 31, 2025
fc113a2
feat: ์ฃผ๊ฐ„ ๋žญํ‚น ์ง‘๊ณ„ Batch Job ๊ตฌํ˜„
jeonga1022 Dec 31, 2025
d2369ac
feat: ์›”๊ฐ„ ๋žญํ‚น ์ง‘๊ณ„ Batch Job ๊ตฌํ˜„
jeonga1022 Dec 31, 2025
4a065de
refactor: Redis ๋žญํ‚น ์ ์ˆ˜๋ฅผ ์ •์ˆ˜(1:2:6)๋กœ ํ†ต์ผ
jeonga1022 Jan 1, 2026
db4572f
feat: Ranking API์— period ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ (daily/weekly/monthly)
jeonga1022 Jan 1, 2026
dc52982
refactor: Batch Writer์—์„œ Step Listener๋กœ ์‚ญ์ œ ์ด๋™
jeonga1022 Jan 1, 2026
08290df
refactor: @Modifying ๋ฉ”์„œ๋“œ์— @Transactional ์ถ”๊ฐ€
jeonga1022 Jan 1, 2026
01ed56a
test: RankingApiE2ETest ํ™œ์„ฑํ™”
jeonga1022 Jan 1, 2026
f162468
feat: ์ฃผ๊ฐ„/์›”๊ฐ„ ๋žญํ‚น Redis ์บ์‹œ fallback ๋ฐ ๋ฐฐ์น˜ ํ›„ ์บ์‹œ ๊ฐฑ์‹  ์ถ”๊ฐ€
jeonga1022 Jan 2, 2026
fe77482
fix: Kafka admin bootstrap.servers ํฌํŠธ 19092๋กœ ํ†ต์ผ
jeonga1022 Jan 2, 2026
cec5275
fix: Consumer์˜ ProductMetrics Lost Update ๋ฌธ์ œ ํ•ด๊ฒฐ
jeonga1022 Jan 2, 2026
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
6 changes: 6 additions & 0 deletions apps/commerce-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ dependencies {
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))

// batch
implementation("org.springframework.boot:spring-boot-starter-batch")

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
Expand All @@ -25,4 +28,7 @@ dependencies {
// test-fixtures
testImplementation(testFixtures(project(":modules:jpa")))
testImplementation(testFixtures(project(":modules:redis")))

// batch test
testImplementation("org.springframework.batch:spring-batch-test")
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
import com.loopers.domain.product.ProductSortType;
import com.loopers.infrastructure.cache.ProductCacheService;
import com.loopers.infrastructure.cache.ProductDetailCache;
import com.loopers.infrastructure.event.ViewEventPublisher;
import com.loopers.infrastructure.ranking.RankingRedisService;
import com.loopers.interfaces.api.product.ProductDto;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
Expand All @@ -26,6 +29,8 @@ public class ProductFacade {
private final ProductDomainService productDomainService;
private final BrandDomainService brandDomainService;
private final ProductCacheService productCacheService;
private final ViewEventPublisher viewEventPublisher;
private final RankingRedisService rankingRedisService;

/**
* ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ (Cache-Aside ํŒจํ„ด)
Expand Down Expand Up @@ -77,11 +82,18 @@ public ProductDto.ProductDetailResponse getProduct(Long productId) {
// 1. ์บ์‹œ ์กฐํšŒ ์‹œ๋„
Optional<ProductDetailCache> cachedDetail = productCacheService.getProductDetail(productId);

// 2. ์กฐํšŒ ์ด๋ฒคํŠธ ๋ฐœํ–‰ (์บ์‹œ ํžˆํŠธ ์—ฌ๋ถ€์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ)
viewEventPublisher.publish(productId);

// 3. ์ˆœ์œ„ ์กฐํšŒ
Long rank = rankingRedisService.getRankingPosition(LocalDate.now(), productId);

if (cachedDetail.isPresent()) {
// Cache Hit: ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ ์ง์ ‘ ๋ฐ˜ํ™˜
return ProductDto.ProductDetailResponse.from(
productId,
cachedDetail.get()
cachedDetail.get(),
rank
);
}

Expand All @@ -94,6 +106,6 @@ public ProductDto.ProductDetailResponse getProduct(Long productId) {
productCacheService.setProductDetail(productId, cache);

// Response ๋ฐ˜ํ™˜
return ProductDto.ProductDetailResponse.from(productId, cache);
return ProductDto.ProductDetailResponse.from(productId, cache, rank);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package com.loopers.application.ranking;

import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.infrastructure.ranking.ProductRankMonthly;
import com.loopers.infrastructure.ranking.ProductRankMonthlyRepository;
import com.loopers.infrastructure.ranking.ProductRankWeekly;
import com.loopers.infrastructure.ranking.ProductRankWeeklyRepository;
import com.loopers.infrastructure.ranking.RankingEntry;
import com.loopers.infrastructure.ranking.RankingRedisService;
import com.loopers.interfaces.api.ranking.RankingDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
@RequiredArgsConstructor
public class RankingFacade {

private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

private final RankingRedisService rankingRedisService;
private final ProductRepository productRepository;
private final ProductRankWeeklyRepository productRankWeeklyRepository;
private final ProductRankMonthlyRepository productRankMonthlyRepository;

public RankingDto.RankingListResponse getRankings(String period, String dateStr, int page, int size) {
return switch (period.toLowerCase()) {
case "weekly" -> getWeeklyRankings(dateStr, page, size);
case "monthly" -> getMonthlyRankings(dateStr, page, size);
default -> getDailyRankings(dateStr, page, size);
};
}
Comment on lines +35 to +41
Copy link

Choose a reason for hiding this comment

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

โš ๏ธ Potential issue | ๐ŸŸก Minor

period ํŒŒ๋ผ๋ฏธํ„ฐ null ์ฒดํฌ ๋ˆ„๋ฝ

period.toLowerCase() ํ˜ธ์ถœ ์‹œ period๊ฐ€ null์ด๋ฉด NullPointerException์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. API ๋ ˆ์ด์–ด์—์„œ ๊ธฐ๋ณธ๊ฐ’์„ ๋ณด์žฅํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ๋ฐฉ์–ด ๋กœ์ง์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
 public RankingDto.RankingListResponse getRankings(String period, String dateStr, int page, int size) {
+    String normalizedPeriod = (period == null) ? "daily" : period.toLowerCase();
-    return switch (period.toLowerCase()) {
+    return switch (normalizedPeriod) {
         case "weekly" -> getWeeklyRankings(dateStr, page, size);
         case "monthly" -> getMonthlyRankings(dateStr, page, size);
         default -> getDailyRankings(dateStr, page, size);
     };
 }
๐Ÿค– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 35-41, the code calls period.toLowerCase() without guarding against
period being null which can throw NullPointerException; update the method to
first normalize period by checking for null/blank (e.g. if period == null or
blank, set to a default like "daily"), then call toLowerCase(Locale.ROOT) on the
normalized value (or use Optional/Objects.requireNonNullElse with a default),
and use that normalized string in the switch so nulls and varying
casing/whitespace are handled safely.


private RankingDto.RankingListResponse getDailyRankings(String dateStr, int page, int size) {
LocalDate date = parseDate(dateStr);
int offset = page * size;

List<RankingEntry> entries = rankingRedisService.getTopProducts(date, offset, size);

if (entries.isEmpty()) {
return new RankingDto.RankingListResponse(List.of(), page, size, 0);
}

long totalCount = rankingRedisService.getTotalCount(date);

List<Long> productIds = entries.stream()
.map(RankingEntry::productId)
.toList();

Map<Long, Product> productMap = productRepository.findAllByIdIn(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));

List<RankingDto.RankingResponse> rankings = new ArrayList<>();
int rank = offset + 1;
for (RankingEntry entry : entries) {
Product product = productMap.get(entry.productId());
if (product != null) {
rankings.add(new RankingDto.RankingResponse(
rank++,
product.getId(),
product.getName(),
product.getPrice(),
entry.score()
));
}
}

return new RankingDto.RankingListResponse(rankings, page, size, totalCount);
}

private RankingDto.RankingListResponse getWeeklyRankings(String dateStr, int page, int size) {
LocalDate date = parseDate(dateStr);
LocalDate weekStart = date.with(DayOfWeek.MONDAY);
int offset = page * size;

// 1. Redis ์บ์‹œ ๋จผ์ € ์กฐํšŒ
List<RankingEntry> cachedEntries = rankingRedisService.getWeeklyRankingCache(weekStart, offset, size);
if (!cachedEntries.isEmpty()) {
long totalCount = rankingRedisService.getWeeklyRankingCacheCount(weekStart);
return buildRankingResponse(cachedEntries, offset, page, size, totalCount);
}

// 2. ์บ์‹œ ์—†์œผ๋ฉด DB ์กฐํšŒ
List<ProductRankWeekly> weeklyRanks = productRankWeeklyRepository
.findByPeriodStartOrderByRankingAsc(weekStart);

if (weeklyRanks.isEmpty()) {
return new RankingDto.RankingListResponse(List.of(), page, size, 0);
}

// 3. DB ๊ฒฐ๊ณผ๋ฅผ Redis์— ์บ์‹œ
List<RankingEntry> allEntries = weeklyRanks.stream()
.map(r -> new RankingEntry(r.getProductId(), r.getScore().doubleValue()))
.toList();
rankingRedisService.cacheWeeklyRanking(weekStart, allEntries);

// 4. ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ํ›„ ๋ฐ˜ํ™˜
int toIndex = Math.min(offset + size, weeklyRanks.size());
if (offset >= weeklyRanks.size()) {
return new RankingDto.RankingListResponse(List.of(), page, size, weeklyRanks.size());
}

List<ProductRankWeekly> pagedRanks = weeklyRanks.subList(offset, toIndex);

List<Long> productIds = pagedRanks.stream()
.map(ProductRankWeekly::getProductId)
.toList();

Map<Long, Product> productMap = productRepository.findAllByIdIn(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));

List<RankingDto.RankingResponse> rankings = new ArrayList<>();
for (ProductRankWeekly rank : pagedRanks) {
Product product = productMap.get(rank.getProductId());
if (product != null) {
rankings.add(new RankingDto.RankingResponse(
rank.getRanking(),
product.getId(),
product.getName(),
product.getPrice(),
rank.getScore().doubleValue()
));
}
}

return new RankingDto.RankingListResponse(rankings, page, size, weeklyRanks.size());
}

private RankingDto.RankingListResponse getMonthlyRankings(String dateStr, int page, int size) {
LocalDate date = parseDate(dateStr);
YearMonth yearMonth = YearMonth.from(date);
LocalDate monthStart = yearMonth.atDay(1);
int offset = page * size;

// 1. Redis ์บ์‹œ ๋จผ์ € ์กฐํšŒ
List<RankingEntry> cachedEntries = rankingRedisService.getMonthlyRankingCache(monthStart, offset, size);
if (!cachedEntries.isEmpty()) {
long totalCount = rankingRedisService.getMonthlyRankingCacheCount(monthStart);
return buildRankingResponse(cachedEntries, offset, page, size, totalCount);
}

// 2. ์บ์‹œ ์—†์œผ๋ฉด DB ์กฐํšŒ
List<ProductRankMonthly> monthlyRanks = productRankMonthlyRepository
.findByPeriodStartOrderByRankingAsc(monthStart);

if (monthlyRanks.isEmpty()) {
return new RankingDto.RankingListResponse(List.of(), page, size, 0);
}

// 3. DB ๊ฒฐ๊ณผ๋ฅผ Redis์— ์บ์‹œ
List<RankingEntry> allEntries = monthlyRanks.stream()
.map(r -> new RankingEntry(r.getProductId(), r.getScore().doubleValue()))
.toList();
rankingRedisService.cacheMonthlyRanking(monthStart, allEntries);

// 4. ํŽ˜์ด์ง• ์ฒ˜๋ฆฌ ํ›„ ๋ฐ˜ํ™˜
int toIndex = Math.min(offset + size, monthlyRanks.size());
if (offset >= monthlyRanks.size()) {
return new RankingDto.RankingListResponse(List.of(), page, size, monthlyRanks.size());
}

List<ProductRankMonthly> pagedRanks = monthlyRanks.subList(offset, toIndex);

List<Long> productIds = pagedRanks.stream()
.map(ProductRankMonthly::getProductId)
.toList();

Map<Long, Product> productMap = productRepository.findAllByIdIn(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));

List<RankingDto.RankingResponse> rankings = new ArrayList<>();
for (ProductRankMonthly rank : pagedRanks) {
Product product = productMap.get(rank.getProductId());
if (product != null) {
rankings.add(new RankingDto.RankingResponse(
rank.getRanking(),
product.getId(),
product.getName(),
product.getPrice(),
rank.getScore().doubleValue()
));
}
}

return new RankingDto.RankingListResponse(rankings, page, size, monthlyRanks.size());
}

private RankingDto.RankingListResponse buildRankingResponse(
List<RankingEntry> entries, int offset, int page, int size, long totalCount) {

List<Long> productIds = entries.stream()
.map(RankingEntry::productId)
.toList();

Map<Long, Product> productMap = productRepository.findAllByIdIn(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));

List<RankingDto.RankingResponse> rankings = new ArrayList<>();
int rank = offset + 1;
for (RankingEntry entry : entries) {
Product product = productMap.get(entry.productId());
if (product != null) {
rankings.add(new RankingDto.RankingResponse(
rank++,
product.getId(),
product.getName(),
product.getPrice(),
entry.score()
));
}
}

return new RankingDto.RankingListResponse(rankings, page, size, totalCount);
}

private LocalDate parseDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) {
return LocalDate.now();
}
return LocalDate.parse(dateStr, DATE_FORMATTER);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

public class ProductLikedEvent {

private final Long productId;
private final Long userId;
private final boolean liked;
private final LocalDateTime occurredAt;
private Long productId;
private Long userId;
private boolean liked;
private LocalDateTime occurredAt;

protected ProductLikedEvent() {
}

private ProductLikedEvent(Long productId, Long userId, boolean liked) {
this.productId = productId;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,30 @@
import com.loopers.domain.order.Order;

import java.time.LocalDateTime;
import java.util.List;

public class OrderCompletedEvent {

private final Long orderId;
private final String userId;
private final long totalAmount;
private final long discountAmount;
private final long paymentAmount;
private final LocalDateTime occurredAt;
private Long orderId;
private String userId;
private long totalAmount;
private long discountAmount;
private long paymentAmount;
private List<OrderItemInfo> items;
private LocalDateTime occurredAt;

protected OrderCompletedEvent() {
}

private OrderCompletedEvent(Order order) {
this.orderId = order.getId();
this.userId = order.getUserId();
this.totalAmount = order.getTotalAmount();
this.discountAmount = order.getDiscountAmount();
this.paymentAmount = order.getPaymentAmount();
this.items = order.getOrderItems().stream()
.map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity()))
.toList();
this.occurredAt = LocalDateTime.now();
}

Expand Down Expand Up @@ -46,7 +54,32 @@ public long getPaymentAmount() {
return paymentAmount;
}

public List<OrderItemInfo> getItems() {
return items;
}

public LocalDateTime getOccurredAt() {
return occurredAt;
}

public static class OrderItemInfo {
private Long productId;
private Long quantity;

protected OrderItemInfo() {
}

public OrderItemInfo(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}

public Long getProductId() {
return productId;
}

public Long getQuantity() {
return quantity;
}
}
}
Loading