본문 바로가기
SpringBoot/성능 개선 흔적들

[Spring] Redis @Cacheable를 이용한 조회 API 성능 개선

by rla124 2024. 8. 15.

Redis @Cacheable을 드디어 프로젝트에 적용하여 리펙토링에 성공했고 엄청난 성능 향상을 경험했다. 이 과정을 트러블슈팅과 함께 소개해보고자 한다.

 

적용 대상 조회 API 탐색

레디스 캐시를 적용하려는 목적 자체가 hibernate mysql db에 직접 접근 없이 == 추가 쿼리가 날라가지 않고!!!! == db 성능 개선을 하고자 하는 것이다. 즉, 조회를 할때 일반적으로 db select 쿼리가 찍힐 텐데 이러한 쿼리 없이 레디스 캐시에 저장하여 조회 속도를 높이는 것이다. 따라서 동적인, 변경 가능성이 있는 데이터를 반환하는 API가 아니라 "정적인 데이터를 조회하는 API"에 redis cache를 적용했어야 했다.

 

따라서 다른 조회 api들은 CRUD에 따라서 변경가능성이 있는데 반해 프로젝트에서 스터디 멤버를 모집하는 기능을 위한 스터디 도메인에서 외부 활동 카테고리 조회를 캐시 대상으로 선별하였다. 왜냐하면 이 데이터는 내가 mysql에 mock으로 insert into한 data이며 변경 가능성이 없고 수정될 여지, 삭제될 가능성 모두 없는 정적 데이터였기 때문이다.

 

그래서 아래와 같이 외부 활동 카테고리 조회에 레디스 캐시를 적용해보았다.

package com.sejong.sejongpeer.domain.externalactivity.service;

import com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse;
import com.sejong.sejongpeer.domain.externalactivity.entity.ExternalActivity;
import com.sejong.sejongpeer.domain.externalactivity.repository.ExternalActivityRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional
public class ExternalActivityService {

	private final ExternalActivityRepository externalActivityRepository;

	@Cacheable(cacheNames = "getAllExternalActivityCategories", key = "'ExternalActivityCategories'", cacheManager = "redisCacheManager")
	public List<ExternalActivityCategoryResponse> getAllExternalActivityCategories() {
		List<ExternalActivity> externalActivityList = externalActivityRepository.findAll();

		return externalActivityList.stream()
			.map(ExternalActivityCategoryResponse::from)
			.collect(Collectors.toUnmodifiableList());
	}
}

 

@Cacheble 어노테이션을 쓸 때 기존에는 아래와 같이 코드를 짰지만 key 이름이 아래와 같이 코드 파일의 루트 디렉토리부터 경로를 나타낼 정도로 복잡할 필요가 없었고 sync true로 동기적 처리를 통해 이전 스레드 요청이 끝날 때까지 대기 상태로 둘 필요가 없어서 default false 값을 유지하도록 제거했다. 

@Cacheable(cacheNames = "getAllExternalActivityCategories", key = "#root.target + #root.methodName", sync = true, cacheManager = "redisCacheManager")

 

 

만났던 에러 : SerializationException

레디스 캐시 key를 수정 전 레디스에 저장된 값이다. 아래와 같이 첫번째 get 요청을 보냈을 때 redis에 저장이 되지만 두 번째 요청을 보냈을 경우 직렬화 500 에러 문제가 터진다. 

 

RedisConfig와 CacheConfig를 각각 아래와 같이 작성했었었다. 여기서 내가 놓쳤던 점은 RedisSerializer에 objectMapper를 파라미터로 넘기는 과정을 누락한 것이다. config 클래스에 Autowired로 objectMapper 의존성 주입을 받아놓고 막상 쓰이지 않았던 것이다. 

 

아래는 500 직렬화 관련 에러가 터져버리는 Configuration 코드이다. 

package com.sejong.sejongpeer.global.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

	@Value("${spring.data.redis.port}")
	public int port;

	@Value("${spring.data.redis.host}")
	public String host;

	@Autowired
	public ObjectMapper objectMapper;

	@Bean
	public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
		RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
		redisTemplate.setKeySerializer(new StringRedisSerializer());
		redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // 여기가 문제
		redisTemplate.setConnectionFactory(connectionFactory);
		return redisTemplate;
	}

	@Bean
	public RedisConnectionFactory redisConnectionFactory() {
		RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
		redisStandaloneConfiguration.setHostName(host);
		redisStandaloneConfiguration.setPort(port);
		LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
		return connectionFactory;
	}
}

 

