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
Expand Up @@ -6,6 +6,7 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductSearchCondition;
import com.loopers.domain.product.ProductService;
import com.loopers.infrastructure.product.ProductCacheStore;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand All @@ -20,10 +21,11 @@ public class ProductFacade {

private final BrandService brandService;
private final ProductService productService;
private final ProductCacheStore productCacheStore;

public List<ProductInfo> createProducts(CreateProductCommand command) {
command.products().keySet().forEach(brandService::getById);

Map<Long, CreateProductRequest> domainRequest = new HashMap<>();
command.products().forEach((brandId, item) -> {
domainRequest.put(brandId, new CreateProductRequest(
Expand All @@ -32,7 +34,7 @@ public List<ProductInfo> createProducts(CreateProductCommand command) {
item.stock()
));
});

List<Product> products = productService.createProducts(domainRequest);
return products.stream()
.map(product -> {
Expand All @@ -44,29 +46,39 @@ public List<ProductInfo> createProducts(CreateProductCommand command) {

@Transactional(readOnly = true)
public ProductInfo getProduct(Long productId) {
Product product = productService.getById(productId);
Brand brand = brandService.getById(product.getRefBrandId());
return ProductInfo.of(product, brand);
return productCacheStore.getProduct(productId)
.orElseGet(() -> {
Product product = productService.getById(productId);
Brand brand = brandService.getById(product.getRefBrandId());
ProductInfo info = ProductInfo.of(product, brand);
productCacheStore.putProduct(productId, info);
return info;
});
}

@Transactional(readOnly = true)
public List<ProductInfo> getProducts(ProductSearchCommand command) {
if (command.hasBrandId()) {
brandService.getById(command.brandId());
}

ProductSearchCondition condition = new ProductSearchCondition(
command.brandId(),
command.sortType(),
command.page(),
command.size()
);

return productService.findProducts(condition).stream()
.map(product -> {
Brand brand = brandService.getById(product.getRefBrandId());
return ProductInfo.of(product, brand);
})
.toList();
return productCacheStore.getProducts(command)
.orElseGet(() -> {
if (command.hasBrandId()) {
brandService.getById(command.brandId());
}

ProductSearchCondition condition = new ProductSearchCondition(
command.brandId(),
command.sortType(),
command.page(),
command.size()
);

List<ProductInfo> list = productService.findProducts(condition).stream()
.map(product -> {
Brand brand = brandService.getById(product.getRefBrandId());
return ProductInfo.of(product, brand);
})
.toList();
productCacheStore.putProducts(command, list);
return list;
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.loopers.infrastructure.product;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loopers.application.product.ProductInfo;
import com.loopers.application.product.ProductSearchCommand;
import com.loopers.config.redis.RedisConfig;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class ProductCacheStore {

private static final String DETAIL_KEY_PREFIX = "product:detail:";
private static final String LIST_KEY_PREFIX = "product:list:";

private static final Duration DETAIL_TTL = Duration.ofSeconds(180);
private static final Duration LIST_TTL = Duration.ofSeconds(30);
Comment on lines +21 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 | 🟠 Major

캐시 적재 경로만 있고 무효화 전략이 없다

현재 변경분에서는 ProductCacheStore 에 조회/적재 API만 있고, apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java:48-82 도 캐시 적재만 호출한다. 그런데 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java:1-29 에는 stock, likeCount 같이 자주 바뀌는 값이 포함되어 있어서, 주문·좋아요·상품 생성 직후에도 detail/list 캐시가 TTL 동안 이전 값을 계속 반환할 수 있다. 운영에서는 재고 오표시나 좋아요 수 지연 반영으로 바로 사용자 혼선을 만든다.

수정안은 write path 에서 호출할 evictProduct(...) 와 목록 캐시 버전 증가 API를 먼저 추가하는 것이다. detail 캐시는 즉시 삭제 또는 write-through 갱신으로 맞추고, list 캐시는 page/sort 조합이 많으므로 개별 키 삭제보다 브랜드/전역 버전 키를 조합하는 방식이 안전하다. 추가 테스트로는 캐시 예열 후 주문 완료 시 상세/목록의 stock 이 즉시 감소하는지, 좋아요 변경 후 likeCount 가 즉시 반영되는지, 상품 생성 후 해당 브랜드 목록에 신상품이 바로 보이는지 통합 테스트를 넣어야 한다.

Also applies to: 41-97

🤖 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 21 - 25, 현재 ProductCacheStore에는 조회/적재만 있고 무효화 경로가 없어
stock/likeCount/신규상품 반영 지연 문제가 발생하므로 ProductCacheStore에 public
evictProduct(productId) 메서드와 목록 캐시용 브랜드/글로벌 버전을 관리하는
incrementListVersion(brandId?) API를 추가하고, ProductFacade의 쓰기 경로(주문 처리·좋아요 토글·상품
생성)에서 해당 evictProduct(...)를 호출하거나 상세 캐시는 즉시 삭제/쓰기-스루로 갱신하고 목록 캐시는 개별 키 삭제 대신
브랜드/전역 버전 키를 조합해 무효화하도록 변경하세요; 또한 통합 테스트를 추가해(캐시 예열 후) 주문 완료 시 detail/list의
stock 감소, 좋아요 변경 시 likeCount 즉시 반영, 상품 생성 시 해당 브랜드 목록에서 신상품 즉시 노출을 검증하세요.


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

public ProductCacheStore(
RedisTemplate<String, String> redisTemplate,
@Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate,
ObjectMapper objectMapper
) {
this.redisTemplate = redisTemplate;
this.masterRedisTemplate = masterRedisTemplate;
this.objectMapper = objectMapper;
}

public Optional<ProductInfo> getProduct(Long productId) {
String key = detailKey(productId);
try {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
return Optional.empty();
}
return Optional.of(objectMapper.readValue(value, ProductInfo.class));
} catch (JsonProcessingException e) {
log.warn("Failed to deserialize product cache. key={}", key, e);
return Optional.empty();
}
}

public void putProduct(Long productId, ProductInfo productInfo) {
String key = detailKey(productId);
try {
masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfo), DETAIL_TTL);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize product cache. key={}", key, e);
Comment on lines +43 to +60
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

Redis 장애가 상품 조회 장애로 바로 번질 수 있다

현재는 Jackson 예외만 처리하므로 opsForValue().get()/set() 에서 발생하는 timeout, connection, DataAccessException 계열 예외가 그대로 전파된다. 운영에서는 캐시가 흔들려도 DB fallback 으로 서비스가 계속되어야 하는데, 지금 구현은 읽기 실패가 miss 로 전환되지 않고, 쓰기 실패도 이미 만든 응답을 반환하지 못한 채 요청 자체를 실패시킬 수 있다.

수정안은 읽기에서 Redis 접근 예외를 캐시 miss 로 흡수하고, 쓰기에서는 경고 로그만 남긴 뒤 본 요청은 성공시키는 것이다. 역직렬화 실패 시에도 같은 손상 데이터를 계속 읽지 않도록 문제 키를 삭제하는 처리까지 함께 넣는 편이 낫다. 추가 테스트로는 Redis read/write mock 이 예외를 던질 때 ProductFacade.getProduct()ProductFacade.getProducts() 가 DB 결과를 정상 반환하는지, 손상된 JSON 이 들어 있을 때 miss 처리 후 해당 키가 제거되는지를 검증해야 한다. As per coding guidelines, **/*.java: "예외 흐름이 명확한지 점검한다".

Also applies to: 66-83

🤖 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 43 - 60, The Redis read/write currently only catches
JsonProcessingException; wrap the redisTemplate.opsForValue().get(key) +
objectMapper.readValue(...) in a broader try that catches DataAccessException
(and other runtime Redis connectivity exceptions) and return Optional.empty() on
those errors, and on JsonProcessingException additionally delete the bad key
(redisTemplate.delete(key)) to avoid repeated poisoned reads before returning
Optional.empty(); in putProduct(Long productId, ProductInfo productInfo) wrap
masterRedisTemplate.opsForValue().set(...) and
objectMapper.writeValueAsString(...) in a try that catches
JsonProcessingException and DataAccessException (or RuntimeException from Redis)
and only log a warning (do not rethrow) so the request can proceed; add unit
tests that mock Redis to throw on read/write to verify
ProductFacade.getProduct()/getProducts() fall back to DB results and a test
inserting corrupted JSON verifies the key is removed and treated as a cache
miss.

}
}

public Optional<List<ProductInfo>> getProducts(ProductSearchCommand command) {
String key = listKey(command);
try {
String value = redisTemplate.opsForValue().get(key);
if (value == null) {
return Optional.empty();
}
return Optional.of(objectMapper.readValue(value, new TypeReference<>() {}));
} catch (JsonProcessingException e) {
log.warn("Failed to deserialize product list cache. key={}", key, e);
return Optional.empty();
}
}

public void putProducts(ProductSearchCommand command, List<ProductInfo> productInfos) {
String key = listKey(command);
try {
masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfos), LIST_TTL);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize product list cache. key={}", key, e);
}
}

private String detailKey(Long productId) {
return DETAIL_KEY_PREFIX + productId;
}

private String listKey(ProductSearchCommand command) {
return LIST_KEY_PREFIX
+ "brandId=" + command.brandId()
+ ":sort=" + command.sortType()
+ ":page=" + command.page()
+ ":size=" + command.size();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.loopers.domain.product.Product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Comment;
Expand All @@ -12,7 +13,12 @@
* Product DB 엔티티
*/
@Entity
@Table(name = "product")
@Table(name = "product", indexes = {
@Index(name = "idx_product_brand_like", columnList = "ref_brand_id, like_count DESC"),
@Index(name = "idx_product_like", columnList = "like_count DESC"),
@Index(name = "idx_product_brand_latest", columnList = "ref_brand_id, created_at DESC"),
@Index(name = "idx_product_latest", columnList = "created_at DESC")
})
Comment on lines +16 to +21
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
# Description: 마이그레이션 스크립트에서 해당 인덱스 생성 DDL 존재 여부 확인

# 1. Flyway/Liquibase 마이그레이션 파일 검색
echo "=== Searching for migration files ==="
fd -e sql -e xml -e yaml -e yml | xargs grep -l -i "idx_product" 2>/dev/null || echo "No migration files found with idx_product"

# 2. 인덱스 DDL 패턴 검색
echo ""
echo "=== Searching for CREATE INDEX statements ==="
rg -i "CREATE\s+(UNIQUE\s+)?INDEX.*idx_product" --type sql 2>/dev/null || echo "No CREATE INDEX statements found"

# 3. resources 하위 SQL 파일 확인
echo ""
echo "=== Listing SQL files in resources ==="
fd -e sql . --full-path | head -20

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

Length of output: 305


🏁 Script executed:

# 1. ProductEntity.java 전체 내용 확인
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java

# 2. Hibernate 버전 확인
fd -name "pom.xml" -o -name "build.gradle" | xargs grep -A 5 "hibernate" 2>/dev/null | head -30

# 3. jpa.yml 설정 확인
fd -name "jpa.yml" -o -name "jpa.yaml" | xargs cat 2>/dev/null

# 4. 마이그레이션 디렉토리 구조 확인
find . -type d -name "db" -o -name "migration" -o -name "flyway" -o -name "liquibase" 2>/dev/null | head -10

# 5. 기존 SQL 마이그레이션 파일 형식 확인
cat db/seed-products.sql | head -20

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

Length of output: 4073


🏁 Script executed:

# 1. jpa.yml 설정 확인
find . -type f \( -name "jpa.yml" -o -name "jpa.yaml" -o -name "application*.yml" -o -name "application*.yaml" \) | head -10

# 2. Hibernate 버전 확인 (pom.xml 또는 build.gradle)
find . -type f \( -name "pom.xml" -o -name "build.gradle" \) -exec grep -l "hibernate" {} \;

# 3. 마이그레이션 관련 의존성 확인
find . -type f -name "pom.xml" -exec grep -A 3 "flyway\|liquibase" {} +

# 4. db 디렉토리 구조 확인
find db -type f 2>/dev/null | head -20

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

Length of output: 318


🏁 Script executed:

# 1. jpa.yml 전체 내용 확인
cat modules/jpa/src/main/resources/jpa.yml

# 2. application.yml 확인
cat apps/commerce-api/src/main/resources/application.yml

# 3. pom.xml에서 Hibernate 및 Spring Boot 버전 확인
find . -maxdepth 3 -name "pom.xml" -type f | xargs grep -E "spring-boot-starter-data-jpa|hibernate|version" | grep -E "hibernate|version" | head -20

# 4. Flyway 의존성 확인
rg "flyway|liquibase" --type xml

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

Length of output: 3191


🏁 Script executed:

# 1. 루트 및 각 모듈의 pom.xml 찾기
find . -name "pom.xml" -type f | head -10

# 2. 최상단 pom.xml에서 버전 정보 확인
cat pom.xml 2>/dev/null | grep -A 100 "<properties>" | grep -E "version|hibernate|spring" | head -20

# 3. 각 모듈 pom.xml에서 JPA/Hibernate 의존성 확인
find apps commerce-api -name "pom.xml" -type f -exec cat {} \; | grep -A 3 "spring-boot-starter-data-jpa\|hibernate-core" | head -20

# 4. Flyway 관련 설정파일 및 마이그레이션 디렉토리 확인
find . -type d -name "db" -o -type d -name "migration" 2>/dev/null
find . -type f -name "*flyway*" -o -name "*liquibase*" 2>/dev/null

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

Length of output: 154


🏁 Script executed:

# 1. pom.xml 위치 찾기
git ls-files | grep "pom.xml"

# 2. 루트 pom.xml 확인
cat pom.xml 2>/dev/null | head -50

# 3. Hibernate 버전 확인
git ls-files | xargs grep -l "hibernate" 2>/dev/null | head -5

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

Length of output: 493


🏁 Script executed:

# 1. pom.xml 찾기
fd "pom.xml" 

# 2. 최상단 pom.xml 읽기
head -100 pom.xml

# 3. Hibernate 버전 정보 검색
grep -r "hibernate" pom.xml 2>/dev/null | head -10

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

Length of output: 151


🏁 Script executed:

# 1. 루트 디렉토리 구조 확인
ls -la | head -20

# 2. build.gradle 찾기
fd "build.gradle"

# 3. Gradle wrapper 확인
ls -la gradlew* settings.gradle* 2>/dev/null

# 4. 프로젝트 구조 전체 확인
git ls-files | grep -E "(pom|gradle|maven)" | head -20

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

Length of output: 2198


🏁 Script executed:

# 1. 루트 build.gradle.kts 확인
cat build.gradle.kts | head -100

# 2. modules/jpa/build.gradle.kts 확인
cat modules/jpa/build.gradle.kts

# 3. gradle.properties 버전 정보
cat gradle.properties

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

Length of output: 4673


🏁 Script executed:

# 1. Hibernate 버전 확인 (Spring Boot BOM에 포함)
cat modules/jpa/build.gradle.kts | grep -i "spring-boot-starter-data-jpa\|hibernate"

# 2. Spring Boot 3.4.4에 포함된 Hibernate 버전 확인
# Spring Boot 3.4.x는 일반적으로 Hibernate 6.4.x 이상 포함
# 웹 검색으로 확인 필요

# 3. 마이그레이션 스크립트 부재 재확인
find . -type f \( -name "*.sql" -o -name "*.xml" \) -exec grep -l "idx_product_brand_like\|idx_product_like\|idx_product_brand_latest\|idx_product_latest" {} \;

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

Length of output: 237


🌐 Web query:

Spring Boot 3.4.4 Hibernate version

💡 Result:

Spring Boot 3.4.4 (its dependency management / BOM) uses Hibernate ORM 6.6.11.Final. [1]


운영 환경에서 인덱스 자동 생성이 되지 않으므로 마이그레이션 스크립트 필수

현재 jpa.yml에서 운영 환경(dev, qa, prd)의 ddl-auto: none 설정으로 인해 @Index 어노테이션은 스키마 문서화 목적일 뿐 실제 인덱스 생성되지 않는다.

현재 코드베이스에 마이그레이션 스크립트가 존재하지 않으므로, 운영 배포 전에 다음을 준비해야 한다:

  • Flyway 또는 Liquibase를 통한 마이그레이션 스크립트 작성 또는 수동 DDL 스크립트 준비
  • 대량 데이터가 존재하는 product 테이블의 경우 인덱스 생성으로 인한 테이블 락 발생 가능성 검토 (MySQL ALGORITHM=INPLACE, LOCK=NONE 옵션 활용)
  • 운영 환경에서 인덱스 생성 소요 시간 예측 및 배포 윈도우 확보
🤖 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/ProductEntity.java`
around lines 16 - 21, ProductEntity 클래스의 `@Table/`@Index(예:
idx_product_brand_like, idx_product_like, idx_product_brand_latest,
idx_product_latest) 어노테이션은 문서화 목적일 뿐 운영환경(jpa.ddl-auto: none)에서는 인덱스가 생성되지 않으므로,
Flyway 또는 Liquibase 마이그레이션 파일을 추가해 동일한 인덱스들을 생성하는 DDL을 배포에 포함시키고(예: CREATE INDEX
... ON product (...)), MySQL에서는 대용량 테이블 잠금 완화를 위해 인덱스 생성 DDL에 ALGORITHM=INPLACE,
LOCK=NONE 옵션을 적용하도록 하며(또는 롤링/오프라인 윈도우 계획), 스테이징에서 실행 시간/잠금 영향 테스트 후 운영 배포 윈도우를
확보해 적용하세요.

@NoArgsConstructor
public class ProductEntity extends BaseEntity {

Expand Down
36 changes: 36 additions & 0 deletions db/seed-products.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- ============================================================
-- 상품 더미 데이터 시드 스크립트 (10만 건)
-- 실행 전제: 앱을 먼저 실행해서 테이블이 생성된 상태여야 합니다.
-- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql
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

실행 예시에 비밀번호를 평문으로 노출하면 보안 운영 습관이 약화된다.

Line 4처럼 자격증명을 커맨드에 직접 넣으면 히스토리/문서 복사로 유출될 수 있다. 수정안은 비밀번호 프롬프트(-p)나 안전한 환경변수 방식을 안내하는 것이다. 추가 테스트로 저장소 전역에서 평문 패스워드 패턴(-p<password>)이 없는지 정적 검색을 권장한다.

수정 예시 diff
--- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql
+-- 실행 방법: mysql -u application -p loopers < db/seed-products.sql
📝 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
-- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql
-- 실행 방법: mysql -u application -p loopers < db/seed-products.sql
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/seed-products.sql` at line 4, 현재 db/seed-products.sql에 예시 명령어 "mysql -u
application -papplication loopers < db/seed-products.sql"처럼 평문 비밀번호가 노출되어 있으니 해당
예시를 수정해 평문 비밀번호를 제거하고 안전한 사용법을 안내하세요; 예: 권장 문구로 "mysql -u application -p loopers
< db/seed-products.sql" (프롬프트에서 비밀번호 입력) 또는 환경변수 사용("MYSQL_PWD" 또는 도커/CI 비밀 관리
방법)으로 대체하고, README나 주석에 이 방식과 함께 저장소 전역에서 평문 패스워드 패턴(-p<password>)이 있는지 정적 검색을
실행하도록(예: git grep -nE "-p[^[:space:]]") 권장하도록 문구를 추가하세요.

-- ============================================================

-- 브랜드 10개 삽입
INSERT INTO brand (name, description, created_at, updated_at)
VALUES
('나이키', '글로벌 스포츠 브랜드', NOW(), NOW()),
('아디다스', '독일 스포츠 브랜드', NOW(), NOW()),
('뉴발란스', '미국 스포츠 브랜드', NOW(), NOW()),
('컨버스', '캐주얼 스니커즈 브랜드', NOW(), NOW()),
('반스', '스케이트 브랜드', NOW(), NOW()),
('푸마', '독일 스포츠 브랜드', NOW(), NOW()),
('리복', '영국 스포츠 브랜드', NOW(), NOW()),
('언더아머', '미국 퍼포먼스 브랜드', NOW(), NOW()),
('살로몬', '아웃도어 브랜드', NOW(), NOW()),
('노스페이스','아웃도어 브랜드', NOW(), NOW());
Comment on lines +8 to +19
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

브랜드 ID를 1~10으로 가정하는 참조 방식은 FK 실패를 유발한다.

운영 관점에서 Line 27의 하드코딩 랜덤 ID는 기존 데이터가 있는 DB에서 즉시 실패하거나 잘못된 브랜드 연결을 만든다. 수정안은 실제 삽입/존재 브랜드의 id 집합에서 ref_brand_id를 선택하도록 바꾸는 방식이다. 추가 테스트로 브랜드 ID가 1부터 시작하지 않는 상태(예: 101~110)에서 스크립트를 실행해 FK 오류가 없는지와 product.ref_brand_id가 모두 brand.id에 존재하는지 검증해야 한다.

수정 예시 diff
 INSERT INTO brand (name, description, created_at, updated_at)
 VALUES
@@
     ('노스페이스','아웃도어 브랜드',        NOW(), NOW());

+CREATE TEMPORARY TABLE tmp_seed_brand_ids AS
+SELECT id
+FROM brand
+WHERE name IN (
+    '나이키','아디다스','뉴발란스','컨버스','반스',
+    '푸마','리복','언더아머','살로몬','노스페이스'
+);

 -- 상품 10만 건 삽입 (cross join으로 빠르게 생성)
 SET `@i` = 0;

 INSERT INTO product (name, ref_brand_id, price, stock, like_count, created_at, updated_at)
 SELECT
     CONCAT('상품_', `@i` := `@i` + 1),
-    FLOOR(1 + RAND() * 10),
+    (SELECT id FROM tmp_seed_brand_ids ORDER BY RAND() LIMIT 1),
     FLOOR(1 + RAND() * 100) * 1000,
@@
 LIMIT 100000;
+
+DROP TEMPORARY TABLE tmp_seed_brand_ids;

Also applies to: 24-36

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

In `@db/seed-products.sql` around lines 8 - 19, The seed assumes brand IDs 1–10
and uses a hardcoded random ID which breaks FK if existing brand IDs differ;
update the product insertion logic in db/seed-products.sql to pick ref_brand_id
from the actual brand.id set (e.g., SELECT id FROM brand ORDER BY ... LIMIT 1 or
use a subquery/ARRAY sampling) so product.ref_brand_id always references an
existing brand, and add a post-seed validation that every product.ref_brand_id
exists in brand.id to ensure no FK failures (check in the same script using
EXISTS/COUNT).


-- 상품 10만 건 삽입 (cross join으로 빠르게 생성)
SET @i = 0;

INSERT INTO product (name, ref_brand_id, price, stock, like_count, created_at, updated_at)
SELECT
CONCAT('상품_', @i := @i + 1),
FLOOR(1 + RAND() * 10),
FLOOR(1 + RAND() * 100) * 1000,
FLOOR(RAND() * 500),
FLOOR(RAND() * 1000),
DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY),
NOW()
FROM
information_schema.columns a,
information_schema.columns b
LIMIT 100000;
Loading