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();
+ }
+}
+