본문 바로가기
Java

[Java]자바 직렬화 (Serialization)

by 에드박 2022. 1. 12.

모든 예시 코드는 이곳에서 볼 수 있습니다.

 

자바 직렬화는 다음의 고민을 해결하는데 사용할 수 있다.

  • 자바 객체를 컴퓨터에 저장했다가 다음에 다시 자바 객체로 변환할 수 없을까? 
  • 네트워크를 통해 컴퓨터간에 객체를 주고 받을 수 없을까?

 

직렬화(Serialization)란?


객체를 데이터 스트림으로 만드는 것을 뜻한다.

 

직렬화

-> 객체에 저장된 데이터를 스트림에 쓰기(write)위해 연속적인(serial) 데이터로 변환하는 것을 의미한다.

 

역직렬화

-> 스트림으로부터 데이터를 읽어서 객체를 만드는 것을 역직렬화(deserialization)라고 한다.

 

객체에 저장된 데이터란 객체의 모든 인스턴스 변수의 값, 즉 객체의 상태를 의미한다.

 

어떤 객체를 저장하고자 한다면, 현재 객체의 상태(인스턴스 변수)를 저장하기만 하면 된다.

저장했던 객체를 다시 생성하려면, 객체를 생성한 후에 저장했던 값을 읽어서 생성한 객체의 인스턴스 변수에 저장하면 된다.

 

객체의 인스턴스 변수가 단순히 기본형(int, long, double ..등등)이라면 값을 저장하는 건 간단한 일이다.

하지만 인스턴스 변수가 참조형(reference type)이라면 간단하지 않다. 만약 리스트라면 내부에 저장된 요소들도 모두 저장되어야한다.

 

자바에서 어떻게 직렬화/역직렬화해야하는지 고민하지 않아도 된다.

ObjectInputStream과 ObjectOutputStream을 사용하는 방법만 알면 된다.

 

ObjectInputStream, ObjectOutputStream


직렬화(스트림에 객체를 출력)에는 ObjectOutputStream을 사용한다.

역직렬화(스트림으로부터 객체를 입력)에는 ObjectInputStream을 사용한다.

각각 OutputStream과 InputStream을 상속받지만 기반스트림을 필요로 하는 보조 스트림이다.

 

그래서 객체를 생성할 때 입출력(직렬화/역직렬화)할 스트림을 지정해주어야 한다.

 

다음 예시는 파일에 객체를 저장(직렬화)하는 코드다.

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

public class SerializationEx1 {
    public static void main(String[] args) throws IOException {
        FileOutputStream fos = new FileOutputStream("objectfile.ser");
        ObjectOutputStream out = new ObjectOutputStream(fos);
        out.writeObject(new User("찰리", 31));
    }
}

class User {
    private final String name;
    private final int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
    
    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

 

이 코드는 런타임에 예외가 발생할 것이다. 일반적인 User 클래스는 직렬화가 불가능하다.

직렬화를 위해서는 Serializable 인터페이스를 implementation 해야한다.

Serializable은 다음 항목에서 알아보고 지금은 ObjectOutputStream, ObjectInputStream을 마저 알아보자.

 

위 코드는 objectfile.ser 이라는 파일에 User객체를 직렬화하여 저장한다.

출력할 스트림(FileOutputStream)을 생성해서 이를 기반스트림으로 하는 ObjectOutputStream을 생성한다.

ObjectOutputStream의 writeObject(Object obj)를 사용해서 객체를 출력하면, 객체가 파일에 직렬화되어 저장된다.

 

다음은 역직렬화를 위한 코드다.

FileInputStream fis = new FileInputStream(filename);
ObjectInputStream in = new ObjectInputStream(fis);
User user = (User) in.readObject();
System.out.println(user);
// 실행결과
// User{name='찰리', age=31}

역직렬화 방법도 간단하다. 입력 스트림을 사용하고 readObject()를 사용하여 저장된 데이터를 읽기만 하면 객체로 역직렬화 된다.

(readObject()의 반환타입이 Object이기 때문에 원래 타입으로 형변환 해주어야 한다.)

 

객체를 직렬화/역직렬화하는 작업은 객체의 모든 인스턴스변수가 참조하고 있는 모든 객체에 대한 것이기 때문에 상당히 복잡하며 시간도 오래 걸린다.

readObject()와 writeObject()를 사용한 자동 직렬화가 편리하기는 하지만 직렬화 작업시간을 단축시키려면 직렬화하고자 하는 객체의 클래스에 추가적으로 다음 2개의 메서드를 직접 구현해주어야 한다.

 

private void writeObject(ObjectOutputStream out) throws IOException {
	// write메서드를 사용해서 직렬화를 수행
}

private void readObject(ObjectInputStream out) throws IOException, ClassNotFoundException {
	// read메서드를 사용해서 역직렬화를 수행
}

 

다음은 직렬화 가능한 객체를 만들기 위한 Serializable과 transient를 알아보자.

직렬화 가능한 객체 만들기 - Serializable, transient


직렬화 가능한 객체를 만드는 방법은 간단하다. 이름도 명확한 Serializable 인터페이스를 직렬화 하고싶은 클래스가 구현(implements)하도록 하면 된다.

public class User implements Serializable {
    private final String name;
    private final int age;

