diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java index 4a1e43a23..f2e6b5bfe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java @@ -1,21 +1,23 @@ package com.loopers.application.product; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.application.catalog.ProductInfo; import com.loopers.application.catalog.ProductInfoList; +import com.loopers.cache.CacheKey; +import com.loopers.cache.CacheTemplate; +import com.loopers.cache.SimpleCacheKey; import com.loopers.domain.product.ProductDetail; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.List; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** - * 상품 조회 결과를 Redis에 캐시하는 서비스. + * 상품 조회 결과를 캐시하는 서비스. *

* 상품 목록 조회와 상품 상세 조회 결과를 캐시하여 성능을 향상시킵니다. *

@@ -29,6 +31,7 @@ * * @author Loopers */ +@Slf4j @Service @RequiredArgsConstructor public class ProductCacheService { @@ -37,8 +40,7 @@ public class ProductCacheService { private static final String CACHE_KEY_PREFIX_DETAIL = "product:detail:"; private static final Duration CACHE_TTL = Duration.ofMinutes(1); // 1분 TTL - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper; + private final CacheTemplate cacheTemplate; /** * 로컬 캐시: 상품별 좋아요 수 델타 (productId -> likeCount delta) @@ -66,21 +68,20 @@ public class ProductCacheService { * @return 캐시된 상품 목록 (없으면 null) */ public ProductInfoList getCachedProductList(Long brandId, String sort, int page, int size) { - try { - String key = buildListCacheKey(brandId, sort, page, size); - String cachedValue = redisTemplate.opsForValue().get(key); - - if (cachedValue == null) { - return null; - } - - ProductInfoList cachedList = objectMapper.readValue(cachedValue, new TypeReference() {}); - - // 로컬 캐시의 좋아요 수 델타 적용 - return applyLikeCountDelta(cachedList); - } catch (Exception e) { + String cacheKey = buildListCacheKey(brandId, sort, page, size); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfoList.class + ); + + Optional cached = cacheTemplate.get(key); + if (cached.isEmpty()) { return null; } + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cached.get()); } /** @@ -100,14 +101,15 @@ public void cacheProductList(Long brandId, String sort, int page, int size, Prod if (page > 2) { return; } - - try { - String key = buildListCacheKey(brandId, sort, page, size); - String value = objectMapper.writeValueAsString(productInfoList); - redisTemplate.opsForValue().set(key, value, CACHE_TTL); - } catch (Exception e) { - // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) - } + + String cacheKey = buildListCacheKey(brandId, sort, page, size); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfoList.class + ); + + cacheTemplate.put(key, productInfoList); } /** @@ -120,21 +122,20 @@ public void cacheProductList(Long brandId, String sort, int page, int size, Prod * @return 캐시된 상품 정보 (없으면 null) */ public ProductInfo getCachedProduct(Long productId) { - try { - String key = buildDetailCacheKey(productId); - String cachedValue = redisTemplate.opsForValue().get(key); - - if (cachedValue == null) { - return null; - } - - ProductInfo cachedInfo = objectMapper.readValue(cachedValue, new TypeReference() {}); - - // 로컬 캐시의 좋아요 수 델타 적용 - return applyLikeCountDelta(cachedInfo); - } catch (Exception e) { + String cacheKey = buildDetailCacheKey(productId); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfo.class + ); + + Optional cached = cacheTemplate.get(key); + if (cached.isEmpty()) { return null; } + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cached.get()); } /** @@ -144,13 +145,14 @@ public ProductInfo getCachedProduct(Long productId) { * @param productInfo 캐시할 상품 정보 */ public void cacheProduct(Long productId, ProductInfo productInfo) { - try { - String key = buildDetailCacheKey(productId); - String value = objectMapper.writeValueAsString(productInfo); - redisTemplate.opsForValue().set(key, value, CACHE_TTL); - } catch (Exception e) { - // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) - } + String cacheKey = buildDetailCacheKey(productId); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfo.class + ); + + cacheTemplate.put(key, productInfo); } /** @@ -164,7 +166,6 @@ public void cacheProduct(Long productId, ProductInfo productInfo) { */ private String buildListCacheKey(Long brandId, String sort, int page, int size) { String brandPart = brandId != null ? "brand:" + brandId : "brand:all"; - // sort가 null이면 기본값 "latest" 사용 (컨트롤러와 동일한 기본값) String sortValue = sort != null ? sort : "latest"; return String.format("%s%s:sort:%s:page:%d:size:%d", CACHE_KEY_PREFIX_LIST, brandPart, sortValue, page, size); diff --git a/modules/redis/build.gradle.kts b/modules/redis/build.gradle.kts index 37ad4f6dd..86aa2a8a6 100644 --- a/modules/redis/build.gradle.kts +++ b/modules/redis/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { api("org.springframework.boot:spring-boot-starter-data-redis") + api("com.fasterxml.jackson.core:jackson-databind") testFixturesImplementation("com.redis:testcontainers-redis") } diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKey.java b/modules/redis/src/main/java/com/loopers/cache/CacheKey.java new file mode 100644 index 000000000..2de210ea1 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheKey.java @@ -0,0 +1,41 @@ +package com.loopers.cache; + +import java.time.Duration; + +/** + * 캐시 키 인터페이스. + *

+ * 캐시 키는 해당 인터페이스를 기반으로 구현되어야 합니다. + *

+ * + * @param 캐시 값의 타입 + * @author Loopers + * @version 1.0 + */ +public interface CacheKey { + + /** + * 캐시 키를 반환합니다. + * + * @return 캐시 키 문자열 + */ + String key(); + + /** + * 캐시 TTL (Time To Live)을 반환합니다. + * + * @return TTL + */ + Duration ttl(); + + /** + * 캐시 값의 타입을 반환합니다. + *

+ * 역직렬화 시 사용됩니다. + *

+ * + * @return 타입 + */ + Class type(); +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java b/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java new file mode 100644 index 000000000..db71b635e --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java @@ -0,0 +1,15 @@ +package com.loopers.cache; + +/** + * 캐시 직렬화/역직렬화 예외. + * + * @author Loopers + * @version 1.0 + */ +public class CacheSerializationException extends RuntimeException { + + public CacheSerializationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java b/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java new file mode 100644 index 000000000..2757dbe8e --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java @@ -0,0 +1,55 @@ +package com.loopers.cache; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * 캐시 템플릿 인터페이스. + *

+ * 캐시 조회, 저장, 삭제 등의 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CacheTemplate { + + /** + * 캐시에서 값을 조회합니다. + * + * @param cacheKey 캐시 키 + * @param 캐시 값의 타입 + * @return 캐시 값 (Optional) + */ + Optional get(CacheKey cacheKey); + + /** + * 캐시에 값을 저장합니다. + * + * @param cacheKey 캐시 키 + * @param value 저장할 값 + * @param 캐시 값의 타입 + */ + void put(CacheKey cacheKey, T value); + + /** + * 캐시를 무효화합니다. + * + * @param cacheKey 캐시 키 + */ + void evict(CacheKey cacheKey); + + /** + * 캐시에서 값을 조회하고, 없으면 로더를 실행하여 값을 가져온 후 캐시에 저장합니다. + *

+ * Cache-Aside 패턴을 구현합니다. + *

+ * + * @param cacheKey 캐시 키 + * @param loader 캐시에 값이 없을 때 실행할 로더 + * @param 캐시 값의 타입 + * @return 캐시 값 또는 로더로부터 가져온 값 + */ + T getOrLoad(CacheKey cacheKey, Supplier loader); +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java b/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java new file mode 100644 index 000000000..0cfeb2bee --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java @@ -0,0 +1,95 @@ +package com.loopers.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Redis 캐시 템플릿 구현체. + *

+ * Redis를 사용하여 캐시를 구현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisCacheTemplate implements CacheTemplate { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional get(CacheKey cacheKey) { + try { + String json = redisTemplate.opsForValue().get(cacheKey.key()); + if (json == null) { + return Optional.empty(); + } + T value = deserialize(json, cacheKey.type()); + return Optional.ofNullable(value); + } catch (Exception e) { + log.warn("캐시 조회 실패. (key: {})", cacheKey.key(), e); + return Optional.empty(); + } + } + + @Override + public void put(CacheKey cacheKey, T value) { + try { + String json = serialize(value); + redisTemplate.opsForValue().set(cacheKey.key(), json, cacheKey.ttl()); + } catch (Exception e) { + log.warn("캐시 저장 실패. (key: {})", cacheKey.key(), e); + // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) + } + } + + @Override + public void evict(CacheKey cacheKey) { + try { + redisTemplate.delete(cacheKey.key()); + } catch (Exception e) { + log.warn("캐시 삭제 실패. (key: {})", cacheKey.key(), e); + } + } + + @Override + public T getOrLoad(CacheKey cacheKey, Supplier loader) { + Optional cached = get(cacheKey); + if (cached.isPresent()) { + return cached.get(); + } + + T value = loader.get(); + if (value != null) { + put(cacheKey, value); + } + return value; + } + + private String serialize(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new CacheSerializationException("캐시 직렬화 실패", e); + } + } + + private T deserialize(String json, Class type) { + try { + return objectMapper.readValue(json, type); + } catch (JsonProcessingException e) { + throw new CacheSerializationException("캐시 역직렬화 실패", e); + } + } +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java b/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java new file mode 100644 index 000000000..28e2c81d9 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java @@ -0,0 +1,34 @@ +package com.loopers.cache; + +import java.time.Duration; + +/** + * 간단한 캐시 키 구현체. + *

+ * 기본적인 캐시 키를 생성할 때 사용합니다. + *

+ * + * @param 캐시 값의 타입 + * @author Loopers + * @version 1.0 + */ +public record SimpleCacheKey( + String key, + Duration ttl, + Class type +) implements CacheKey { + + /** + * 캐시 키를 생성합니다. + * + * @param key 캐시 키 문자열 + * @param ttl TTL + * @param type 캐시 값의 타입 + * @param 캐시 값의 타입 + * @return 캐시 키 + */ + public static SimpleCacheKey of(String key, Duration ttl, Class type) { + return new SimpleCacheKey<>(key, ttl, type); + } +} + diff --git a/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java b/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java new file mode 100644 index 000000000..77ca2f29a --- /dev/null +++ b/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java @@ -0,0 +1,37 @@ +package com.loopers.cache; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * 테스트용 No-Op 캐시 템플릿. + *

+ * 테스트에서 캐시를 사용하지 않을 때 사용합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public class NoOpCacheTemplate implements CacheTemplate { + + @Override + public Optional get(CacheKey cacheKey) { + return Optional.empty(); + } + + @Override + public void put(CacheKey cacheKey, T value) { + // No-op + } + + @Override + public void evict(CacheKey cacheKey) { + // No-op + } + + @Override + public T getOrLoad(CacheKey cacheKey, Supplier loader) { + return loader.get(); + } +} +