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
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@
- 요구사항 분석, 설계 문서 작성 요청 시 해당 파일을 Read 도구로 읽고 지침을 따를 것
- 시퀀스 다이어그램, 클래스 다이어그램, ERD 작성 시 Mermaid 문법 사용

### 조회 API 설계 리뷰
- 파일: `.claude/skills/read-optimization-review/SKILL.md`
- 조회 API 설계 검토, 캐시 전략 리뷰, 읽기 최적화 리뷰 요청 시 해당 파일을 Read 도구로 읽고 지침을 따를 것
- 구조적 리스크와 trade-off 관점에서만 분석하며, 구현 코드나 설정 값은 제안하지 않음


## 도메인 & 객체 설계 전략
- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.user.UserRepository;
import com.loopers.infrastructure.product.ProductCacheStore;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import jakarta.persistence.OptimisticLockException;
Expand Down Expand Up @@ -36,6 +37,7 @@ public class LikeFacade {
private final UserRepository userRepository;
private final BrandRepository brandRepository;
private final LikeService likeService;
private final ProductCacheStore productCacheStore;

@Transactional
@Retryable(
Expand Down Expand Up @@ -80,6 +82,7 @@ public LikeDto.LikeResult likeWithStatus(Long userId, Long productId) {
product.increaseLikeCount();
Like saved = likeRepository.save(result.like());
productRepository.save(product);
productCacheStore.evictProduct(productId);
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

트랜잭션 커밋 전에 캐시를 삭제하면 오래된 값이 장시간 재캐시될 수 있다

Line 85와 Line 115에서 캐시를 즉시 삭제하면, 커밋 전 동시 조회가 DB의 이전 값을 읽어 1시간 TTL로 다시 캐시할 수 있어 운영 중 데이터 신뢰도가 떨어진다. 수정안으로 캐시 무효화는 afterCommit 훅에서 실행해야 한다. 추가 테스트로 “쓰기 트랜잭션 커밋 지연 중 동시 조회” 시나리오와 “롤백 시 캐시 미삭제” 시나리오를 통합 테스트에 넣어야 한다.

수정 예시 (after-commit eviction)
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
@@
-        productCacheStore.evictProduct(productId);
+        evictProductAfterCommit(productId);
@@
-        productCacheStore.evictProduct(productId);
+        evictProductAfterCommit(productId);
     }
+
+    private void evictProductAfterCommit(Long productId) {
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                `@Override`
+                public void afterCommit() {
+                    productCacheStore.evictProduct(productId);
+                }
+            });
+            return;
+        }
+        productCacheStore.evictProduct(productId);
+    }

Also applies to: 115-115

