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
108 changes: 108 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,115 @@ Infrastructure Layer (RepositoryImpl, JpaRepository, Converter)
- AI는 제안만 가능, 임의 판단 금지
- 중간 결과 보고 및 개발자 개입 허용

### 의사결정 체크포인트 (필수)
AI는 아래 시점마다 **반드시 멈추고 개발자에게 승인을 요청**한다. 승인 없이 다음 단계로 진행 금지.

1. **기능 단위 구현 시작 전**: 구현할 내용, 영향 범위, 예상 파일 목록을 보고하고 승인 대기
2. **설계 방향 결정 시**: 복수 대안이 존재하는 경우 각 대안의 트레이드오프를 제시하고 선택 요청
3. **기존 코드 수정 시**: 수정 대상 파일과 변경 이유를 명시하고 승인 대기
4. **테스트 전략 결정 시**: 단위/통합/E2E 중 어떤 테스트를 어떻게 작성할지 제안 후 승인 대기

### 대안 제시 원칙 (필수)
구현 방식을 결정해야 하는 모든 시점에서 AI는 **독단적으로 선택하지 않는다**.
반드시 **최소 2개 이상의 대안**을 제시하고 개발자가 선택한 뒤에만 구현을 시작한다.

#### 대안 제시가 필요한 시점
- 라이브러리/프레임워크 선택 (예: SQL vs QueryDSL, Redis vs in-memory)
- 구현 패턴 선택 (예: switch vs enum Strategy, 인터페이스 vs 추상 클래스)
- 데이터 저장 방식 선택 (예: SQL 스크립트 vs CommandLineRunner vs TestFixture)
- 캐시/동시성/트랜잭션 전략 선택
- 테스트 방식 선택 (예: Mockito vs TestContainers, 단위 vs 통합)
- 기타 "어떻게 구현할까"라는 질문이 생기는 모든 순간

#### 대안 제시 형식
```
구현 방식을 결정해야 합니다. 아래 대안 중 선택해주세요.

**A: [방법명]**
- 방식: ...
- 장점: ...
- 단점: ...

**B: [방법명]**
- 방식: ...
- 장점: ...
- 단점: ...

(필요 시 C도 추가)

어떤 방식으로 진행할까요?
```

#### 금지 행동
- ❌ 대안 제시 없이 AI가 판단한 "더 나은" 방법을 바로 구현하는 행위
- ❌ 대안을 설명하면서 동시에 구현을 시작하는 행위
- ❌ "A 방식으로 진행하겠습니다"처럼 AI가 선택을 결론 짓는 행위

### 과정 기록 원칙 (필수)

모든 설계 결정, 실험 과정, 테스트 결과는 **`.idea/volume-{N}-discussion.md`에 기록**한다.
파일이 없으면 대화 시작 시 생성. 파일이 있으면 기존 내용에 **추가(append)**.

#### 기록 대상 (반드시 기록)
- 설계 방향 선택, 라이브러리/전략 결정, 트레이드오프 논의
- 구현 방식 변경 및 변경 이유
- **테스트 실행 결과** (PASS/FAIL, 수치, EXPLAIN 결과 등) — 결정의 근거로 반드시 첨부
- 단계적 접근에서 각 단계의 결과와 다음 단계로 넘어간 이유
- 기각된 대안과 기각 이유
- **테스트 코드가 이후 삭제/변경되더라도 당시의 실행 기록과 의사결정은 반드시 유지**

#### 기록 형식
```markdown
## [날짜] 주제

### 상황
현재 문제 또는 목표

### 실험/테스트 기록
- 시도한 방법: ...
- 실행 결과: (수치, EXPLAIN 출력, 테스트 PASS/FAIL 등)
- 관찰: ...

### 선택지 비교
| 항목 | 방법 A | 방법 B |
|------|--------|--------|
| 성능 | ... | ... |
| 복잡도 | ... | ... |

### 결정
- 선택: 방법 A
- 이유: (테스트 결과 기반으로 구체적으로)

### 기각된 대안
- 방법 B: 기각 이유 (근거 포함)

### 다음 단계
- ...
```

