Skip to content
Open
409 changes: 409 additions & 0 deletions .docs/product-index-analysis.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.loopers.application.brand;

import com.loopers.application.product.ProductCacheEvictEvent;
import com.loopers.application.product.ProductDetailCacheEvictEvent;
import com.loopers.domain.brand.Brand;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.like.LikeService;
import com.loopers.domain.product.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
Expand All @@ -18,6 +21,7 @@ public class BrandAdminFacade {
private final BrandService brandService;
private final ProductService productService;
private final LikeService likeService;
private final ApplicationEventPublisher eventPublisher;

// 브랜드 등록
@Transactional
Expand All @@ -40,9 +44,13 @@ public Page<BrandInfo> findAll(Pageable pageable){
}

// 브랜드 정보 수정
// 브랜드명은 상품 목록/상세 캐시에 스냅샷되므로, 수정 시 해당 브랜드 상품 캐시 전체 무효화
@Transactional
public BrandInfo update(BrandUpdateCommand command){
Brand brand = brandService.update(command.id(), command.name());
List<Long> productIds = productService.findIdsByBrandId(command.id());
eventPublisher.publishEvent(new ProductCacheEvictEvent());
productIds.forEach(pid -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(pid)));
return BrandInfo.from(brand);
}

Expand All @@ -61,5 +69,8 @@ public void delete(Long id){
productService.deleteAllByBrandId(id);
// 브랜드 soft delete
brand.delete();
// 삭제된 상품들의 목록/상세 캐시 무효화
eventPublisher.publishEvent(new ProductCacheEvictEvent());
productIds.forEach(pid -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(pid)));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.loopers.application.order;