package com.sejong.sejongpeer.global.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
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;

@Configuration
public class CacheConfig {

	@Autowired
	RedisConnectionFactory redisConnectionFactory;

	@Autowired
	ObjectMapper objectMapper;

	@Autowired
	RedisConnectionFactory connectionFactory;

	@Bean
	public CacheManager redisCacheManager() {
		RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
			.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
			.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); // 여기가 문제

		RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory)
			.cacheDefaults(redisCacheConfiguration).build();

		return redisCacheManager;
	}
}

 

 

레디스와 같이 캐시 시스템에 데이터를 저장해야 할 때는 직렬화를 해서 저장해야 한다. 따라서 나는 처음에 이 오류를 직면했을 때 ExternalActivity entity가 Serializable을 implements 하도록 추가하고 response dto record에서도 마찬가지로 캐시된 데이터를 표현하는 객체이기 때문에 직렬화 가능하도록 수정했었으나 이 방법은 오류의 본질적인 원인을 해소한 것이 아니었다. 

2024-08-15T15:16:08.218+09:00 DEBUG 5928 --- [nio-8080-exec-3] .m.m.a.ExceptionHandlerExceptionResolver : Using @ExceptionHandler com.sejong.sejongpeer.global.error.GlobalExceptionHandler#handleException(Exception)
2024-08-15T15:16:08.218+09:00 ERROR 5928 --- [nio-8080-exec-3] c.s.s.g.error.GlobalExceptionHandler     : Internal Server Error : Could not read JSON:Unexpected token (START_OBJECT), expected VALUE_STRING: need String, Number of Boolean value that contains type id (for subtype of java.lang.Object)
 at [Source: (byte[])"[{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":1,"categoryName":"프로젝트","categoryDescription":"창업, 클라우드, 펀딩 등"},{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":2,"categoryName":"공모전","categoryDescription":"아이디어, 광고, 공학 등"},{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":3,"categoryName":"대�"[truncated 789 bytes]; line: 1, column: 2] 

org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Unexpected token (START_OBJECT), expected VALUE_STRING: need String, Number of Boolean value that contains type id (for subtype of java.lang.Object)
 at [Source: (byte[])"[{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":1,"categoryName":"프로젝트","categoryDescription":"창업, 클라우드, 펀딩 등"},{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":2,"categoryName":"공모전","categoryDescription":"아이디어, 광고, 공학 등"},{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":3,"categoryName":"대�"[truncated 789 bytes]; line: 1, column: 2] 
	at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:253) ~[spring-data-redis-3.1.7.jar:3.1.7]
	at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:229) ~[spring-data-redis-3.1.7.jar:3.1.7]
	at org.springframework.data.redis.serializer.DefaultRedisElementReader.read(DefaultRedisElementReader.java:46) ~[spring-data-redis-3.1.7.jar:3.1.7]
	at org.springframework.data.redis.serializer.RedisSerializationContext$SerializationPair.read(RedisSerializationContext.java:275) ~[spring-data-redis-3.1.7.jar:3.1.7]
	at org.springframework.data.redis.cache.RedisCache.deserializeCacheValue(RedisCache.java:294) ~[spring-data-redis-3.1.7.jar:3.1.7]
	at org.springframework.data.redis.cache.RedisCache.lookup(RedisCache.java:169) ~[spring-data-redis-3.1.7.jar:3.1.7]
	
    // 많은 에러 로그 생략
    
    at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3874) ~[jackson-databind-2.15.3.jar:2.15.3]
	at org.springframework.data.redis.serializer.JacksonObjectReader.lambda$create$0(JacksonObjectReader.java:54) ~[spring-data-redis-3.1.7.jar:3.1.7]
	at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:250) ~[spring-data-redis-3.1.7.jar:3.1.7]
	... 145 common frames omitted

