[트러블 슈팅] 인기 검색어 구현 Redis 설정 및 문제 해결하기
1. 레디스 설정
package com.example.eightyage.global.config;
import org.springframework.cache.CacheManager;
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.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@EnableCaching
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
// 기본 캐시 설정
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 캐시 별로 TTL 설정
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
configMap.put("keywordCountMap", defaultConfig.entryTtl(Duration.ZERO));
configMap.put("keywordKeySet", defaultConfig.entryTtl(Duration.ZERO));
configMap.put("popularKeywords", defaultConfig.entryTtl(Duration.ofMinutes(5)));
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configMap)
.build();
}
}
🤔 직렬화와 역직렬화란?
용어 | 정의 | 예시 |
직렬화 (serialized) | 객체를 저장하거나 전송하도록 바이트 형태로 변환하는 것 | User 객체 → JSON 문자열 |
역직렬화 (deserialized) | 바이트나 문자열로 변환된 데이터를 다시 객체로 변환하는 것 | JSON 문자열 → User 객체 |
2. 레디스 실행하기
// Redis 서버 상태 확인
brew services list
// Redis 실행
brew services start redis
// Redis 클라이언트 접속
redis-cli
3. 문제 해결하기
(1) 문제 1. 시간이 지났지만 최신 인기 검색어가 조회 되지 않았다
인기 검색어 (`/api/v2/keyword/popular?days=30`) 를 호출했지만,
검색어를 1회 입력한 후 5분이 지나도 반영되지 않았다.
Redis에 TTL이 설정되어 있어 5분 후에 인기 검색어가 최신화 되기를 기대했지만,
여전히 이전 데이터가 조회되었다.
뭐가 문제일까?
캐시에 저장된 값 확인해보니 캐시는 잘 저장된 것을 알 수 있었다.
`@Cacheable(value = "popularKeywords", key = "#days")` 를 사용했기 때문에
days 값 해당하는 key가 캐싱된 것을 알 수 있었다.
ex) popularKeywords::30
근데 문제는 이미 캐시된 인기 검색어 (popularKeywords::30)가 Redis에서 사라지지 않았다.
최신화가 되지 않았다는 건 TTL이 적용이 되지 않았다는 의미로,
TTL이 잘 적용 되었는지 확인해 보았다.
숫자 | 의미 |
- 1 | TTL 없음 (무제한 저장) |
> 0 | TTL이 남아있음 (초 단위) |
- 2 | 이미 만료됨 |
-1이 뜬 것으로 보아, TTL 적용이 안된 것 같았다.
cacheConfig에 TTL 설정이 잘 되었는지 확인하였다.
// 캐시 별로 TTL 설정
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
configMap.put("keywordCountMap", defaultConfig.entryTtl(Duration.ZERO));
configMap.put("keywordKeySet", defaultConfig.entryTtl(Duration.ZERO));
configMap.put("popularKeywords", defaultConfig.entryTtl(Duration.ofMinutes(5)));
TTL이 5분으로 RedisCacheManager에 제대로 반영 되었다는 것을 확인하였다.
`@Cacheable` 도 잘 적용되어 있는 것을 알 수 있었다.
//캐시O 인기 검색어 조회
@Transactional(readOnly = true)
@Cacheable(value = "popularKeywords", key = "#days")
public List<PopularKeywordDto> searchPopularKeywords(int days) {
if (days < MIN_DAYS || days > MAX_DAYS) {
throw new BadRequestException("조회 일 수는 1~365 사이여야 합니다.");
}
LocalDateTime since = LocalDateTime.now().minusDays(days);
return searchLogRepository.findPopularKeywords(since);
}
TTL은 처음 저장될 때만 적용된다.
그래서 기존에 `@Cacheable` 없이 `cache.put()` 같은 방식으로 저장된 데이터가 있다면,
TTL이 적용되지 않은 상태로 남아 있을 수 있다.
Redis에 있는 오래된 캐시 삭제 후 테스트를 진행 해보았다.
// 캐시 삭제
> DEL "popularKeywords::30"
// TTL 확인
> TTL popularKeywords::30
이제 -1이 아닌 0보다 큰 숫자가 입력되는 것을 보아 TTL이 잘 적용된 것을 알 수 있다.
🧑💻 테스트 : "립스틱"을 검색 후 인기 검색어 조회
(2) 문제2. 같은 키워드로 2회 이상 검색이 되지 않는다
검색어를 입력하면 검색 결과가 정상적으로 출력되고, 캐시에도 저장된다.
하지만 같은 키워드로 두 번째 검색 부터는 IllegalStateException 오류가 발생했다.
[에러 메시지 요약]
java.lang.IllegalStateException: Cached value is not of required type [java.lang.Long]: 1
‼️ 문제가 발생한 이유는?
처음 Redis 캐시 설정에서
GenericJackson2JsonRedisSerializer를 사용해 모든 객체를 JSON 문자열로 직렬화 하도록 했다.
따라서 1L은 숫자(Long)가 아닌 "1"이라는 문자열(String 형태의 JSON) 으로 저장되었다.
그 결과, `cache.get(key, Long.class)` 호출 시
내부적으로는 "1" (String)을 Long으로 변환하려다 타입 불일치로 오류 발생하게 된 것이다.
💡 해결 방법
1️⃣ 대안 1 : JdkSerializationRedisSerializer 사용
Java 객체를 그대로 직렬화/역직렬화하는 JdkSerializationRedisSerializer를 사용해볼 수 있다.
1L을 저장하여 다시 꺼낼 때, Long 타입이 유지되어 타입 오류가 없다.
하지만 Redis내부에 저장된 값이 직렬화된 자바 객체인 바이너리이기 때문에,
사람이 읽기 힘들다. 또한 다른 시스템과 연동이 어렵고, 이식성이 떨어지는 단점이 있다.
2️⃣ 대안2 : 캐시에서 꺼낼 때, String에서 Long 타입으로 파싱하는 방법 사용
// 검색 시 키워드 카운트 증가
@Transactional
public void increaseKeywordCount(String keyword) {
if (!StringUtils.hasText(keyword)) return;
Cache countCache = cacheManager.getCache(KEYWORD_COUNT_MAP);
if (countCache != null) {
String countStr = countCache.get(keyword, String.class);
long count = (countStr == null) ? 1L : Long.parseLong(countStr) + 1; // 파싱
countCache.put(keyword, Long.toString(count));
}
updateKeywordSet(keyword);
}
(3) 문제 3. 캐시가 지워지지 않고, DB에 저장되지 않음
`@Scheduled`를 사용하여 5분마다 Redis 캐시를 DB에 저장(Flush) 하도록 설정했지만,
캐시 값도 남아있고, DB(keyword_count 테이블)에 저장되지 않는 문제가 발생하였다.
캐시에서 값을 가져올 때 타입 불일치(Long.class) 때문에
count == null이 되어 DB 저장도 안 되고, evict 도 안 되고 있다.
설정한 캐시 직렬화 방식이 GenericJackson2JsonRedisSerializer이었기 때문에,
숫자 1L도 "1" (String) 형태로 저장해버려서, null을 반환한다.
// 반복문을 이용하여 저장하기
for (String keyword : keywordSet) {
Long count = countCache.get(keyword, Long.class);
if (count == null || count == 0L) continue;
keywordCountRepository.findById(keyword)
.ifPresentOrElse(
exist -> exist.updateCount(exist.getCount() + count),
() -> keywordCountRepository.save(new KeywordCount(keyword, count))
);
flushed++;
countCache.evict(keyword);
}
그래서, `if (count == null)` 조건에 걸려서, DB에 저장도 안되고, 캐시도 안지워진다.
따라서 String으로 받고 Long으로 파싱하는 방법을 이용하고자 한다.
[수정한 코드]
// 반복문을 이용하여 저장하기
for (String keyword : keywordSet) {
String countStr = countCache.get(keyword, String.class);
if (countStr == null) continue;
Long count = Long.parseLong(countStr);
if (count == 0L) continue;
keywordCountRepository.findById(keyword)
.ifPresentOrElse(
exist -> exist.updateCount(exist.getCount() + count),
() -> keywordCountRepository.save(new KeywordCount(keyword, count))
);
flushed++;
countCache.evict(keyword);
}
문제 해결이 되지 않았다.....
`@Scheduled` 가 작동하지 않은 것 같아 확인 해보니
프로젝트의 설정 클래스에 `@EnableScheduling` 누락되어 스케줄러가 작동되지 않았던 것이다.
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
@SpringBootApplication
@EnableScheduling // 추가
public class EightyageApplication {
public static void main(String[] args) {
SpringApplication.run(EightyageApplication.class, args);
}
}
3. 테스트 해보기