Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.loopers.application;

import org.springframework.data.domain.Page;

import java.util.List;
import java.util.function.Function;

public record PageResult<T>(
List<T> items,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageResult<T> from(Page<T> page) {
return new PageResult<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}

public <R> PageResult<R> map(Function<T, R> mapper) {
return new PageResult<>(
items.stream().map(mapper).toList(),
page,
size,
totalElements,
totalPages
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

import com.loopers.application.brand.BrandService;
import com.loopers.application.like.LikeService;
import lombok.RequiredArgsConstructor;
import com.loopers.application.PageResult;
import com.loopers.config.CacheConfig;
import org.springframework.data.domain.Page;
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.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -26,22 +31,28 @@ public ProductInfo register(ProductCreateCommand command) {
return productService.register(command);
}

@Cacheable(cacheNames = CacheConfig.PRODUCT, key = "#id")
public ProductInfo getActiveProduct(Long id) {
ProductInfo product = productService.getActiveProduct(id);
String brandName = brandService.getBrandNameMap(List.of(product.brand().id()))
.get(product.brand().id());
return product.withBrand(new ProductInfo.BrandSummary(product.brand().id(), brandName));
}

public Page<ProductInfo> getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) {
@Cacheable(cacheNames = CacheConfig.PRODUCTS, key = "#brandId + ':' + #sort + ':' + #pageable.pageNumber + ':' + #pageable.pageSize")
public PageResult<ProductInfo> getActiveProducts(Long brandId, ProductSort sort, Pageable pageable) {
Page<ProductInfo> products = productService.getActiveProducts(brandId, sort, pageable);
Set<Long> brandIds = products.stream().map(p -> p.brand().id()).collect(Collectors.toSet());
Map<Long, String> brandNameMap = brandService.getBrandNameMap(brandIds);
return products.map(p -> p.withBrand(
return PageResult.from(products.map(p -> p.withBrand(
new ProductInfo.BrandSummary(p.brand().id(), brandNameMap.getOrDefault(p.brand().id(), null))
));
)));
Comment on lines +42 to +49
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
# ProductFacade.java 전체 확인
find . -name "ProductFacade.java" -type f

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

Length of output: 169


🏁 Script executed:

#!/bin/bash
# CacheConfig 확인
find . -name "CacheConfig.java" -type f

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

Length of output: 154


🏁 Script executed:

#!/bin/bash
# ProductService.java 위치 확인
find . -name "ProductService.java" -type f

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

Length of output: 170


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

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

Length of output: 3123


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java

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

Length of output: 3372


🏁 Script executed:

cat -n ./apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java

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

Length of output: 7213


🏁 Script executed:

find . -path "*/test*" -name "*ProductFacade*Test*.java" -o -path "*/test*" -name "*Product*Integration*Test*.java" | grep -i test

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

Length of output: 173


🏁 Script executed:

find . -path "*/test*" -type f -name "*.java" | grep -i product | head -20

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

Length of output: 696


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheE2ETest.java

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

Length of output: 11092


🏁 Script executed:

wc -l ./apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java

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

Length of output: 177


🏁 Script executed:

cat -n ./apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java

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

Length of output: 7796


신규 상품 등록 뒤 목록 캐시를 비워야 한다.

현재 register() 경로는 PRODUCTS 캐시를 무효화하지 않아, 이미 캐시된 목록이 최대 10분 동안 새 상품을 누락한다. 운영 관점에서 관리자 등록 직후 사용자에게 신규 상품이 즉시 노출되어야 한다. 같은 클래스의 delete(), update() 메서드가 이미 @CacheEvict(cacheNames = CacheConfig.PRODUCTS, allEntries = true)를 사용하는 것과 일관되게 register() 메서드에도 동일한 어노테이션을 추가하고, 목록 캐시를 먼저 채운 뒤 상품을 등록했을 때 다음 조회에 새 상품이 즉시 포함되는 통합 테스트를 추가해 달라.

예시 수정안
+    `@CacheEvict`(cacheNames = CacheConfig.PRODUCTS, allEntries = true)
     `@Transactional`
     public ProductInfo register(ProductCreateCommand command) {
         brandService.getBrand(command.brandId());
         return productService.register(command);
     }
🤖 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 42 - 49, getActiveProducts is cached under CacheConfig.PRODUCTS but
register() does not evict that cache, so newly registered products can be hidden
until cache TTL expires; add `@CacheEvict`(cacheNames = CacheConfig.PRODUCTS,
allEntries = true) to the register(...) method in ProductFacade (matching
delete() and update()), and add an integration test that: 1) calls
getActiveProducts to prime the cache, 2) calls register(...) to create a new
product, and 3) calls getActiveProducts again asserting the new product is
present to verify the cache was invalidated.

}

