1. 서론
자동 직렬화는 비행기의 자동 조종 장치와 같습니다 — 모든 것이 계획대로일 때는 훌륭히 작동합니다. 하지만 특별한 조건이 등장하면 단순한 메커니즘만으로는 부족하다는 사실이 드러납니다. 객체를 저장해야 하는데 모든 필드를 저장하고 싶지는 않을 수 있습니다. 어떤 데이터는 일시적이고, 어떤 데이터는 파일에 기록하기엔 너무 민감합니다. 혹은 반대로 — 저장할 때 버전이나 체크섬 같은 자신만의 정보를 추가해야 할 수도 있습니다. 때로는 기록하거나 로드하기 전에 검증이나 변환을 수행해야 할 수도 있습니다. 더 복잡한 과제도 있습니다 — 시간이 지나 클래스 구조가 바뀌더라도 이전 버전과의 호환성을 보장해야 하는 경우입니다.
이런 상황에서는 표준 직렬화만으로는 충분하지 않다는 것이 분명해집니다. 직접 제어권을 가져와야 합니다.
직렬화를 위한 특수 메서드: writeObject와 readObject
Java에는 객체의 직렬화와 역직렬화 과정을 완전히 제어할 수 있게 해 주는 두 가지 특수 메서드가 있습니다:
private void writeObject(ObjectOutputStream out) throws IOException
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
중요!
- 메서드는 반드시 private이어야 합니다 (public, protected, package-private 아님).
- 시그니처는 위에 제시된 것과 정확히 일치해야 합니다.
- 이 메서드들이 클래스에 선언되어 있으면 표준 직렬화/역직렬화 대신 이 메서드들이 호출됩니다.
어떻게 동작하나요?
ObjectOutputStream.writeObject(obj)를 호출하면, JVM은 먼저 클래스 obj에 private void writeObject(ObjectOutputStream) 메서드가 있는지 찾습니다. 있다면 그 메서드가 호출됩니다. 마찬가지로 역직렬화 시에는 private void readObject(ObjectInputStream)이 호출됩니다.
메서드가 선언되어 있지 않다면 표준 직렬화가 사용됩니다.
writeObject와 readObject의 구조
메서드 시그니처
private void writeObject(ObjectOutputStream out) throws IOException
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
이 메서드 내부에서는 다음을 반드시 호출해야 합니다:
- out.defaultWriteObject(); — 슈퍼클래스와 현재 클래스의 표준(transient가 아닌) 필드를 직렬화합니다.
- in.defaultReadObject(); — 표준 필드를 역직렬화합니다.
이 메서드들을 호출하지 않으면 표준 필드가 직렬화되지 않아서, 역직렬화 시 객체가 “비어 있는” 상태가 됩니다. 여권을 캐리어에 넣는 것을 잊은 것과 같습니다 — 형식상 도착하긴 했지만, 자신이 누구인지 증명할 수 없습니다.
2. 예제: 직렬화 시 체크섬 추가
실용적인 예제를 살펴봅시다. 사용자 클래스를 가지고 있고, 직렬화 시 객체에 체크섬을 추가한 뒤 역직렬화 때 데이터의 무결성을 검증하고 싶다고 가정해 봅시다.
import java.io.*;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
// transient 필드 — 직렬화하지 않음
private transient int checksum;
public User(String name, int age) {
this.name = name;
this.age = age;
this.checksum = calculateChecksum();
}
private int calculateChecksum() {
return (name != null ? name.hashCode() : 0) + age;
}
// 커스텀 직렬화
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // 표준 필드를 저장합니다
int sum = calculateChecksum();
out.writeInt(sum); // 체크섬을 기록합니다
System.out.println("[LOG] User 직렬화: checksum=" + sum);
}
// 커스텀 역직렬화
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 표준 필드를 복원합니다
int sum = in.readInt(); // 체크섬을 읽습니다
int actual = calculateChecksum();
System.out.println("[LOG] User 역직렬화: checksum=" + sum + ", actual=" + actual);
if (sum != actual) {
throw new IOException("데이터가 손상되었습니다! 체크섬이 일치하지 않습니다.");
}
this.checksum = actual;
}
@Override
public String toString() {
return "User{name='" + name + "', age=" + age + ", checksum=" + checksum + "}";
}
}
사용 예:
// 객체를 저장합니다
User user = new User("Alice", 42);
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.bin"))) {
out.writeObject(user);
}
// 객체를 로드합니다
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.bin"))) {
User loaded = (User) in.readObject();
System.out.println("복원된 객체: " + loaded);
}
무슨 일이 일어나나요?
- 직렬화 시 writeObject가 호출되어 표준 필드와 체크섬이 저장됩니다.
- 역직렬화 시 readObject가 호출되어 필드를 복원하고 체크섬을 검증합니다.
- 콘솔에 로그가 출력되고, 문제가 있으면 예외가 발생합니다.
3. 민감한 데이터 직렬화에서 제외하기
때로는 특정 필드를 직렬화하지 않아야 할 때가 있습니다(예: 비밀번호). 이를 위해 transient 키워드를 사용할 수 있고(자세한 내용은 다음 강의), writeObject를 구현하는 경우에는 수동으로 해당 필드를 직렬화하지 않는 방법도 있습니다.
예:
public class Account implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private transient String password; // transient — 직렬화되지 않습니다
// 이렇게 해도 됩니다:
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
// password는 기록하지 않습니다!
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
// password는 null로 남습니다
}
}
주의:
객체의 일부만 직렬화하고 싶다면 — 불필요한 필드를 스트림에 쓰지 않으면 됩니다.
4. 슈퍼클래스 메서드 호출: defaultWriteObject와 defaultReadObject
여러분의 writeObject와 readObject 메서드 내부에서는 거의 항상 defaultWriteObject()와 defaultReadObject()를 호출해야 합니다. 이는 자신의 메모를 추가하기 전에 “임시 저장”을 누르는 것과 같습니다.
이 메서드들은 현재 클래스와 슈퍼클래스의 모든 transient도 static도 아닌 표준 필드의 직렬화를 담당합니다. 이를 호출하지 않으면 해당 필드들이 직렬화되지 않아, 역직렬화 시 비어 있게 됩니다.
잘못된 사용 예:
private void writeObject(ObjectOutputStream out) throws IOException {
// out.defaultWriteObject(); // 호출하는 것을 잊었습니다!
out.writeInt(123); // 자신만의 데이터를 씁니다
}
이 경우 표준 필드가 전혀 저장되지 않습니다!
5. 실습: 직렬화 과정 로깅
사용자 클래스에 로깅을 추가하여 직렬화와 역직렬화가 언제 발생하는지 확인해 봅시다.
public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private void writeObject(ObjectOutputStream out) throws IOException {
System.out.println("[LOG] Person 직렬화: " + name + ", 나이 " + age);
out.defaultWriteObject();
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
System.out.println("[LOG] Person 역직렬화: " + name + ", 나이 " + age);
}
}
사용 예:
Person p = new Person("Bob", 30);
// 파일로 저장
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("person.bin"))) {
out.writeObject(p);
}
// 파일에서 로드
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("person.bin"))) {
Person loaded = (Person) in.readObject();
}
결과:
콘솔에서 객체가 직렬화되고 역직렬화되는 메시지를 확인할 수 있습니다.
6. writeObject/readObject 사용 시 흔한 실수
오류 №1: defaultWriteObject/defaultReadObject를 호출하지 않음. 이 메서드들을 호출하지 않으면 표준 필드가 직렬화되지 않아, 역직렬화 후 객체가 비어 있거나 올바르지 않을 수 있습니다.
오류 №2: 메서드 시그니처가 올바르지 않음. 메서드는 반드시 private void writeObject(ObjectOutputStream) 및 private void readObject(ObjectInputStream)이어야 합니다. public/protected로 만들거나 매개변수를 바꾸면 자동으로 호출되지 않습니다.
오류 №3: 메서드에서 예외 발생. writeObject나 readObject에서 예외가 발생하면 직렬화 또는 역직렬화가 중단되어, 객체가 올바르게 저장/로딩되지 않습니다.
오류 №4: 슈퍼클래스의 직렬화/역직렬화를 누락함. 클래스가 다른 직렬화 가능한 클래스를 상속한다면 반드시 defaultWriteObject/defaultReadObject를 호출해야 합니다. 그렇지 않으면 슈퍼클래스의 필드가 저장되지 않습니다.
오류 №5: 민감한 데이터 직렬화. 비밀번호 등 민감한 데이터를 제외하는 것을 잊으면 직렬화된 파일에 그대로 포함됩니다. transient를 사용하거나 수동으로 직렬화하지 마세요.
GO TO FULL VERSION