본문 바로가기

Java 길찾기/Java의 정석

[Java] 직렬화가 가능한 클래스 만들기 - Serializable, transient

직렬화가 가능한 클래스를 만드는 방법은 간단하다. 직렬화하고자 하는 클래스가 java.io.Serializable 인터페이스를 구현하도록 하면 된다.

 

예를들어 다음과 같이 UserInfo 클래스가 있을 때, 이 클래스를 직렬화가 가능하도록 변경하려면 Serializable 인터페이스를 구현하도록 변경하면 된다.

 

// 변경 전
public class UserInfo {
    String name;
    String password;
    int age;
}

// 변경 후
public class UserInfo implements java.io.Serializable {
    String name;
    String password;
    int age;
}

 

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

 

아래와 같이 Serializable을 구현한 클래스를 상속받는다면,  Serializable을 구현하지 않아도 된다. UserInfo는 Serializable을 구현하지 않았지만 조상인 SuperUserInfo가 Serializable을 구현했으므로 UserInfo 역시 직렬화가 가능하다.

 

 

public class SuperUserInfo implements Serializable {
    String name;
    String password;
}

public class UserInfo extends SuperUserInfo {
    int age;
}

 

위 의 경우 UserInfo 객체를 직렬화하면 조상이 SuperUserInfo에 정의된 인스턴스변수 name, password도 함께 직렬화 된다.

그러나 다음과 같이 조상클래스가 Serializable을 구현하지 않았다면 자손클래스를 직렬화할 때 조상클래스에 정의된 인스턴스변수 name과 password는 직렬화 대상에서 제외된다.

 

 

public class SuperUserInfo {
    String name;
    String password;
}

public class UserInfo extends SuperUserInfo implements Serializable {
    int age;
}

 

조상클래스에 정의된 인스턴스변수 name과 password를 직렬화 대상에 포함시키기 위해서는 조상클래스가 Serializable을 구현하도록 하던가, UserInfo에서 조상의 인스턴스변수들이 직렬화되도록 처리하는 코드를 직접 추가해 주어야한다.

 

아래의 UserInfo클래스는 Serializable을 구현하고 있지만, 이 클래스의 객체를 직렬화하면 java.io.NotSerializableException이 발생하면서 직렬화에 실패한다. 그 이유는 직렬화할 수 없는 클래스의 객체를 인스턴스변수가 참조하고 있기 때문이다.

모든 클래스의 최고조상인 Object는 Serializable을 구현하지 않았기 때문에 직렬화할 수 없다. 만일 Object가 Serializable을 구현했다면 모든 클래스가 직렬화 될 수 있을 것이다.

 

 

public class UserInfo implements Serializable {
    String name;
    String password;
    int age;
    
    Object obj = new Object(); // Object 객체는 직렬화할 수 없다.
}

 

위의 경우와 비교해서 다음과 같은 경우에는 직렬화가 가능하다는 것을 알아두자. 인스턴스변수 obj의 타입이 직렬화가 안되는 Object이긴 하지만 실제로 저장된 객체는 직렬화가 가능한 String인스턴스이기 때문에 직렬화가 가능하다.

인스턴스변수의 타입이 아닌 실제로 연결된 객체의 종류에 의해서 결정된다는 것을 기억하자.

 

 

public class UserInfo implements Serializable {
    String name;
    String password;
    int age;
    
    Object obj = new String("abc"); // String 객체는 직렬화할 수 있다.
}

 

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

또는 password와 같이 보안상 직렬화되면 안 되는 값에 대해서 transient를 사용할 수 있다. 다르게 표현하면 transient가 붙은 인스턴스변수의 값은 그 타입의 기본값으로 직렬화된다고 볼 수 있다.

즉, UserInfo 객체를 역직렬화하면 참조변수인 obj와 password의 값은 null이 된다.

 

 

public class UserInfo implements Serializable {
    String name;
    transient String password; // 직렬화 대상에서 제외된다.
    int age;
    
    transient Object obj = new Object(); // 직렬화 대상에서 제외된다.
}

 

public class UserInfo implements java.io.Serializable {
    String name;
    String password;
    in age;
    
    public UserInfo() {
        this("Unknown", "1111", 0);
    }
    
    public UserInfo(String name, String password, int age) {
        this.name = name;
        this.password = password;
        this.age = age;
    }
    
    public String toString() {
        return "(" + name + "," + password + "," + age + ")";
    }
}

 

위 예제는 다음 예제에 사용될 UserInfo 클래스의 소스이다. 그래서 다음 예제를 실행하기 전에 위 예제를 먼저 실행해야 한다.

 

 

import java.io.*;
import java.util.ArrayList;

