-
Notifications
You must be signed in to change notification settings - Fork 44
Round5 cache #222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: hey-sion
Are you sure you want to change the base?
Round5 cache #222
Changes from all commits
378b3f9
89d8ea6
a0ded2b
4698899
59c0d7d
0bae0f6
88b8e10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 javaRepository: 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.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 3372 🏁 Script executed: rg -n 'evictProductListAfterCommit|@EventListener|TransactionalEventListener' --type java -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: rg -n 'spring-data-redis' pom.xml build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 132 🏁 Script executed: rg -n 'class ProductService' --type java -A 30 | head -60Repository: 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.javaRepository: 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.javaRepository: 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 -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 252 🏁 Script executed: rg -n 'spring-data-redis' --type gradle --type kotlinRepository: 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 3Repository: 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.javaRepository: 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.ktsRepository: 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 -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 294 🏁 Script executed: cat gradle.propertiesRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 621 🏁 Script executed: rg -n 'transactionAware\(\)' --type kotlin --type javaRepository: 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 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🏁 Script executed: cat modules/redis/build.gradle.ktsRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 291 🏁 Script executed: rg -n 'class ProductFacade' --type java -A 70 | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 13033 RedisCacheManager가 transaction-aware 설정이 필요하다. 현재 이후 두 개의 병렬 스레드가 update/delete 도중 동시에 조회하는 시나리오에서 stale cache entry가 남지 않는지 검증하는 통합 테스트도 추가해 달라. 수정안 return RedisCacheManager.builder(connectionFactory)
+ .transactionAware()
.withCacheConfiguration(PRODUCT, productConfig)
.withCacheConfiguration(PRODUCTS, productsConfig)
.build();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DB 자격증명 하드코딩은 보안 및 환경 이식성 문제를 야기한다.
🛠️ 환경변수 또는 시스템 프로퍼티 활용 수정안 `@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"));
}또는 🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| @PersistenceContext | ||||||||||||||||||||
| EntityManager entityManager; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| @Autowired | ||||||||||||||||||||
| TransactionTemplate transactionTemplate; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| @Test | ||||||||||||||||||||
| void initialize() { | ||||||||||||||||||||
|
Comment on lines
+39
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
이 클래스는 성능 테스트용 데이터 시딩 유틸리티이나, 🛠️ 수정안 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| 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() | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 169
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 154
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 170
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3123
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3372
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 7213
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 173
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 696
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 11092
🏁 Script executed:
Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 177
🏁 Script executed:
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