import com.loopers.application.product.ProductDetailCacheEvictEvent;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.coupon.CouponService;
import com.loopers.domain.order.Order;
Expand All @@ -12,6 +13,7 @@
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -32,6 +34,7 @@ public class OrderFacade {
private final ProductService productService;
private final BrandService brandService;
private final CouponService couponService;
private final ApplicationEventPublisher eventPublisher;

/**
* 주문 생성 (US-O01)
Expand Down Expand Up @@ -90,6 +93,8 @@ public OrderInfo create(Long userId, OrderCreateCommand command) {
Quantity quantity = quantityByProductId.get(product.getId());
product.decreaseStock(quantity);
}
// 재고 변동 → 주문된 각 상품 상세 캐시 즉시 무효화 (커밋 후 처리)
products.forEach(p -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(p.getId())));

// 브랜드명 일괄 조회 (스냅샷용)
List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
Expand All @@ -21,13 +22,15 @@ public class ProductAdminFacade {
private final ProductService productService;
private final BrandService brandService;
private final LikeService likeService;
private final ApplicationEventPublisher eventPublisher;

// 상품 등록 - 브랜드 존재 확인은 Facade 책임 (BR-P01, US-P05)
@Transactional
public ProductInfo register(ProductRegisterCommand command) {
Brand brand = brandService.findById(command.brandId()); // 브랜드 미존재 시 NOT_FOUND 예외
Product product = productService.register(
command.brandId(), command.name(), command.price(), command.stock());
eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 커밋 후 캐시 무효화 예약
return ProductInfo.from(product, brand.getName());
}

Expand Down Expand Up @@ -57,13 +60,15 @@ public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
public ProductInfo update(ProductUpdateCommand command) {
Product product = productService.update(
command.id(), command.name(), command.price(), command.stock());
eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 목록 캐시 전체 무효화
eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(product.getId())); // 상세 캐시 핀포인트 무효화
String brandName = brandService.findById(product.getBrandId()).getName();
return ProductInfo.from(product, brandName);
}

/**
* 상품 삭제 (US-P07)
* 좋아요(hard delete) → 상품(soft delete) 순서로 처리
* 좋아요(hard delete) → 상품(soft delete) → 커밋 후 캐시 무효화 순서로 처리
*/
@Transactional
public void delete(Long id) {
Expand All @@ -73,5 +78,7 @@ public void delete(Long id) {
likeService.deleteAllByProductId(id);
// 상품 soft delete (이미 managed 상태이므로 dirty checking으로 처리)
product.delete();
eventPublisher.publishEvent(new ProductCacheEvictEvent()); // 목록 캐시 전체 무효화
eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(id)); // 상세 캐시 핀포인트 무효화
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.loopers.application.product;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@RequiredArgsConstructor
@Component
public class ProductCacheEventListener {

private final ProductCacheRepository productCacheRepository;

/**
* DB 트랜잭션 커밋 완료 후에 캐시 무효화 실행.
* BEFORE_COMMIT이 아닌 AFTER_COMMIT을 사용하는 이유:
* 커밋 전에 캐시를 삭제하면, 삭제~커밋 사이 구간에 다른 요청이
* 캐시 미스 → DB 조회(구 버전) → 구 버전 재캐싱하는 레이스가 발생하기 때문.
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleProductChanged(ProductCacheEvictEvent event) {
productCacheRepository.evictAll();
}

// 특정 상품의 상세 캐시만 핀포인트 무효화 (주문 재고 차감, 상품/브랜드 수정 시 호출)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleProductDetailChanged(ProductDetailCacheEvictEvent event) {
productCacheRepository.evictDetail(event.productId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.loopers.application.product;

/**
* 상품 데이터 변경 시 캐시 무효화를 요청하는 이벤트.
* 트랜잭션 커밋 후(AFTER_COMMIT)에 처리되어
* "미커밋 상태에서 구 버전 재캐싱" 레이스 컨디션을 방지한다.
*/
public record ProductCacheEvictEvent() {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.loopers.application.product;

import java.util.Optional;

/**
* 상품 캐시 포트 (Secondary Port / Driven Port).
* application 레이어는 이 인터페이스에만 의존 → Redis 등 구현 기술이 바뀌어도 Facade 코드 변경 없음.
*/
public interface ProductCacheRepository {

// ── 목록 캐시 ────────────────────────────────────────────────────────────

// 캐시 조회 - 미스 시 Optional.empty() 반환 (예외 미전파)
Optional<ProductPageResult> getList(String cacheKey);

// 캐시 저장 (Redis 장애 시에도 예외 미전파)
void saveList(String cacheKey, ProductPageResult result);

// 상품 등록/수정/삭제 시 목록 캐시 전체 무효화
void evictAll();

// ── 상세 캐시 ────────────────────────────────────────────────────────────

// 상세 캐시 조회 - 미스 시 Optional.empty() 반환
Optional<ProductInfo> getDetail(Long productId);

// 상세 캐시 저장 (목록 페이지 워밍업 시 호출)
void saveDetail(Long productId, ProductInfo info);

// 특정 상품 상세 캐시 무효화
void evictDetail(Long productId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.loopers.application.product;

/**
* 특정 상품 상세 캐시를 무효화하는 이벤트.
* 재고 차감(주문), 상품 정보 수정/삭제, 브랜드명 수정 등 민감한 데이터 변경 시 발행.
* AFTER_COMMIT에서 처리되어 커밋 전 구 버전 재캐싱 레이스를 방지한다.
*/
public record ProductDetailCacheEvictEvent(Long productId) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.loopers.application.product;

import com.loopers.config.CacheProperties;
import com.loopers.domain.brand.BrandService;
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductService;
Expand All @@ -11,28 +12,68 @@

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Component
public class ProductFacade {

private final ProductService productService;
private final BrandService brandService;
private final ProductCacheRepository productCacheRepository;
private final CacheProperties cacheProperties;

// 상품 상세 조회
// 상품 상세 조회 (Cache-Aside)
@Transactional(readOnly = true)
public ProductInfo findById(Long id) {
Product product = productService.findById(id);
String brandName = brandService.findById(product.getBrandId()).getName();
return ProductInfo.from(product, brandName);
return productCacheRepository.getDetail(id).orElseGet(() -> {
Product product = productService.findById(id);
String brandName = brandService.findById(product.getBrandId()).getName();
ProductInfo info = ProductInfo.from(product, brandName);
productCacheRepository.saveDetail(id, info);
return info;
});
}

// 상품 목록 조회 (brandId 필터 선택)
// 상품 목록 조회 (Cache-Aside 패턴)
@Transactional(readOnly = true)
public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
public ProductPageResult findAll(Long brandId, Pageable pageable) {
// 딥 페이징 구간은 캐시 미적용 → DB 직접 조회
if (!cacheProperties.isCacheable(pageable.getPageNumber())) {
return fetchFromDb(brandId, pageable);
}

String cacheKey = buildCacheKey(brandId, pageable);

// 캐시 히트: 즉시 반환
return productCacheRepository.getList(cacheKey).orElseGet(() -> {
// 캐시 미스: DB 조회 후 캐시에 저장
ProductPageResult result = fetchFromDb(brandId, pageable);
productCacheRepository.saveList(cacheKey, result);
return result;
});
}

private ProductPageResult fetchFromDb(Long brandId, Pageable pageable) {
Page<Product> products = productService.findAll(brandId, pageable);
List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList();
Map<Long, String> brandNameMap = brandService.findNamesByIds(brandIds);
return products.map(product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId())));
Page<ProductInfo> infoPage = products.map(
product -> ProductInfo.from(product, brandNameMap.get(product.getBrandId()))
);
return ProductPageResult.from(infoPage);
}

/**
* 캐시 키 레이어링 전략: {서비스}:{도메인}:{오퍼레이션}:{파라미터}
* 예) loopers:product:list:brand=1:sort=createdAt_DESC:page=0:size=20
*/
private String buildCacheKey(Long brandId, Pageable pageable) {
String brandPart = brandId == null ? "brand=all" : "brand=" + brandId;
String sortPart = pageable.getSort().stream()
.map(order -> order.getProperty() + "_" + order.getDirection())
.collect(Collectors.joining(","));
return String.format("loopers:product:list:%s:sort=%s:page=%d:size=%d",
brandPart, sortPart, pageable.getPageNumber(), pageable.getPageSize());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.loopers.application.product;

import org.springframework.data.domain.Page;

import java.util.List;

/**
* 상품 목록 조회 결과를 캐시에 저장하기 위한 직렬화 가능 record.
* Page<ProductInfo>는 PageImpl에 @JsonCreator가 없어 Jackson 역직렬화 불가 → 직접 필드를 선언하여 해결
*/
public record ProductPageResult(
List<ProductInfo> products,
int page,
int size,
long totalElements,
int totalPages
) {
public static ProductPageResult from(Page<ProductInfo> page) {
return new ProductPageResult(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.loopers.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(CacheProperties.class)
public class CacheConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.loopers.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;

/**
* cache.* 설정을 타입 안전하게 바인딩.
* default-ttl-seconds: 전역 기본 TTL
* ttl-overrides: 캐시 이름별 TTL 재정의 (예: product:list → 180초)
* jitter-range-seconds: TTL에 ±N초 무작위 오차를 더해 동시 만료 방지 (캐시 스탬피드 완화)
* max-cacheable-page: 이 페이지 번호 미만(0-indexed)만 캐싱 - 딥 페이징 캐시 키 폭발 방지
*/
@ConfigurationProperties(prefix = "cache")
public record CacheProperties(
long defaultTtlSeconds,
Map<String, Long> ttlOverrides,
long jitterRangeSeconds,
int maxCacheablePage
) {
public CacheProperties {
if (ttlOverrides == null) {
ttlOverrides = new HashMap<>();
}
}
Comment on lines +23 to +27
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

ttlOverrides Map의 불변성을 보장해야 한다.

compact constructor에서 new HashMap<>()을 직접 할당하면, record 필드는 final이지만 Map 내용은 외부에서 수정 가능하다. @ConfigurationProperties로 바인딩된 설정 객체는 런타임에 변경되지 않아야 한다.

운영 관점 문제점:

  • 실수로 cacheProperties.ttlOverrides().put(...) 호출 시 전역 설정 오염
  • 테스트에서 설정 변경 시 다른 테스트에 영향
🛡️ 불변 Map으로 감싸기
 public CacheProperties {
     if (ttlOverrides == null) {
-        ttlOverrides = new HashMap<>();
+        ttlOverrides = Map.of();
+    } else {
+        ttlOverrides = Map.copyOf(ttlOverrides);
     }
 }
📝 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 CacheProperties {
if (ttlOverrides == null) {
ttlOverrides = new HashMap<>();
}
}
public CacheProperties {
if (ttlOverrides == null) {
ttlOverrides = Map.of();
} else {
ttlOverrides = Map.copyOf(ttlOverrides);
}
}
🤖 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/config/CacheProperties.java`
around lines 23 - 27, The compact constructor for record CacheProperties must
defensively copy and wrap ttlOverrides as an immutable map to prevent external
mutation; modify the compact constructor logic in CacheProperties to replace
direct new HashMap<>() allocation with an immutable copy (e.g., use
Map.copyOf(...) or Collections.unmodifiableMap(new HashMap<>(...))) so that
ttlOverrides is always stored as an unmodifiable map whether the incoming value
is null or provided, ensuring any provided map is copied and then made immutable
inside the CacheProperties compact constructor.


public long getTtlSeconds(String cacheName) {
long base = ttlOverrides.getOrDefault(cacheName, defaultTtlSeconds);
if (jitterRangeSeconds <= 0) {
return base;
}
// Bounded Jitter: base ± jitterRange 범위에서 무작위 TTL
long jitter = ThreadLocalRandom.current().nextLong(-jitterRangeSeconds, jitterRangeSeconds + 1);
return Math.max(1, base + jitter); // 최소 1초 보장
}

// page=0, 1, 2 → 캐싱 대상 / page=3 이상 → DB 직접 조회
public boolean isCacheable(int pageNumber) {
return pageNumber < maxCacheablePage;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,22 @@
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "product")
@Table(
name = "product",
indexes = {
@Index(name = "idx_brand_deleted_created", columnList = "brand_id, deleted_at, created_at DESC"),
@Index(name = "idx_brand_deleted_price", columnList = "brand_id, deleted_at, price"),
@Index(name = "idx_brand_deleted_likes", columnList = "brand_id, deleted_at, like_count DESC"),
@Index(name = "idx_deleted_created", columnList = "deleted_at, created_at DESC")
}
)
Comment on lines +17 to +25
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
# Hibernate가 생성하는 실제 DDL 확인
rg -n "ddl-auto|hbm2ddl" --type=yaml apps/commerce-api/src/main/resources/

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Find application configuration files
find apps/commerce-api/src/main/resources -type f \( -name "application*.yml" -o -name "application*.properties" -o -name "*.properties" \) -exec head -50 {} + 2>/dev/null | head -100

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

Length of output: 1246


🏁 Script executed:

#!/bin/bash
# Check for Flyway migration directory
fd -t d "db|migration|sql" apps/commerce-api/src/main/resources --max-depth 3

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# View Product.java file to understand index definitions
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java | head -50

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

Length of output: 2147


🏁 Script executed:

#!/bin/bash
# Check Hibernate version in pom.xml or build config
rg "hibernate|jpa" -A 2 apps/commerce-api/pom.xml 2>/dev/null | head -50

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check jpa.yml file
cat -n apps/commerce-api/src/main/resources/jpa.yml

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

Length of output: 161


🏁 Script executed:

#!/bin/bash
# Check pom.xml for Hibernate/JPA dependencies
cat apps/commerce-api/pom.xml | grep -A 3 -B 1 "hibernate\|spring-boot-starter-data-jpa\|flyway" | head -100

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

Length of output: 142


🏁 Script executed:

#!/bin/bash
# Search for any database-related files (schema, migration, init)
find apps/commerce-api -type f \( -name "*.sql" -o -name "schema*" -o -name "*migration*" \) 2>/dev/null

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check for Flyway in parent pom.xml
cat pom.xml 2>/dev/null | grep -A 3 -B 1 "flyway" | head -50

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check directory structure
ls -la apps/commerce-api/src/main/resources/ 2>/dev/null | head -50

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

Length of output: 260


🏁 Script executed:

#!/bin/bash
# Find all YAML files
find apps/commerce-api/src/main/resources -name "*.yml" -o -name "*.yaml" 2>/dev/null

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

Length of output: 137


🏁 Script executed:

#!/bin/bash
# Check parent pom.xml structure
cat pom.xml | head -100

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

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Check if jpa.yml exists anywhere in the repo
find . -name "jpa.yml" 2>/dev/null

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

Length of output: 125


🏁 Script executed:

#!/bin/bash
# List all application config files
find . -path "*/src/main/resources/*" -name "application*" -type f 2>/dev/null

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

Length of output: 256


🏁 Script executed:

#!/bin/bash
# Read jpa.yml file
cat -n ./modules/jpa/src/main/resources/jpa.yml

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

Length of output: 2993


🏁 Script executed:

#!/bin/bash
# Check for build files (gradle, maven)
find . -maxdepth 2 -name "build.gradle*" -o -name "pom.xml" 2>/dev/null | head -10

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

Length of output: 103


🏁 Script executed:

#!/bin/bash
# Look for Flyway migration directories
find . -type d -name "db" -o -type d -name "migration" -o -type d -name "flyway" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Search for schema.sql or database initialization files
find . -name "schema.sql" -o -name "init*.sql" -o -name "database.sql" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check build.gradle.kts for Flyway/Liquibase
cat build.gradle.kts | grep -i "flyway\|liquibase" -A 2 -B 2

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there are any database schema files at all
find . -name "*.sql" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check gradle subprojects or modules that might handle database
find . -maxdepth 2 -name "build.gradle.kts" -exec grep -l "flyway\|liquibase\|database" {} \; 2>/dev/null

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check README or documentation for schema management
find . -maxdepth 3 -name "README*" -o -name "*.md" | xargs grep -l "schema\|database\|ddl\|migration" 2>/dev/null | head -10

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there's a separate directory for database scripts
ls -la . | grep -i "db\|database\|schema\|sql\|migration"

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Look for any reference to database initialization in gradle files
find . -name "build.gradle.kts" -exec cat {} \; 2>/dev/null | grep -i "database\|schema" -A 2 -B 2 | head -50

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check git for any deleted SQL files that might have existed
git log --name-only --pretty=format: -- "*.sql" 2>/dev/null | sort -u | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there's actual database schema in git tracked files
git ls-files | grep -E "\.sql|\.ddl|schema|migration" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check the actual Hibernate version in gradle
cat build.gradle.kts | grep -i "spring\|hibernate" | head -30

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

Length of output: 957


🏁 Script executed:

#!/bin/bash
# Look for any documentation about how indexes are managed
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "*.rst" \) -exec grep -l "index\|schema\|ddl" {} \; 2>/dev/null | head -10

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

Length of output: 118


🏁 Script executed:

#!/bin/bash
# Check if indexes are referenced anywhere in repository documentation
rg "idx_brand_deleted_created|idx_deleted_created|@Index" --type=md --type=txt --type=yaml 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Read the product index analysis documentation
cat -n ./.docs/product-index-analysis.md

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

Length of output: 17488


🏁 Script executed:

#!/bin/bash
# Check if there's any schema initialization or management documentation
find .docs -type f \( -name "*.md" -o -name "*.txt" \) -exec cat {} \; 2>/dev/null | head -200

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

Length of output: 4140


🏁 Script executed:

#!/bin/bash
# Verify if there's any SQL execution or initialization mechanism for creating indexes
rg "CREATE INDEX|ALTER TABLE.*ADD INDEX" --type=sql --type=txt 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there are any scripts or documentation about database initialization
find . -maxdepth 3 -type f \( -name "*.sh" -o -name "*init*" -o -name "*setup*" \) 2>/dev/null | head -20

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

Length of output: 67


@Index 정의가 생산 환경에 적용되지 않는 문제

jpa.yml에서 ddl-auto: none으로 설정되어 있어 Hibernate가 DDL을 자동 생성하지 않는다. 따라서 Product.java의 @Index 애너테이션은 메타데이터일 뿐 실제 데이터베이스 인덱스 생성에 영향을 주지 않는다. product-index-analysis.md에서 분석한 4개의 인덱스가 생산 데이터베이스에 실제로 생성되려면, Flyway 마이그레이션 또는 명시적인 DDL 스크립트로 관리해야 한다.

권장 조치:

  1. db/migration/ 디렉토리를 생성하고 Flyway를 통해 인덱스 DDL을 관리하거나
  2. 데이터베이스 초기화 스크립트에 아래 DDL을 추가하여 배포 시 인덱스 자동 생성 확보:
ALTER TABLE product ADD INDEX idx_brand_deleted_created (brand_id, deleted_at, created_at DESC);
ALTER TABLE product ADD INDEX idx_brand_deleted_price   (brand_id, deleted_at, price);
ALTER TABLE product ADD INDEX idx_brand_deleted_likes   (brand_id, deleted_at, like_count DESC);
ALTER TABLE product ADD INDEX idx_deleted_created       (deleted_at, created_at DESC);
  1. 배포 후 각 환경에서 SHOW INDEX FROM product;로 인덱스 생성 여부 검증
🤖 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 17 - 25, Product.java's `@Table/`@Index annotations are only metadata
because jpa.yml sets ddl-auto: none, so the four indexes declared
(idx_brand_deleted_created, idx_brand_deleted_price, idx_brand_deleted_likes,
idx_deleted_created) are not created in production; fix this by adding a Flyway
migration (under db/migration) or other deployment DDL that runs the equivalent
ALTER TABLE ... ADD INDEX statements for those index names/columns (as noted in
product-index-analysis.md), and after deployment verify with SHOW INDEX FROM
product; to ensure the indexes created successfully.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Product extends BaseEntity {
Expand Down
Loading