@Caching(evict = {
@CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id"),
@CacheEvict(cacheNames = CacheConfig.PRODUCTS, allEntries = true)
})
@Transactional
public void delete(Long id) {
likeService.deleteAllByProductIds(List.of(id));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import com.loopers.domain.product.Product;
import com.loopers.domain.product.ProductRepository;
import com.loopers.application.order.OrderItemCommand;
import com.loopers.config.CacheConfig;
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.Caching;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -55,6 +58,10 @@ public Page<ProductInfo> getProducts(Long brandId, Pageable pageable) {
return productRepository.findAllProducts(brandId, pageable).map(ProductInfo::from);
}

@Caching(evict = {
@CacheEvict(cacheNames = CacheConfig.PRODUCT, key = "#id"),
@CacheEvict(cacheNames = CacheConfig.PRODUCTS, allEntries = true)
})
@Transactional
public ProductInfo update(Long id, ProductUpdateCommand command) {
Product product = findById(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.loopers.config;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.loopers.application.PageResult;
import com.loopers.application.product.ProductInfo;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@EnableCaching
@Configuration
public class CacheConfig {

public static final String PRODUCT = "product";
public static final String PRODUCTS = "products";

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

StringRedisSerializer keySerializer = new StringRedisSerializer();

RedisCacheConfiguration productConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new Jackson2JsonRedisSerializer<>(objectMapper, ProductInfo.class)
))
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues();

JavaType pageResultType = objectMapper.getTypeFactory()
.constructParametricType(PageResult.class, ProductInfo.class);
RedisCacheConfiguration productsConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new Jackson2JsonRedisSerializer<>(objectMapper, pageResultType)
))
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues();

return RedisCacheManager.builder(connectionFactory)
.withCacheConfiguration(PRODUCT, productConfig)
.withCacheConfiguration(PRODUCTS, productsConfig)
.build();
Comment on lines +54 to +57
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C2 'TransactionAwareCacheManagerProxy|transactionAware\s*\(' --type java
rg -n -C2 '@EnableCaching|@EnableTransactionManagement|order\s*=' --type java

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

Length of output: 67


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java

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

Length of output: 3372


🏁 Script executed:

rg -n 'evictProductListAfterCommit|@EventListener|TransactionalEventListener' --type java -A 5

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

Length of output: 67


🏁 Script executed:

rg -n 'spring-data-redis' pom.xml build.gradle.kts

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

Length of output: 132


🏁 Script executed:

rg -n 'class ProductService' --type java -A 30 | head -60

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

Length of output: 7480


🏁 Script executed:

wc -l apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java

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

Length of output: 172


🏁 Script executed:

cat apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java

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

Length of output: 6093


🏁 Script executed:

find . -name build.gradle.kts -o -name build.gradle | head -5

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

Length of output: 252


🏁 Script executed:

rg -n 'spring-data-redis' --type gradle --type kotlin

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

Length of output: 67


🏁 Script executed:

rg -n '@Cacheable|@CacheEvict|@CachePut' --type java -B 2 -A 3

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

Length of output: 5069


🏁 Script executed:

cat apps/commerce-api/build.gradle.kts | grep -A 50 "dependencies"

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

Length of output: 1077


🏁 Script executed:

cat apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductCacheE2ETest.java

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

Length of output: 9461


🏁 Script executed:

cat modules/redis/build.gradle.kts | grep -i "spring-data-redis"

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

Length of output: 67


🏁 Script executed:

cat build.gradle.kts | grep -i "spring"

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

Length of output: 957


🏁 Script executed:

rg -n 'spring.version|springBootVersion|redisVersion' build.gradle.kts

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