🤖 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/like/LikeFacade.java`
at line 85, productCacheStore.evictProduct is being called immediately (e.g., in
the methods where productCacheStore.evictProduct is invoked) which can cause
stale DB reads to be re-cached before the write transaction commits; change
those immediate evictions to run in an after-commit callback (use
TransactionSynchronizationManager.registerSynchronization or
TransactionSynchronization.afterCompletion / afterCommit equivalent) so eviction
executes only when the transaction successfully commits, and ensure evictions
are skipped on rollback; update the places currently calling
productCacheStore.evictProduct (the two occurrences) to register an afterCommit
task that calls productCacheStore.evictProduct(productId), and add integration
tests covering “concurrent read during pending commit re-caching” and
“transaction rollback should not delete cache” scenarios to verify correct
behavior.

return new LikeDto.LikeResult(LikeDto.LikeInfo.from(saved), false);
}

Expand Down Expand Up @@ -109,6 +112,7 @@ public void unlike(Long userId, Long productId) {
product.decreaseLikeCount();
likeRepository.save(like);
productRepository.save(product);
productCacheStore.evictProduct(productId);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.product.exception.ProductInsufficientStockException;
import com.loopers.domain.product.exception.ProductNotDeletedException;
import com.loopers.infrastructure.product.ProductCacheStore;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
Expand All @@ -28,6 +29,7 @@ public class ProductFacade {
private final ProductRepository productRepository;
private final BrandRepository brandRepository;
private final ProductService productService;
private final ProductCacheStore productCacheStore;

Comment on lines +32 to 33
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

캐시 무효화가 ProductFacade 경로에만 묶여 있어 다른 쓰기 경로에서 정합성이 깨진다

운영 관점에서 현재 무효화는 이 파사드 경로에만 있어, OrderFacade(apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java Line 84-92), OrderPlacementTxService(Line 92-98), BrandFacade(Line 76-80, 94-100)처럼 productRepository.save(...)를 직접 호출하는 경로에서는 단건 캐시가 오래 남을 수 있다. 수정안으로 제품 변경 후행 이벤트(예: ProductChangedEvent)를 발행하고 단일 리스너에서 after-commit 무효화를 통합해야 한다. 추가 테스트로 주문 생성/취소, 브랜드 삭제/복구 후 getProduct가 즉시 최신 값을 반환하는 통합 테스트를 보강해야 한다.

🤖 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 32 - 33, Right now cache invalidation is only in ProductFacade
(productCacheStore) so direct saves via OrderFacade, OrderPlacementTxService,
BrandFacade (calls to productRepository.save(...)) can leave stale single-item
caches; emit a ProductChangedEvent from every place that mutates a product
(replace direct productCacheStore.invalidate calls), implement a single
`@TransactionalEventListener`(phase = AFTER_COMMIT) listener that clears
productCacheStore for the given product IDs, and add integration tests covering
order creation/cancellation and brand deletion/restoration asserting getProduct
returns the updated value immediately after these flows.

@Transactional
public ProductDto.ProductInfo register(Long brandId, String name, String description, BigDecimal price, Integer stock) {
Expand Down Expand Up @@ -58,43 +60,65 @@ public ProductDto.ProductInfo update(Long productId, String name, String descrip
Product saved = productRepository.save(product);
Brand brand = brandRepository.findById(saved.getBrandId())
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
productCacheStore.evictProduct(productId);
return ProductDto.ProductInfo.of(saved, brand.getName());
Comment on lines +63 to 64
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

쓰기 메서드 전반에서 커밋 전 캐시 무효화가 동일하게 반복된다

Line 63, 132, 148, 158, 174의 즉시 삭제는 커밋 전 재캐시 경쟁 상태를 만들어 오래된 단건 캐시가 1시간 유지될 수 있다. 수정안으로 공통 헬퍼를 두고 afterCommit에서만 evictProduct를 실행해야 한다. 추가 테스트로 update/delete/restore/increase/decrease 각각에 대해 “커밋 전 동시 조회 시 오래된 캐시 고착이 없는지”를 검증해야 한다.

Also applies to: 132-133, 148-149, 158-159, 174-175

🤖 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 63 - 64, ProductFacade currently calls
productCacheStore.evictProduct immediately in multiple write methods, which can
cause stale-cache races before DB commit; replace those immediate evict calls
with a shared helper (e.g., evictAfterCommit or registerEvictAfterCommit) that
defers productCacheStore.evictProduct(productId) to run only after the
transaction successfully commits (use transaction synchronization/afterCommit
hook used in your stack) and call this helper from all affected write methods
(update, delete, restore, increase, decrease) instead of direct evictProduct
calls; also add tests for update/delete/restore/increase/decrease that simulate
concurrent reads during the pre-commit window to assert no long-lived stale
cache remains.

}

@Transactional(readOnly = true)
public Page<ProductDto.ProductInfo> getProducts(ProductSortType sortType, Pageable pageable) {
Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort());
Page<Product> products = productRepository.findAll(sorted);

Set<Long> brandIds = products.stream()
.map(Product::getBrandId)
.collect(Collectors.toSet());

Map<Long, String> brandNameMap = brandRepository.findAllByIds(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, Brand::getName));

return products.map(product ->
ProductDto.ProductInfo.of(product, brandNameMap.getOrDefault(product.getBrandId(), ""))
);
int page = pageable.getPageNumber();
int size = pageable.getPageSize();

return productCacheStore.getProductList(null, sortType, page, size)
.orElseGet(() -> {
Pageable sorted = PageRequest.of(page, size, sortType.toSort());
Page<Product> products = productRepository.findAll(sorted);

Set<Long> brandIds = products.stream()
.map(Product::getBrandId)
.collect(Collectors.toSet());

Map<Long, String> brandNameMap = brandRepository.findAllByIds(brandIds).stream()
.collect(Collectors.toMap(Brand::getId, Brand::getName));

Page<ProductDto.ProductInfo> result = products.map(product ->
ProductDto.ProductInfo.of(product, brandNameMap.getOrDefault(product.getBrandId(), ""))
);
productCacheStore.putProductList(null, sortType, page, size, result);
return result;
});
}

@Transactional(readOnly = true)
public Page<ProductDto.ProductInfo> getProductsByBrand(Long brandId, ProductSortType sortType, Pageable pageable) {
Brand brand = brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));

Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort());
return productRepository.findByBrandId(brandId, sorted)
.map(product -> ProductDto.ProductInfo.of(product, brand.getName()));
int page = pageable.getPageNumber();
int size = pageable.getPageSize();

return productCacheStore.getProductList(brandId, sortType, page, size)
.orElseGet(() -> {
Brand brand = brandRepository.findById(brandId)
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));

Pageable sorted = PageRequest.of(page, size, sortType.toSort());
Page<ProductDto.ProductInfo> result = productRepository.findByBrandId(brandId, sorted)
.map(product -> ProductDto.ProductInfo.of(product, brand.getName()));
productCacheStore.putProductList(brandId, sortType, page, size, result);
return result;
});
}

@Transactional(readOnly = true)
public ProductDto.ProductInfo getProduct(Long productId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND));
Brand brand = brandRepository.findById(product.getBrandId())
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
return ProductDto.ProductInfo.of(product, brand.getName());
return productCacheStore.getProduct(productId)
.orElseGet(() -> {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND));
Brand brand = brandRepository.findById(product.getBrandId())
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
ProductDto.ProductInfo info = ProductDto.ProductInfo.of(product, brand.getName());
productCacheStore.putProduct(productId, info);
return info;
});
}

@Transactional
Expand All @@ -105,6 +129,7 @@ public ProductDto.ProductInfo increaseStock(Long productId, Integer quantity) {
Product saved = productRepository.save(product);
Brand brand = brandRepository.findById(saved.getBrandId())
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
productCacheStore.evictProduct(productId);
return ProductDto.ProductInfo.of(saved, brand.getName());
}

Expand All @@ -120,6 +145,7 @@ public ProductDto.ProductInfo decreaseStock(Long productId, Integer quantity) {
Product saved = productRepository.save(product);
Brand brand = brandRepository.findById(saved.getBrandId())
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
productCacheStore.evictProduct(productId);
return ProductDto.ProductInfo.of(saved, brand.getName());
}

Expand All @@ -129,6 +155,7 @@ public void delete(Long productId) {
.orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND));
productService.deleteProduct(product);
productRepository.save(product);
productCacheStore.evictProduct(productId);
}

@Transactional
Expand All @@ -144,6 +171,7 @@ public ProductDto.ProductInfo restore(Long productId) {
Product saved = productRepository.save(product);
Brand brand = brandRepository.findByIdIncludingDeleted(saved.getBrandId())
.orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND));
productCacheStore.evictProduct(productId);
return ProductDto.ProductInfo.of(saved, brand.getName());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@
import com.loopers.domain.product.exception.ProductInsufficientStockException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;

import java.math.BigDecimal;

@Entity
@Table(name = "products")
@Table(
name = "products",
indexes = {
@Index(name = "idx_products_brand_id_like_count", columnList = "brand_id, like_count DESC"),
@Index(name = "idx_products_brand_id_created_at", columnList = "brand_id, created_at DESC"),
@Index(name = "idx_products_brand_id_price", columnList = "brand_id, price ASC")
}
)
Comment on lines +15 to +22
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

인덱스 애노테이션만으로는 운영 스키마에 반영되지 않는다

운영 관점에서 ddl-auto: none 환경이면 Line 15-22의 인덱스 정의가 실제 DB에 생성되지 않아 성능 개선이 적용되지 않는다. 수정안으로 명시적 마이그레이션 SQL(Flyway/Liquibase/수동 DDL)을 추가해야 한다. 추가 테스트로 배포 파이프라인에서 information_schema.statistics 검증을 넣어 세 인덱스 존재를 자동 확인해야 한다.

운영 반영용 SQL 예시
CREATE INDEX idx_products_brand_id_like_count
    ON products (brand_id, like_count DESC);

CREATE INDEX idx_products_brand_id_created_at
    ON products (brand_id, created_at DESC);

CREATE INDEX idx_products_brand_id_price
    ON products (brand_id, price ASC);
🤖 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/product/Product.java`
around lines 15 - 22, The `@Table` index annotations on Product (indexes
idx_products_brand_id_like_count, idx_products_brand_id_created_at,
idx_products_brand_id_price) are not applied when ddl-auto is disabled, so add
an explicit migration to create these indexes (e.g., Flyway/Liquibase SQL
migration with CREATE INDEX statements for the three names) and include a
deployment pipeline verification step that queries information_schema.statistics
to assert the three index names exist for the products table; update migration
files and CI/CD checks accordingly.