    ...
}

Serializable 인터페이스는 아무런 내용도 없는 빈 인터페이스지만, 직렬화를 고려하여 작성한 클래스인지를 판단하는 기준이 된다.

(이를 마커 인터페이스(marker interface)라고 한다. 자세한 내용은 해당 키워드로 검색을 해보자)

 

만약 상위 클래스가 Serializable을 구현했다면 하위 클래스는 Serializable을 구현하지 않아도 직렬화가 가능하다.

class ParentUser implements Serializable {
    private final String name;
    private final int age;

    public ParentUser(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

class ChildUser extends ParentUser {
    private final String nickname;

    public ChildUser(String name, int age, String nickname) {
        super(name, age);
        this.nickname = nickname;
    }

    public String getNickname() {
        return nickname;
    }
}

 

상위 클래스는 Serializable을 구현하지 않고 하위클래스만 구현했다면 어떤 방식으로 직렬화될까?

직렬화할때 상위 클래스의 인스턴스 필드는 무시된다.

 

public class ParentChildSerialization2 {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        String filename = "objectfile.ser";

        FileOutputStream fos = new FileOutputStream(filename);
        ObjectOutputStream out = new ObjectOutputStream(fos);
        out.writeObject(new ChildUser2("찰리", 31, "초콜릿공장장"));

        FileInputStream fis = new FileInputStream(filename);
        ObjectInputStream in = new ObjectInputStream(fis);
        ChildUser2 user = (ChildUser2) in.readObject();
        System.out.println(user);
        
        // 출력결과
        // ChildUser2{name='null', age=0, nickname='초콜릿공장장'}
    }
}

class ParentUser2 {
    protected String name;
    protected int age;

    public ParentUser2() {
    }

    public ParentUser2(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

class ChildUser2 extends ParentUser2 implements Serializable {
    private final String nickname;

    public ChildUser2(String nickname) {
        this(null, 0, nickname);
    }

    public ChildUser2(String name, int age, String nickname) {
        super(name, age);
        this.nickname = nickname;
    }

    public String getNickname() {
        return nickname;
    }

    @Override
    public String toString() {
        return "ChildUser2{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", nickname='" + nickname + '\'' +
                '}';
    }
}

 

위 예시에서 상위 클래스 ParentUser2 에 정의된 name과 password는 직렬화 대상에서 제외된다.

상위 클래스의 필드까지 직렬화하려면 상위 클래스가 Serializable을 구현하도록 하던지, writeObject()와 readObject() 메서드를 재정의하여 직렬화 코드를 추가해야한다. 두 메서드의 접근 제어자가 private인게 의아하겠지만, 이것은 단순히 규칙일 뿐히다.

readObject() 재정의시 사용한 defaultWriteObject() 메서드는 ChildUser3 클래스 자신에 정의된 인스턴스 변수 직렬화를 수행한다.

 

public class ParentChildSerialization3 {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        String filename = "objectfile.ser";

        FileOutputStream fos = new FileOutputStream(filename);
        ObjectOutputStream out = new ObjectOutputStream(fos);
        out.writeObject(new ChildUser3("찰리", 31, "초콜릿공장장"));

        FileInputStream fis = new FileInputStream(filename);
        ObjectInputStream in = new ObjectInputStream(fis);
        ChildUser3 user = (ChildUser3) in.readObject();
        System.out.println(user);
    }
}

class ParentUser3 {
    protected String name;
    protected int age;

    public ParentUser3() {
    }

    public ParentUser3(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

class ChildUser3 extends ParentUser3 implements Serializable {
    private String nickname;

    public ChildUser3(String nickname) {
        this(null, 0, nickname);
    }

    public ChildUser3(String name, int age, String nickname) {
        super(name, age);
        this.nickname = nickname;
    }

	// writeObject를 재정의하여 부모의 필드도 직렬화한다.
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeObject(name);
        out.writeInt(age);
        out.defaultWriteObject();
    }
	
    // readObject를 재정의하여 부모의 필드도 역직렬화할 때 읽어온다.
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        name = (String)in.readObject();
        age = in.readInt();
        in.defaultReadObject();
    }

