Skip to content
Open
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
@@ -1,47 +1,209 @@
package com.loopers.application.product;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.config.redis.RedisConfig;
import com.loopers.domain.brand.BrandService;
import com.loopers.application.like.LikeApplicationService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.SellingStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.stereotype.Component;

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

@Component
public class ProductFacade {

// 읽기 전용 조회 (Replica Preferred) - 캐시 히트 확인에 사용
private final RedisTemplate<String, String> redisTemplate;
// 쓰기 전용 (Master) - 캐시 저장 및 삭제에 사용
private final RedisTemplate<String, String> masterRedisTemplate;
Comment on lines +28 to +31
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Replica 읽기와 Master 쓰기를 분리하면 무효화 직후 stale cache를 다시 읽을 수 있다.

운영에서는 Master에서 키를 삭제하거나 갱신해도 Replica 복제가 늦으면 바로 다음 조회가 이전 값을 다시 읽을 수 있다. 그러면 이 PR이 의도한 update/delete 후 즉시 일관성이 깨지고, 단일 Redis로 도는 테스트에서는 이 문제가 드러나지 않는다. 수정안은 최소한 캐시 무효화 직후 정확성이 필요한 목록/상세 조회는 같은 노드에서 읽고 쓰도록 통일하는 것이다. 추가 테스트로는 읽기/쓰기 Redis를 분리한 환경에서 수정·삭제 직후 GET이 stale 응답을 내지 않는지 검증하면 된다.


private final ProductService productService;
private final BrandService brandService;
private final LikeApplicationService likeService;
private final ObjectMapper objectMapper;

public ProductFacade(
ProductService productService,
BrandService brandService,
LikeApplicationService likeService,
RedisTemplate<String, String> redisTemplate,
@Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate,
ObjectMapper objectMapper
) {
this.productService = productService;
this.brandService = brandService;
this.likeService = likeService;
this.redisTemplate = redisTemplate;
this.masterRedisTemplate = masterRedisTemplate;
this.objectMapper = objectMapper;
}

public ProductInfo findById(Long productId) {
return findById(productId, null);
}

/**
* Cache-Aside 패턴으로 상품 상세를 조회한다.
* - 캐시 히트: isLiked는 사용자별 데이터이므로 캐시에 저장하지 않고 별도 조회 후 오버레이
* - 캐시 미스: DB 조회 후 isLiked=null 상태로 캐시 저장 (TTL 1분), 이후 isLiked 오버레이
* - 캐시 키: product:detail:{productId}
*/
public ProductInfo findById(Long productId, Long userId) {
String cacheKey = "product:detail:" + productId;

// 캐시 히트: isLiked를 별도 조회해 오버레이 후 반환
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
try {
ProductInfo info = objectMapper.readValue(cached, ProductInfo.class);
Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null;
return info.withIsLiked(isLiked);
} catch (JsonProcessingException ignored) {
}
}

// 캐시 미스: DB 조회 후 isLiked=null 상태로 캐시 저장
Product product = productService.findById(productId);
ProductInfo info = ProductInfo.from(product);
try {
masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(info), Duration.ofMinutes(1));
} catch (JsonProcessingException ignored) {
}
Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null;
return ProductInfo.from(product, isLiked);
return info.withIsLiked(isLiked);
Comment on lines +80 to +86
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "[1] Like write service candidates"
fd 'Like.*Service\.java' apps/commerce-api/src/main/java -x sed -n '1,260p' {}

echo
echo "[2] Cache invalidation usages"
rg -n -C3 'product:list:|product:detail:|evictProductListCache|masterRedisTemplate\.delete' apps/commerce-api/src/main/java

echo
echo "[3] Like count mutation call sites"
rg -n -C3 'increaseLikes\(|decreaseLikes\(|isLiked\(' apps/commerce-api/src/main/java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 17448


🏁 Script executed:

# Verify the controller/API layer to understand the call chain
fd 'Like.*Controller\.java' apps/commerce-api/src/main/java -x cat

# Check if there's a LikeFacade
fd 'LikeFacade\.java' apps/commerce-api/src/main/java -x cat

# Check imports in LikeApplicationService to see dependencies
grep -n "import\|package" apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 4808


🏁 Script executed:

# Check if ProductFacade is accessible or if we need to expose evictProductListCache as public
grep -n "class ProductFacade\|public ProductInfo\|private void evictProductListCache" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -10

# Check the full signature of evictProductListCache
sed -n '159,164p' apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

# Check if Product has brandId accessible
grep -n "brandId\|getBrandId" apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 985


