이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Java › 입출력
직렬화
저장한 객체를 되살리면 무엇이 돌아오나
자바의 기본 직렬화는 객체를 바이트로 바꿔 파일에 쓰고(writeObject), 그 바이트로 객체를 다시 만들어 냅니다(readObject). 그런데 객체의 모든 것이 저장되는 것은 아니고, 아무 객체나 저장할 수 있는 것도 아닙니다. point 값을 50으로 두고 저장한 다음 70으로 바꾸고 나서 되살려 보면서, 무엇이 저장되고 무엇이 저장되지 않는지 확인합니다.
실행으로 대조 · JDK 21에서 열다섯 가지 조합을 컴파일하고 실행해 대조
이 실습에 나오는 개념
- 직렬화와 역직렬화
- 객체를 저장하거나 전송할 수 있는 형태(바이트열, JSON 등)로 바꾸는 것이 직렬화, 그것을 다시 객체로 복원하는 것이 역직렬화다. 웹 API에서는 객체와 JSON 사이의 변환(Jackson 등)이 대표적이다. 자바 기본 직렬화(Serializable)는 클래스 구조가 바뀌면 호환성 문제가 생기고 보안 위험이 있어 지금은 JSON 등의 형식을 주로 쓴다.
- Serializable
- 자바의 기본 직렬화로 저장할 수 있는 클래스임을 표시하는 인터페이스다. 구현할 메서드가 하나도 없고, 클래스 선언에 implements Serializable을 붙이는 것만으로 표시가 된다. 이 표시가 없는 객체를 ObjectOutputStream으로 쓰려고 하면 NotSerializableException이 발생한다. 객체를 저장할 때는 그 객체가 필드로 가리키는 객체들도 함께 저장되므로, 그 객체들의 클래스도 모두 Serializable이어야 한다.
- transient
- 직렬화할 때 이 필드는 저장하지 말라고 표시하는 키워드다. transient를 붙인 필드는 파일이나 네트워크로 나가는 바이트에 들어가지 않고, 되살린 객체에서는 그 타입의 기본값(숫자는 0, 참조형은 null)이 된다. 비밀번호처럼 밖에 남기면 안 되는 값, 다시 계산하면 되는 값, Serializable이 아니어서 저장할 수 없는 객체를 가리키는 필드에 쓴다. 되살린 뒤에 그 필드가 비어 있다는 점을 코드에서 고려해야 한다.
- serialVersionUID
- 직렬화된 데이터가 어떤 버전의 클래스로 만들어졌는지를 나타내는 번호다. 객체를 저장할 때 이 번호가 함께 기록되고, 되살릴 때 지금 클래스의 번호와 다르면 InvalidClassException이 발생한다. 클래스에 private static final long serialVersionUID = 1L;처럼 직접 적지 않으면 자바가 클래스의 이름, 필드, 메서드 등으로부터 값을 계산하는데, 클래스를 조금만 고쳐도 이 값이 달라질 수 있다. 그래서 Serializable 클래스에는 이 번호를 직접 적어 두는 것이 권장된다.
- static
- 인스턴스가 아니라 클래스에 속하는 멤버임을 나타내는 키워드다. static 필드는 클래스당 하나만 존재해 모든 인스턴스가 공유하고, static 메서드는 객체를 만들지 않고 호출한다. 값이 바뀌는 static 필드는 사실상 전역 변수이므로 여러 스레드가 동시에 접근할 때 문제가 되기 쉽다.
이렇게 바꿔 보세요
- 처음 설정으로 — transient 필드. 50을 저장했는데 되살리면 0입니다.
- 보통 필드 — 저장할 때의 값 50이 그대로 돌아옵니다.
- static 필드 — 저장된 값이 아니라 지금 클래스가 가진 값 70이 보입니다.
- Serializable을 구현하지 않으면 — 저장할 때 NotSerializableException이 발생합니다.
- 필드로 가진 객체가 문제라면 — User가 아니라 Address 때문에 저장에 실패합니다.
- 저장한 뒤 serialVersionUID를 바꾸면 — 되살릴 때 InvalidClassException이 발생합니다.
- serialVersionUID 없이 필드를 추가하면 — 자동으로 계산되는 값이 달라져 역시 되살리지 못합니다.
처음 설정으로 실행하면
- new User("kim"): User 객체를 만듭니다. 생성자가 실행되어 "생성자 실행"이 한 번 출력됩니다.
- u.point = 50: 객체 u의 point 필드에 50을 넣습니다.
- out.writeObject(u): 객체 u의 필드 값을 바이트로 바꿔 파일에 씁니다. name = "kim"만 저장됩니다. point는 transient로 선언했으므로 건너뜁니다. 어떤 클래스의 객체인지 알 수 있도록 클래스 이름 User와 serialVersionUID(1)도 함께 기록됩니다.
- u.point = 70: 저장한 뒤에 메모리의 값을 70으로 바꿉니다. 이미 써 둔 파일의 내용은 바뀌지 않습니다.
- in.readObject(): 파일에 기록된 클래스 이름과 serialVersionUID가 지금의 User 클래스와 같은지 확인합니다. 같으므로 새 User 객체를 만들고 저장된 필드 값을 채워 넣습니다. 이때 User의 생성자는 실행되지 않습니다. "생성자 실행"은 다시 출력되지 않습니다.
- r.point: 0입니다. transient 필드는 파일에 저장되지 않았으므로 채워 넣을 값이 없습니다. 그 타입의 기본값(int는 0, 참조형이면 null)이 됩니다.
실행 결과
- transient를 붙인 필드는 저장되지 않고, 되살린 객체에서는 기본값이 됩니다. 비밀번호처럼 파일에 남기면 안 되는 값이나, 다시 계산하면 되는 값에 씁니다.
- 되살릴 때는 Serializable 클래스의 생성자가 실행되지 않습니다. 생성자에서 하던 값 검사나 준비 작업이 건너뛰어진다는 뜻이므로, 믿을 수 없는 곳에서 온 데이터를 되살리는 것은 위험합니다.
코드
class User implements Serializable {
private static final long serialVersionUID = 1L;
String name;
transient int point; // transient: 저장하지 않는다
User(String name) {
this.name = name;
System.out.println("생성자 실행");
}
}
User u = new User("kim");
u.point = 50;
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.ser"))) {
out.writeObject(u); // 객체를 바이트로 바꿔 파일에 쓴다
}
u.point = 70; // 저장한 뒤에 값을 바꾼다
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.ser"))) {
User r = (User) in.readObject(); // 파일의 바이트로 객체를 되살린다
System.out.println(r.name + " " + r.point);
}
이 모형의 전제
열다섯 가지 조합마다 화면의 코드와 같은 소스를 이 PC의 JDK 21에서 컴파일하고 실행해, 저장과 되살리기의 성공 여부, 발생한 예외, 되살린 값, 생성자가 실행된 횟수를 대조했습니다. 저장한 뒤 클래스를 고치는 조합은 같은 이름의 User 클래스를 두 벌 따로 컴파일해, 한쪽으로 저장한 파일을 다른 쪽으로 읽었습니다. serialVersionUID를 적어 둔 채 필드를 추가하면 읽을 수 있고 새 필드가 null이라는 것, transient인 참조형 필드가 null이 된다는 것, static 필드를 다른 프로그램에서 읽으면 0이라는 것, Address 필드에 transient를 붙이면 저장된다는 것도 따로 실행해 확인했습니다. 파일에 실제로 기록되는 바이트의 형식은 그림에서 줄였습니다. 생성자가 실행되지 않는 것은 Serializable을 구현한 클래스의 이야기이며, 부모 클래스가 Serializable이 아닌 경우의 규칙, writeObject와 readObject를 직접 정의하는 방법, record의 직렬화는 다루지 않았습니다. 자바의 기본 직렬화는 보안 문제 때문에 새 코드에서는 잘 쓰지 않고 JSON 같은 형식을 주로 쓰지만, 기존 코드와 라이브러리를 이해하려면 이 규칙들을 알아야 합니다.
앞 실습: 입출력 스트림과 버퍼