2024-08-15T15:16:08.237+09:00 DEBUG 5928 --- [nio-8080-exec-3] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Using 'application/json', given [*/*] and supported [application/json, application/*+json, application/cbor]
2024-08-15T15:16:08.241+09:00 DEBUG 5928 --- [nio-8080-exec-3] o.s.w.s.m.m.a.HttpEntityMethodProcessor  : Writing [GlobalResponse[success=false, status=500, data=ErrorResponse[errorClassName=SerializationException,  (truncated)...]
2024-08-15T15:16:08.243+09:00 TRACE 5928 --- [nio-8080-exec-3] o.s.s.w.header.writers.HstsHeaderWriter  : Not injecting HSTS header since it did not match request to [Is Secure]
2024-08-15T15:16:08.244+09:00 DEBUG 5928 --- [nio-8080-exec-3] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Unexpected token (START_OBJECT), expected VALUE_STRING: need String, Number of Boolean value that contains type id (for subtype of java.lang.Object)<EOL> at [Source: (byte[])"[{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":1,"categoryName":"프로젝트","categoryDescription":"창업, 클라우드, 펀딩 등"},{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":2,"categoryName":"공모전","categoryDescription":"아이디어, 광고, 공학 등"},{"@class":"com.sejong.sejongpeer.domain.externalactivity.dto.ExternalActivityCategoryResponse","id":3,"categoryName":"대�"[truncated 789 bytes]; line: 1, column: 2] ]
2024-08-15T15:16:08.245+09:00 DEBUG 5928 --- [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet        : Completed 500 INTERNAL_SERVER_ERROR

 

 

해결 방법 : ObjectMapper를 GenericJackson2JsonRedisSerializer의 파라미터로 주입 

위의 에러 내역에서도 볼 수 있듯이 Spring Data Redis에서 GenericJackson2JsonRedisSerializer를 사용하여 역직렬화하는 동안 SerializationException가 발생한 것이므로 직렬화된 데이터가 JSON 구조를 포함하고 있지만 역직렬화기가 JSON 데이터 구조 타입을 처리할 준비가 되어있지 않았던 것이다.  따라서 GenericJackson2JsonRedisSerializer가 다형성 타입을 올바르게 처리할 수 있도록 직렬화기에서 사용하는 ObjectMapper를 설정해주었다. 

 

먼저 RedisConfig에서 수정한 부분이다.

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); // objectMapper 파라미터 추가
    redisTemplate.setConnectionFactory(connectionFactory);
    return redisTemplate;
}

 

 

마지막으로 CacheConfig에서 수정한 부분이다.

@Bean
public CacheManager redisCacheManager() {
    RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
        .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
        .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))); // 파라미터 주입하도록 수정

    RedisCacheManager redisCacheManager = RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory)
        .cacheDefaults(redisCacheConfiguration).build();

    return redisCacheManager;
}

 

 

그러면 캡쳐본 같이 정상적인 데이터 형태로 캐싱이 된 것을 확인할 수 있었다.

처음의 캐싱된 데이터 key-value와 다른 점을 눈여겨 봐야했다

 

ObjectMapper의 역할

Jackson 라이브러리에서 JSON 데이터를 처리하여 JSON 문자열을 JAVA 객체로 변환(역직렬화)하거나 JAVA 객체를 JSON 문자열으로 변환(직렬화)할 때 사용한다. 또한 상속 관계가 있는 등 다형적인 객체를 직렬화하고 역직렬화할 때 필요한 타입 정보를 유지해준다. 

 

GenericJackson2JsonRedisSerializer는 JSON을 처리하는 Redis의 직렬화기이다. 이 직렬화기를 사용할 때 objectMapper를 파라미터로 전달하므로써 JSON 데이터의 직렬화 및 역직렬화 방식을 제어할 수 있게 된다. 

 

성능 개선 결과

레디스 캐시를 적용 전 Mysql로 직접 접근하여 get 요청을 보냈을 경우 아래와 같이 백자리수 대의 ms 시간이 소요되었지만

 

레디스에 저장한 후 get 재요청을 보내면 십의 자리수 수치로 크게 속도가 감소되어 성능이 개선된 것을 확인할 수 있었다.

 

 


Redis에 자신감 붙여보기 완료

 

2024.08.16 업데이트

재학중인 대학의 단과대 및 그에 따른 모든 학과 정보를 반환하는 API도 정적인 데이터를 반환하기 때문에 아래와 같이 레디스 캐시를 추가로 적용하였다. getAll 서비스 레이어 메소드 + 정적 데이터 반환에 레디스 캐시 적용하기!!

@Cacheable(cacheNames = "getAllColleges", key = "'CollegeMajor'", cacheManager = "redisCacheManager")
public List<String> getAllColleges() {
    return collegeMajorRepository.findAllColleges();
}