@Getter
public class Product extends BaseEntity {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.loopers.infrastructure.product;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.application.product.ProductDto;
import com.loopers.application.product.ProductSortType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 java.time.Duration;
import java.util.List;
import java.util.Optional;

@Slf4j
@Component
@RequiredArgsConstructor
public class ProductCacheStore {

private static final String PRODUCT_KEY_PREFIX = "product:";
private static final String PRODUCT_LIST_KEY_PREFIX = "product:list:";
private static final Duration PRODUCT_TTL = Duration.ofHours(1);
private static final Duration PRODUCT_LIST_TTL = Duration.ofMinutes(1);

private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;

public Optional<ProductDto.ProductInfo> getProduct(Long productId) {
try {
String json = redisTemplate.opsForValue().get(productKey(productId));
if (json == null) return Optional.empty();
return Optional.of(objectMapper.readValue(json, ProductDto.ProductInfo.class));
} catch (Exception e) {
log.warn("캐시 조회 실패 - product:{}", productId, e);
return Optional.empty();
}
}

public void putProduct(Long productId, ProductDto.ProductInfo info) {
try {
String json = objectMapper.writeValueAsString(info);
redisTemplate.opsForValue().set(productKey(productId), json, PRODUCT_TTL);
} catch (Exception e) {
log.warn("캐시 저장 실패 - product:{}", productId, e);
}
}

public void evictProduct(Long productId) {
redisTemplate.delete(productKey(productId));
}
Comment on lines +51 to +53
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:

#!/bin/bash
set -euo pipefail

# 1) evictProduct가 예외를 처리하지 않는지 확인
rg -n "public void evictProduct|redisTemplate.delete" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java -C2

# 2) evictProduct가 트랜잭션 메서드에서 직접 호출되는지 확인
rg -n "(`@Transactional`|evictProduct\\()" apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java -C2
rg -n "(`@Transactional`|evictProduct\\()" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java -C2

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

Length of output: 4218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) View full ProductCacheStore.java to see all methods
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java

# 2) Check if get/put methods have try/catch
grep -n "try\|catch\|public.*get\|public.*put" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java || true

# 3) Search for other evict methods in the codebase
find apps/commerce-api -name "*.java" -type f | xargs grep -l "public.*evict" 2>/dev/null || true

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

Length of output: 4468


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Search for all evict methods in the codebase
find apps/commerce-api -name "*.java" -type f -exec grep -l "evict" {} \; 2>/dev/null

# 2) Check ProductService to see if there are other eviction patterns
rg -n "evict|clear\(\)" apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java -C2 || true

# 3) Check if there are transactional boundaries after evictProduct call
rg -B5 "evictProduct\(" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -30

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

Length of output: 1961


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the exact line where evictProduct is called in a `@Transactional` context
# to confirm transaction boundary
rg -B10 "evictProduct\(productId\)" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -40

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

Length of output: 1986


Redis 삭제 예외 전파로 쓰기 트랜잭션이 실패할 수 있다

Line 51-53의 evictProduct() 메서드는 예외 처리가 없어 Redis 장애 시 예외가 그대로 전파된다. 호출 지점이 모두 @Transactional 메서드 내에 있고 productRepository.save() 이후에 실행되므로, Redis 연결 실패 시 캐시 삭제 예외가 전체 트랜잭션을 롤백시킨다. 캐시는 부가 기능이므로 인프라 장애가 핵심 쓰기 작업을 실패하게 하면 안 된다.

같은 클래스의 getProduct(), putProduct(), getProductList(), putProductList() 메서드는 모두 예외를 처리하므로 evictProduct()도 동일하게 수정해야 한다.

수정 예시
 public void evictProduct(Long productId) {
-    redisTemplate.delete(productKey(productId));
+    try {
+        redisTemplate.delete(productKey(productId));
+    } catch (Exception e) {
+        log.warn("캐시 삭제 실패 - product:{}", productId, e);
+    }
 }

추가로 ProductFacade.update(), increaseStock(), decreaseStock() 등 evictProduct()를 호출하는 @Transactional 메서드에서 Redis 장애 시에도 트랜잭션이 성공하는지 테스트해야 한다.

🤖 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/infrastructure/product/ProductCacheStore.java`
around lines 51 - 53, evictProduct currently calls
redisTemplate.delete(productKey(productId)) without handling exceptions, so
Redis failures will bubble up and rollback surrounding `@Transactional` writes;
change evictProduct to mirror
getProduct/putProduct/getProductList/putProductList by wrapping the delete in a
try-catch that catches RuntimeException (or Redis-specific exceptions), logs the
error with context (productId and key) and suppresses it (do not rethrow),
ensuring cache failures don’t fail the DB transaction; also add tests in
ProductFacade.update, increaseStock, and decreaseStock to simulate Redis failure
and assert the transaction still commits.


public Optional<Page<ProductDto.ProductInfo>> getProductList(Long brandId, ProductSortType sortType, int page, int size) {
try {
String json = redisTemplate.opsForValue().get(listKey(brandId, sortType, page, size));
if (json == null) return Optional.empty();
ProductListCache cache = objectMapper.readValue(json, ProductListCache.class);
return Optional.of(new PageImpl<>(cache.content(), PageRequest.of(page, size), cache.totalElements()));
} catch (Exception e) {
log.warn("캐시 조회 실패 - product list brand:{} sort:{} page:{}", brandId, sortType, page, e);
return Optional.empty();
}
}

public void putProductList(Long brandId, ProductSortType sortType, int page, int size, Page<ProductDto.ProductInfo> result) {
try {
ProductListCache cache = new ProductListCache(result.getContent(), result.getTotalElements());
String json = objectMapper.writeValueAsString(cache);
redisTemplate.opsForValue().set(listKey(brandId, sortType, page, size), json, PRODUCT_LIST_TTL);
} catch (Exception e) {
log.warn("캐시 저장 실패 - product list brand:{} sort:{} page:{}", brandId, sortType, page, e);
}
}

private String productKey(Long id) {
return PRODUCT_KEY_PREFIX + id;
}

private String listKey(Long brandId, ProductSortType sortType, int page, int size) {
return PRODUCT_LIST_KEY_PREFIX + "brand=" + brandId + ":sort=" + sortType + ":page=" + page + ":size=" + size;
}

private record ProductListCache(List<ProductDto.ProductInfo> content, long totalElements) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ void like_concurrent_likeCount() throws InterruptedException {
List<Long> userIds = new ArrayList<>();
for (int i = 0; i < concurrency; i++) {
UserDto.UserInfo u = userFacade.register(
"likeuser" + i, "TestPass1!", "좋아요유저" + i,
"likeuser" + i, "TestPass1!", "좋아요유저",
LocalDate.of(2000, 1, 1), "like" + i + "@loopers.com", Gender.MALE
);
userIds.add(u.id());
Expand Down Expand Up @@ -218,4 +218,50 @@ void like_concurrent_likeCount() throws InterruptedException {
Product product = productRepository.findById(productId).orElseThrow();
assertThat(product.getLikeCount()).isEqualTo(concurrency);
}

@DisplayName("동일한 상품에 여러 명이 동시에 좋아요 취소를 해도 likeCount가 정상 반영된다")
@Test
void unlike_concurrent_likeCount() throws InterruptedException {
int concurrency = 3;
List<Long> userIds = new ArrayList<>();
for (int i = 0; i < concurrency; i++) {
UserDto.UserInfo u = userFacade.register(
"unlike" + i, "TestPass1!", "취소유저",
LocalDate.of(2000, 1, 1), "unlike" + i + "@loopers.com", Gender.MALE
);
userIds.add(u.id());
likeFacade.like(u.id(), productId);
}

ExecutorService executor = Executors.newFixedThreadPool(concurrency);
CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
List<Throwable> failures = Collections.synchronizedList(new ArrayList<>());

for (int i = 0; i < concurrency; i++) {
final Long uid = userIds.get(i);
executor.submit(() -> {
ready.countDown();
try {
start.await();
likeFacade.unlike(uid, productId);
} catch (Throwable t) {
failures.add(t);
} finally {
done.countDown();
}
});
}

assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
start.countDown();
assertThat(done.await(15, TimeUnit.SECONDS)).isTrue();
executor.shutdown();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();

assertThat(failures).isEmpty();
Product product = productRepository.findById(productId).orElseThrow();
assertThat(product.getLikeCount()).isZero();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.user.User;
import com.loopers.domain.user.UserRepository;
import com.loopers.infrastructure.product.ProductCacheStore;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -44,7 +45,8 @@ void setUp() {
productRepository,
userRepository,
brandRepository,
new LikeService()
new LikeService(),
mock(ProductCacheStore.class)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.ProductRepository;
import com.loopers.infrastructure.product.ProductCacheStore;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -41,7 +42,8 @@ void setUp() {
productFacade = new ProductFacade(
productRepository,
brandRepository,
new ProductService()
new ProductService(),
mock(ProductCacheStore.class)
);
Comment on lines 42 to 47
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

캐시 연동 회귀를 막는 검증이 누락되어 있다

운영 관점에서 캐시 로직이 깨져도 현재 테스트는 통과할 수 있어 장애를 사전에 차단하기 어렵다. 수정안으로 ProductCacheStore를 필드 목으로 보관하고 getProduct/getProducts/getProductsByBrand의 hit/miss 및 write 메서드의 evictProduct 호출 여부를 verify로 검증해야 한다. 추가 테스트로 idempotent/no-op 경로에서 evictProduct가 호출되지 않는 케이스까지 포함해야 한다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java`
around lines 42 - 47, The test must assert cache interactions to prevent
regressions: change the inline mock to a field-level mock ProductCacheStore and
inject it into the ProductFacade constructor, then add verifications for cache
hit/miss behavior for ProductFacade.getProduct, getProducts, and
getProductsByBrand (verify the cache read methods were called and that writes
occur on misses); also verify evictProduct is called for write paths that should
invalidate cache and verify evictProduct is NOT called for idempotent/no-op
update paths. Reference the ProductFacade constructor injection, the
ProductCacheStore mock, the getProduct/getProducts/getProductsByBrand methods,
and the evictProduct method when adding these verify assertions.


brand = Brand.create(new BrandName("LOOPERS"), new BrandDescription("루퍼스 브랜드"));
Expand Down
Loading