좋아요 쓰기 경로에서 상품 캐시를 무효화하지 않아 데이터 불일치 발생 가능하다.

LikeApplicationService.like()/unlike()에서 product.increaseLikes()/decreaseLikes()를 호출하고 저장하지만, 캐시 무효화가 없다. ProductInfoProductListCacheEntry에 포함된 likeCountlikes_desc 정렬이 최대 1분간 stale해진다.

수정안: LikeFacade.like()/unlike() 메서드에서 likeService.like/unlike() 트랜잭션 완료 후 반환된 ProductbrandId를 이용해 다음을 함께 무효화한다:

  • product:detail:{productId} exact key 삭제
  • product:list:all:*product:list:{brandId}:* 패턴 삭제

추가 테스트: 상품 상세 캐시 적재 후 like/unlike 수행하고, API 응답에서 likeCount와 목록의 likes_desc 정렬 순서가 즉시 갱신되는지 검증한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 80 - 86, After likeService.like()/unlike() completes in
LikeFacade.like()/unlike(), invalidate the product caches using the returned
Product.brandId: remove the exact key "product:detail:{productId}" and delete
pattern keys "product:list:all:*" and "product:list:{brandId}:*" so ProductInfo
and ProductListCacheEntry likeCount and likes_desc sorting are not stale;
perform the invalidation after the transaction-returned Product is obtained (use
product.getBrandId()) and ensure deletion uses masterRedisTemplate (or
equivalent) to evict cached list and detail entries. Also add a test that loads
the product detail and list caches, calls like() and unlike(), and asserts API
responses reflect updated likeCount and list ordering immediately.

}

/**
* Cache-Aside 패턴으로 상품 목록을 조회한다.
* - page == 0: 캐시 우선 조회 → 미스 시 DB 조회 후 캐시 저장 (TTL 5분)
* - page > 0: 캐시 없이 DB 직접 조회 (페이지 수가 많아질수록 캐시 효용이 낮아 적용 제외)
*/
public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
return productService.findAll(brandId, pageable).map(ProductInfo::from);
// 첫 페이지가 아닌 경우 캐시 미적용
if (pageable.getPageNumber() != 0) {
return productService.findAll(brandId, pageable).map(ProductInfo::from);
}

String cacheKey = buildCacheKey(brandId, pageable);
Comment on lines 94 to +100
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

page=0의 임의 size를 전부 캐시하면 공개 API 하나로 캐시 키가 무한히 늘어난다.

지금 구현은 첫 페이지면 size 값과 무관하게 캐시하고, 키에도 pageSize를 그대로 넣는다. 운영에서는 ?page=0&size=1..N 요청만으로 Redis 키 수와 payload 크기가 끝없이 늘어나서 메모리 압박과 낮은 적중률로 바로 이어진다. 수정안은 캐시 대상 size를 고정값 하나로 제한하거나, 비정상적으로 큰 size는 캐시를 우회하도록 막는 것이다. 추가 테스트로는 size=10/20/1000 요청 후 허용한 size만 캐시 키가 생성되는지 검증하면 된다.

수정 예시
+    private static final int CACHEABLE_PAGE_SIZE = 20;
+
     public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
-        // 첫 페이지가 아닌 경우 캐시 미적용
-        if (pageable.getPageNumber() != 0) {
+        // 첫 페이지 + 표준 페이지 크기만 캐시
+        if (pageable.getPageNumber() != 0 || pageable.getPageSize() != CACHEABLE_PAGE_SIZE) {
             return productService.findAll(brandId, pageable).map(ProductInfo::from);
         }

Also applies to: 190-193

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 94 - 100, The current ProductFacade.findAll caches any page=0
response using the raw pageable size, which allows infinite cache keys; change
the caching logic to only cache when pageable.getPageNumber()==0 AND
pageable.getPageSize() matches an allowed/normalized size (e.g., a single fixed
CACHE_PAGE_SIZE constant or limited to a MAX_CACHE_SIZE), otherwise bypass cache
and call productService.findAll(...). Update buildCacheKey to use the normalized
cache size (or a sentinel like "uncached" when not caching) so keys cannot
explode, and add tests calling findAll with size=10/20/1000 to assert only the
allowed size produces a Redis key and payload.


// 캐시 히트: 직렬화된 JSON을 PageImpl로 복원해 반환
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
try {
ProductListCacheEntry entry = objectMapper.readValue(cached, ProductListCacheEntry.class);
return new PageImpl<>(entry.content(), pageable, entry.totalElements());
} catch (JsonProcessingException ignored) {
}
}

