Skip to content
Merged
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
@@ -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에 캐시하는 서비스.
* 상품 조회 결과를 캐시하는 서비스.
* <p>
* 상품 목록 조회와 상품 상세 조회 결과를 캐시하여 성능을 향상시킵니다.
* </p>
Expand All @@ -29,6 +31,7 @@
*
* @author Loopers
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductCacheService {
Expand All @@ -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<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private final CacheTemplate cacheTemplate;

/**
* 로컬 캐시: 상품별 좋아요 수 델타 (productId -> likeCount delta)
Expand Down Expand Up @@ -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<ProductInfoList>() {});

// 로컬 캐시의 좋아요 수 델타 적용
return applyLikeCountDelta(cachedList);
} catch (Exception e) {
String cacheKey = buildListCacheKey(brandId, sort, page, size);
CacheKey<ProductInfoList> key = SimpleCacheKey.of(
cacheKey,
CACHE_TTL,
ProductInfoList.class
);

Optional<ProductInfoList> cached = cacheTemplate.get(key);
if (cached.isEmpty()) {
return null;
}

// 로컬 캐시의 좋아요 수 델타 적용
return applyLikeCountDelta(cached.get());
}

/**
Expand All @@ -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<ProductInfoList> key = SimpleCacheKey.of(
cacheKey,
CACHE_TTL,
ProductInfoList.class
);

cacheTemplate.put(key, productInfoList);
}

/**
Expand All @@ -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<ProductInfo>() {});

// 로컬 캐시의 좋아요 수 델타 적용
return applyLikeCountDelta(cachedInfo);
} catch (Exception e) {
String cacheKey = buildDetailCacheKey(productId);
CacheKey<ProductInfo> key = SimpleCacheKey.of(
cacheKey,
CACHE_TTL,
ProductInfo.class
);

Optional<ProductInfo> cached = cacheTemplate.get(key);
if (cached.isEmpty()) {
return null;
}

// 로컬 캐시의 좋아요 수 델타 적용
return applyLikeCountDelta(cached.get());
}

/**
Expand All @@ -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<ProductInfo> key = SimpleCacheKey.of(
cacheKey,
CACHE_TTL,
ProductInfo.class
);

cacheTemplate.put(key, productInfo);
}

/**
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions modules/redis/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
41 changes: 41 additions & 0 deletions modules/redis/src/main/java/com/loopers/cache/CacheKey.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.loopers.cache;

import java.time.Duration;

/**
* 캐시 키 인터페이스.
* <p>
* 캐시 키는 해당 인터페이스를 기반으로 구현되어야 합니다.
* </p>
*
* @param <T> 캐시 값의 타입
* @author Loopers
* @version 1.0
*/
public interface CacheKey<T> {

/**
* 캐시 키를 반환합니다.
*
* @return 캐시 키 문자열
*/
String key();

/**
* 캐시 TTL (Time To Live)을 반환합니다.
*
* @return TTL
*/
Duration ttl();

/**
* 캐시 값의 타입을 반환합니다.
* <p>
* 역직렬화 시 사용됩니다.
* </p>
*
* @return 타입
*/
Class<T> type();
}

Original file line number Diff line number Diff line change
@@ -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);
}
}

55 changes: 55 additions & 0 deletions modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.loopers.cache;

import java.util.Optional;
import java.util.function.Supplier;

/**
* 캐시 템플릿 인터페이스.
* <p>
* 캐시 조회, 저장, 삭제 등의 기능을 제공합니다.
* </p>
*
* @author Loopers
* @version 1.0
*/
public interface CacheTemplate {

/**
* 캐시에서 값을 조회합니다.
*
* @param cacheKey 캐시 키
* @param <T> 캐시 값의 타입
* @return 캐시 값 (Optional)
*/
<T> Optional<T> get(CacheKey<T> cacheKey);

/**
* 캐시에 값을 저장합니다.
*
* @param cacheKey 캐시 키
* @param value 저장할 값
* @param <T> 캐시 값의 타입
*/
<T> void put(CacheKey<T> cacheKey, T value);

/**
* 캐시를 무효화합니다.
*
* @param cacheKey 캐시 키
*/
void evict(CacheKey<?> cacheKey);

/**
* 캐시에서 값을 조회하고, 없으면 로더를 실행하여 값을 가져온 후 캐시에 저장합니다.
* <p>
* Cache-Aside 패턴을 구현합니다.
* </p>
*
* @param cacheKey 캐시 키
* @param loader 캐시에 값이 없을 때 실행할 로더
* @param <T> 캐시 값의 타입
* @return 캐시 값 또는 로더로부터 가져온 값
*/
<T> T getOrLoad(CacheKey<T> cacheKey, Supplier<T> loader);
}

Original file line number Diff line number Diff line change
@@ -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 캐시 템플릿 구현체.
* <p>
* Redis를 사용하여 캐시를 구현합니다.
* </p>
*
* @author Loopers
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisCacheTemplate implements CacheTemplate {

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

@Override
public <T> Optional<T> get(CacheKey<T> 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 <T> void put(CacheKey<T> 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> T getOrLoad(CacheKey<T> cacheKey, Supplier<T> loader) {
Optional<T> 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> T deserialize(String json, Class<T> type) {
try {
return objectMapper.readValue(json, type);
} catch (JsonProcessingException e) {
throw new CacheSerializationException("캐시 역직렬화 실패", e);
}
}
}

Loading