public class SerialEx1 {
    public static void main(String[] args) {
        try {
            String fileName = "userInfo.ser";
            FileOutputStream fos = new FileOutputStream(fileName);
            BufferedOutputStream bos = new BufferedOutputStream(fos);
            
            ObjectOutputStream out = new ObjectOutputStream(bos);
            
            UserInfo u1 = UserInfo("JavaMan", "1234", 30);
            UserInfo u2 = UserInfo("JavaWoman", "4321", 26);
            
            ArrayList<UserInfo> list = new ArrayList<>();
            list.add(u1);
            list.add(u2);
            
            // 객체를 직렬화한다.
            out.writeObject(u1);
            out.writeObject(u2);
            out.writeObject(list);
            out.close();
            System.out.println("직렬화 종료");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

생성한 객체를 직렬화하여 파일(UserInfo.ser)에 저장하는 예제이다. 버퍼를 이용한  FileOutputStream을 기반으로 하는 ObjectOutputStream을 생성한 다음, writeObject()를 이용해서 객체를 ObjectOutputStream에 출력하면 UserInfo.ser 파일에 객체가 직렬화되어 저장된다.

 

객체를 직렬화하는 것은 이처럼 간단하지만, 객체에 정의된 모든 인스턴스변수에 대한 참조를 찾아들어가기 때문에 상당히 복잡하고 시간이 걸리는 작업이 될 수 있다.

이 예제처럼 ArrayList와 같은 객체를 직렬화하면 ArrayList에 저장된 모든 객체들과 각 객체의 인스턴스변수가 참조하고 있는 객체들까지 모두 직렬화된다.

|참고| 확장자를 직렬화(serialization)의 약자인 'ser'로 하는 것이 보통이지만 이에 대한 제약은 없다.

 

 

import java.io.*;
import java.util.ArrayList;

public class SerialEx2 {
    public static void main(String[] args) {
        try {
            String fileName = "userInfo.ser";
            FileInputStream fis = new FileInputStream(fileName);
            BufferedInputStream bis = new BufferedInputStream(fis);

            ObjectInputStream in = new ObjectInputStream(bis);

            // 객체를 읽을 때는 출력한 순서와 일치해야 한다.
            UserInfo u1 = (UserInfo) in.readObject();
            UserInfo u1 = (UserInfo) in.readObject();
            ArrayList list = (ArrayList) in.readObject();

            System.out.println("u1");
            System.out.println("u2");
            System.out.println("ulist");
            in.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

이전의 예제에서 직렬화한 객체를 역직렬화하는 예제이다. 이전과는 반대로 FileInputStream과 ObjectInputStream을 그리고 writeObject() 대신 readObject()를 사용했다는 점을 제외하고는 거의 같다.

ObjectInputstream의 readObject()로 직렬화한 객체를 역직렬화하였는데, readObject()의 리턴타입이 Object이므로 원래의 타입으로 형변환을 해주어야 한다.

 

한 가지 주의해야할 점은 객체를 역직렬화 할 때는 직렬화할 때의 순서와 일치해야한다는 것이다. 예를 들어 객체 u1, u2, list의 순서로 직렬화 했다면, 역직렬화할 때도 u1, u2, list의 순서로 처리해야 한다.

 

그래서 직렬화할 객체가 많을 때는 각 객체를 개별적으로 직렬화하는 것보다 ArrayList와 같은 컬렉션에 저장해서 직렬화하는 것이 좋다.

역직렬화할 때 ArrayList 하나만 역직렬화하면 되므로 역직렬화할 객체의 순서를 고려하지 않아도 되기 때문이다.

 

 

import java.io.*;

class SuperUserInfo {
    String name;
    String password;
    
    SuperUserInfo() {
        this("Unknown","1111");
    }
    
    SuperUserInfo(String name, String password) {
        this.name = name;
        this.password = password;
    }
}

public class UserInfo2 extends SuperUserInfo implements java.io.Serializable {
    int age;
    
    public UserInfo2() {
        this("Unknown","1111", 0);
    }
    
    public UserInfo2(String name, String password, int age) {
        super(name, password);
        this.age = age;
    }
    
    public String toString() {
        return "(" + name + "," + password + "," + age + ")";
    }
    
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.writeUTF(name);
        out.writeUTf(password);
        out.defaultWriteObject();
    }
    
    private void readObejct(ObjectInputStream in) throws IOException {
        name = in.readUTF();
        password = in.readUTF();
        in.defaultReadObject();
    }
}

 

이 예제는 이전에 언급한 직렬화되지 않는 조상으로부터 상속받은 인스턴스변수에 대한 직렬화를 구현한 것이다. 직렬화될 객체의 클래스에 위 예제의 맨아래와 같이 writeObject()와 readObject()를 추가해서 조상으로부터 상속받은 인스턴스변수인 name과 password가 직접 직렬화되도록 해야 한다. 이 메서드들은 직렬화/역직렬화 작업시에 자동적으로 호출된다.

 

이 두 메서드의 접근 제어자가 private이라는 사실이 이상하게 다가올지 모르겠지만, 이것은 단순히 미리 정해진 규칙일 뿐이다. 우리는 그 규칙을 충실히 따르면 되는 것이다.

 

name과 password의 타입이 String이기 때문에 writeUTF()/readUTF()를 사용했으며 이 외에도 ObjectInputStream, ObjectOutputStream에는 writeInt(), readInt()와 같은 타입에 따른 다양한 종류의 메서드들을 제공하므로 각 인스턴스변수의 타입에 맞는 것을 선택해서 사용하면 된다. 그리고 defaultWriteObject()는 UserInfo2 클래스에 자신에 정의된 인스턴스변수 age의 직렬화를 수행한다.