직렬화, Serialization #1 - Java Serializable

choi jeong heon
슬기로운 개발생활
12 min readDec 4, 2021

직렬화 왜 해야 하는가?

다차원의 자료를 파일로 저장하거나 네트워크로 보내기에 알맞게 일차원으로 펼치고 다시 원래대로 되돌리는 것을 우리는 직렬화(serialization)라고 부릅니다. 프로토콜의 바이트 인코딩은 직렬화가 쓰이는 대표적인 예 입니다.

JVM 메모리 구조는 크게 2가지 입니다.

  1. Value Type , Primitive type NonNull, Wrapper Class가 있음, Stack 메모리에 저장됨, 값을 저장
  2. Reference Type Primitive type 제외한 모든 타입들. Nullable, 값이 저장되어있는 주소값을 저장. 하나의 인스턴스로 존재. Stack에는 참조값만 있고, 실제 값은 Heap에 존재

이 데이터들을 통신하거나 디스크에 저장하려면 어떻게 해야 할까?

Value Type 은 디스크에 그냥 저장했다가 불러오면 됩니다. 하지만 Referece Type은 ? 주소를 디스크에 저장해봤자 다음에 불러왔을 때 그 주소가 유효할지 보장할 수 없습니다. 메모리 어디에 로드될지 모르기 때문입니다.

네트워크 통신도 마찬가지입니다. 다른 Host로 전송한 객체의 주소값이 의미가 있을까요? 해당 Host에서 해당 주소에 뭐가 들어있을 줄 모르죠..

이런 이유들 때문에 각 주소값이 가지는 데이터들을 모아서 Value Type 으로 변환해야 합니다. 이를 데이터 직렬화 라고 합니다.

Java 직렬화, Serializable

정의

  • 자바 직렬화란 자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에서도 사용할 수 있도록 바이트(byte) 형태로 데이터 변환하는 기술과 바이트로 변환된 데이터를 다시 객체로 변환하는 기술(역직렬화)을 아울러서 이야기합니다.
  • 시스템적으로 이야기하자면 JVM(Java Virtual Machine 이하 JVM)의 메모리에 상주(힙 또는 스택)되어 있는 객체 데이터를 바이트 형태로 변환하는 기술과 직렬화된 바이트 형태의 데이터를 객체로 변환해서 JVM으로 상주시키는 형태를 같이 이야기합니다.

어떻게?

java.io.Serializable 인터페이스를 상속받으면 직렬화 할 수 있는 클래스가 됩니다.

class Person(val name: String): Serializable

Serializable

Serializable 은 Android SDK 가 아닌 표준 Java 의 인터페이스입니다.

val kim = Person("Kim")

var serializedPerson: ByteArray
ByteArrayOutputStream().use { baos ->
ObjectOutputStream(baos).use { oos ->
oos.writeObject(kim)

serializedPerson = baos.toByteArray()
}
}

직렬화는 java.io.ObjectOutputStream 을 이용하게 되는데, writeObject() 가 object를 OutputStream으로 만드는 함수입니다.

writeObject() 내부 함수를 쫓아가다 보면,

// BEGIN Android-changed: Make Class and ObjectStreamClass replaceable.
if (obj instanceof Class) {
writeClass((Class) obj, unshared);
} else if (obj instanceof ObjectStreamClass) {
writeClassDesc((ObjectStreamClass) obj, unshared);
// END Android-changed: Make Class and ObjectStreamClass replaceable.
} else if (obj instanceof String) {
writeString((String) obj, unshared);
} else if (cl.isArray()) {
writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
writeOrdinaryObject(obj, desc, unshared);
} else {
if (extendedDebugInfo) {
throw new NotSerializableException(
cl.getName() + "\\n" + debugInfoStack.toString());
} else {
throw new NotSerializableException(cl.getName());
}
}

직렬화 하길 원하는 object의 type을 체크하는 로직이 있습니다. 기본적으로 Class, ObjectStreamClass, String, Enum은 직렬화할 수 있고, 그 외에는 Serializable을 상속받아야 가능합니다. 그렇지 않으면 Exception을 뱉어내도록 되어있네요.

참고로, 주석을 잘 보면 Android 개발자들이 ObjectOutputStream 클래스를 일부 수정한 것을 알 수 있습니다.

reflection

Java 직렬화는 리플렉션을 사용하여 직렬화해야 하는 객체 필드의 모든 데이터를 긁어냅니다. 여기에는 private 및 final 필드가 포함됩니다. 필드에 다른 class가 포함된 경우 해당 class는 재귀적으로 직렬화됩니다. getter와 setter가 있더라도 이러한 함수는 Java에서 개체를 직렬화할 때 사용되지 않습니다.

바이트 스트림을 객체로 역직렬화할 때 생성자를 사용하지 않습니다. 빈 개체를 만들고 리플렉션을 사용하여 필드에 데이터를 씁니다. 직렬화와 마찬가지로 private 및 final 필드도 포함됩니다.

역직렬화 조건

  • 직렬화 대상이 된 객체의 클래스가 클래스 패스에 존재해야 하며 import 되어 있어야 합니다.
  • 중요한 점은 직렬화와 역직렬화를 진행하는 시스템이 서로 다를 수 있다는 것을 반드시 고려해야 합니다.(같은 시스템 내부이라도 소스 버전이 다를 수 있습니다)
  • 자바 직렬화 대상 객체는 동일한 serialVersionUID 를 가지고 있어야 합니다.