#### 단계적 구현 원칙
- 기술 도입은 **가장 단순한 것부터 시작해 점진적으로 고도화**한다
- 각 단계에서 테스트로 효과를 검증한 뒤 다음 단계로 진행
- 예: 캐시 → (1) Spring 기본 캐시(in-memory) → (2) TTL 제어 → (3) Redis 분산 캐시
- 각 단계의 한계를 직접 확인하고 기록한 뒤 다음 단계로 이동

### 커밋 단위 원칙 (필수)
- **1 논리 단위 = 1 커밋**: 기능 하나, 리팩토링 하나, 테스트 추가 하나를 각각 별도 커밋
- 금지: 여러 기능/도메인/레이어 변경을 하나의 커밋에 혼합
- 커밋 메시지 형식: `type(scope): 설명` (예: `feat(product): 상품 목록 인덱스 추가`)
- 커밋 타이밍: AI가 자의적으로 커밋 실행 금지. 커밋 시점 시 커밋 메세지만 전달.

**커밋 단위 예시 (올바른 분리)**
```
feat(product): products 테이블 복합 인덱스 추가
feat(product): 상품 목록 조회 QueryDSL 필터/정렬 구현
test(product): 상품 목록 조회 통합 테스트 추가
feat(cache): 상품 상세 Redis 캐시 적용
feat(cache): 상품 목록 Redis 캐시 적용
```