    @Override
    public String toString() {
        return "ChildUser3{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", nickname='" + nickname + '\'' +
                '}';
    }
}

 

아래의 User 클래스는 Serializable을 구현하고 있지만, NotSerializableException 예외를 발생시키며 직렬화에 실패한다.

class User2 implements Serializable {
    private final String name;
    private final int age;
    // Object 타입은 직렬화가 불가능하다. (Serializable을 구현하지 않았기 때문)
    private final Object obj;

    public User2(String name, int age, Object obj) {
        this.name = name;
        this.age = age;
        this.obj = obj;
    }
}

예외 발생의 원인은 직렬화할 수 없는 객체를 참조하고 있기 때문이다. Object는 Serializable을 구현하지 않았으므로 직렬화가 불가능하다.

 

만약 직렬화하고자 하는 객체의 클래스에 직렬화가 안되는 객체에 대한 참조를 포함해야 한다면, 또 해당 객체는 직렬화에서 제외하고 싶다면 제어자 transient를 붙여서 직렬화 대상에서 제외되도록 할 수 있다.

  • 예를들어, password와 같이 보안상 직렬화되면 안 되는 값에 대해서는 transient를 사용할 수 있다.

transient가 붙은 인스턴스 변수의 값은 그 타입의 기본값으로 직렬화된다.

  • 기본값 타입 : 각 타입의 디폴트 값 ex) int = 0
  • 참조 타입 : null을 가진다.
class User3 implements Serializable {
    private final String name;
    private final int age;
    private final transient Object obj;

    public User3(String name, int age, Object obj) {
        this.name = name;
        this.age = age;
        this.obj = obj;
    }
}

 

만약 여러개의 객체를 직렬화하고 이를 역직렬화 한다면 주의해야할 점이 있다.

역직렬화 할 때는 직렬화할 때의 순서와 일치해야한다.

예를들어 객체 user1, user2, userList 순서로 직렬화 했다면, 역직렬화 할 때도 user1, user2, userList의 순서로 처리해야한다.

따라서 직렬화할 객체가 많다면 각 객체를 개별적으로 직렬화하는것 보다 가능한 한 ArrayList와 같은 컬렉션에 저장해서 직렬화 하는것이 좋다.

(ArrayList 하나만 역직렬화하면 되므로 객체의 순서를 고려할 필요가 없어진다.)

 

다음으로 직렬화 가능한 클래스의 버전을 관리해보자

 

직렬화 가능한 클래스의 버전관리 - serialVersionUID


직렬화된 객체를 역직렬화할 때는 직렬화 했을 때와 같은 클래스를 사용해야한다.

그러나 클래스의 이름이 같더라도 클래스의 내용이 변경된 경우 역직렬화는 실패하며 다음과 같은 예외가 발생한다.

(클래스의 인스턴스 필드가 추가된 경우나 인스턴스 필드가 줄어든 경우)

 

me.charlie.javaserialization.ChildUser3; 
local class incompatible: stream classdesc serialVersionUID = 2289798870660079959, 
local class serialVersionUID = 4786714516782382768

위 예외의 내용은 직렬화 할 때와 역직렬화 할 때의 클래스의 버전이 같아야 하는데 다르다는 것이다.

객체가 직렬화 될 때 클래스에 정의된 멤버들의 정보를 이용해서 serialVersionUID라는 클래스의 버전을 자동생성해서 직렬화 내용에 포함된다.

역직렬화 할 때 클래스의 버전을 비교함으로써 직렬화 할 때의 클래스의 버전과 일치하는지 확인할 수 있는 것이다.

 

만약 static 변수나 상수 또는 transient가 붙은 인스턴스변수가 추가되는 경우에는
직렬화에 영향을 미치지 않기 때문에 클래스의 버전을 다르게 인식하도록 할 필요는 없다.

 

네트워크로 객체를 직렬화하고 전송하는 경우, 송신측과 수신측 모두 같은 버전의 클래스를 가지고 있어야하는데 클래스가 조금만 변경되어도 해당 클래스를 재배포하는 것은 프로그램을 관리하기 어렵게 만든다.

 

이럴 때는 클래스의 버전을 수동으로 관리해줄 필요가 있다.

클래스의 버전을 직접 명시해주면, 클래스의 내용이 변경되어도 클래스의 버전이 자동생성된 값으로 변경되지 않는다.

 

class User4 implements Serializable {
    static final long serialVersionUID = 3518731767529258119L;

    private final String name;
    private final int age;
    // 직렬화 후 추가된 필드
    private final String nickName;

    public User4(String name, int age, String nickName) {
        this.name = name;
        this.age = age;
        this.nickName = nickName;
    }
}

serialVersionUID의 값은 정수값이면 어떠한 값으로도 지정할 수 있지만 서로 다른 클래스간에 같은 값을 갖지 않도록 시리얼 버전값을 생성해주는 프로그램을 사용하는게 좋다. (같은 값을 갖지 않도록 하는것이 중요!!)

 

 

댓글