Deserializer를 직접 구현해서 클래스 패키지 경로를 컨트롤 해보자
개요
자바에는 직렬화 개념이 있습니다.
직렬화(直列化) 또는 시리얼라이제이션(serialization)은 컴퓨터 과학의 데이터 스토리지 문맥에서 데이터 구조나 오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장(이를테면 파일이나 메모리 버퍼에서, 또는 네트워크 연결 링크 간 전송)하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정이다.
출처 : https://ko.wikipedia.org/wiki/%EC%A7%81%EB%A0%AC%ED%99%94
직렬화 - 위키백과, 우리 모두의 백과사전
ko.wikipedia.org
자바에서는 Serializable을 구현한 클래스를 직렬화 역직렬화할 때, 패키지 정보 가 포함되게 됩니다.
문제 상황
Redis와 연동중인 프로젝트가 있다고 합시다.
이 프로젝트는 Class A를 캐시 연동하여 저장하도록 하고 있습니다.
그러던 중 리팩터링이 필요하여 Class A의 패키지를 변경하게 되었습니다.
이렇게 되면, Cache Get 과정 중에 ClassNotFouncException이 발생할 것 입니다.
직렬화 할 때랑, 역직렬화 할 때 패키지 정보가 달라졌으니깐요.
리팩터링을 진행하다보면 생각보다 패키지 변경을 자주 할 수 있는데요.
그렇다면 캐시에 한번 올라간 Object의 pacakage path는 불변해야 할까요?
해결
spring-core 에는 Deserializer라는 인터페이스가 있습니다.
@FunctionalInterface
public interface Deserializer<T> {
/**
* Read (assemble) an object of type T from the given InputStream.
* <p>Note: Implementations should not close the given InputStream
* (or any decorators of that InputStream) but rather leave this up
* to the caller.
* @param inputStream the input stream
* @return the deserialized object
* @throws IOException in case of errors reading from the stream
*/
T deserialize(InputStream inputStream) throws IOException;
/**
* Read (assemble) an object of type T from the given byte array.
* @param serialized the byte array
* @return the deserialized object
* @throws IOException in case of deserialization failure
* @since 5.2.7
*/
default T deserializeFromByteArray(byte[] serialized) throws IOException {
return deserialize(new ByteArrayInputStream(serialized));
}
}
inputStream을 받아 객체화 하는 인터페이스 입니다.
이 인터페이스를 구현한 클래스 DefaultDeserializer를 확인 해 봅시다.
/**
* A default {@link Deserializer} implementation that reads an input stream
* using Java serialization.
*
* @author Gary Russell
* @author Mark Fisher
* @author Juergen Hoeller
* @since 3.0.5
* @see ObjectInputStream
*/
public class DefaultDeserializer implements Deserializer<Object> {
@Nullable
private final ClassLoader classLoader;
/**
* Create a {@code DefaultDeserializer} with default {@link ObjectInputStream}
* configuration, using the "latest user-defined ClassLoader".
*/
public DefaultDeserializer() {
this.classLoader = null;
}
/**
* Create a {@code DefaultDeserializer} for using an {@link ObjectInputStream}
* with the given {@code ClassLoader}.
* @since 4.2.1
* @see ConfigurableObjectInputStream#ConfigurableObjectInputStream(InputStream, ClassLoader)
*/
public DefaultDeserializer(@Nullable ClassLoader classLoader) {
this.classLoader = classLoader;
}
/**
* Read from the supplied {@code InputStream} and deserialize the contents
* into an object.
* @see ObjectInputStream#readObject()
*/
@Override
@SuppressWarnings("resource")
public Object deserialize(InputStream inputStream) throws IOException {
ObjectInputStream objectInputStream = new ConfigurableObjectInputStream(inputStream, this.classLoader);
try {
return objectInputStream.readObject();
}
catch (ClassNotFoundException ex) {
throw new NestedIOException("Failed to deserialize object type", ex);
}
}
}
생성할 떄 classLoader를 같이 받고, deserialize 과정에서 해당 classLoader로 ConfigurableObjectInputStream을 이용하는 것을 볼 수 있습니다.
ConfigurableObjectInputStream을 확인 해 봅시다.
public class ConfigurableObjectInputStream extends ObjectInputStream {
@Nullable
private final ClassLoader classLoader;
private final boolean acceptProxyClasses;
/**
* Create a new ConfigurableObjectInputStream for the given InputStream and ClassLoader.
* @param in the InputStream to read from
* @param classLoader the ClassLoader to use for loading local classes
* @see java.io.ObjectInputStream#ObjectInputStream(java.io.InputStream)
*/
public ConfigurableObjectInputStream(InputStream in, @Nullable ClassLoader classLoader) throws IOException {
this(in, classLoader, true);
}
/**
* Create a new ConfigurableObjectInputStream for the given InputStream and ClassLoader.
* @param in the InputStream to read from
* @param classLoader the ClassLoader to use for loading local classes
* @param acceptProxyClasses whether to accept deserialization of proxy classes
* (may be deactivated as a security measure)
* @see java.io.ObjectInputStream#ObjectInputStream(java.io.InputStream)
*/
public ConfigurableObjectInputStream(
InputStream in, @Nullable ClassLoader classLoader, boolean acceptProxyClasses) throws IOException {
super(in);
this.classLoader = classLoader;
this.acceptProxyClasses = acceptProxyClasses;
}
@Override
protected Class<?> resolveClass(ObjectStreamClass classDesc) throws IOException, ClassNotFoundException {
try {
if (this.classLoader != null) {
// Use the specified ClassLoader to resolve local classes.
return ClassUtils.forName(classDesc.getName(), this.classLoader);
}
else {
// Use the default ClassLoader...
return super.resolveClass(classDesc);
}
}
catch (ClassNotFoundException ex) {
return resolveFallbackIfPossible(classDesc.getName(), ex);
}
}
@Override
protected Class<?> resolveProxyClass(String[] interfaces) throws IOException, ClassNotFoundException {
if (!this.acceptProxyClasses) {
throw new NotSerializableException("Not allowed to accept serialized proxy classes");
}
if (this.classLoader != null) {
// Use the specified ClassLoader to resolve local proxy classes.
Class<?>[] resolvedInterfaces = new Class<?>[interfaces.length];
for (int i = 0; i < interfaces.length; i++) {
try {
resolvedInterfaces[i] = ClassUtils.forName(interfaces[i], this.classLoader);
}
catch (ClassNotFoundException ex) {
resolvedInterfaces[i] = resolveFallbackIfPossible(interfaces[i], ex);
}
}
try {
return ClassUtils.createCompositeInterface(resolvedInterfaces, this.classLoader);
}
catch (IllegalArgumentException ex) {
throw new ClassNotFoundException(null, ex);
}
}
else {
// Use ObjectInputStream's default ClassLoader...
try {
return super.resolveProxyClass(interfaces);
}
catch (ClassNotFoundException ex) {
Class<?>[] resolvedInterfaces = new Class<?>[interfaces.length];
for (int i = 0; i < interfaces.length; i++) {
resolvedInterfaces[i] = resolveFallbackIfPossible(interfaces[i], ex);
}
return ClassUtils.createCompositeInterface(resolvedInterfaces, getFallbackClassLoader());
}
}
}
/**
* Resolve the given class name against a fallback class loader.
* <p>The default implementation simply rethrows the original exception,
* since there is no fallback available.
* @param className the class name to resolve
* @param ex the original exception thrown when attempting to load the class
* @return the newly resolved class (never {@code null})
*/
protected Class<?> resolveFallbackIfPossible(String className, ClassNotFoundException ex)
throws IOException, ClassNotFoundException{
throw ex;
}
/**
* Return the fallback ClassLoader to use when no ClassLoader was specified
* and ObjectInputStream's own default class loader failed.
* <p>The default implementation simply returns {@code null}, indicating
* that no specific fallback is available.
*/
@Nullable
protected ClassLoader getFallbackClassLoader() throws IOException {
return null;
}
}
resolveClass가 있는데, 해당 메서드는 readObject호출 할 때 호출 되며, 스트림에 저장된 클래스 패스를 이용하여
현재 구동된 자바 프로그램에서 class를 찾습니다.
찾지 못했을 경우, ClassNotFoundException이 발생하며, resolveFallbackIfPossible 메서드를 호출합니다.
이 resolveFallbackIfPossible 메서드를 직접 구현해봅시다.
public class PackageSafeValueSerializer implements Deserializer {
private static final String ORIGIN_CLASS_PATH = "com.joohyeok.jeong.spring.cache.a";
private static final String TARGET_CLASS_PATH = "com.joohyeok.jeong.spring.cache.b";
private final ClassLoader classLoader;
public PackageSafeValueSerializer(ClassLoader classLoader) {
this.classLoader = classLoader;
}
@Override
public Object deserialize(InputStream inputStream) throws IOException {
ObjectInputStream objectInputStream = new PackageSafeObjectStream(inputStream, this.classLoader);
try {
return objectInputStream.readObject();
} catch (ClassNotFoundException ex) {
throw new NestedIOException("Failed to deserialize object type", ex);
}
}
public class PackageSafeObjectStream extends ConfigurableObjectInputStream {
private final ClassLoader classLoader;
public PackageSafeObjectStream(InputStream in, ClassLoader classLoader) throws IOException {
super(in, classLoader);
this.classLoader = classLoader;
}
@Override
protected Class<?> resolveFallbackIfPossible(String className, ClassNotFoundException ex)
throws IOException, ClassNotFoundException {
if (className.startsWith(ORIGIN_CLASS_PATH)) {
String targetClassName = className.replace(ORIGIN_CLASS_PATH, TARGET_CLASS_PATH);
Class<?> aClass = ClassUtils.forName(targetClassName, this.classLoader);
if (Objects.nonNull(aClass)) {
return aClass;
}
}
throw ex;
}
}
}
ORIGIN_CLASS_PATH, TARGET_CLASS_PATH 을 정의해 두었으며, ORIGIN_CLASS_PATH로 시작하면, TARGET_CLASS_PATH을 변경한 뒤 클래스를 탐색합니다. 발견하면 해당클래스를 이용하여 역직렬화를 하게 됩니다.
테스트
redis 설치
테스트용 redis를 설치합니다.
https://redis.io/docs/getting-started/
프로젝트 생성
spring boot 프로젝트를 이용,
maven/gradle 에 spring-boot-starter-data-redis 의존성을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.7.0'
캐시 Config 클래스 생성
@Configuration
public class CacheConfig {
@Bean
LettuceConnectionFactory lettuceConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName("127.0.0.1");
redisStandaloneConfiguration.setPort(6379);
LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
return lettuceConnectionFactory;
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(lettuceConnectionFactory());
JdkSerializationRedisSerializer jdkSerializationRedisSerializer = new JdkSerializationRedisSerializer(
new SerializingConverter(),
new DeserializingConverter(new PackageSafeValueSerializer(template.getClass().getClassLoader()))
);
template.setValueSerializer(jdkSerializationRedisSerializer);
template.setKeySerializer(jdkSerializationRedisSerializer);
return template;
}
}
redis는 기본 주소 127.0.0.1:6379 로 세팅하였습니다.
RedisTemplate 내에 JdkSerializationRedisSerializer 를 생성하고, DeserializingConverter에 미리 정의한 PackageSafeValueSerializer를 포함시켜 줍니다.
테스트 코드 작성
junit을 활용합니다.
1. com.joohyeok.jeong.spring.cache.a 객체를 생성 후 캐시에 저장합니다.
2. com.joohyeok.jeong.spring.cache.a 클래스를 삭제합니다.
3. com.joohyeok.jeong.spring.cache.a 클래스로 역직렬화 합니다.
2에서 삭제하는 이유는 classLoader에 포함되게 하지 않기 위해서 입니다.
@SpringBootTest
class ApplicationTests {
private static final String key = "1";
@Autowired
private RedisTemplate redisTemplate;
/**
* TC1. com.joohyeok.jeong.spring.cache.a.TestObject 캐시 set
*/
@Test
void cacheTestA() {
TestObject testObject = new TestObject();
testObject.setName("테스트A");
testObject.setPrice(BigDecimal.valueOf(25_000L));
testObject.setStockQuantity(50L);
// 1. A 패키지 cache set
redisTemplate.opsForValue().set(key, testObject);
// 2. A 패키지 cache get
TestObject getObjectA = (TestObject)redisTemplate.opsForValue().get(key);
System.out.println(String.format("getObjectA : %s", getObjectA));
}
/**
* TC2. com.joohyeok.jeong.spring.cache.a.TestObject 클래스 삭제후 get
*/
@Test
public void cacheGetB(){
com.joohyeok.jeong.spring.cache.b.TestObject getObjectB = (com.joohyeok.jeong.spring.cache.b.TestObject)redisTemplate.opsForValue().get(key);
System.out.println(String.format("getObject : %s,", getObjectB));
}
}

직렬화한 class path와 역직렬화 하는 class path가 달라졌지만, 데이터를 정상적으로 가져올 수 있습니다.