개요
자바에는 직렬화 개념이 있습니다.
직렬화(直列化) 또는 시리얼라이제이션(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 |