Length of output: 67


🏁 Script executed:

grep -r "springBootVersion\|spring-data-redis\|redisVersion" . --include="*.kts" --include="*.gradle" --include="*.properties" 2>/dev/null | head -20

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

Length of output: 294


🏁 Script executed:

cat gradle.properties

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

Length of output: 621


🏁 Script executed:

rg -n 'transactionAware\(\)' --type kotlin --type java

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

Length of output: 67


🏁 Script executed:

rg -n '@EventListener.*afterCommit|TransactionalEventListener' --type java -B 2 -A 5

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

Length of output: 67


🏁 Script executed:

cat modules/redis/build.gradle.kts

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

Length of output: 291


🏁 Script executed:

rg -n 'class ProductFacade' --type java -A 70 | head -100

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

Length of output: 13033


RedisCacheManager가 transaction-aware 설정이 필요하다.

현재 @CacheEvict는 트랜잭션 커밋 전에 캐시 제거를 수행하므로, 커밋 진행 중인 시간 윈도우에서 동시 조회가 들어오면 제거된 캐시 항목이 다시 채워질 수 있다. 특히 PRODUCT(TTL 30분)와 PRODUCTS(TTL 10분)의 TTL이 길어서 stale data가 오래 유지된다. RedisCacheManager.builder().transactionAware() 메서드를 추가하여 transaction commit 이후에만 eviction이 발동되도록 보장하거나, 그 방법이 불가능한 경우 TransactionAwareCacheManagerProxy로 래핑하여 같은 보장을 제공해야 한다.

이후 두 개의 병렬 스레드가 update/delete 도중 동시에 조회하는 시나리오에서 stale cache entry가 남지 않는지 검증하는 통합 테스트도 추가해 달라.

수정안
        return RedisCacheManager.builder(connectionFactory)
+               .transactionAware()
                .withCacheConfiguration(PRODUCT, productConfig)
                .withCacheConfiguration(PRODUCTS, productsConfig)
                .build();
