e-commerce 웹 애플리케이션을 구현하던 중 가장 빈번하게 요청되면서도 거의 동일한 응답을 반환할 '단일 가게 조회' 기능에 Redis 캐싱을 적용하여 1) 정보 조회 속도를 높이고, 2) DB의 부하를 낮추는 것을 목표로 프로젝트를 진행했다.
☆ 코드 ☆
// build.gradle
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// common/RedisRepositoryConfig.java
@Configuration
@EnableRedisRepositories(basePackages = "com.example.baglemonster.common.config")
public class RedisRepositoryConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
// jackson LocalDateTime mapper
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // timestamp 형식 안따르도록 설정
mapper.registerModules(new JavaTimeModule(), new Jdk8Module()); // LocalDateTime 매핑을 위해 모듈 활성화
return mapper;
}
// redisConnectionFactory 를 통해 외부 redis 를 연결합니다.
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
//RedisTemplate을 통해 RedisConnection에서 넘겨준 byte 값을 객체 직렬화합니다.
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper()));
return redisTemplate;
}
}
// store/service/StoreService.java
@Service
@RequiredArgsConstructor
public class StoreService {
private final StoreRepository storeRepository;
private final RedisTemplate<String, StoreResponseDto> redisTemplate;
// 가게 단일 조회
@Transactional(readOnly = true)
public StoreResponseDto selectStore(Long storeId) {
StoreResponseDto result = null;
String key = "storeIdx::" + storeId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
result = getRedisStore(key);
} else {
Store store = findStore(storeId);
result = StoreResponseDto.of(store, true);
setRedisStore(key, result);
}
return result;
}
...
private StoreResponseDto getRedisStore(String key) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModules(new JavaTimeModule(), new Jdk8Module());
try {
String json = mapper.writeValueAsString(redisTemplate.opsForValue().get(key));
return mapper.readValue(json, StoreResponseDto.class);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
private void setRedisStore(String key, StoreResponseDto result) {
ValueOperations<String, StoreResponseDto> valueOperations = redisTemplate.opsForValue();
valueOperations.set(key, result, 20, TimeUnit.SECONDS);
}
private void deleteRedisStore(Long storeId) {
String key = "storeIdx::" + storeId;
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
redisTemplate.delete(key);
}
}
}
☆ 트러블슈팅 ☆
위 사항들을 구현하면서 총 2번 정도 큰 문제가 발생했었는데 사실 모두 Java ↔ JSON 직렬화/역직렬화를 위한 ObjectMapper 문제였다.
1. java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.example.baglemonster.store.dto.StoreResponseDto (java.util.LinkedHashMap is in module java.base of loader 'bootstrap';
▶ `StoreResponseDto result = redisTemplate.opsForValue().get(key);` 에서 발생된 문제로, redis에 저장된 값이 LinkedHashMap 형태여서 JSON 형식의 DTO로 변환할 수 없다는 문제였다.
▶ ObjectMapper를 이용해 redis object를 String으로 읽어 들여와 해결하였다.
2. Java 8 date/time type java.time.LocalTime not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
▶ parse 시 StoreResponseDto에 담긴 LocalDateTime 및 LocalTime으로 String을 변환할 수 없다는 문제였다.
▶ ObjectMapper 생성 시 직접적으로 JavaTimeModule과 Jdk8Module 모듈 주입하여 해결하였다.
◁ 이미 RedisRepositoryConfig에서 ValueSerializer로 시간 모듈을 탑재한 ObjectMapper를 등록했는데 결국 실제 Service 단에서 다시 넣어줘야 했다.. 아직 스프링부트의 @Bean에 대한 이해가 부족한 듯 싶다.
☆ 결과 ☆
Redis를 이용한 정보 조회 기능을 구현하고 이를 기존에 있던 기능과 동일한 조건(반복 횟수: 1000회, 실행 간격: 10ms)으로 비교해봤는데, 예상치 못한 결과가 나왔다.
- MySQL -- 수행시간: 1m 40s, 평균 응답 시간 : 12 ms
- Redis -- 수행시간: 1m 45s, 평균 응답 시간 : 11 ms
그 동안 했던 프로젝트에서 실행 시간을 고려하고 애플리케이션을 개선해본 경험이 없어서 몰랐는데, MySQL 또한 동일한 요청이 반복되면 해당 응답을 저장해놓는 특징이 있는지 정말 빨랐다....!!!
☆ 결론 ☆
평균 응답시간은 1ms 차이로 미미했고, 심지어 총 수행 시간은 MySQL 쪽이 더 빨랐다.
여러 번 테스트를 실행해봤지만 거의 변함없이 동일한 기능을 보면서 솔직히 많이 허탈했다...
결과에 납득하지 못하고, 해당 기능을 구현해본 사람들의 글과 말을 들어보니, Redis를 이용한 속도 개선보다 Redis가 메모리 DB이자 TTL을 통해 회원의 동작을 제한하는 등 보다 구체적인 예시로 사용되는 것을 보고, 판단이 부족했다는 걸 많이 느낀 아쉬운 경험이 되었다...
DB의 부하를 낮추는 것 또한 목표 중에 하나였지만, 일단 성능의 차이가 거의 없고 어느 정도의 DB 성능 향상을 가져올지 확인하기 어렵다는 점 때문에 결과적으론 실제 프로젝트에 반영하지 않기로 결정했다..
◈ 혹시라도 Redis에 DTO를 담기 위해 이 글을 보는 사람이 있다면 어떤 기능을 구현하기 위해 댓글로 공유해주시면 좋겠습니다 :D
참고 자료
'CS > 데이터베이스' 카테고리의 다른 글
[DB] 2. DB 모델링의 주요 개념 (0) | 2023.11.25 |
---|---|
[DB] 1. 데이터베이스 개요 및 관계형 DB 용어 정리 (0) | 2023.11.23 |
[ Redis ] Redis 캐싱 패턴 (0) | 2023.11.14 |
[Redis] Redis를 활용한 Refresh Token의 구현 및 장점 (1) | 2023.10.18 |