Redis를 사용해 API Response 캐시화하기(with. Spring Boot) - 1
개요
실무를 진행하며 개발환경에서 나타나지 않았던 문제들이 실제 운영에서는 많이 발생합니다.
특히 개발환경에서 트래픽이 몰리거나 스트레스 테스트를 진행하기에는 많은 어려움들이 있었습니다.
실제 운영 중에 트래픽이 몰렸을 때 DB connection 문제로 API의 레이턴시가 늘어나는 현상이 나타났었습니다...
이를 방지하고자 DB 환경이나 서버 환경을 스케일 업, 스케일 아웃할 수 있겠지만 백엔드로써 서버에 의존하지 말고 줄일 수 있는 부분은 모두 줄여보자 하는 생각이었습니다.
그래서 ElasticCache Redis를 통해 데이터를 캐시 화하기로 마음을 먹었습니다.
1. 데이터 캐시화
첫 번째로 생각한 것이 대용량의 데이터 자체를 캐시화 하기였습니다.
데이터 자체를 캐시화 한다면 연결된 데이터들도 구조에 맞게 설계해야 하고, 실제 데이터와 동기화가 되었는지, 동기화를 어디마다 해줘야 하는지 구멍 없이 잘 설정해주어야 하는데 해당 데이터가 여러 곳에서 사용되고 있고 로직이 파악하기 좋게 잘 정리된 상태가 아니어서 리스크가 어느 정도 존재하고 그만큼 적용하는 시간이 오래 걸릴 것이라 판단했습니다.
2. API 캐시화
그래서 생각한 것이 API 자체를 캐시 화하자! 였습니다.
API Response를 캐시화 한다면 데이터 구조와 상관없이 캐시화가 가능하다고 생각했고 정적이지 않은 데이터만 잘 동기화를 해준다면 리스크를 줄일 수 있고 간단하게 적용할 수 있다고 생각이 들었습니다.
머릿속으로는 떠오른 것은 어노테이션과 AOP를 통해 API를 호출하였을 때 캐시를 조회하거나 캐시가 없다면 저장하는 구조로 설계하면 어떨까.. 하며 구현을 시작했습니다.
3. 코드
RedisConfig
RedisTemplate 의존성 주입을 위한 Config 코드입니다. 서비스에 맞게 설정하시면 될 것 같습니다.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
CacheType, CacheBundleType
저장하고 삭제할 캐시의 유형입니다.
만약 캐시 저장에 key값과 삭제할 캐시의 key이 다르다면 캐시 데이터가 동기화되지 않을 수 있으므로 최대한 휴먼에러를 줄이기 위해 Enum화 하였습니다.
CacheBundleType enum 같은 경우에는 여러 캐시들을 한 번에 선언으로 삭제할 수 있도록 CacheType을 묶어놓았습니다.
@Getter
@AllArgsConstructor
@RequiredArgsConstructor
public enum CacheType {
HOME_BANNER("HOME_BANNER"), // 홈 베너
NOTICE_LIST("NOTICE_LIST"), // 공지사항 목록
FAQ_LIST("FAQ_LIST"), // 자주묻는 질문 목록
...
private final String key;
}
@Getter
@AllArgsConstructor
public enum CacheBundleType {
CONTENTS_LIST_CACHE(Set.of(
CacheType.NOTICE_LIST,
CacheType.FAQ_LIST,
...
));
private final Set<CacheType> cacheTypes;
}
@SaveCache
캐시 저장을 위한 SaveCache 어노테이션입니다.
@CacheHashKey 어노테이션으로 캐시의 hashKey를 설정할 수 있는데, 이때 expression를 통해 메서드에서 특정한 값을 추출할 수 있습니다.
캐시의 defulat timout은 1일로 설정되어 있습니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SaveCache {
CacheType type();
CacheHashKey[] hashKey() default {};
int timeout() default 1;
TimeUnit timeUnit() default TimeUnit.DAYS;
}
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheHashKey {
String name();
String expression();
}
@deleteCache
캐시 삭제을 위한 DeleteCache 어노테이션입니다.
마찬가지로 @CacheHashKey 어노테이션으로 캐시의 HashKey를 설정해 원하는 HashKey의 캐시만 삭제가 가능하도록 구현하였습니다.
그리고 한 메서드에 여러 유형의 캐시를 삭제할 수도 있기 때문에 @Repeatable를 이용해 중복 선언이 가능하도록 하였습니다.
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(value = DeleteCaches.class)
public @interface DeleteCache {
CacheType[] type() default {};
CacheBundleType[] bundleType() default {};
CacheHashKey[] hashKey() default {};
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DeleteCaches {
DeleteCache[] value();
}
CacheService
실질적으로 캐시를 저장하거나 삭제하는 로직이 존재하는 클래스입니다.
@Service
@RequiredArgsConstructor
public class CacheService {
@Getter
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private ValueOperations<String, String> operations;
@PostConstruct
public void init() {
this.operations = redisTemplate.opsForValue();
}
private static final String SPLIT_TEXT = ":@:";
private String createCacheKey(CacheType cacheType, Map<String, Object> hashKey) throws JsonProcessingException {
return String.format("%s%s%s", cacheType.getKey(), SPLIT_TEXT, hashKey == null || hashKey.isEmpty() ?
"" : objectMapper.writeValueAsString(hashKey));
}
private Map<String, Object> extractHashKey(String cacheKey) throws JsonProcessingException {
String[] cacheKeySplit = cacheKey.split(SPLIT_TEXT);
String hashKeyString = cacheKeySplit[cacheKeySplit.length - 1];
if (StringUtils.isNotBlank(hashKeyString))
return objectMapper.readValue(hashKeyString, new TypeReference<>(){});
else
return new HashMap<>();
}
public void saveCache(CacheType cacheType, Map<String, Object> hashKey, Object value, Integer timeout, TimeUnit timeUnit) {
try {
String cacheKey = createCacheKey(cacheType, hashKey);
String valueString = value != null ? objectMapper.writeValueAsString(value) : "";
if (Optional.ofNullable(timeout).filter(t -> t > 0).isPresent() && Optional.ofNullable(timeUnit).isPresent()) {
operations.set(cacheKey, valueString, timeout, timeUnit);
} else {
operations.set(cacheKey, valueString);
}
} catch (JsonProcessingException ignored) {
}
}
public void saveCache(CacheType cacheType, Map<String, Object> hashKey, Object value) {
this.saveCache(cacheType, hashKey, value, null, null);
}
public void saveCache(CacheType cacheType, Object value, Integer timeout, TimeUnit timeUnit) {
this.saveCache(cacheType, null, value, timeout, timeUnit);
}
public void saveCache(CacheType cacheType, Object value) {
this.saveCache(cacheType, null, value, null, null);
}
public String getCache(CacheType cacheType, Map<String, Object> hashKey) {
try {
String cacheKey = createCacheKey(cacheType, hashKey);
return operations.get(cacheKey);
} catch (JsonProcessingException e) {
return null;
}
}
public <T> T getCache(CacheType cacheType, Map<String, Object> hashKey, Class<T> returnClass) {
try {
String cache = getCache(cacheType, hashKey);
return StringUtils.isNotBlank(cache) ? objectMapper.readValue(cache, returnClass) : null;
} catch (JsonProcessingException e) {
e.printStackTrace();
return null;
}
}
public <T> T getCache(CacheType cacheType, Class<T> returnClass) {
return getCache(cacheType, null, returnClass);
}
public void deleteCache(CacheType cacheType, Map<String, Object> hashKey) {
try {
Set<String> cacheKeys = redisTemplate.keys(this.createCacheKey(cacheType, null) + "*");
if (cacheKeys == null) return;
Set<String> deleteTargetCacheKey = new HashSet<>();
if (hashKey == null || hashKey.isEmpty()) {
deleteTargetCacheKey = cacheKeys;
} else {
for (String cacheKey : cacheKeys) {
Map<String, Object> cacheHashKey = extractHashKey(cacheKey);
if (cacheHashKey.isEmpty()) {
deleteTargetCacheKey.add(cacheKey);
} else {
if (hashKey.keySet().stream()
.filter(key -> Objects.nonNull(cacheHashKey.get(key)))
.allMatch(key -> String.valueOf(hashKey.get(key)).equals(String.valueOf(cacheHashKey.get(key))))) {
deleteTargetCacheKey.add(cacheKey);
}
}
}
}
if (!deleteTargetCacheKey.isEmpty()) redisTemplate.delete(deleteTargetCacheKey);
} catch (JsonProcessingException ignored) {
}
}
public void deleteCache(CacheType cacheType) {
this.deleteCache(cacheType, null);
}
public void deleteCache(Set<CacheType> cacheTypes, Map<String, Object> hashKey) {
cacheTypes.forEach(cacheType -> this.deleteCache(cacheType, hashKey));
}
public void deleteCache(Set<CacheType> cacheTypes) {
this.deleteCache(cacheTypes, null);
}
public void clearCache() {
redisTemplate.execute((RedisCallback) connection -> {
connection.flushAll();
return null;
});
}
}
hashKey를 사용할 거면 HashOperations을 사용하면 되지 않을까?라는 생각이 들었지만 HashOperations에는 timeout을 지정해 주는 메서드가 없었습니다.
찾아보니 RedisTemplate에 timout을 지정할 수 있는 expire 메서드가 있었는데 키 별로만 유효기간을 설정할 수 있었습니다.
hashKey 별로 유효기간을 지정하고 원하는 hashKey를 필터링해 부분 캐시 삭제할 수 있었으면 했습니다.
그래서 ValueOperations을 통해 내부적으로 HashOperations과 비슷하게 구현하기로 하였습니다.
(다른 좋은 방법이 있다면 제발 알려주세요..)
그럼 cacheService에 메서드들을 설명드리겠습니다.
createCacheKey
먼저 createCacheKey 메서드를 보면 CacheType에 선언된 key와 HashKey를 구분자를 두어 {key}:@:{hashKey}의 형태로 ":@:" 텍스트를 통해 key와 hashKey의 구분을 두고 있습니다. 이렇게 만들어진 키값이 캐시의 키로 사용됩니다.
private String createCacheKey(CacheType cacheType, Map<String, Object> hashKey) throws JsonProcessingException {
return String.format("%s%s%s", cacheType.getKey(), SPLIT_TEXT, hashKey == null || hashKey.isEmpty() ?
"" : objectMapper.writeValueAsString(hashKey));
}
extractHashKey
extractHashKey 메서드는 cacheKey에서 hashKey를 추출하는 메서드입니다.
캐시 삭제 시 원하는 hashKey를 필터링하기 위해 사용합니다.
private Map<String, Object> extractHashKey(String cacheKey) throws JsonProcessingException {
String[] cacheKeySplit = cacheKey.split(SPLIT_TEXT);
String hashKeyString = cacheKeySplit[cacheKeySplit.length - 1];
if (StringUtils.isNotBlank(hashKeyString))
return objectMapper.readValue(hashKeyString, new TypeReference<>(){});
else
return new HashMap<>();
}
saveCache
saveCache 메서드는 createCacheKey로 생성된 키로 Object 형태의 데이터를 String화 하여 캐시에 저장합니다.
public void saveCache(CacheType cacheType, Map<String, Object> hashKey, Object value, Integer timeout, TimeUnit timeUnit) {
try {
String cacheKey = createCacheKey(cacheType, hashKey);
String valueString = value != null ? objectMapper.writeValueAsString(value) : "";
if (Optional.ofNullable(timeout).filter(t -> t > 0).isPresent() && Optional.ofNullable(timeUnit).isPresent()) {
operations.set(cacheKey, valueString, timeout, timeUnit);
} else {
operations.set(cacheKey, valueString);
}
} catch (JsonProcessingException ignored) {
}
}
getCache
getCache 메서드는 저장된 캐시를 불러와 원하는 Class로 변환해 반환합니다.
public String getCache(CacheType cacheType, Map<String, Object> hashKey) {
try {
String cacheKey = createCacheKey(cacheType, hashKey);
return operations.get(cacheKey);
} catch (JsonProcessingException e) {
return null;
}
}
public <T> T getCache(CacheType cacheType, Map<String, Object> hashKey, Class<T> returnClass) {
try {
String cache = getCache(cacheType, hashKey);
return StringUtils.isNotBlank(cache) ? objectMapper.readValue(cache, returnClass) : null;
} catch (JsonProcessingException e) {
e.printStackTrace();
return null;
}
}
deleteCache
마지막으로 deleteCache 메서드는 캐시를 삭제하는 메서드입니다.
public void deleteCache(CacheType cacheType, Map<String, Object> hashKey) {
try {
Set<String> cacheKeys = redisTemplate.keys(this.createCacheKey(cacheType, null) + "*");
if (cacheKeys == null) return;
Set<String> deleteTargetCacheKey = new HashSet<>();
if (hashKey == null || hashKey.isEmpty()) {
deleteTargetCacheKey = cacheKeys;
} else {
for (String cacheKey : cacheKeys) {
Map<String, Object> cacheHashKey = extractHashKey(cacheKey);
if (cacheHashKey.isEmpty()) {
deleteTargetCacheKey.add(cacheKey);
} else {
if (hashKey.keySet().stream()
.filter(key -> Objects.nonNull(cacheHashKey.get(key)))
.allMatch(key -> String.valueOf(hashKey.get(key)).equals(String.valueOf(cacheHashKey.get(key))))) {
deleteTargetCacheKey.add(cacheKey);
}
}
}
}
if (!deleteTargetCacheKey.isEmpty()) redisTemplate.delete(deleteTargetCacheKey);
} catch (JsonProcessingException ignored) {
}
}
cacheType에 선언된 Key로 시작되는 캐시 키를 모두 불러오고, 두 번째 매개변수인 hashKey에 해당되는 캐시만 필터링해 삭제합니다.
아래 코드를 통해 예를 들어 보겠습니다.
// 예를 들어 캐시가 아래처럼 저장되어 있을때
NOTICE_LIST:@:{"page":1, "size":10}
NOTICE_LIST:@:{"page":2, "size":10}
NOTICE_LIST:@:{"page":3, "size":10}
NOTICE_LIST:@:{"page":4, "size":10}
NOTICE_LIST:@:{"page":5, "size":10}
// 전체 NOTICE_LIST 캐시를 삭제
cacheService.deleteCache(NOTICE_LIST);
// NOTICE_LIST 캐시 중 hashKey에 page가 1인 캐시만 삭제
cacheService.deleteCache(NOTICE_LIST, Map.ofEntries(Map.entry("page", 1)));
// NOTICE_LIST 캐시 중 hashKey에 size가 10인 캐시만 삭제
cacheService.deleteCache(NOTICE_LIST, Map.ofEntries(Map.entry("size", 10)));
// NOTICE_LIST 캐시 중 hashKey에 page가 1이고 size가 10인 캐시만 삭제
cacheService.deleteCache(NOTICE_LIST, Map.ofEntries(
Map.entry("page", 1),
Map.entry("size", 10)));
Redis는 싱글 스레드이기 때문에 Keys 명령어를 사용하는 것은 지양하지만
API 형태로 캐시화를 진행하다 보니 데이터가 많지 않고 어차피 삭제될 데이터라면 크게 문제가 되지 않을까 생각합니다....??
(이 부분은 실실 적으로 사용해 봐야 알 것 같습니다..)
글이 너무 길어져 실제 구현한 AOP와 어노테이션 사용예제는 다음글로 작성하겠습니다.