장점

Serializable은 해당 클래스가 직렬화 대상이라고 알려주기만 할뿐 어떠한 메서드도 가지지 않는 단순한 "마커 인터페이스" 이므로, 사용자는 매우 쉽게 사용할 수 있습니다.

객체를 어떻게 직렬화할지에 대한 코드가 ObjectOutputStream 에 이미 다 구현되어있으므로 Serializable만 상속받으면 됩니다.

단점

  1. 비싸다
    사용 방법이 쉽다는 것은 곧 시스템 적인 비용이 비싸다는 것을 의미합니다.
    Serializable은 내부에서 Reflection을 사용하여 직렬화를 처리합니다.
    Reflection은 프로세스 동작 중에서 사용되며 처리 과정 중에 많은 추가 객체를 생성합니다. 이 많은 쓰레기들은 가비지 컬렉터의 타겟이 되고 가비지 컬렉터의 과도한 동작으로 인하여 성능 저하 및 배터리 소모가 발생합니다.
    뿐만 아니라 용량 문제도 있습니다.
    자바 직렬화시에 클래스의 메타 정보도 가지고 있기 때문에 JSON 같은 최소의 메타정보만 가지는 포맷에 비해 용량 측면에서 차이가 있습니다.
  2. 보안 취약점
    또한, 보안 취약점도 가지고 있습니다.
    앞에서 Java deserialization이 생성자를 사용하여 객체를 생성하는 것이 아니라 리플렉션을 사용하여 필드를 로드한다고 설명했습니다. 즉, 생성자에서 수행된 유효성 검사는 개체를 다시 만들 때 호출되지 않습니다.
    예를 들어,
public class ValueObject implements Serializable {   private String value;
private String sideEffect;
public ValueObject() {
this("empty");
}
public ValueObject(String value) {
this.value = value;
this.sideEffect = java.time.LocalTime.now().toString();
}
}
ValueObject vo1 = new ValueObject("Hi");
FileOutputStream fileOut = new FileOutputStream("ValueObject.ser");
ObjectOutputStream out = new ObjectOutputStream(fileOut);
out.writeObject(vo1);
out.close();
fileOut.close();

실제로 바이트로 바뀐 객체를 string으로 읽으면 위와 같이 보입니다. 만약 해커가 파일에 접근했다면 저 Hi 라는 값을 쉽게 다른 값으로 바꿀 수 있습니다.

FileInputStream fileIn = new FileInputStream("ValueObject2.ser");
ObjectInputStream in = new ObjectInputStream(fileIn);
ValueObject vo2 = (ValueObject) in.readObject();

바꾼 걸 다시 역직렬화 하면 실제로 값이 바뀌었음을 확인할 수 있습니다.

class Person(val name: String, var age: Int = 0): Serializable {
init {
setInt(age)
}
fun setInt(newAge: Int) {
if (newAge < 0) this.age = 1
else this.age = newAge
}
}

만약 위와 같이 setter에서 값의 유효성 검사를 하고 있더라도, deserialization할 때 setter가 호출되지 않으므로 해커가 age 값을 음수로 바꾸면 그 값이 그대로 들어가게 됩니다.

3. 클래스 구조 변경 문제

이건 직렬화 자체의 문제가 아니라, 직렬화한 데이터를 저장하고 다시 사용할때 발생할 수 있는 문제입니다. 아래의 클래스를 직렬화한 데이터를 보관하고 있다고 생각해봅시다.

class Person(val name: String): Serializable

그러다가 요구사항이 변경되어, Person 클래스가 아래와 같이 수정되었습니다.

class Person(val name: String, val age: Int): Serializable

이 상태에서 이전에 보관했던 직렬화 데이터를 역직렬화시키면 어떻게 될까요?

java.io.InvalidClassException 이 발생합니다.

예외 메시지를 읽어보면 serialVersionUID 가 일치하지 않는다고 나와있습니다.

SUID 값은 우리가 설정하지 않으면 클래스의 기본 해쉬값으로 자동 설정됩니다. 클래스를 수정하면 이 값이 변경되기 때문에 보관했던 데이터의 SUID 값과 수정된 클래스의 SUID 값이 일치하지 않게 됩니다.

class Person(val name: String, val age: Int): Serializable {
companion object {
private const val serialVersionUID = 1L
}
}

위와 같이 우리가 임의로 SUID 값을 고정시킬 수 있습니다.

이렇게 하면 클래스의 멤버가 추가되거나, 삭제되는 경우에 예외가 발생하지 않지만, 기존 멤버의 타입이 변경되면 여전히 예외를 발생하게 됩니다.

마무리하며

Serializable을 사용한다면, 용량 문제와 클래스 구조 변경 문제가 있기 때문에 클래스 구조가 단순하고 변경될 일이 없는 경우에 사용해야 할 것 같습니다.

하지만 클래스가 변경될 수 없다는 것을 과연 장담할 수 있을지 의문이 듭니다. 안드로이드 문서에서도 Serializable 대신 Json을 추천하고 있기에 Serializable 사용은 지양하는 것이 좋아보입니다.

참고 자료

--

--