📝 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
return RedisCacheManager.builder(connectionFactory)
.withCacheConfiguration(PRODUCT, productConfig)
.withCacheConfiguration(PRODUCTS, productsConfig)
.build();
return RedisCacheManager.builder(connectionFactory)
.transactionAware()
.withCacheConfiguration(PRODUCT, productConfig)
.withCacheConfiguration(PRODUCTS, productsConfig)
.build();
🤖 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/CacheConfig.java` around
lines 54 - 57, In CacheConfig (RedisCacheManager.builder usage), make the
RedisCacheManager transaction-aware so evictions occur after commit: update the
builder call that constructs the manager (the call that
.withCacheConfiguration(PRODUCT, productConfig).withCacheConfiguration(PRODUCTS,
productsConfig).build()) to include .transactionAware() before build(); if that
is not feasible in your environment, wrap the produced CacheManager in a
TransactionAwareCacheManagerProxy to ensure `@CacheEvict` happens post-commit.
After this change add an integration test that targets the PRODUCT and PRODUCTS
caches: run two parallel threads where one thread performs update/delete (inside
a transaction) and the other performs concurrent reads, then assert no stale
entries remain (cache entries are evicted only after commit).

}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.loopers.interfaces.api;

import com.loopers.application.PageResult;
import org.springframework.data.domain.Page;

import java.util.List;
Expand All @@ -20,4 +21,14 @@ public static <T> PageResponse<T> from(Page<T> page) {
page.getTotalPages()
);
}

public static <T> PageResponse<T> from(PageResult<T> result) {
return new PageResponse<>(
result.items(),
result.page(),
result.size(),
result.totalElements(),
result.totalPages()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.loopers.interfaces.api.product;

import com.loopers.application.PageResult;
import com.loopers.application.product.ProductFacade;
import com.loopers.application.product.ProductInfo;
import com.loopers.application.product.ProductSort;
import com.loopers.interfaces.api.ApiResponse;
import com.loopers.interfaces.api.PageResponse;
import com.loopers.interfaces.api.product.dto.ProductV1Dto;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down Expand Up @@ -35,9 +35,9 @@ public ApiResponse<PageResponse<ProductV1Dto.ProductResponse>> getProducts(
@RequestParam(defaultValue = "LATEST") ProductSort sort,
@PageableDefault(size = 20) Pageable pageable
) {
Page<ProductV1Dto.ProductResponse> page = productFacade.getActiveProducts(brandId, sort, pageable)
.map(ProductV1Dto.ProductResponse::from);
PageResult<ProductV1Dto.ProductResponse> result = productFacade.getActiveProducts(brandId, sort, pageable)
.map(ProductV1Dto.ProductResponse::from);

return ApiResponse.success(PageResponse.from(page));
return ApiResponse.success(PageResponse.from(result));
}
}
94 changes: 94 additions & 0 deletions apps/commerce-api/src/test/java/com/loopers/DataInitializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.loopers;

import com.loopers.domain.brand.Brand;
import com.loopers.domain.product.Product;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.transaction.support.TransactionTemplate;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;

@SpringBootTest
@ActiveProfiles("local")
public class DataInitializer {

// TestContainers(MySqlTestContainersConfig)가 System.setProperty로 DB URL을 덮어쓰므로
// DynamicPropertySource로 로컬 MySQL로 재설정 (우선순위: DynamicPropertySource > System.setProperty)
@DynamicPropertySource
static void overrideDataSource(DynamicPropertyRegistry registry) {
registry.add("datasource.mysql-jpa.main.jdbc-url", () -> "jdbc:mysql://localhost:3306/loopers");
registry.add("datasource.mysql-jpa.main.username", () -> "application");
registry.add("datasource.mysql-jpa.main.password", () -> "application");
}
Comment on lines +27 to +31
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

DB 자격증명 하드코딩은 보안 및 환경 이식성 문제를 야기한다.

localhost:3306, application/application 자격증명이 코드에 직접 노출되어 있다. 테스트 코드라 하더라도 Git 히스토리에 남으며, 개발자마다 로컬 환경이 다를 경우 매번 수정이 필요하다.

🛠️ 환경변수 또는 시스템 프로퍼티 활용 수정안
     `@DynamicPropertySource`
     static void overrideDataSource(DynamicPropertyRegistry registry) {
-        registry.add("datasource.mysql-jpa.main.jdbc-url", () -> "jdbc:mysql://localhost:3306/loopers");
-        registry.add("datasource.mysql-jpa.main.username", () -> "application");
-        registry.add("datasource.mysql-jpa.main.password", () -> "application");
+        registry.add("datasource.mysql-jpa.main.jdbc-url",
+            () -> System.getenv().getOrDefault("TEST_DB_URL", "jdbc:mysql://localhost:3306/loopers"));
+        registry.add("datasource.mysql-jpa.main.username",
+            () -> System.getenv().getOrDefault("TEST_DB_USERNAME", "application"));
+        registry.add("datasource.mysql-jpa.main.password",
+            () -> System.getenv().getOrDefault("TEST_DB_PASSWORD", "application"));
     }

또는 application-local.yml에 테스트 전용 프로파일 설정을 두고 참조하는 방식도 고려할 수 있다.

🤖 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/DataInitializer.java` around
lines 27 - 31, The overrideDataSource method currently hardcodes DB credentials;
change it to read JDBC URL, username, and password from environment variables or
system properties (e.g., System.getenv("TEST_DB_URL") /
System.getProperty("test.db.url")) with sensible defaults or fallbacks, and
register those values into the DynamicPropertyRegistry inside
overrideDataSource; alternatively make overrideDataSource load from a test
profile (application-local.yml) by referencing spring test properties so
credentials aren't stored in code (update references in overrideDataSource and
any tests that rely on it).


@PersistenceContext
EntityManager entityManager;

@Autowired
TransactionTemplate transactionTemplate;

