Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2f797fb
feat: ๋žญํ‚น ์„ค์ • ์ถ”๊ฐ€
sylee6529 Dec 26, 2025
d313560
feat: ๋žญํ‚น ZSET ์บ์‹œ ๊ตฌํ˜„ (commerce-streamer)
sylee6529 Dec 26, 2025
6ab67c3
feat: ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„ ์„œ๋น„์Šค ๋žญํ‚น ์—ฐ๋™
sylee6529 Dec 26, 2025
d4e6aef
feat: ๋žญํ‚น ์บ์‹œ ์กฐํšŒ ๊ธฐ๋Šฅ ๊ตฌํ˜„ (commerce-api)
sylee6529 Dec 26, 2025
bac5304
feat: ๋žญํ‚น Facade ๋ฐ Info ๊ตฌํ˜„
sylee6529 Dec 26, 2025
b3b3270
feat: ๋žญํ‚น ์กฐํšŒ API ์ปจํŠธ๋กค๋Ÿฌ ๊ตฌํ˜„
sylee6529 Dec 26, 2025
8626727
feat: ์ƒํ’ˆ ์ƒ์„ธ์— ๋žญํ‚น ์ •๋ณด ์ถ”๊ฐ€
sylee6529 Dec 26, 2025
bd05e11
refactor: ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์ตœ์ ํ™”
sylee6529 Dec 26, 2025
376903f
test: ๋žญํ‚น ์‹œ์Šคํ…œ ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
sylee6529 Dec 26, 2025
57fe5a7
fix: TTL CAS ๋กœ์ง TOCTOU ์ด์Šˆ ์ˆ˜์ •
sylee6529 Dec 26, 2025
d5117fb
feat: ๋žญํ‚น API date ํŒŒ๋ผ๋ฏธํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์ถ”๊ฐ€
sylee6529 Dec 26, 2025
c9c03b2
feat: ๋žญํ‚น API page/size ํŒŒ๋ผ๋ฏธํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์ถ”๊ฐ€
sylee6529 Dec 26, 2025
31047f5
feat: IllegalArgumentException, ConstraintViolationException ํ•ธ๋“ค๋Ÿฌ ์ถ”๊ฐ€
sylee6529 Dec 26, 2025
836b809
fix: ๊ธฐ์กด ํ…Œ์ŠคํŠธ ์‹คํŒจ ์ˆ˜์ •
sylee6529 Dec 26, 2025
9c3b0e0
test: ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ ๋žญํ‚น ์ •๋ณด ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€
sylee6529 Dec 26, 2025
ee508d0
refactor: Info ํด๋ž˜์Šค @Getter ์• ๋„ˆํ…Œ์ด์…˜ ํ†ต์ผ
sylee6529 Dec 26, 2025
e89e5c4
refactor: ๋žญํ‚น ์ฝ”๋“œ ๋ถˆํ•„์š”ํ•œ ์ฃผ์„ ์ •๋ฆฌ
sylee6529 Dec 26, 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
Original file line number Diff line number Diff line change
@@ -1,39 +1,25 @@
package com.loopers.application.product;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import com.loopers.domain.common.vo.Money;
import com.loopers.domain.product.vo.Stock;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
@JsonDeserialize(builder = ProductDetailInfo.ProductDetailInfoBuilder.class)
public class ProductDetailInfo {

private final Long id;
private final String name;
private final String description;
private final Long brandId;
private final String brandName;
private final String brandDescription;
private final Money price;
private final Stock stock;
private final int likeCount;
private final boolean isLikedByMember;

public Long getId() { return id; }
public String getName() { return name; }
public String getDescription() { return description; }
public String getBrandName() { return brandName; }
public String getBrandDescription() { return brandDescription; }
public Money getPrice() { return price; }
public Stock getStock() { return stock; }
public int getLikeCount() { return likeCount; }

@JsonProperty("likedByMember")
public boolean isLikedByMember() { return isLikedByMember; }

@JsonPOJOBuilder(withPrefix = "")
public static class ProductDetailInfoBuilder {
}
private final boolean isLikedByMember;
private final Integer ranking; // ์ˆœ์œ„ (1-based), ์ˆœ์œ„๊ถŒ ๋ฐ–์ด๋ฉด null
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

import com.loopers.application.event.product.ProductViewedEvent;
import com.loopers.domain.like.service.LikeReadService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.repository.ProductRepository;
import com.loopers.domain.product.service.ProductReadService;
import com.loopers.domain.product.command.ProductSearchFilter;
import com.loopers.domain.product.enums.ProductSortCondition;
import com.loopers.infrastructure.cache.ProductDetailCache;
import com.loopers.infrastructure.cache.ProductListCache;
import com.loopers.infrastructure.cache.ProductRankingCache;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
Expand All @@ -29,7 +28,7 @@ public class ProductFacade {
private final LikeReadService likeReadService;
private final ProductDetailCache productDetailCache;
private final ProductListCache productListCache;
private final ProductRepository productRepository;
private final ProductRankingCache productRankingCache;
private final ApplicationEventPublisher eventPublisher;

@Transactional(readOnly = true)
Expand Down Expand Up @@ -100,33 +99,33 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
return result;
});

