티스토리 뷰
[Spring] 스프링에서 레디스 설정 및 직렬화/역직렬화(Redis Serializer/Deserializer) 고도화하기
망나니개발자 2025. 1. 21. 10:00이전 포스팅에서는 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)이 갖는 한계점에 대해서 살펴보았다. 일반적인 현대의 애플리케이션에서는 CPU 사용량보다는 메모리 사용량이 높기 때문에, 압축 기능을 통해 CPU를 조금 더 사용하더라도 저장 공간의 사용량을 줄이는 것이 합리적이라고 볼 수 있다. 따라서 이번 포스팅에서는 Gzip 압축 기능이 적용된 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)를 구현하고, 스프링에서의 설정을 고도화 해보도록 하자.
1. 스프링에서 레디스 설정 및 직렬화/역직렬화(Redis Serializer/Deserializer) 고도화하기
스프링 프레임워크에서 레디스를 활용할 때, 크게 @Cacheable 애노테이션 또는 RedisTemplate을 사용할 수 있다. 따라서 두 가지 경우를 나누어 살펴볼 필요가 있다.
[ 압축이 적용된 커스텀 레디스 직렬화기의 구현(GzipRedisSerializer) ]
일반적인 웹 애플리케이션에는 CPU 집약적인 작업이 많지 않기 때문에 압축을 적용하는 것이 합리적이다. 또한 키를 직렬화하기 위해서는 StringRedisSerializer를 사용하면 되지만, 값을 직렬화 하기 위한 3가지 JdkSerializationRedisSerializer, GenericJackson2JsonRedisSerializer, Jackson2JsonRedisSerializer 모두 각각의 장점과 단점을 가지고 있었다. 따라서 다음과 같이 압축이 적용되는 커스텀 Serializer를 구현하여 사용할 수 있다. 추가적으로 내부에서 바이트 크기에 따른 버퍼링 처리를 하여 Humongous 객체의 생성 및 할당 역시 방지할 수 있다.
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.util.FileCopyUtils;
import java.io.*;
import java.util.zip.Deflater;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class GzipRedisSerializer<T> implements RedisSerializer<T> {
private final ObjectMapper objectMapper;
private final TypeReference<T> typeRef;
private final int minCompressionSize;
private final int bufferSize;
private static final byte[] GZIP_MAGIC_BYTES = new byte[]{
(byte) (GZIPInputStream.GZIP_MAGIC & 0xFF),
(byte) ((GZIPInputStream.GZIP_MAGIC >> 8) & 0xFF)
};
public GzipRedisSerializer(ObjectMapper objectMapper, TypeReference<T> typeRef) {
this(objectMapper, typeRef, 1024, 4096);
}
@Override
public byte[] serialize(T t) {
if (t == null) {
return null;
}
try {
if (minCompressionSize == -1) {
return encodeGzip(t);
}
byte[] bytes = objectMapper.writeValueAsBytes(t);
if (bytes.length <= minCompressionSize) {
return bytes;
}
return encodeGzip(bytes);
} catch (IOException ex) {
throw new IllegalStateException("Couldn't serialize object", ex);
}
}
@Override
public T deserialize(byte[] bytes) {
if (bytes == null) {
return null;
}
try {
if (isGzipCompressed(bytes)) {
byte[] rawBytes = decodeGzip(bytes);
return objectMapper.readValue(rawBytes, 0, rawBytes.length, typeRef);
}
return objectMapper.readValue(bytes, 0, bytes.length, typeRef);
} catch (IOException ex) {
throw new IllegalStateException("Couldn't deserialize object", ex);
}
}
private byte[] encodeGzip(byte[] original) {
try (
ByteArrayOutputStream bos = new ByteArrayOutputStream(bufferSize);
GZIPOutputStream gos = new GZIPOutputStream(bos, bufferSize) { { def.setLevel(Deflater.BEST_SPEED);} }
) {
FileCopyUtils.copy(original, gos);
return bos.toByteArray();
} catch (IOException ex) {
throw new IllegalStateException("Couldn't encode to gzip", ex);
}
}
private byte[] encodeGzip(T t) {
try (
ByteArrayOutputStream bos = new ByteArrayOutputStream(bufferSize);
GZIPOutputStream gos = new GZIPOutputStream(bos, bufferSize) { { def.setLevel(Deflater.BEST_SPEED); } }
) {
objectMapper.writeValue(gos, t);
return bos.toByteArray();
} catch (IOException ex) {
throw new IllegalStateException("Couldn't encode to gzip", ex);
}
}
private byte[] decodeGzip(byte[] encoded) {
try (
ByteArrayInputStream bis = new ByteArrayInputStream(encoded);
GZIPInputStream gis = new GZIPInputStream(bis, bufferSize);
ExposedBufferByteArrayOutputStream out = new ExposedBufferByteArrayOutputStream(bufferSize)
) {
FileCopyUtils.copy(gis, out);
return out.getRawByteArray();
} catch (IOException ex) {
throw new IllegalStateException("Couldn't decode gzip", ex);
}
}
private boolean isGzipCompressed(byte[] bytes) {
return bytes.length > 2 && bytes[0] == GZIP_MAGIC_BYTES[0] && bytes[1] == GZIP_MAGIC_BYTES[1];
}
static class ExposedBufferByteArrayOutputStream extends ByteArrayOutputStream {
public ExposedBufferByteArrayOutputStream(int size) {
super(size);
}
public byte[] getRawByteArray() {
return this.buf;
}
}
}
여기서 기본적인 로직은 어렵지 않다. objectMapper를 활용해 객체를 byte 배열로 직렬화하고, 바이트 배열의 크기가 압축 대상이라면 Gzip 기반의 압축을 진행한다. 그리고 gzip을 항상 강제하고 싶은 상황이라면, minCompressionSize을 -1로 주어 불필요한 byte 배열 복사 없이 즉각 압축이 가능하도록 해두었다.
그리고 여기서는 어떠한 타입에 대하여 직렬화를 한 것인지를 직접 제공하여 타입이 없는 상황의 문제를 해결하고 있다. 여기서 Class 정보가 아닌 jackson의 TypeReference를 활용하는 이유는 단건 Member 외에도 List<Member>를 저장하는 경우에도 구체적인 타입을 지정하기 위함이다.
여기서 또 중요한 부분은 ExposedBufferByteArrayOutputStream를 직접 구현하여 활용한 부분이다. 레디스로부터 읽은 데이터를 Gzip 압축 해제한 byte 배열을 ByteArrayOutputStream에 그대로 쓴다면, 최종적으로 전체 byte 배열을 구해오기 위해서는 다음과 같이 코드가 작성될 것이다.
private byte[] decodeGzip(byte[] encoded) {
try (
ByteArrayInputStream bis = new ByteArrayInputStream(encoded);
GZIPInputStream gis = new GZIPInputStream(bis, bufferSize);
ByteArrayOutputStream out = new ByteArrayOutputStream(bufferSize)
) {
FileCopyUtils.copy(gis, out);
return out.toByteArray();
} catch (IOException ex) {
throw new IllegalStateException("Couldn't decode gzip", ex);
}
}
하지만 ByteArrayOutputStream의 toByteArray() 내부 구현을 보면 다음과 같이 배열을 방어적 복사하여 제공하고 있다.
public synchronized byte[] toByteArray() {
return Arrays.copyOf(buf, count);
}
이러한 로직은 높은 트래픽으로 인해 레디스에 다수의 요청을 보내야 하는 경우에, 불필요하게 메모리 사용량을 증가시켜 GC를 유발하고 시스템 pause에 의해 성능이 저하되는 상황이 생길 수 있다. 따라서 byte 배열 버퍼를 복사해서 활용하는 것이 아니라 직접 참조할 수 있도록 ExposedBufferByteArrayOutputStream 클래스를 생성하여 활용한 것이다.
[ @Cacheable 설정에 GzipRedisSerializer 적용하기 ]
@Cacheable을 사용하는 경우에는 다음과 같이 CacheName과 TTL 그리고 역직렬화할 클래스의 타입 정보를 작성할 수 있다.
@Getter
@RequiredArgsConstructor
public enum RedisCache {
MEMBER_CACHE_NAME(MEMBER, Duration.ofMinutes(30), Member.class),
;
private final String cacheName;
private final Duration expiredAfterWrite;
private final Class<?> clazz;
}
public class RedisCacheName {
public static final String MEMBER = "member";
}
@Getter
@RequiredArgsConstructor
public enum LocalCache {
MEMBER(LocalCacheName.MEMBER, Duration.ofMinutes(2).getSeconds(), 200000);
private final String cacheName;
private final long expiredAfterWrite;
private final long maximumSize;
}
public class LocalCacheName {
public static final String MEMBER = "member";
}
그리고 위와 같이 작성된 정보들은 Redis 설정 시에 다음과 같이 활용할 수 있다.
@EnableCaching
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class CacheableConfiguration {
private final RedisConnectionFactory redisConnectionFactory;
private final ObjectMapper objectMapper;
@Bean
@Primary
public CacheManager cacheManager() {
return new CompositeCacheManager(localCacheManager(), redisCacheManager());
}
private CacheManager localCacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
cacheManager.setCaches(
Arrays.stream(LocalCache.values())
.map(cache -> new CaffeineCache(
cache.getCacheName(),
Caffeine.newBuilder()
.expireAfterWrite(cache.getExpiredAfterWrite().toSeconds(), TimeUnit.SECONDS)
.maximumSize(cache.getMaximumSize())
.recordStats()
.build()
))
.collect(Collectors.toList())
);
return cacheManager;
}
private CacheManager redisCacheManager() {
Map<String, RedisCacheConfiguration> cacheConfigMap = Arrays.stream(RedisCache.values())
.collect(Collectors.toMap(
RedisCache::getCacheName,
redisCache -> RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GzipRedisSerializer<>(objectMapper, redisCache.getTypeRef(), 1024, 4096)))
.disableCachingNullValues()
.entryTtl(redisCache.getExpiredAfterWrite())
.prefixCacheNameWith("mangkyu:")
));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.withInitialCacheConfigurations(cacheConfigMap)
.build();
}
}
이렇게 작성된 Cacheable을 위한 설정은 다음과 같이 사용할 수 있다.
@Cacheable(
value = RedisCacheName.Member,
key = "'#memberId",
unless = "#result == false"
)
public Member getMember(Long memberId) {
return loadMemberPort.findByIdOrThrow(memberId);
}
[ RedisTemplate 설정에 GzipRedisSerializer 적용하기 ]
그 다음으로는 RedisTemplate을 사용하는 경우에도 GzipRedisSerializer를 적용할 수 있다. 이때 RedisTemplate은 타입 별로 선언해주어여 하며, RedisTemplate<String, Object>와 같이 광범위하게 활용한다면 타입 정보가 레디스 직렬화 시에 없기 때문에 예상치 못한 문제를 만날 수 있어 주의가 필요하다.
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class CacheConfiguration {
private final RedisConnectionFactory redisConnectionFactory;
private final ObjectMapper objectMapper;
@Bean
public RedisTemplate<String, Member> memberRedisTemplate() {
return createGzipJsonRedisTemplate(objectMapper, new TypeReference<>() {});
}
private <V> RedisTemplate<String, V> createGzipJsonRedisTemplate(
ObjectMapper objectMapper,
TypeReference<V> typeRef
) {
RedisTemplate<String, V> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GzipRedisSerializer<>(objectMapper, typeRef));
return redisTemplate;
}
}
예를 들어 다음과 같은 코드에서 redisTemplate.opsForValue().increment()의 반환값은 Long이다. 따라서 get()으로 조회한 결과를 자연스럽게 Long으로 캐스팅 하려고 한다고 하자.
@RestController
@RequiredArgsConstructor
public class SystemController {
private final RedisTemplate<String, Object> redisTemplate;
@GetMapping("/test")
public long test() {
redisTemplate.opsForValue().increment("mangkyu", 3000L);
return (Long) redisTemplate.opsForValue().get("mangkyu");
}
}
이때 타입 정보가 없어서 objectMapper는 내부적으로 숫자에 해당하는 값을 Integer로 변형했기 때문에, 위의 로직을 실행하면 다음과 같은 에러가 발생한다. 따라서 RedisTemplate을 활용하는 경우에는 반드시 Value에 해당하는 타입이 지정된 RedisTemplate 빈을 등록하여 활용하도록 하자.
java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long (java.lang.Integer and java.lang.Long are in module java.base of loader 'bootstrap')
at com.mangkyu.api.app.system.SystemController.test(SystemController.kt:30)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:569)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:529)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:623)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:209)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at im.toss.secutiry.FireWallFilter.doFilter(FireWallFilter.java:47)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:96)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
본문에도 적어두었지만, 여기서도 마찬가지로 설계 트레이드 오프를 해야 한다. 압축 기능을 적용하면 저장 공간의 사용을 줄일 수 있겠지만, 압축 작업을 위한 추가적인 CPU 처리가 요구된다. 대부분의 일반적인 웹 애플리케이션에는 CPU 집약적인 작업이 많지 않기 때문에 압축을 적용하는 것이 합리적이다. 하지만 이러한 판단은 비즈니스에 따라서 혹은 상황에 따라서 달라질 수 있기 때문에 이를 인지하는 것 자체가 매우 중요하다.
관련 포스팅
- 스프링이 제공하는 레디스 직렬화/역직렬화(Redis Serializer/Deserializer)의 종류와 한계
- 스프링에서 레디스 설정 및 직렬화/역직렬화(Redis Serializer/Deserializer) 고도화하기