직렬화에 대한 기본적인 내용만 정리했으며 read, write 커스텀같은 경우 다른 글로 정리할 예정
오브젝트 -> byte stream으로 컨버팅하는 기술을 의미한다.
반대 방향인 byte stream -> 오브젝트는 역직렬화(Deserialization)이라고 한다.
각각의 프로세스는 별도의 메모리를 가지고있기에 프로세스 간 데이터 공유를 위해서는 별도의 기법이 필요하다. 이를 IPC(Inter-Process Communication)이라고 한다.
안드로이드에서는 프로세스 간 통신을 지원하는 Binder를 사용할 수 있다. IPC를 통해 다른 프로세스에 byte stream을 전달할 수 있다.
여기서 나온 IPC와 Binder에 대해서는 안드로이드의 Intent에 대해 정리할 때 같이 챙길 개념이다.
여기서 Serialzable은 마커 인터페이스로 따로 구현할 내용이 없는 인터페이스다. 직렬화에 대한 처리는 JVM 내부에서 자동으로 프로세스를 통해 처리하게 된다.
ObjectOutputStream.writeObject 함수를 통해서 오브젝트를 byteStream으로 변경하는 방법으로 직렬화를 하고 반대의 경우 ObjectInputStream.readObject를 통해서 역질렬화를 하게된다. 이때 Serializable 인터페이스를 구현하는 모든 객체는 고유의 식별번호 (SUID)를 부여받는다. 이 식별번호를 통해서 직렬화, 역직렬화 시 동일한 객체인지를 검사한다. 따라서 객체의 구성이 변경되면 식별번호가 달라지기에 InvalidClassException이 발생할 수 있다.
기본값으로 런타임에 JVM이 별도의 해시함수를 통해 자동설정해주긴 하지만 안전한 작업을 위해서는 수동으로 관리해주는 것을 권장하고있다.
수동으로 관리한다고해도 완벽하다고는 할 수 없다. 새로운 필드의 추가뿐만 아니라 타입이 변경되어도 InvalidClassException은 발생한다.
ObjectInputStream.readObject 단계에서 reflection을 통해 불필요한 객체가 생성된다. 이러한 객체들은 성능적으로 좋지 않다는 단점이 있다. 개발자가 직접 readObject, writeObject를 구현함으로 이런 문제를 개선할 수 있다.
역직렬화 시 객체 그래프가 역직렬화되면서 classPath 안의 모든 타입의 객체를 만드는데 이때 모든 코드가 수행가능해지기에 전체 프로젝트가 공격 범위가 포함된다고 한다. 직렬화를 제외할 수 없는 환경이라면 역직렬화 필터링(ObjectInputFilter)같은 역직렬화 방어 기법을 사용하라고 한다.
Serializable과 동일하게 직렬화에 사용되는 인터페이스다. 다만 안드로이드 의존성을 가지고 있다.
class TestClass() : Parcelable {
var name: String? = ""
var coinCount: Int = 0
var level: Int = 1
constructor(parcel: Parcel) : this() {
name = parcel.readString()
coinCount = parcel.readInt()
level = parcel.readInt()
}
override fun describeContents(): Int {
TODO("Not yet implemented")
}
override fun writeToParcel(dest: Parcel, flags: Int) {
TODO("Not yet implemented")
}
companion object CREATOR : Parcelable.Creator<TestClass> {
override fun createFromParcel(parcel: Parcel): TestClass {
return TestClass(parcel)
}
override fun newArray(size: Int): Array<TestClass?> {
return arrayOfNulls(size)
}
}
}
Parcelable 인터페이스는 직렬화 방식을 개발자가 직접 작성할 수 있도록 메서드를 제공해준다. 따라서 Serializable과 달리 자동으로 처리되지 않으며 Reflection도 존재하지 않는다.
writeToParcel메서드를 통해 이루어진다. byteStream으로 생성하는것이 아닌 Parcel객체를 통해 직렬화할 데이터를 만든다.
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.run {
writeString(this@TestClass.name)
writeInt(this@TestClass.coinCount)
writeInt(this@TestClass.level)
}
}
생성되는 Creator를 통해 진행된다.
companion object CREATOR : Parcelable.Creator<TestClass> {
override fun createFromParcel(parcel: Parcel): TestClass {
return TestClass(parcel)
}
override fun newArray(size: Int): Array<TestClass?> {
return arrayOfNulls(size)
}
}
Parcel을 받는 생성자는
constructor(parcel: Parcel) : this() {
name = parcel.readString()
coinCount = parcel.readInt()
level = parcel.readInt()
}
과 같이 생성해주면 된다.
직렬화, 역직렬화에 대한 과정을 커스텀하기 쉽다는 장점이 있지만 많은 보일러플레이트가 생성된다는게 단점이다 이를 해결하기위해 안드로이드는 @Percelize 어노테이션을 지원해준다.
kotlin-parcelize플러그인이 자동으로 보일러플레이트 코드를 생성해준다.
그럼 어느때에 어느걸 사용하는게 좋을까?
먼저 성능으로 생각해보면 Serializable은 Parcelable에 대해서 느리다고 하지만 이는 Serializable은 모든 클래스에 대한 검사를 하다보니 불필요한 코드가 많이 생성되고 느려지지만 Parcelable은 하나의 클래스 객체만을 위한 커스텀 코드가 들어가기에 쓰레기를 생성할 이유가 없고 그 결과 성능이 더 좋다.
위에서 언급한 것 처럼 Serializable의 직렬화, 역직렬화 과정을 readObject, writeObject로 커스텀이 가능하다. 해당 커스텀을 한 뒤 진행한 결과에서는 Serializable가 쓰기 속도가 3배 더, 읽기의 경우는 1.6배더 빠르다는 결과가 나왔다고 한다. 출처
도메인 레이어처럼 안드로이드와 독립된 파트면 Serializable을 커스텀 없이 사용해도 괜찮을 것 같다. 그 외에는 Intent는 데이터 전달 시 putParcelableArrayListExtra을 통해 arrayList도 넘길 수 있도 있고 어노테이션으로 간단하게 처리가 간단한 Parcelable이 사용에 더 편하다고 생각한다.
만약 0.1초의 차이라도 줄여야하는 상황이라면 Serializable 커스텀을 통해 읽기, 쓰기에 대한 최적화를 해야하지만 그게 아니라면 간편하고 정확한 기능구현에 더 집중하는 것이 좋을 것 같다.