Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -15,6 +15,6 @@ public record OrderCreatedEvent(
public record OrderItemData(
Long productId,
Long quantity,
Long price
Long unitPrice
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public void publishEvent(OutboxEvent outboxEvent) {
record.headers().add("eventType", outboxEvent.getEventType().getBytes());
record.headers().add("aggregateType", outboxEvent.getAggregateType().getBytes());
record.headers().add("aggregateId", outboxEvent.getAggregateId().getBytes());
record.headers().add("eventTime", outboxEvent.getCreatedAt().toString().getBytes());

CompletableFuture<SendResult<Object, Object>> future = kafkaTemplate.send(record);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.loopers.application.like.LikeCacheRepository;
import com.loopers.application.like.LikeInfo;
import com.loopers.application.ranking.RankingService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.stock.Stock;
Expand All @@ -16,6 +17,7 @@
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

@RequiredArgsConstructor
Expand All @@ -25,15 +27,16 @@ public class ProductCacheService {
private final ProductListViewService productListViewService;
private final ProductService productService;
private final StockService stockService;
private final RankingService rankingService;
private final LikeCacheRepository likeCacheRepository;
private final RedisProductCacheRepository cacheRepository;

private static final Duration LIST_TTL = Duration.ofMinutes(10);
private static final Duration DETAIL_TTL = Duration.ofMinutes(10);
private static final String LIST_KEY_PREFIX = "product:list:";

public Page<ProductWithLikeCount> getProductList(Long userId, Long brandId,
String sort, int page, int size) {
public Page<ProductListItem> getProductList(Long userId, Long brandId,
String sort, int page, int size) {
String key = LIST_KEY_PREFIX + brandId + ":" + sort + ":" + page + ":" + size;
Duration ttl = getListTtl(sort, page);

Expand All @@ -49,26 +52,32 @@ public Page<ProductWithLikeCount> getProductList(Long userId, Long brandId,
List<Long> missIds = productIds.stream()
.filter(id -> cacheRepository.get(id) == null)
.toList();
List<ProductListItem> list = getProductListByProductIds(userId, missIds);
return new PageImpl<>(list, PageRequest.of(page, size), productIds.size());

}

if (!missIds.isEmpty()) {
List<ProductStock> stocks = missIds.stream().map(id -> this.getProductStock(id)).toList();
stocks.forEach(stock -> cacheRepository.save(stock, DETAIL_TTL));
public List<ProductListItem> getProductListByProductIds(Long userId, List<Long> productIds) {
List<ProductStock> productStockList = new ArrayList<>();
if (!productIds.isEmpty()) {
productStockList = productIds.stream().map(id -> this.getProductStock(id)).toList();
}

// 3) 상세 μΊμ‹œ μ‘°ν•© + μ’‹μ•„μš” 쑰회
List<ProductWithLikeCount> list = productIds.stream()
.map(id -> {
ProductStock stock = cacheRepository.get(id);
List<ProductListItem> list = productStockList.stream()
.map(productStock -> {
Long id = productStock.product().getId();
LikeInfo like = likeCacheRepository.getLikeInfo(userId, id);
return new ProductWithLikeCount(
Integer rank = rankingService.getProductRank(id);
return new ProductListItem(
id,
stock.product().getName(),
stock.product().getPrice().getAmount(),
like.likeCount()
productStock.product().getName(),
productStock.product().getPrice().getAmount(),
like.likeCount(),
rank
);
}).toList();

return new PageImpl<>(list, PageRequest.of(page, size), productIds.size());
return list;
}

public ProductStock getProductStock(Long productId) {
Expand All @@ -85,7 +94,7 @@ public ProductStock getProductStock(Long productId) {
public void evictListCache() {
cacheRepository.evictByPrefix(LIST_KEY_PREFIX);
}

private Duration getListTtl(String sort, int page) {
if ("latest".equals(sort)) {
if (page == 0) return Duration.ofMinutes(1); // 첫 νŽ˜μ΄μ§€
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,24 @@

import com.loopers.application.brand.BrandInfo;
import com.loopers.application.like.LikeInfo;
import com.loopers.application.ranking.RankInfo;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;

import java.math.BigDecimal;

public record ProductDetailInfo(Long id, String name, BigDecimal price, long stock
, BrandInfo brandInfo, LikeInfo likeInfo) {
public static ProductDetailInfo from(ProductStock model, LikeInfo likeInfo) {
public record ProductDetailInfo(Long id, String name, BigDecimal price, long stock, BrandInfo brandInfo, LikeInfo likeInfo,
Integer rank) {
public static ProductDetailInfo from(ProductStock model, LikeInfo likeInfo, Integer rank) {
if (model == null) throw new CoreException(ErrorType.NOT_FOUND, "μƒν’ˆμ •λ³΄λ₯Ό μ°Ύμ„μˆ˜ μ—†μŠ΅λ‹ˆλ‹€.");
return new ProductDetailInfo(
model.product().getId(),
model.product().getName(),
model.product().getPrice().getAmount(),
model.stock().getAvailable(),
BrandInfo.from(model.product().getBrand()),
likeInfo
likeInfo,
rank
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -28,13 +29,14 @@ public class ProductFacade {
private final StockService stockService;
private final OutboxService outboxService;
private final ApplicationEventPublisher eventPublisher;
private final RedisTemplate<String, String> redisTemplate;

@Transactional(readOnly = true)
public Page<ProductWithLikeCount> getProductList(long userId,
Long brandId,
String sortType,
int page,
int size) {
public Page<ProductListItem> getProductList(long userId,
Long brandId,
String sortType,
int page,
int size) {
return productQueryService.getProductList(userId, brandId, sortType, page, size);
}

Expand Down Expand Up @@ -70,7 +72,7 @@ public ProductDetailInfo createProduct(Long brandId, String name, Money price) {

ProductStock productStock = ProductStock.from(savedProduct, savedStock);
LikeInfo likeInfo = LikeInfo.from(0L, false);
return ProductDetailInfo.from(productStock, likeInfo);
return ProductDetailInfo.from(productStock, likeInfo, null);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.loopers.application.product;

import java.math.BigDecimal;

public record ProductListItem(Long id, String name, BigDecimal price, long likeCount, Integer rank) {
public static ProductListItem from(Long id, String name, BigDecimal price, long likeCount, Integer rank) {
return new ProductListItem(
id,
name,
price,
likeCount,
rank
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,44 @@

import com.loopers.application.like.LikeCacheRepository;
import com.loopers.application.like.LikeInfo;
import com.loopers.application.ranking.RankingService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;

import java.util.List;


@RequiredArgsConstructor
@Service
public class ProductQueryService {

private final ProductCacheService productCacheService;
private final LikeCacheRepository likeCacheRepository;
private final RankingService rankingService;

public Page<ProductWithLikeCount> getProductList(Long userId,
Long brandId,
String sort,
int page,
int size) {
public Page<ProductListItem> getProductList(Long userId,
Long brandId,
String sort,
int page,
int size) {
return productCacheService.getProductList(userId, brandId, sort, page, size);
}

public List<ProductListItem> getProductListByProductIds(Long userId, List<Long> productIds) {
List<ProductListItem> products = productCacheService.getProductListByProductIds(userId, productIds);
return products;
}

public ProductDetailInfo getProductDetail(Long userId, Long productId) {
ProductStock productStock = productCacheService.getProductStock(productId);
LikeInfo likeInfo = likeCacheRepository.getLikeInfo(userId, productId);

return ProductDetailInfo.from(productStock, likeInfo);
Integer rank = rankingService.getProductRank(productId);
return ProductDetailInfo.from(productStock, likeInfo, rank);
}

public void evictListCache() {
productCacheService.evictListCache();
}


}

This file was deleted.

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

public record RankInfo(Integer rank, Double score) {

public static RankInfo of(Integer rank, Double score) {
return new RankInfo(rank, score);
}

public static RankInfo empty() {
return new RankInfo(null, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.loopers.application.ranking;

import com.loopers.application.product.ProductQueryService;
import com.loopers.application.product.ProductListItem;
import com.loopers.interfaces.api.product.ProductV1Dto;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

@RequiredArgsConstructor
@Component
public class RankingFacade {

private final RedisTemplate<String, String> redisTemplate;
private final ProductQueryService productQueryService;
private final RankingService rankingService;

@Transactional(readOnly = true)
public Page<ProductListItem> getProductRankings(Long userId, String date, int size, int page) {
String rankingKey = "ranking:all:" + date;

// Redisμ—μ„œ λž­ν‚Ή 데이터 쑰회 (νŽ˜μ΄μ§• 처리)
long start = (long) (page - 1) * size;
long end = start + size - 1;

Set<String> rankedProductIds = redisTemplate.opsForZSet().reverseRange(rankingKey, start, end);

if (rankedProductIds == null || rankedProductIds.isEmpty()) {
return new PageImpl<>(List.of(), PageRequest.of(page - 1, size), 0);
}

// productIds 리슀트 생성
List<Long> productIds = rankedProductIds.stream()
.map(Long::parseLong)
.toList();

// μƒν’ˆ 정보 일괄 쑰회 (μ’‹μ•„μš” 정보 포함)
List<ProductListItem> productsWithLike = productQueryService.getProductListByProductIds(userId, productIds);
Long totalCount = rankingService.getTotalRankingCount(date);

return new PageImpl<>(productsWithLike, PageRequest.of(page - 1, size), totalCount);
Comment on lines +40 to +49
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.java" | grep -E "(ProductQueryService|RankingFacade)" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 263


🏁 Script executed:

rg -n "getProductListByProductIds" --type java -A 20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 6530


🏁 Script executed:

rg -n "reverseRange" --type java -A 10 -B 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4054


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java | head -50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2287


🏁 Script executed:

rg -n "class RankingFacade" --type java -A 50

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 4009


🏁 Script executed:

rg -n "LinkedHashSet" --type java apps/commerce-api

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


🏁 Script executed:

rg -n "rankedProductIds" --type java -B 2 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1933


Redis reverseRangeμ—μ„œ λ°˜ν™˜λœ μˆœμ„œκ°€ λ³΄μ‘΄λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

Set<String> rankedProductIds = redisTemplate.opsForZSet().reverseRange(...)μ—μ„œ λ°˜ν™˜λœ Set은 μˆœμ„œλ₯Ό 보μž₯ν•˜μ§€ μ•ŠλŠ” μ»¬λ ‰μ…˜μ΄λ―€λ‘œ, 이λ₯Ό List둜 λ³€ν™˜ν•˜λ©΄ Redis의 점수 λ‚΄λ¦Όμ°¨μˆœμ΄ μ†μ‹€λ©λ‹ˆλ‹€. 결과적으둜 API μ‘λ‹΅μ˜ μˆœμ„œκ°€ λž­ν‚Ή μˆœμ„œμ™€ μΌμΉ˜ν•˜μ§€ μ•Šμ„ 수 μžˆμŠ΅λ‹ˆλ‹€.

λŒ€μ‹  reverseRangeWithScores() λ˜λŠ” κ²°κ³Όλ₯Ό LinkedHashSet으둜 λ°›μ•„μ„œ μˆœμ„œλ₯Ό μœ μ§€ν•˜κ±°λ‚˜, λ‹€μ‹œ μ •λ ¬ν•˜λŠ” λ°©μ‹μœΌλ‘œ μˆ˜μ •ν•΄μ•Ό ν•©λ‹ˆλ‹€.

πŸ€– Prompt for AI Agents
In
apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingFacade.java
around lines 40 to 49, the code converts the Set returned by
redisTemplate.opsForZSet().reverseRange(...) to a List which can lose Redis
ordering; replace that call with reverseRangeWithScores(...) (or otherwise
collect into an ordered collection like LinkedHashSet) and map the returned
ordered tuples to a List<String>/List<Long> in the same order before parsing to
Long, so the ranking order from Redis is preserved when calling
productQueryService and building the PageImpl.

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

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

@RequiredArgsConstructor
@Service
public class RankingService {

private final RedisTemplate<String, String> redisTemplate;

public Integer getProductRank(Long productId) {
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String rankingKey = "ranking:all:" + date;
Long rank = redisTemplate.opsForZSet().reverseRank(rankingKey, productId.toString());

// rankκ°€ null이면 λž­ν‚Ήμ— μ—†λŠ” μƒν’ˆ
return rank != null ? rank.intValue() + 1 : null; // 0λΆ€ν„° μ‹œμž‘ν•˜λ―€λ‘œ +1
}

/**
* νŠΉμ • λ‚ μ§œμ˜ 전체 λž­ν‚Ήμ—μ„œ μƒν’ˆμ˜ μˆœμœ„λ₯Ό 쑰회
*
* @param date μ‘°νšŒν•  λ‚ μ§œ (yyyyMMdd ν˜•μ‹)
* @param productId μƒν’ˆ ID
* @return λž­ν‚Ή μˆœμœ„ (1λΆ€ν„° μ‹œμž‘), λž­ν‚Ήμ— μ—†μœΌλ©΄ null
*/
public Integer getProductRank(String date, Long productId) {
String rankingKey = "ranking:all:" + date;
Long rank = redisTemplate.opsForZSet().reverseRank(rankingKey, productId.toString());

// rankκ°€ null이면 λž­ν‚Ήμ— μ—†λŠ” μƒν’ˆ
return rank != null ? rank.intValue() + 1 : null; // 0λΆ€ν„° μ‹œμž‘ν•˜λ―€λ‘œ +1
}

/**
* νŠΉμ • λ‚ μ§œμ˜ 전체 λž­ν‚Ήμ—μ„œ μƒν’ˆμ˜ 점수λ₯Ό 쑰회
*
* @param date μ‘°νšŒν•  λ‚ μ§œ (yyyyMMdd ν˜•μ‹)
* @param productId μƒν’ˆ ID
* @return λž­ν‚Ή 점수, λž­ν‚Ήμ— μ—†μœΌλ©΄ null
*/
public Double getProductScore(String date, Long productId) {
String rankingKey = "ranking:all:" + date;
return redisTemplate.opsForZSet().score(rankingKey, productId.toString());
}

/**
* νŠΉμ • λ‚ μ§œμ˜ 전체 λž­ν‚Ή 총 개수 쑰회
*
* @param date μ‘°νšŒν•  λ‚ μ§œ (yyyyMMdd ν˜•μ‹)
* @return λž­ν‚Ήμ— μžˆλŠ” 총 μƒν’ˆ 개수
*/
public Long getTotalRankingCount(String date) {
String rankingKey = "ranking:all:" + date;
return redisTemplate.opsForZSet().zCard(rankingKey);
}
}
Loading