개요


자바에는 직렬화 개념이 있습니다. 

직렬화(直列化) 또는 시리얼라이제이션(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가 달라졌지만, 데이터를 정상적으로 가져올 수 있습니다. 

'백엔드 > spring' 카테고리의 다른 글

ErrorHandlingDeserializer  (0) 2025.02.11
@Transactional 과 외부 시스템 연동  (0) 2024.12.23
@Transactional(readOnly=true)  (0) 2022.06.13
@InitBinder autoGrowCollectionLimit  (0) 2022.06.04

+ Recent posts