-
Notifications
You must be signed in to change notification settings - Fork 44
[Volume 5] 상품 목록 조회 성능, 좋아요 수 정렬 구조 개선 및 캐시 적용 #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: YoHanKi
Are you sure you want to change the base?
Changes from all commits
4e5812e
007482d
5a23d3c
4aec8cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,48 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.loopers.application.product; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.redis.connection.RedisConnectionFactory; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.redis.core.RedisTemplate; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.redis.serializer.RedisSerializer; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.redis.serializer.StringRedisSerializer; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Component; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Optional; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.concurrent.TimeUnit; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Component | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class ProductCacheStore { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final String KEY_PREFIX = "product::"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final long TTL_SECONDS = 60; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+14
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# ProductCacheStore 사용처 확인
rg -n "ProductCacheStore" --type java -A2
# `@Cacheable`("product") 사용처 확인
rg -n '@Cacheable.*"product"' --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1633 🏁 Script executed: # CacheConfig 클래스 전체 내용 확인
fd "CacheConfig.java" --type f | xargs cat -n
# ProductApp에서 productCacheStore 사용 패턴 확인
rg -n "productCacheStore\." --type java -B2 -A3
# ProductCacheStore 전체 구현 확인
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java
# `@Cacheable` 사용처 전체 확인
rg -n "@Cacheable" --type java -B1 -A2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 14400 🏁 Script executed: # 최종 확인: CacheConfig에서 "product" 캐시가 실제로 사용되는지 확인
rg -n "\"product\"" --type java -B2 -A2 apps/commerce-api/src/
# ProductCacheStore 단독 사용 확인
rg -n "productCacheStore\.(get|put)" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5006
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final RedisTemplate<String, byte[]> redisTemplate; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final GenericJackson2JsonRedisSerializer serializer; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public ProductCacheStore(RedisConnectionFactory redisConnectionFactory) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.serializer = new GenericJackson2JsonRedisSerializer(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.redisTemplate = buildRedisTemplate(redisConnectionFactory); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public Optional<ProductInfo> get(String productId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (bytes == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Optional.empty(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Optional.of((ProductInfo) serializer.deserialize(bytes)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
YoHanKi marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public void put(String productId, ProductInfo productInfo) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| byte[] bytes = serializer.serialize(productInfo); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
YoHanKi marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+27
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redis 장애 시 예외 처리가 누락되었다. PR 설명에서 "Redis failure 시 DB fallback"을 언급했으나, 이 클래스에서는 Redis 연결 실패나 타임아웃 시 예외가 그대로 전파된다. Redis 장애가 전체 상품 조회 기능 장애로 이어질 수 있다. 운영 환경에서 Redis 불안정 시 서비스 가용성을 보장하려면 다음 처리가 필요하다:
🛡️ Redis 장애 대응 코드 제안+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
`@Component`
public class ProductCacheStore {
+ private static final Logger log = LoggerFactory.getLogger(ProductCacheStore.class);
public Optional<ProductInfo> get(String productId) {
+ try {
byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId);
if (bytes == null) {
return Optional.empty();
}
return Optional.of((ProductInfo) serializer.deserialize(bytes));
+ } catch (Exception e) {
+ log.warn("Redis 캐시 조회 실패, DB fallback 진행: productId={}", productId, e);
+ return Optional.empty();
+ }
}
public void put(String productId, ProductInfo productInfo) {
+ try {
byte[] bytes = serializer.serialize(productInfo);
redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ log.warn("Redis 캐시 저장 실패: productId={}", productId, e);
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private RedisTemplate<String, byte[]> buildRedisTemplate(RedisConnectionFactory connectionFactory) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| RedisTemplate<String, byte[]> template = new RedisTemplate<>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| template.setConnectionFactory(connectionFactory); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| template.setKeySerializer(new StringRedisSerializer()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| template.setValueSerializer(RedisSerializer.byteArray()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| template.afterPropertiesSet(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return template; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import com.loopers.application.brand.BrandApp; | ||
| import com.loopers.application.brand.BrandInfo; | ||
| import com.loopers.domain.common.cursor.CursorPageResult; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.Pageable; | ||
|
|
@@ -40,6 +41,11 @@ public Page<ProductInfo> getProducts(String brandId, String sortBy, Pageable pag | |
| return products.map(this::enrichProductInfo); | ||
| } | ||
|
|
||
| public CursorPageResult<ProductInfo> getProductsByCursor(String brandId, String sortBy, String cursor, int size) { | ||
| CursorPageResult<ProductInfo> result = productApp.getProductsByCursor(brandId, sortBy, cursor, size); | ||
| return result.map(this::enrichProductInfo); | ||
| } | ||
|
Comment on lines
+44
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# BrandApp.getBrandByRefId 캐시 적용 여부 확인
rg -n "getBrandByRefId" --type java -B2 -A5
# Brand 조회에 캐시가 있는지 확인
rg -n '@Cacheable.*brand' --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3034 🏁 Script executed: #!/bin/bash
# CursorPageResult.map() 구현 확인
rg -n "class CursorPageResult" --type java -A30
# getProducts 메서드 확인 (N+1 패턴 비교)
rg -n "public.*getProducts" --type java -A10 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
# 브랜드 정보를 한번에 조회하는 메서드 확인
rg -n "getBrand.*List\|getBrands\|getBrandsByIds" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 893 🏁 Script executed: #!/bin/bash
# Find CursorPageResult to understand map() behavior
fd -e java | xargs rg -l "class CursorPageResult|interface CursorPageResult" --type java
# Look for batch loading or multi-get methods for brands
rg -n "public.*BrandInfo.*List|public.*List.*Brand" --type java apps/commerce-api/src/main/java/com/loopers/application/brand/
# Check if any caching exists on brand queries
rg -n "@Cacheable|@Cache" --type java apps/commerce-api/src/main/java/com/loopers/application/brand/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: #!/bin/bash
# Broader search for CursorPageResult definition
rg -n "CursorPageResult" --type java -B2 -A20 | head -100
# Check all methods in BrandApp
cat apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java
# Check for batch/collection methods in brand repository
rg -n "findAllById|findByIdIn" --type java apps/commerce-api/src/main/java/com/loopers/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 15894 🏁 Script executed: #!/bin/bash
# Find CursorPageResult class definition
find . -type f -name "*.java" | xargs grep -l "class CursorPageResult"
# Look at the file
fd "CursorPageResult.java" -x cat {}
# Check BrandRepository methods
rg -n "public.*Brand.*find\|public.*List.*Brand" --type java apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 567 🏁 Script executed: #!/bin/bash
# Check BrandRepository interface/class
find . -path "*/infrastructure/brand/*Repository*.java" -type f | xargs cat
# Search for any batch loading patterns with brands in the codebase
rg -n "findAllById|findByIdIn|IN\(" --type java apps/commerce-api/src/main/java/com/loopers/ | head -30
# Check if LikeFacade has similar issue and any solutions
cat apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 2743
이 문제는 개선 방안:
검증:
🤖 Prompt for AI Agents |
||
|
|
||
| public ProductInfo getProductByRefId(Long id) { | ||
| ProductInfo product = productApp.getProductByRefId(id); | ||
| return enrichProductInfo(product); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.loopers.domain.common.cursor; | ||
|
|
||
| import java.util.List; | ||
| import java.util.function.Function; | ||
|
|
||
| public record CursorPageResult<T>( | ||
| List<T> items, | ||
| String nextCursor, | ||
| boolean hasNext, | ||
| int size | ||
| ) { | ||
| public <R> CursorPageResult<R> map(Function<T, R> mapper) { | ||
| return new CursorPageResult<>( | ||
| items.stream().map(mapper).toList(), | ||
| nextCursor, | ||
| hasNext, | ||
| size | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,17 @@ | |
| import java.math.BigDecimal; | ||
|
|
||
| @Entity | ||
| @Table(name = "products") | ||
| @Table( | ||
| name = "products", | ||
| indexes = { | ||
| // Q1 likes_desc: 브랜드 필터 + 좋아요 내림차순 + 최신순 보조 정렬 | ||
| @Index(name = "idx_products_brand_like", columnList = "ref_brand_id, like_count DESC, deleted_at"), | ||
| // Q2 latest: 브랜드 필터 + 최신순 | ||
| @Index(name = "idx_products_brand_latest", columnList = "ref_brand_id, updated_at DESC, deleted_at"), | ||
| // Q3 price_asc: 브랜드 필터 + 가격 오름차순 | ||
| @Index(name = "idx_products_brand_price", columnList = "ref_brand_id, price, deleted_at") | ||
| } | ||
| ) | ||
|
Comment on lines
+22
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 새 인덱스가 키셋 정렬의 전체 ORDER BY를 덮지 못한다. 현재 정의는 🤖 Prompt for AI Agents |
||
| @Getter | ||
| public class ProductModel extends BaseEntity { | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.