### Never Do (절대 금지)
- ❌ 코드에 불필요한 주석 작성 금지 — 로직이 자명하지 않은 경우에만 작성. 클래스/메서드 설명 주석, Phase 설명 주석, 한계 설명 주석 등 코드로 표현 가능한 내용은 모두 금지
- ❌ 실제 동작하지 않는 코드 작성 금지
- ❌ null-safety 위반 금지 (Optional 활용)
- ❌ println 코드 남기지 말 것 (`@Slf4j` 사용)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.loopers.domain.like.LikeRepository;
import com.loopers.domain.like.LikeService;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
Expand All @@ -17,12 +19,20 @@ public class LikeApp {
private final LikeService likeService;
private final LikeRepository likeRepository;

@Caching(evict = {
@CacheEvict(value = "product", key = "#productId"),
@CacheEvict(value = "products", allEntries = true)
})
@Transactional
public LikeInfo addLike(Long memberId, String productId) {
LikeModel like = likeService.addLike(memberId, productId);
return LikeInfo.from(like);
}

@Caching(evict = {
@CacheEvict(value = "product", key = "#productId"),
@CacheEvict(value = "products", allEntries = true)
})
@Transactional
public void removeLike(Long memberId, String productId) {
likeService.removeLike(memberId, productId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.loopers.application.product;

import com.loopers.domain.common.cursor.CursorPageResult;
import com.loopers.domain.product.ProductModel;
import com.loopers.domain.product.ProductRepository;
import com.loopers.domain.product.ProductService;
import com.loopers.domain.product.vo.ProductId;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
Expand All @@ -20,6 +24,7 @@ public class ProductApp {

private final ProductService productService;
private final ProductRepository productRepository;
private final ProductCacheStore productCacheStore;

@Transactional
public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) {
Expand All @@ -29,27 +34,45 @@ public ProductInfo createProduct(String productId, String brandId, String produc

@Transactional(readOnly = true)
public ProductInfo getProduct(String productId) {
ProductModel product = productRepository.findByProductId(new ProductId(productId))
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다."));
return ProductInfo.from(product);
return productCacheStore.get(productId).orElseGet(() -> {
ProductModel product = productRepository.findByProductId(new ProductId(productId))
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 상품이 존재하지 않습니다."));
ProductInfo info = ProductInfo.from(product);
productCacheStore.put(productId, info);
return info;
});
}

@CacheEvict(value = "products", allEntries = true)
@Transactional
public ProductInfo updateProduct(String productId, String productName, BigDecimal price, int stockQuantity) {
ProductModel product = productService.updateProduct(productId, productName, price, stockQuantity);
return ProductInfo.from(product);
ProductInfo info = ProductInfo.from(product);
productCacheStore.put(productId, info);
return info;
}

@Caching(evict = {
@CacheEvict(value = "product", key = "#productId"),
@CacheEvict(value = "products", allEntries = true)
})
@Transactional
public void deleteProduct(String productId) {
productService.deleteProduct(productId);
}

@Cacheable(value = "products", key = "#brandId + ':' + #sortBy + ':' + #pageable.pageNumber + ':' + #pageable.pageSize")
@Transactional(readOnly = true)
public Page<ProductInfo> getProducts(String brandId, String sortBy, Pageable pageable) {
return productService.getProducts(brandId, sortBy, pageable).map(ProductInfo::from);
}

@Transactional(readOnly = true)
public CursorPageResult<ProductInfo> getProductsByCursor(String brandId, String sortBy, String cursor, int size) {
Long refBrandId = productService.resolveRefBrandId(brandId);
return productRepository.findProductsByCursor(refBrandId, sortBy, cursor, size).map(ProductInfo::from);
}

@Transactional(readOnly = true)
public ProductInfo getProductByRefId(Long id) {
ProductModel product = productRepository.findById(id)
Expand Down
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
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
# ProductCacheStore 사용처 확인
rg -n "ProductCacheStore" --type java -A2

# `@Cacheable`("product") 사용처 확인
rg -n '@Cacheable.*"product"' --type java

Repository: 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 -A2

Repository: 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 java

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

Length of output: 5006


ProductCacheStore와 CacheConfig의 "product" 캐시 무효화가 불일치한다.

ProductCacheStore는 RedisTemplate을 사용해 product::{productId} 형태로 데이터를 저장하고, CacheConfig의 "product" 캐시는 Spring CacheManager를 통해 별도 네임스페이스로 관리된다. 업데이트 시 문제가 발생한다:

  • ProductApp.updateProduct()productCacheStore.put() 실행
  • 동시에 @CacheEvict(value="product") → CacheManager 캐시만 무효화
  • ProductCacheStore의 캐시는 무효화되지 않음 → 만료까지 stale 데이터 반환

ProductCacheStore를 CacheManager로 통합하거나, @CacheEvict 시 ProductCacheStore도 명시적으로 무효화하도록 수정해야 한다. 캐시 일관성 검증 테스트를 추가하여 무효화 동작을 확인한다.

🤖 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/ProductCacheStore.java`
around lines 14 - 17, ProductCacheStore uses RedisTemplate with keys prefixed
"product::" but CacheConfig's "product" cache is managed by Spring CacheManager,
causing `@CacheEvict`(value="product") in ProductApp.updateProduct() to not clear
ProductCacheStore entries; fix by either 1) integrating ProductCacheStore into
the Spring Cache abstraction (implement Cache and register it in CacheConfig
under name "product"), or 2) keep ProductCacheStore as-is but update
ProductApp.updateProduct() (or the method annotated with `@CacheEvict`) to call
productCacheStore.evict(productId) after the `@CacheEvict` runs (or add a custom
cache eviction listener that calls ProductCacheStore.evict(key)), and add a test
that updates a product, asserts both the CacheManager "product" cache and
ProductCacheStore (methods: ProductCacheStore.put/get/evict) reflect the
eviction to prevent stale reads.


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));
}

public void put(String productId, ProductInfo productInfo) {
byte[] bytes = serializer.serialize(productInfo);
redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
}
Comment on lines +27 to +38
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

Redis 장애 시 예외 처리가 누락되었다.

PR 설명에서 "Redis failure 시 DB fallback"을 언급했으나, 이 클래스에서는 Redis 연결 실패나 타임아웃 시 예외가 그대로 전파된다. Redis 장애가 전체 상품 조회 기능 장애로 이어질 수 있다.

운영 환경에서 Redis 불안정 시 서비스 가용성을 보장하려면 다음 처리가 필요하다:

  • get(): 예외 발생 시 Optional.empty() 반환 후 로그 기록
  • put(): 예외 발생 시 무시하고 로그 기록 (캐시 갱신 실패는 치명적이지 않음)
🛡️ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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));
}
public void put(String productId, ProductInfo productInfo) {
byte[] bytes = serializer.serialize(productInfo);
redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
}
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);
}
}
🤖 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/ProductCacheStore.java`
around lines 27 - 38, ProductCacheStore currently lets Redis exceptions
propagate from get(...) and put(...); wrap the redisTemplate calls inside
try-catch blocks so Redis failures don't break product read path: in get(String
productId) catch Redis/DataAccess/RuntimeException around
redisTemplate.opsForValue().get(...) and on exception log the error (include
productId and exception) and return Optional.empty(); in put(String productId,
ProductInfo productInfo) catch Redis/DataAccess/RuntimeException around
serializer.serialize(...) and redisTemplate.opsForValue().set(...) and on
exception log the failure (include productId, TTL_SECONDS, KEY_PREFIX and
exception) and swallow the exception so cache write failures are non-fatal. Use
the existing class logger (or add a private static final Logger) and preserve
serializer usage when no error occurs.


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
Expand Up @@ -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;
Expand Down Expand Up @@ -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
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
# BrandApp.getBrandByRefId 캐시 적용 여부 확인
rg -n "getBrandByRefId" --type java -B2 -A5

# Brand 조회에 캐시가 있는지 확인
rg -n '@Cacheable.*brand' --type java

Repository: 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 java

Repository: 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.java

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

Length of output: 2743


enrichProductInfo에서 브랜드 조회 시 N+1 쿼리 문제가 발생한다.

CursorPageResult.map(this::enrichProductInfo) 실행 시 각 상품마다 brandApp.getBrandByRefId() 가 호출되며, getBrandByRefId는 캐싱이 적용되지 않아 동일 브랜드의 상품이 다수 포함된 경우 중복 쿼리가 발생한다. 커서 페이지네이션에서 한 번에 조회하는 상품 수만큼 추가 데이터베이스 호출이 증가하므로 성능 저하를 초래한다.

이 문제는 getProducts() (39-42줄)와 LikeFacade.getMyLikedProducts() 에서도 동일한 패턴으로 발생한다.

개선 방안:

  • 브랜드 정보를 일괄 조회 후 메모리에서 매핑 (IN 쿼리로 한 번에 조회)
  • 상품 조회 시 브랜드 정보를 JOIN으로 함께 가져오기
  • getBrandByRefId@Cacheable 적용하여 중복 조회 방지

검증:

  • 동일 브랜드 다수 상품 조회 시 쿼리 수 측정 (현재 상품 수 + 1 → 1로 감소 확인)
  • 캐시 또는 배치 로딩 적용 후 부하 테스트 진행
🤖 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 44 - 47, enrichProductInfo causes an N+1 by calling
brandApp.getBrandByRefId per product when
CursorPageResult.map(this::enrichProductInfo) (used by getProductsByCursor,
getProducts and LikeFacade.getMyLikedProducts) runs; fix by batching brand
fetches: collect all distinct brandRefIds from the page, call brandApp to load
brands in one IN query (or add a join in product query) and build an in-memory
Map<brandRefId, Brand> to pass into enrichProductInfo (or refactor
enrichProductInfo to accept the map), alternatively annotate getBrandByRefId
with `@Cacheable` if caching is acceptable — verify by ensuring queries drop from
N+1 to 1 for the page.


public ProductInfo getProductByRefId(Long id) {
ProductInfo product = productApp.getProductByRefId(id);
return enrichProductInfo(product);
Expand Down
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
Expand Up @@ -9,6 +9,7 @@
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
Expand All @@ -17,6 +18,7 @@ public class LikeService {
private final LikeRepository likeRepository;
private final ProductRepository productRepository;

@Transactional
public LikeModel addLike(Long memberId, String productId) {
// 상품 존재 확인
ProductModel product = productRepository.findByProductId(new ProductId(productId))
Expand Down Expand Up @@ -45,6 +47,7 @@ public LikeModel addLike(Long memberId, String productId) {
});
}

@Transactional
public void removeLike(Long memberId, String productId) {
// 상품 존재 확인
ProductModel product = productRepository.findByProductId(new ProductId(productId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
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

새 인덱스가 키셋 정렬의 전체 ORDER BY를 덮지 못한다.

현재 정의는 latest/price/like 모두에서 동률 해소용 cursor 컬럼이 빠져 있고, idx_products_brand_like는 PR 요약대로 보조 정렬 컬럼이 없어 likes 정렬에 filesort가 계속 남는다. 운영에서 updated_at, price, like_count가 같은 상품이 많아지면 추가 스캔과 정렬 비용이 다시 커지고, 페이지 경계가 흔들려 중복·누락 위험도 커진다. deleted_at을 동등 조건 쪽으로 앞당기고, 각 정렬의 실제 ORDER BY 전체를 인덱스에 반영하라. 추가 테스트로 동률 데이터셋에서 EXPLAIN에 filesort가 사라지는지와 cursor 순회에 중복/누락이 없는지 확인하라.

🤖 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/ProductModel.java`
around lines 22 - 32, The defined indexes in ProductModel
(idx_products_brand_like, idx_products_brand_latest, idx_products_brand_price)
don’t cover the full ORDER BY and omit the cursor/tiebreaker columns and place
deleted_at last; update each `@Index` columnList so the equality columns
(ref_brand_id, deleted_at) come first and then the exact ORDER BY sequence
including the primary sort and necessary tie-breaker cursor (e.g., updated_at,
price, like_count as applicable and finally the primary key/id) — for example:
for like sorting include ref_brand_id, deleted_at, like_count DESC, updated_at
DESC, id; for latest include ref_brand_id, deleted_at, updated_at DESC, id; for
price include ref_brand_id, deleted_at, price ASC, id. After changing the
indexes in ProductModel, verify with EXPLAIN on the tie cases that filesort is
gone and run the cursor traversal test with many equal-sort-value rows to ensure
no duplicates/missing items.

@Getter
public class ProductModel extends BaseEntity {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.loopers.domain.product;

import com.loopers.domain.common.cursor.CursorPageResult;
import com.loopers.domain.product.vo.ProductId;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -16,6 +17,8 @@ public interface ProductRepository {

Page<ProductModel> findProducts(Long refBrandId, String sortBy, Pageable pageable);

CursorPageResult<ProductModel> findProductsByCursor(Long refBrandId, String sortBy, String cursor, int size);

/**
* 재고를 차감합니다. 재고가 부족하면 false를 반환합니다.
* 동시성 제어를 위해 조건부 UPDATE를 사용합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,17 @@ public void deleteProduct(String productId) {
productRepository.save(product);
}

public Page<ProductModel> getProducts(String brandId, String sortBy, Pageable pageable) {
// brandId가 제공되면 Brand PK로 변환
Long refBrandId = null;
if (brandId != null && !brandId.isBlank()) {
BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId))
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다."));
refBrandId = brand.getId();
public Long resolveRefBrandId(String brandId) {
if (brandId == null || brandId.isBlank()) {
return null;
}
BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId))
.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다."));
return brand.getId();
}

public Page<ProductModel> getProducts(String brandId, String sortBy, Pageable pageable) {
Long refBrandId = resolveRefBrandId(brandId);
return productRepository.findProducts(refBrandId, sortBy, pageable);
}

Expand Down
Loading
Loading