public class Person {
private String name;
public Person(String name) {
this.name = name;
}
}
위와 같은 클래스가 있다고 가정할 때, Json 데이터 형식을 예로 들면
Person person = new Person("김철수");
객체를 Json 형식인 {"name" : "김철수"}
로 변경하는 것을 직렬화, {"name" : "김철수"}
데이터를 받아서 Person이라는 객체의 name필드에 "김철수"
를 할당하고 객체를 생성하는 것을 역직렬화라고 할 수 있습니다.
자바에는 Primitive Type이 byte, int, char등 총 8가지가 있습니다. 그리고 그 외 객체(Reference Type : String, Integer..등)들은 주소값을 갖는 참조형 타입입니다.
Primitive Type은 stack에서 값 그 자체를 가지고 있어 외부로 데이터를 전달할 때 값을 일정한 형식의 raw byte 형태로 변경하여 전달할 수 있습니다.
하지만 위 그림과 같이 Reference Type 객체의 경우 실제로 Heap 영역에 존재하고, 스택에서는 Heap 영역에 존재하고 있는 객체의 주소(메모리 주소)를 가지고 있습니다.
위 주소값을 그대로 다른 곳에 보낸다고 가정한다면 ❓
먼저 프로그램이 종료되거나 객체가 쓸모없다고 판단되면 Heap 영역에 있던 데이터는 제거되고, 따라서 본인 메모리에서도 데이터가 사라지게 됩니다.
외부로 전송했다고 가정했을때도, 전송받은 기기의 메모리 주소에 내가 전송하려고 했던 데이터는 존재할 리가 없습니다.
따라서 이 주소값의 데이터(실체)를 Primitive한 값 형식 데이터로 변환하는 작업을 거친 후, 전달해야 합니다. 그렇게 해야 파일 저장이나 네트워크 전송시 Parsing할 수 있는 유의미한 데이터가 됩니다.
자바의 primitive type과 java.io.Serializable
인터페이스를 상속받은 객체는 직렬화 할 수 있는 기본 조건을 가지게 됩니다.
public class Person implements Serializable {
private String name;
public Person(String name) {
this.name = name;
}
// Getter 생략 . .
@Override
public String toString() {
return String.format("Person", name);
}
}
직렬화 하는 방법은 java.io.ObjectOutputStream
객체를 이용합니다.
Person person = new Person("김철수");
byte serializedPerson[];
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(person);
// serializedPerson -> 직렬화된 person 객체
serializedPerson = baos.toByteArray();
}
}
// 바이트 배열로 생성된 직렬화 데이터를 base64로 변환
System.out.println(Base64.getEncoder().encodeToString(serializedPerson));
위 예제는 객체를 직렬화하여 바이트 배열(byte[]) 형태로 변환해보았습니다.
역직렬화를 구현해보기 전에 역직렬화의 조건에 대해 먼저 알아보겠습니다.
import
가 되어있어야 합니다.serialVersionUID
를 가지고 있어야 합니다.private static final long serialVersionUID = 1L;
하지만 Person 객체를 직렬화할 땐 serialVersionUID
를 설정하지 않았는데 ? serialVersionUID
가 뭔데 ?
이 얘기는 역직렬화를 구현한 뒤 다시 설명하겠습니다.
역직렬화 예제를 살펴보겠습니다.
// 직렬화 예제에서 생성된 base64 데이터
String base64Person = "..생략";
byte serializedPerson = Base64.getDecoder().decode(base64Person);
try (ByteArrayInputStream bais = new ByteArrayInputStream(serializedPerson)) {
try(ObjectInputStream ois = new ObjectInputStream(bais)) {
// 역직렬화된 Person 객체를 읽어옵니다.
Object objectPerson = ois.readObject();
Person person = (Person) objectPerson;
System.out.println(person);
}
}
java.io.ObjectInputStream
객체를 이용해 역직렬화를 해줄 수 있습니다.
serialVersionUID
는 무엇인지 예제를 통해 바로 알아보겠습니다.
먼저 기존의 Person
객체를 직렬화 시켜보겠습니다.
Base64.getEncoder().encodeToString(serializedPerson);
rO0ABXNyABp3b293YWhhbi5ibG9nLLkAABSQADYWdlSQAEYWdlM2ltQGJhZW1pbi5jb210AAnquYDrsLDrr7w=
이 문자열을 바로 역직렬화 시키면 Person
객체로 변환됩니다.
하지만 Person
클래스에 나이를 추가해야 하는걸 깜빡 잊어버리고 만들어둔 Person
객체에 얼른 나이 필드를 추가합니다.
public class Person implements Serializable {
private String name;
// 나이 추가
private int age;
// ... 생략 ...
}
여기서 우리는 age
는 null
이 되어도기존에 있던 name
필드엔 데이터가 채워지길 원합니다.
이제 직렬화 해둔 Person
데이터를 역직렬화 해보겠습니다.
java.io.InvalidClassException: ..local class incompatible: stream classdesc serialVersionUID = -12345678909876543321, local class serialVersionUID = 19283746564738291
예외 메세지를 읽어보면 serialVersionUID
의 정보가 일치하지 않기 때문에 InvalidClassException
에러가 발생한 것을 알 수 있습니다.
우리는 Person
객체에 serialVersionUID
를 설정한 적이 없는데도 말이죠..!
그래서 자바 직렬화 스펙을 확인해보았습니다. 링크
It may be declared in the original class but is not required.
The value is fixed for all compatible classes.
If the SUID is not declared for a class, the value defaults to the hash for that class.
serialVersionUID
를 직접 기술하지 않아도 내부 적으로 serialVersionUID
정보가 추가되며, 내부 값도 자바 직렬화 스펙 그대로 자동으로 생성된 클래스의 해시 값을 이라는 것을 확인할 수 있었습니다. (해시 값은 클래스 구조를 이용해서 생성한다고 합니다.)
그럼 어떤 형태가 좋을까요 ?
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
// . . 생략 . .
}
"조금이라도 역직렬화 대상 클래스 구조가 변경된다면 에러가 발생해야 한다." 정도의 민감한 시스템이 아니라면 클래스를 변경할 때 직접 serialVersionUID
값을 관리해주는 것이 클래스 변경 시 혼란을 줄일 수 있습니다.
그럼 serialVersionUID
만 관리해주면 문제가 없을까요 ?
serialVersionUID
값이 동일할 때에도 문제가 생길 수 있는 부분을 살펴보겠습니다.
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private StringBuilder name;
private int age;
// . . 생략 . .
}
기존 직렬화된 name
데이터는 String
이었지만 StringBuilder
타입으로 변경해봤습니다.
java.lang.ClassCastException: cannot assign instance of java.lang.String to field Person.name of type java.lang.StringBuilder in instance of ~~.Person
혹시 primitive type인 int
를 long
으로 바꾸는건 괜찮지 않을까요?
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private long age;
// . . 생략 . .
}
java.lang.ClassCastException: ~~.incompatible types for field age
역시 ClassCastException
예외가 발생했습니다. 자바 직렬화는 상당히 타입에 엄격하다는 것을 알 수 있습니다.
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
// . . 생략 . .
}
Person
에러는 발생하지 않았지만 값 자체만 없어졌습니다.
그럼 필드를 추가해보겠습니다.
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
// 추가된 필드
private String email;
// . . 생략 . .
}
Person
이번에도 원하는 형태로 값은 채워졌지만 에러는 발생하지 않았습니다.
serialVersionUID
의 값은 개발 시 직접 관리해야 합니다.serialVersionUID
의 값이 동일하면 멤버 변수 및 메서드 추가는 크게 문제가 없습니다. 그리고 멤버 변수 및 이름 변경은 오류가 발생하지는 않지만 데이터는 누락됩니다.Ref.
https://techblog.woowahan.com/2550/
https://techblog.woowahan.com/2551/