@Test
void initialize() {
Comment on lines +39 to +40
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

@Disabled 어노테이션 누락으로 테스트 스위트 실행 시 의도치 않은 데이터 적재 위험이 있다.

이 클래스는 성능 테스트용 데이터 시딩 유틸리티이나, @Test 어노테이션만 있고 @Disabled가 없어 mvn test 또는 CI 파이프라인에서 전체 테스트 실행 시 자동으로 수행된다. 약 20만 건의 상품 데이터가 예기치 않게 적재되어 다른 테스트 결과에 영향을 주거나, 로컬 DB 상태를 오염시킬 수 있다.

🛠️ 수정안
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Disabled;
 import org.springframework.beans.factory.annotation.Autowired;
+    `@Disabled`("성능 테스트 데이터 시딩 전용 - 수동 실행 필요")
     `@Test`
     void initialize() {

추가 검증: CI 파이프라인에서 이 테스트가 실행되지 않는지 확인이 필요하다.

📝 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
@Test
void initialize() {
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
`@Disabled`("성능 테스트 데이터 시딩 전용 - 수동 실행 필요")
`@Test`
void initialize() {
🤖 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/DataInitializer.java` around
lines 39 - 40, Mark this performance-data seeding test so it won't run in CI by
adding the JUnit disable annotation to DataInitializer (either on the class or
the initialize() method); import org.junit.jupiter.api.Disabled and annotate the
DataInitializer class or the initialize() method with `@Disabled` and include a
short comment explaining it's a manual/perf-only seeder, then verify CI excludes
it from test runs.

List<Long> brandIds = saveBrands(50);

for (int i = 0; i < brandIds.size(); i++) {
Long brandId = brandIds.get(i);

boolean isTopBrand = i < 10; // 앞 10개 브랜드만 top
int productCount = isTopBrand ? 10_000 : 2_500;
int likeMin = isTopBrand ? 300 : 0;
int likeMax = isTopBrand ? 3_000 : 2_000;

insertProducts(brandId, productCount);
updateLikeCount(brandId, likeMin, likeMax);
}
}

List<Long> saveBrands(int count) {
return transactionTemplate.execute(status -> {
List<Long> ids = new ArrayList<>();
for (int i = 1; i <= count; i++) {
Brand brand = Brand.create("브랜드-" + i, null);
entityManager.persist(brand);
entityManager.flush();
ids.add(brand.getId());
}

return ids;
});
}

void insertProducts(Long brandId, int count) {
transactionTemplate.executeWithoutResult(status -> {
for (int i = 0; i < count; i++) {
int price = ThreadLocalRandom.current().nextInt(1_000, 100_001);
entityManager.persist(Product.create(brandId,
"상품_" + UUID.randomUUID().toString()
.substring(0, 8), null, price, 100));
if (i % 500 == 499) {
entityManager.flush();
entityManager.clear();
}
}
});
}

void updateLikeCount(Long brandId, int min, int max) {
transactionTemplate.executeWithoutResult(status ->
entityManager.createNativeQuery("UPDATE products SET like_count = FLOOR(:min + RAND() * (:max - :min + 1)) WHERE brand_id = :brandId")
.setParameter("min", min)
.setParameter("max", max)
.setParameter("brandId", brandId)
.executeUpdate()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
import com.loopers.domain.brand.InMemoryBrandRepository;
import com.loopers.domain.like.InMemoryLikeRepository;
import com.loopers.domain.product.InMemoryProductRepository;
import com.loopers.application.PageResult;
import com.loopers.support.error.CoreException;
import com.loopers.support.error.ErrorType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;

import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -68,11 +68,11 @@ void returnsBrandName_forEachProduct() {
productService.register(new ProductCreateCommand(adidas.id(), "슈퍼스타", "신발", 120000, 8));

// act
Page<ProductInfo> result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20));
PageResult<ProductInfo> result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20));

// assert
assertAll(
() -> assertThat(result.getContent()).extracting(p -> p.brand().name())
() -> assertThat(result.items()).extracting(p -> p.brand().name())
.containsExactlyInAnyOrder("나이키", "아디다스")
);
}
Expand All @@ -86,10 +86,10 @@ void returnsSameBrandName_whenMultipleProductsOfSameBrand() {
productService.register(new ProductCreateCommand(brand.id(), "조던", "농구화", 200000, 5));

// act
Page<ProductInfo> result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20));
PageResult<ProductInfo> result = productFacade.getActiveProducts(null, ProductSort.LATEST, PageRequest.of(0, 20));

// assert
assertThat(result.getContent()).extracting(p -> p.brand().name())
assertThat(result.items()).extracting(p -> p.brand().name())
.containsOnly("나이키");
}
}
Expand Down
Loading