글을 작성하게 된 계기
직렬화를 사용하면서 주의해야 할 점에 대해 학습하게 됐고, 이를 정리하기 위해 글을 작성하게 되었습니다.
1 직렬화/역 직렬화
먼저 직렬화/역직렬화의 개념에 대해 간단히 살펴본 후 주의할 점에 대해 살펴보겠습니다.
1-1. 직렬화
직렬화(Serialization)는 프로그램에서 사용하는 객체나 데이터 구조를 이진 데이터(Binary Data) 나 JSON, XML, YAML 등의 텍스트 형식 으로 변환해 파일에 저장하거나 네트워크를 통해 전송하기 위한 과정입니다. 이를 통해 프로그램에서 사용하는 객체나 데이터 구조를 플랫폼 독립적 으로 저장하고, 다른 시스템에서 재사용할 수 있도록 합니다.
1-2. 역직렬화
역직렬화(Deserialization)는 반대로 직렬화된 데이터를 다시 원래의 객체 나 데이터 구조 로 복원 하는 과정입니다. 이를 통해 네트워크나 파일 시스템을 통해 전송된 데이터를 다시 객체로 변환하여, 프로그램에서 해당 데이터를 활용할 수 있습니다.
2. 주의할 점
직렬화/역직렬화를 사용할 때, 다양한 주의점들이 있는데 이에 대해 살펴보겠습니다.
- 직렬화 필드 순서
- 가급적 UID를 지정하자
- 내부 클래스는 직렬화 하지 말자
- 깨지는 캡슐화
- 상속과 직렬화 문제
- transient는 특정 필드만 가린다
2-1. 직렬화 필드 순서
직렬화를 할 때, 특정 자료구조는 내부 순서 가 중요할 수 있습니다. 배열(Array), 리스트(List), Set, Map 등 몇 가지 경우에 대해 살펴보겠습니다. 컬렉션 예제에서는 아래 클래스를 사용해 필드 순서 때문에 어떤 문제가 발생할 수 있는지 살펴보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class PersonInfo(
@JsonProperty("name") val name: String,
@JsonProperty("age") val age: Int,
@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "hobbies")
@JsonProperty("hobbies") val hobbies: List<String>? = mutableListOf(),
@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "skills")
@JsonProperty("skills") val skills: Set<String>,
@JsonProperty("attributes") val attributes: Map<String, String>? = mutableMapOf(),
) {
constructor() : this("", 0, mutableListOf(), mutableSetOf(), mutableMapOf())
}
2-1-1. 배열
배열은 직렬화 시 순서를 유지해야만 올바르게 역직렬화할 수 있습니다. 아래와 같이 순서가 다른 배열이 있다면 직렬화/역직렬화를 했을 때 결과가 달라지게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@DisplayName("[UnitTest] Array Serialization Unit Test")
class ArraySerializationTest {
private val fileName = "files/array.ser"
@Test
fun whenArrayOrderIsChangedThenItShouldFail() {
val originalArray = intArrayOf(5, 10, 15, 20, 25)
val incorrectArray = intArrayOf(25, 20, 15, 10, 5)
serializeArray(originalArray)
val deserializedArray = deserializeArray() as IntArray
assertNotEquals(deserializedArray.joinToString(), incorrectArray.joinToString())
}
......
}
2-1-2. 리스트
리스트도 마찬가지로 순서가 중요합니다. 순서가 달라질 경우, 역직렬화했을 때 결과가 달라지기 때문입니다. 아래 XML/YAML의 리스트 순서를 변경하면 역직렬화 결과가 일치하지 않는 것을 볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@DisplayName("[UnitTest] Serialization Order Unit Test with Empty Fields")
class SerializationOrderUnitTest {
companion object {
private val xmlMapper = XmlMapper()
private val yamlMapper = YAMLMapper()
private const val ARRAY_FILE_NAME = "files/array.ser"
}
@Test
fun whenXmlListOrderChangedThenResultShouldNotBeEquals() {
val person = PersonInfo(
name = "Alice",
age = 0,
hobbies = listOf("Reading", "Hiking", "Swimming"),
skills = setOf(),
attributes = mapOf()
)
val xmlString = xmlMapper.writeValueAsString(person)
val xmlWithChangedOrder = """
<PersonInfo>
<name>Alice</name>
<age>0</age>
<hobbies>Swimming</hobbies>
<hobbies>Hiking</hobbies>
<hobbies>Reading</hobbies> <!-- Order Changed -->
<skills></skills>
<attributes></attributes>
</PersonInfo>
""".trimIndent()
val originalXML = xmlMapper.readValue(xmlString, PersonInfo::class.java)
val changedXML = xmlMapper.readValue(xmlWithChangedOrder, PersonInfo::class.java)
assertNotEquals(originalXML.hobbies, changedXML.hobbies)
}
@Test
fun whenYamlListOrderChangedThenResultShouldNotBeEquals() {
val person = PersonInfo(
name = "Alice",
age = 0,
hobbies = listOf("Reading", "Hiking", "Swimming"),
skills = setOf(),
attributes = mapOf()
)
val yamlString = yamlMapper.writeValueAsString(person)
val yamlWithChangedOrder = """
name: Alice
age: 0
hobbies:
- Swimming
- Hiking
- Reading # 순서 변경됨
skills: []
attributes: {}
""".trimIndent()
val originalYAML = yamlMapper.readValue(yamlString, PersonInfo::class.java)
val changedYAML = yamlMapper.readValue(yamlWithChangedOrder, PersonInfo::class.java)
assertNotEquals(originalYAML.hobbies, changedYAML.hobbies)
}
private fun serialize(array: Serializable) {
ObjectOutputStream(FileOutputStream(ARRAY_FILE_NAME)).use { oos ->
oos.writeObject(array)
}
}
private fun deserialize(): Any {
return ObjectInputStream(FileInputStream(ARRAY_FILE_NAME)).use { ois ->
ois.readObject()
}
}
}
2-1-3. Set
Set은 순서가 중요하지 않습니다. 순서가 달라지더라도, 역직렬화했을 때 결과는 동일하기 때문입니다. 아래 XML/YAML의 리스트 순서를 변경하면 역직렬화 결과가 일치하는 것을 볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@DisplayName("[UnitTest] Serialization Order Unit Test")
class SerializationOrderUnitTest {
companion object {
private val xmlMapper = XmlMapper()
private val yamlMapper = YAMLMapper()
private const val ARRAY_FILE_NAME = "files/array.ser"
}
@Test
fun whenSetOrderInXmlThenOrderShouldNotMatter() {
val person = PersonInfo(
name = "",
age = 0,
hobbies = listOf(),
skills = setOf("Programming", "Communication", "Problem-Solving"),
attributes = mapOf()
)
val xmlString = xmlMapper.writeValueAsString(person)
val xmlWithChangedOrder = """
<Person>
<name></name>
<age>0</age>
<skills>Problem-Solving</skills>
<skills>Communication</skills>
<skills>Programming</skills> <!-- 순서가 변경됨 -->
<hobbies></hobbies>
<attributes></attributes>
</Person>
""".trimIndent()
val originalXML = xmlMapper.readValue(xmlString, PersonInfo::class.java)
val changedXML = xmlMapper.readValue(xmlWithChangedOrder, PersonInfo::class.java)
assertEquals(originalXML.skills, changedXML.skills)
}
@Test
fun whenSetOrderInYamlThenOrderShouldNotMatter() {
val person = PersonInfo(
name = "",
age = 0,
hobbies = listOf(),
skills = setOf("Programming", "Communication", "Problem-Solving"),
attributes = mapOf()
)
val yamlString = yamlMapper.writeValueAsString(person)
val yamlWithChangedOrder = """
name: ""
age: 0
skills:
- Problem-Solving
- Communication
- Programming # 순서가 변경됨
hobbies: []
attributes: {}
""".trimIndent()
val originalYAML = yamlMapper.readValue(yamlString, PersonInfo::class.java)
val changedYAML = yamlMapper.readValue(yamlWithChangedOrder, PersonInfo::class.java)
assertEquals(originalYAML.skills, changedYAML.skills)
}
}
2-1-4. Map
Map도 순서가 중요하지 않습니다. 순서가 달라지더라도, 역직렬화했을 때 결과는 동일합니다. 아래 XML/YAML의 리스트 순서를 변경하면 역직렬화 결과가 일치하는 것을 볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@DisplayName("[UnitTest] Serialization Order Unit Test with Empty Fields")
class SerializationOrderUnitTest {
companion object {
private val xmlMapper = XmlMapper()
private val yamlMapper = YAMLMapper()
private const val ARRAY_FILE_NAME = "files/array.ser"
}
@Test
fun whenMapOrderChangedThenResultShouldBeEquals() {
val person = PersonInfo(
name = "",
age = 0,
hobbies = listOf(),
skills = setOf(),
attributes = mapOf("height" to "170", "weight" to "60")
)
val xmlString = xmlMapper.writeValueAsString(person)
val xmlWithChangedOrder = """
<Person>
<name></name>
<age>0</age>
<hobbies></hobbies>
<skills></skills>
<attributes>
<weight>60</weight> <!-- 순서 변경됨 -->
<height>170</height>
</attributes>
</Person>
""".trimIndent()
val originalXML = xmlMapper.readValue(xmlString, PersonInfo::class.java)
val changedXML = xmlMapper.readValue(xmlWithChangedOrder, PersonInfo::class.java)
assertEquals(originalXML.attributes, changedXML.attributes)
}
@Test
fun whenMapOrderChangedInYamlThenResultShouldBeEquals() {
val person = PersonInfo(
name = "",
age = 0,
hobbies = listOf(),
skills = setOf(),
attributes = mapOf("height" to "170", "weight" to "60")
)
val yamlString = yamlMapper.writeValueAsString(person)
val yamlWithChangedOrder = """
name: ""
age: 0
hobbies: []
skills: []
attributes:
weight: 60 # 순서 변경됨
height: 170
""".trimIndent()
val originalYAML = yamlMapper.readValue(yamlString, PersonInfo::class.java)
val changedYAML = yamlMapper.readValue(yamlWithChangedOrder, PersonInfo::class.java)
assertEquals(originalYAML.attributes, changedYAML.attributes)
}
private fun serialize(array: Serializable) {
ObjectOutputStream(FileOutputStream(ARRAY_FILE_NAME)).use { oos ->
oos.writeObject(array)
}
}
private fun deserialize(): Any {
return ObjectInputStream(FileInputStream(ARRAY_FILE_NAME)).use { ois ->
ois.readObject()
}
}
}
2-2. 가급적 UID를 지정하자
직렬화를 할 때, UID(Unique Identifier)를 지정하지 않으면 랜덤 UID 가 할당됩니다. UID는 자바에서 객체 직렬화 시 사용하는 고유 식별자로, 클래스 버전을 나타내는 용도로 사용 됩니다. 이 값은 별도로 지정하지 않으면 JVM 자동으로 랜덤하게 생성합니다.
예를 들어, 아래와 같이 Email 클래스가 Serializable을 구현했을 때, serialVersionUID 을 지정하지 않으면 -3205712491164267078 라는 값이 자동 할당된 것을 볼 수 있습니다.
1
2
3
4
data class Email(
val subject: String,
val body: String,
) : Serializable
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@DisplayName("[UnitTest] RandomUID Test")
class EmailSerializationTest {
private val log = LoggerFactory.getLogger(EmailSerializationTest::class.java)
@Test
fun whenSerializeWithNotFixedUidThenUidShouldNotBeNull() {
val clazz = Email::class.java
val serialVersionUID = getSerialVersionUID(clazz)
log.info("UID: $serialVersionUID")
assertNotNull(serialVersionUID)
}
private fun getSerialVersionUID(clazz: Class<*>): Long {
return try {
val field: Field = clazz.getDeclaredField("serialVersionUID")
field.isAccessible = true
field.getLong(null)
} catch (ex: NoSuchFieldException) {
ObjectStreamClass.lookup(clazz).serialVersionUID
}
}
}
INFO project.io.serialization.EmailSerializationTest – UID: -3205712491164267078
이 값은 클래스 구조가 변경될 때마다 함께 변경됩니다. 즉, 클래스의 이름, 필드, 메서드 시그니처 등이 변경되면 serialVersionUID가 달라지므로, 이전에 직렬화한 객체는 더 이상 호환되지 않게 됩니다. 이를 직렬화 호환성 문제 라고 부릅니다.
예를 들어, 아래 테스트에서 1번 테스트로 직렬화 파일을 생성한 후, DataV0의 주석을 제거합니다. 이후 2번 테스트를 실행하면 InvalidClassException 이 발생하게 되는데, 이렇게 데이터 구조가 변경되면 UID도 함께 변경 되므로 역직렬화가 실패할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@DisplayName("[UnitTest] Changed DataStructure Serialization Unit Test")
class SerializationTest {
companion object {
private const val FILE = "files/personV1.ser"
}
/**
* 1. 테스트 실행을 통해 files/personV1.ser 파일을 생성
* */
@Test
fun whenSerializeObjThenFileShouldBeMade() {
val dataV1 = DataV0("Alice", 30)
val fileOut = FileOutputStream(FILE)
ObjectOutputStream(fileOut).use {
it.writeObject(dataV1)
}
val fileIn = FileInputStream(FILE)
val deserializedData = ObjectInputStream(fileIn).use {
it.readObject() as DataV0
}
assertNotNull(deserializedData)
}
/**
* 2. DavaV0의 address 주석을 제거해 구조를 바꾼 후 테스트.
* 데이터 구조가 변경되었기 때문에 예외 발생
* */
@Test
fun whenSerializeObjThfenFileShouldBeMade() {
val fileIn = FileInputStream(FILE)
val deserializedData = ObjectInputStream(fileIn).use {
it.readObject() as DataV0
}
assertNotNull(deserializedData)
}
}
internal class DataV0(
private val name: String,
private val age: Int,
// private var address: String = "KOREA"
) : Serializable {
override fun toString(): String {
return "PersonV0(name='$name', age=$age)"
}
}
java.io.InvalidClassException: project.io.serialization.DataV0; local class incompatible: stream classdesc serialVersionUID = 478519579614515634, local class serialVersionUID = 3248600178962760914
Serializable은 한 번 구현하면 릴리스한 뒤 수정하기 힘듭니다. 이는 직렬화된 byte 스트림 인코딩도 하나의 공개 API가 되며, 공개 API가 되면 직렬화 형태도 영원히 지원해야 하기 때문입니다. 이를 해결하기 위해서는 기본 값 을 설정하고 serialVersionUID를 유지 합니다.
1
2
3
4
5
6
7
8
9
data class Person(
val name: String,
val age: Int,
val address: String? = null // 필드 추가, 기본값 설정
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L // UID를 명시적으로 지정
}
}
2-3. 내부 클래스는 직렬화 하지 말자
비정적 내부 클래스는 직렬화를 구현하지 않는 것이 좋습니다. 이는 외부 클래스의 인스턴스에 대한 참조를 암묵적으로 포함하고 있기 때문에 외부 클래스에 대한 의존성 을 가지며, 직렬화 성능 문제 를 야기할 수 있기 때문입니다.
비정적 내부 클래스는 외부 클래스의 인스턴스를 암묵적으로 참조 합니다. 즉, 내부 클래스를 직렬화하면, 외부 클래스의 인스턴스도 함께 직렬화되기 때문입니다. 이로 인해 직렬화 과정이 불필요하게 복잡해집니다.
1
2
3
class Outer(val outerField: String) : Serializable {
inner class Inner(val innerField: String) : Serializable
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class InnerClassSerializationTest {
private val fileName = "files/inner.ser"
@Test
fun whenSerializeInnerClaseeThenOuterClassMustBeReferenced() {
val outer = Outer("Outer Field")
val inner = outer.Inner("Inner Field") // 외부 클래스 참조
serialize(inner, fileName)
val deserializedInner = deserialize(fileName) as Outer.Inner
assertEquals(inner.innerField, deserializedInner.innerField)
}
private fun serialize(obj: Serializable, fileName: String) {
ObjectOutputStream(FileOutputStream(fileName)).use { it.writeObject(obj) }
}
private fun deserialize(fileName: String): Any {
return ObjectInputStream(FileInputStream(fileName)).use { it.readObject() }
}
}
또한 역직렬화할 때, 외부 클래스의 인스턴스가 존재하지 않거나 구조가 달라지면, 역직렬화 과정에서 예상치 못한 오류가 발생할 수 있으며, 사용하지 않는 객체 참조로 인해 메모리가 낭비 되고 성능이 안 좋아 질 수도 있죠. 이를 해결하기 위해서는 정적 중첩 클래스 를 사용할 수 있습니다. 정적 중첩 클래스는 외부 클래스에 대한 참조가 없기 때문에 독립적으로 직렬화되기 때문입니다.
1
2
3
class OuterWithStatic(val outerField: String) : Serializable {
class Inner(val innerField: String) : Serializable
}
2-4. 깨지는 캡슐화
자바에서 기본 직렬화를 사용하면, 클래스의 모든 필드 가 접근 제한자와 상관없이 직렬화에 포함 됩니다. 예를 들어, private, protected, 또는 package-private 필드도 직렬화되어 byte 스트림으로 변환됩니다. 이는 역직렬화 시 해당 객체의 상태를 완벽하게 복원하는 데 유용하지만, 클래스의 내부 구현 세부 사항까지 외부에 노출될 위험이 있습니다.
즉, 클래스가 private이나 package-private 필드를 직렬화하는 것은, 외부에서 이 필드들에 직접 접근할 수 없도록 만든 캡슐화를 직렬화가 깨트릴 수 있다는 의미입니다.
직렬화된 데이터에 모든 필드의 값이 포함되므로, 이 데이터를 역직렬화할 때는 이 필드들이 노출되는 것과 마찬가지입니다. 직렬화를 통해 외부에 원래는 보이지 않던 private 필드의 값도 외부에 노출되게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person(
private val name: String,
private val age: Int
) : Serializable {
override fun toString(): String {
return "Person(name='$name', age=$age)"
}
}
fun main() {
val person = Person("Alice", 30)
val fileOut = FileOutputStream("person.ser")
ObjectOutputStream(fileOut).use { it.writeObject(person) }
val fileIn = FileInputStream("person.ser")
val deserializedPerson = ObjectInputStream(fileIn).use { it.readObject() as Person }
println("Deserialized Person: $deserializedPerson")
}
1
Deserialized Person: Person(name='Alice', age=30)
2-5. 상속과 직렬화 문제
상속 구조에서 직렬화를 구현할 때는 상위 클래스가 Serializable을 구현하지 않으면 하위 클래스의 직렬화도 제대로 작동하지 않습니다. 특히 상위 클래스에 매개변수가 없는 기본 생성자가 없으면, 자바는 역직렬화 과정에서 객체를 생성할 수 없어 예외가 발생합니다. 이런 경우에는 직렬화 프록시 패턴처럼 복잡한 우회 방법을 사용해야 하므로 설계 부담이 커집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Person private constructor(
private val name: String,
private val age: Int
) : Serializable {
override fun toString(): String = "Person(name=$name, age=$age)"
// 정적 팩토리 메서드 사용
companion object {
fun of(name: String, age: Int): Person = Person(name, age)
private const val serialVersionUID = 1L
}
// 프록시로 대체
private fun writeReplace(): Any = PersonProxy(name, age)
// 역직렬화 방지
private fun readObject(stream: java.io.ObjectInputStream) {
throw java.io.InvalidObjectException("Proxy required")
}
// 내부 프록시 클래스
private class PersonProxy(
private val name: String,
private val age: Int
) : Serializable {
companion object {
private const val serialVersionUID = 1L
}
private fun readResolve(): Any {
return Person.of(name, age)
}
}
}
또한 내부 클래스는 외부 클래스의 인스턴스를 암묵적으로 참조하기 때문에, 직렬화 시 외부 클래스도 함께 직렬화됩니다. 이로 인해 불필요한 의존성과 메모리 낭비가 발생하고, 구조 변경 시 역직렬화 오류가 날 수 있습니다. 따라서 내부 클래스는 직렬화 대상에서 제외하고, 필요할 경우 외부 클래스에 대한 참조가 없는 정적 중첩 클래스로 분리하는 것이 바람직합니다.
1
2
3
4
5
class OuterStatic(val outerField: String) : Serializable {
class Inner(val innerField: String) : Serializable {
override fun toString(): String = "Inner(innerField=$innerField)"
}
}
BigInteger는 자바에서 Serializable을 구현하고 있지만, 기본 직렬화 방식에 의존하고 있어 여러 문제를 안고 있습니다. 기본 직렬화는 객체의 내부 구조를 그대로 저장하는 방식으로, BigInteger의 경우 signum과 magnitude라는 내부 필드가 그대로 byte 스트림에 포함됩니다. 이는 객체의 논리적 상태만 저장하는 것이 아니라 내부 구현 방식 자체가 직렬화 포맷에 고정된다는 것을 의미합니다.
1
2
private int signum;
private int[] magnitude;
2-6. transient는 기본 값으로 복원된다
transient로 선언된 필드는 직렬화 과정에서 직렬화되지 않고, 역직렬화 시 기본값으로 복원됩니다. 예를 들어 int형 필드는 0, Object형 필드는 null로 설정됩니다. transient는 직렬화 과정의 세부 제어가 부족합니다. 즉, transient는 특정 필드를 직렬화에서 제외할 수 있을 뿐, 직렬화 및 역직렬화 과정의 전체 흐름을 제어할 수는 없습니다. 만약 transient 필드의 값을 특정 방식으로 처리하거나, 복잡한 계산을 통해 값을 저장하거나 복원해야 한다면, writeObject( )와 readObject( ) 같은 커스텀 직렬화 메서드가 필요합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
data class User(
val id: Long,
val name: String,
@Transient private var password: String? = null
) : Serializable {
fun getPassword(): String? = password
private fun writeObject(out: ObjectOutputStream) {
out.defaultWriteObject()
// password는 직렬화에서 제외됨
}
private fun readObject(input: ObjectInputStream) {
input.defaultReadObject()
// 복원 로직: 기본값 설정
password = "defaultPassword"
}
companion object {
private const val serialVersionUID: Long = 1L
}
}
transient 키워드는 직렬화 대상에서 특정 필드를 제외할 때 사용되지만, 이 필드를 제외한다고 해서 직렬화에 대한 모든 의존성을 해결할 수 있는 것은 아닙니다. 클래스 내의 다른 필드들은 여전히 직렬화된 데이터에 포함되기 때문에, 클래스 구조가 변경될 경우 여전히 호환성 문제가 발생할 수 있습니다.
1
2
3
4
5
6
7
8
9
data class UserV1(
val id: Long,
val name: String,
@Transient val password: String? = null
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}
1
2
3
4
5
6
7
8
9
10
data class UserV2(
val id: Long,
val name: String,
val email: String = "", // 새 필드 추가로 구조 변경
@Transient val password: String? = null
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}
자바에서 기본 직렬화는 객체의 현재 상태를 그대로 저장합니다. 객체가 다른 객체를 참조하고 있으면, 그 참조 대상도 같이 저장되죠. 즉, 메모리에 있는 구조 자체를 그대로 따라가면서 저장하는 방식입니다. 이걸 “객체 그래프의 물리적 모습”이라고 부르는데, 즉 객체가 가진 필드를 통째로 byte로 넣는다 는 것 입니다. 문제는 이렇게 저장하면 내부 구현 방식이 그대로 직렬화 데이터에 묶이기 때문에 필드가 하나만 바뀌어도 이전 데이터랑 호환이 안 돼요.
마치 클래스 안에 있는 private 필드까지 외부에 공개 API처럼 박제되는 거죠. 그래서 구조를 바꾸기 어렵고, 영원히 그 구조에 묶여야 합니다.
게다가 필요 없는 내부 필드까지 다 저장되니 용량 낭비가 심하고, 객체 구조가 복잡하면 저장하거나 복원할 때도 시간이 오래 걸립니다. 순환 참조라도 있으면 최악의 경우 스택 오버플로우도 납니다. 그래서 기본 직렬화를 쓸 땐 readObject( ) 같은 메서드를 만들어서 최소한의 검증이나 보호 장치를 추가해주는 게 좋고, 구조가 조금이라도 복잡하거나 변경될 가능성이 있다면 그냥 프록시 패턴을 쓰거나 아예 JSON, protobuf 같은 포맷으로 바꾸는 게 낫습니다.