// 2. Product ์—”ํ‹ฐํ‹ฐ ์กฐํšŒ (brandId ํš๋“์šฉ)
Product product = productRepository.findById(productId)
.orElseThrow(() -> new com.loopers.support.error.CoreException(
com.loopers.support.error.ErrorType.NOT_FOUND,
"์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));

// 3. isLikedByMember ๋™์  ๊ณ„์‚ฐ
// 2. isLikedByMember ๋™์  ๊ณ„์‚ฐ
boolean isLiked = memberIdOrNull != null && likeReadService.isLikedBy(memberIdOrNull, productId);

// 4. isLikedByMember ํ•„๋“œ๋งŒ ๊ต์ฒดํ•ด์„œ ๋ฐ˜ํ™˜
// 3. ์ˆœ์œ„ ์กฐํšŒ (์‹ค์‹œ๊ฐ„)
Integer ranking = productRankingCache.getRank(productId);

// 4. ๋™์  ํ•„๋“œ(isLikedByMember, ranking)๋ฅผ ๊ต์ฒดํ•ด์„œ ๋ฐ˜ํ™˜
ProductDetailInfo result = ProductDetailInfo.builder()
.id(cachedInfo.getId())
.name(cachedInfo.getName())
.description(cachedInfo.getDescription())
.brandId(cachedInfo.getBrandId())
.brandName(cachedInfo.getBrandName())
.brandDescription(cachedInfo.getBrandDescription())
.price(cachedInfo.getPrice())
.stock(cachedInfo.getStock())
.likeCount(cachedInfo.getLikeCount())
.isLikedByMember(isLiked) // โญ ๋™์  ๊ณ„์‚ฐ
.isLikedByMember(isLiked)
.ranking(ranking)
.build();

// 5. ProductViewedEvent ๋ฐœํ–‰ (์กฐํšŒ์ˆ˜ ์ง‘๊ณ„)
// brandId๋Š” ์บ์‹œ๋œ ์ •๋ณด์—์„œ ๊ฐ€์ ธ์˜ด (๋ถˆํ•„์š”ํ•œ DB ์กฐํšŒ ์ œ๊ฑฐ)
eventPublisher.publishEvent(new ProductViewedEvent(
memberIdOrNull, // ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋Š” null
productId,
product.getBrandId(),
cachedInfo.getBrandId(),
LocalDateTime.now()
));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,19 @@
package com.loopers.application.product;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import com.loopers.domain.common.vo.Money;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
@JsonDeserialize(builder = ProductSummaryInfo.ProductSummaryInfoBuilder.class)
public class ProductSummaryInfo {

private final Long id;
private final String name;
private final String brandName;
private final Money price;
private final int likeCount;
private final boolean isLikedByMember;

public Long getId() { return id; }
public String getName() { return name; }
public String getBrandName() { return brandName; }
public Money getPrice() { return price; }
public int getLikeCount() { return likeCount; }

@JsonProperty("likedByMember")
public boolean isLikedByMember() { return isLikedByMember; }

@JsonPOJOBuilder(withPrefix = "")
public static class ProductSummaryInfoBuilder {
}
private final boolean isLikedByMember;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.loopers.application.ranking;

import com.loopers.application.ranking.RankingInfo.RankingItemInfo;
import com.loopers.application.ranking.RankingInfo.RankingPageInfo;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.repository.BrandRepository;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.repository.ProductRepository;
import com.loopers.infrastructure.cache.ProductRankingCache;
import com.loopers.infrastructure.cache.ProductRankingCache.RankingEntry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
@Component
@Transactional(readOnly = true)
public class RankingFacade {

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

private final ProductRankingCache productRankingCache;
private final ProductRepository productRepository;
private final BrandRepository brandRepository;

/** @param page 0-based */
public RankingPageInfo getRankings(String date, int page, int size) {
// ๋‚ ์งœ ๊ฒ€์ฆ ๋ฐ ๊ธฐ๋ณธ๊ฐ’ ์ฒ˜๋ฆฌ
String targetDate = validateAndNormalizeDate(date);

// 1. ZSET์—์„œ ๋žญํ‚น ์กฐํšŒ
List<RankingEntry> rankingEntries = productRankingCache.getTopRankings(targetDate, page, size);

if (rankingEntries.isEmpty()) {
return RankingPageInfo.of(Collections.emptyList(), targetDate, page, size, 0);
}

// 2. ์ƒํ’ˆ ID ๋ชฉ๋ก ์ถ”์ถœ
List<Long> productIds = rankingEntries.stream()
.map(RankingEntry::productId)
.toList();

// 3. ์ƒํ’ˆ ์ •๋ณด ์กฐํšŒ
List<Product> products = productRepository.findByIdIn(productIds);
Map<Long, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));

// 4. ๋ธŒ๋žœ๋“œ ์ •๋ณด ์กฐํšŒ (N+1 ๋ฐฉ์ง€)
List<Long> brandIds = products.stream()
.map(Product::getBrandId)
.distinct()
.toList();
List<Brand> brands = brandRepository.findByIdIn(brandIds);
Map<Long, Brand> brandMap = brands.stream()
.collect(Collectors.toMap(Brand::getId, Function.identity()));

// 5. ์‘๋‹ต ์ƒ์„ฑ
List<RankingItemInfo> rankings = rankingEntries.stream()
.map(entry -> {
Product product = productMap.get(entry.productId());
if (product == null) {
log.warn("[Ranking] Product not found - productId: {}", entry.productId());
return null;
}
Brand brand = brandMap.get(product.getBrandId());
String brandName = brand != null ? brand.getName() : "Unknown";

return new RankingItemInfo(
entry.rank(),
product.getId(),
product.getName(),
brandName,
product.getPrice(),
product.getLikeCount(),
entry.score()
);
})
.filter(item -> item != null)
.toList();

// 6. ์ „์ฒด ๊ฐœ์ˆ˜ ์กฐํšŒ
long totalCount = productRankingCache.getTotalCount(targetDate);

return RankingPageInfo.of(rankings, targetDate, page, size, totalCount);
}

/** @throws IllegalArgumentException ์œ ํšจํ•˜์ง€ ์•Š์€ ๋‚ ์งœ ํ˜•์‹ */
private String validateAndNormalizeDate(String date) {
if (date == null || date.isBlank()) {
return productRankingCache.getTodayDate();
}

try {
LocalDate.parse(date, DATE_FORMATTER);
return date;
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid date format. Expected yyyyMMdd, got: " + date);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.loopers.application.ranking;

import com.loopers.domain.common.vo.Money;

import java.util.List;

public class RankingInfo {

public record RankingPageInfo(
List<RankingItemInfo> rankings,
String date,
int page,
int size,
long totalCount,
int totalPages,
boolean hasNext
) {
public static RankingPageInfo of(
List<RankingItemInfo> rankings,
String date,
int page,
int size,
long totalCount
) {
int totalPages = size > 0 ? (int) Math.ceil((double) totalCount / size) : 0;
boolean hasNext = page < totalPages - 1;
return new RankingPageInfo(rankings, date, page, size, totalCount, totalPages, hasNext);
}
}

public record RankingItemInfo(
int rank,
Long productId,
String productName,
String brandName,
Money price,
int likeCount,
Double score
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ public ProductDetailInfo getProductDetail(Long productId, Long memberIdOrNull) {
.id(product.getId())
.name(product.getName())
.description(product.getDescription())
.brandId(product.getBrandId())
.brandName(brand.getName())
.brandDescription(brand.getDescription())
.price(product.getPrice())
.stock(product.getStock())
.likeCount(product.getLikeCount())
.isLikedByMember(isLikedByMember)
.ranking(null) // ์บ์‹œ ์ €์žฅ์šฉ, Facade์—์„œ ์‹ค์‹œ๊ฐ„ ์กฐํšŒ๋กœ ๊ต์ฒด
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,10 @@ public static String productLikeCountKey(Long productId) {
public static String productLikeCountKeyPattern() {
return String.format("product:like:count:%s:*", VERSION);
}

// Daily ranking ZSET key
public static String dailyRankingKey(String date) {
return String.format("ranking:all:%s:%s", VERSION, date);
// ์˜ˆ: "ranking:all:v1:20251223"
}
}
Loading