// 캐시 미스: DB 조회 후 결과를 캐시에 저장
Page<ProductInfo> result = productService.findAll(brandId, pageable).map(ProductInfo::from);
try {
ProductListCacheEntry entry = new ProductListCacheEntry(result.getContent(), result.getTotalElements());
masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(entry), Duration.ofMinutes(5));
} catch (JsonProcessingException ignored) {
}
return result;
}

public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) {
brandService.findById(brandId);
return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
}
Comment on lines 122 to 125
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

상품 생성 후 목록 캐시를 비우지 않아 새 상품이 최대 5분간 보이지 않는다.

목록 캐시는 첫 페이지를 5분 동안 유지하는데, create 경로는 현재 캐시 무효화를 하지 않는다. 그래서 latest 전체 목록과 브랜드별 목록이 이미 warm 상태면 새 상품 생성 직후에도 사용자는 이전 결과를 계속 받게 된다. 수정안은 생성 직후 evictProductListCache(created.brandId())를 호출하는 것이다. 추가 테스트로는 목록 캐시를 먼저 적재한 뒤 관리자 상품 생성 API를 호출하고, 다음 조회에서 새 상품이 즉시 보이는지 검증하면 된다.

수정 예시
     public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) {
         brandService.findById(brandId);
-        return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+        ProductInfo info = ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+        evictProductListCache(info.brandId());
+        return info;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 122 - 125, The create method in ProductFacade currently calls
brandService.findById(...) and productService.create(...) but does not
invalidate the product list cache, so newly created products may not appear
until cache TTL expires; after creating the product (the return value of
productService.create called in ProductFacade.create), call
evictProductListCache(created.brandId()) to evict both the global "latest" and
brand-specific list caches before returning ProductInfo, and add a test that
warms the list cache, calls the admin create API, then fetches the list to
assert the new product appears immediately.


/**
* 상품 수정 후 목록 캐시 및 상세 캐시를 무효화한다.
* - 목록 캐시: brandId 기반 SCAN 패턴 삭제
* - 상세 캐시: product:detail:{productId} exact key 삭제
* update는 반환된 ProductInfo에서 brandId를 얻을 수 있으므로 별도 조회 불필요.
*/
public ProductInfo update(Long productId, String name, String description, int price, SellingStatus sellingStatus) {
return ProductInfo.from(productService.update(productId, name, description, price, sellingStatus));
ProductInfo info = ProductInfo.from(productService.update(productId, name, description, price, sellingStatus));
evictProductListCache(info.brandId());
masterRedisTemplate.delete("product:detail:" + productId);
return info;
}

/**
* 상품 삭제 후 목록 캐시 및 상세 캐시를 무효화한다.
* - 목록 캐시: brandId 기반 SCAN 패턴 삭제
* - 상세 캐시: product:detail:{productId} exact key 삭제
* delete는 void 반환이므로 삭제 전에 별도로 조회해 brandId를 얻는다.
*/
public void delete(Long productId) {
Product product = productService.findById(productId);
Long brandId = product.getBrandId();
productService.delete(productId);
evictProductListCache(brandId);
masterRedisTemplate.delete("product:detail:" + productId);
}

/**
* brandId와 관련된 캐시 키를 모두 삭제한다.
* - "product:list:all:*" : brandId 필터 없는 전체 목록 캐시
* - "product:list:{brandId}:*" : 특정 브랜드 목록 캐시
*/
private void evictProductListCache(Long brandId) {
deleteByPattern("product:list:all:*");
if (brandId != null) {
deleteByPattern("product:list:" + brandId + ":*");
}
}

/**
* SCAN 기반으로 패턴에 일치하는 키를 순회해 삭제한다.
* KEYS 명령은 싱글 스레드 Redis를 블로킹할 수 있어 운영 환경에서 사용 금지.
*/
private void deleteByPattern(String pattern) {
masterRedisTemplate.execute((RedisCallback<Void>) connection -> {
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();
List<byte[]> keys = new ArrayList<>();
try (var cursor = connection.keyCommands().scan(options)) {
cursor.forEachRemaining(keys::add);
} catch (Exception ignored) {
}
if (!keys.isEmpty()) {
connection.keyCommands().del(keys.toArray(new byte[0][]));
}
return null;
});
}

/**
* 캐시 키 형식: product:list:{brandId|all}:{sort}:0:{size}
* 예) product:list:all:latest:0:20
* product:list:123:price_asc:0:20
*/
private String buildCacheKey(Long brandId, Pageable pageable) {
String brandPart = brandId != null ? String.valueOf(brandId) : "all";
String sortPart = sortKey(pageable.getSort());
return "product:list:" + brandPart + ":" + sortPart + ":0:" + pageable.getPageSize();
}

/**
* Sort 객체를 캐시 키용 문자열로 변환한다.
* ProductV1Controller.toSort()의 역매핑.
*/
private String sortKey(Sort sort) {
if (!sort.isSorted()) return "latest";
Sort.Order order = sort.iterator().next();
String prop = order.getProperty();
if (prop.equals("createdAt")) return "latest";
if (prop.equals("price.value")) return "price_asc";
if (prop.equals("likeCount.value")) return "likes_desc";
return prop + "_" + order.getDirection().name().toLowerCase();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,12 @@ public static ProductInfo from(Product product, Boolean isLiked) {
isLiked
);
}

/**
* isLiked만 교체한 새 인스턴스를 반환한다.
* Cache-Aside 조회 시 캐시에 저장된 상품 정보(isLiked=null)에 사용자별 좋아요 여부를 오버레이할 때 사용한다.
*/
public ProductInfo withIsLiked(Boolean isLiked) {
return new ProductInfo(id, brandId, name, description, price, stock, sellingStatus, likeCount, isLiked);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.loopers.application.product;

import java.util.List;

/**
* Page<ProductInfo>는 PageImpl 역직렬화 이슈로 Redis에 직접 저장할 수 없어,
* 캐시 직렬화/역직렬화용 래퍼 record로 분리한다.
* 복원 시: new PageImpl<>(entry.content(), pageable, entry.totalElements())
*/
public record ProductListCacheEntry(List<ProductInfo> content, long totalElements) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
Expand All @@ -14,7 +15,9 @@

@Getter
@Entity
@Table(name = "likes")
@Table(name = "likes", indexes = {
@Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC")
})
Comment on lines +18 to +20
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

운영 스키마에는 이 인덱스가 생성되지 않는다.

운영에서는 modules/jpa/src/main/resources/jpa.yml 기본값이 ddl-auto: none이라 이 @Index 선언이 실제 DDL로 반영되지 않는다. 지금 상태로는 local/test만 빨라지고 dev/qa/prd는 기존 스키마를 그대로 써서 likes뿐 아니라 같은 방식으로 추가한 orders, products 인덱스도 전부 누락된다. 수정안은 엔티티 어노테이션에 기대지 말고 Flyway/Liquibase나 운영 DDL migration으로 세 테이블 인덱스를 명시적으로 추가하는 것이다. 추가 테스트로는 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX로 각 인덱스 존재를 검증하면 된다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java` around
lines 18 - 20, 현재 엔티티의 `@Index` 어노테이션(Like.java의 `@Table` indexes)은 운영에서 ddl-auto:
none 설정 때문에 DDL로 반영되지 않으므로, 엔티티 주석에 의존하지 말고 Flyway 또는 Liquibase 마이그레이션을 만들어
likes, orders, products 테이블에 대해 user_id + created_at DESC 인덱스를 명시적으로 추가하고 배포
스크립트에 포함시켜라; 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX를 사용해 각 테이블에
인덱스가 존재하는지 검증하도록 테스트를 추가하라.

public class Like {

@EmbeddedId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
Expand All @@ -22,7 +23,9 @@

@Getter
@Entity
@Table(name = "orders")
@Table(name = "orders", indexes = {
@Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC")
})
public class Order extends BaseEntity {

@Column(name = "user_id", nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.AccessLevel;
import lombok.Getter;

@Getter
@Entity
@Table(name = "products")
@Table(name = "products", indexes = {
@Index(name = "idx_like_count", columnList = "like_count DESC"),
@Index(name = "idx_brand_like_count", columnList = "brand_id, like_count DESC"),
@Index(name = "idx_created_at", columnList = "created_at DESC"),
@Index(name = "idx_brand_created_at", columnList = "brand_id, created_at DESC"),
@Index(name = "idx_price", columnList = "price ASC"),
@Index(name = "idx_brand_price", columnList = "brand_id, price ASC")
})
public class Product extends BaseEntity {

@Column(name = "brand_id", nullable = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public ApiResponse<ProductV1Dto.ProductPageResponse> getProducts(
@RequestParam(required = false) Long brandId,
@RequestParam(defaultValue = "latest") String sort,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
@RequestParam(defaultValue = "20") int size // 캐시 키 설계 기준 사이즈 (기본값 20)
) {
PageRequest pageable = PageRequest.of(page, size, toSort(sort));
return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(productFacade.findAll(brandId, pageable)));
Expand Down
Loading