본문으로 바로가기

Serializable 인터페이스에 대하여

category Java 2020. 11. 14. 01:01

Serializable (직렬화)에 대하여

최근 학교에서 진행한 프로젝트와 토이 프로젝트를 진행하면서 자바 기초 지식에 대한 부족함을 뼈져리게 느끼게 되었다.😭

그래서 '자바의 신' 이라는 자바 기본서적을 구매해서 읽던 도중 여러 클래스에 구현되어있는 Serializable 이라는 interface가 도대체 어떤 역할을 하는지 궁금해서 정리해 보았다.

(Serializable을 구현한 클래스 예)

public final class LocalDateTime
        implements Temporal, TemporalAdjuster, ChronoLocalDateTime<LocalDate>, Serializable
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence 

먼저 이 인터페이스를 인텔리제이에서 열어보았다. 아래와 같이 이 인터페이스의 API에는 선언된 변수나 메소드가 없다.

package java.io;
//중략
public interface Serializable {
}

이 말은 즉슨 우리가 구현해야할 메소드가 없다는 뜻이다. 그렇다면 이 인터페이스는 도대체 왜 존재하는 것일까?

1. Serializable의 존재 이유

우리가 개발을 하다보면, 생성한 객체를 파일로 저장할 일이 있을 수도 있고, 저장한 객체를 읽을 일이 생길수도 있다. 그리고 다른 서버로 보낼 때도 있고, 다른 서버에서 생성한 객체를 받을 일도 있을 것이다.

이럴때 반드시 구현해야 하는 인터페이스가 바로 Serializable 이다. 이 인터페이스를 구현하면 JVM에서 해당 객체를 저장하거나, 다른 서버로 전송할 수 있도록 해준다.

객체를 파일로 저장하는 간단한 예제를 살펴보자

이 예제 코드에서 사용될 ObjectOutputStream 은 자바에서 객체를 저장할때 사용하는 클래스다.

public class khDTO  {
    private String name;
    private int age;
    private String address;
    public khDTO(String name, int age, String address){
        this.name = name;
        this.age = age;
        this.address = address;
    }

    @Override
    public String toString() {
       // .. 생략
    }
}
  • 위 코드는 단순하고 평범한 DTO 클래스이다.
  • 이제 이 DTO를 저장하는 클래스를 만들자.
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import static java.io.File.separator;

public class ManageObject {
    public static void main(String[] args) {
        ManageObject manager = new ManageObject();
        String fullPath = separator+"KH"+separator+"text"+separator+"KH.obj"; //설명 참고
        khDTO dto = new khDTO("JEONG",20,Incheon);
        manager.saveObject(fullPath,dto);
    }

    public void saveObject(String fullPath, khDTO dto) {
        FileOutputStream fos = null;
        ObjectOutputStream oos = null;
        try {
            fos = new FileOutputStream(fullpath);
            oos = new ObjectOutputStream(fos);
            oos.writeObject(dto);
            System.out.println("Write Success");
        }catch {
             // 생략
        }
    }
 String fullPath = separator+"KH"+separator+"text"+separator+"KH.obj"
  • fullPath 변수는 단순히 KH/text라는 경로에 KH.obj 라는 파일로 dto를 저장한다는 것을 명시한 변수다.
 fos = new FileOutputStream(fullpath);  //  1
            oos = new ObjectOutputStream(fos); //  2
            oos.writeObject(dto); //  3
  1. FileOutputStream 객체를 fullPath 경로와 파일명에 알맞게 생성한다.
  2. 객체를 저장하기 위해 ObjectOutputStream 객체를 생헝했다. 이 객체에서 (1)에서 생성한 객체를 매개 변수로 넘겼다. 이렇게하면 해당 객체는 파일에 저장된다.
  3. writeObject라는 메소드를 사용하여 매개 변수로 넘어온 객체를 저장한다.

이제 ManageObject 클래스를 실행해보자. 아마도 'Write Success' 라는 메시지가 출력되리라고 생각했을 것이다.

하지만 이 클래스 실행 결과는 NotSerializableException 이다. 그 이유는 바로 khDTO에 Serialzable 인터페이스를 구현하지 않았기 때문이다.

DTO 클래스에 다음과 같이 인터페이스를 구현하면 정상적으로 "Write Success" 라는 메시지가 출력될 것이다.

import java.io.Serializable;
public class khDTO implements Serializable   {
}

이번에는 반대로, 객체를 읽어어는 예제를 살펴보자

import java.io.FileInputStream;
import java.io.ObjectInputStream;

public class ManageObjectInput {
    public void loadObject(String fullPath) {
        FileInputStream fis = null;
        ObjectInputStream ois = null;
        try {
            fis = new FileInputStream((fullPath));
            ois = new ObjectInputStream(fis);
            Object obj = ois.readObject();
            khDTO dto = (khDTO)obj;
          }catch {
             // 생략
        }
    }
  • 객체를 저장할때 사용했던 코드와 거의 동일하다. 단지 Output 대신 Input으로 되어있는 클래스들을 사용하면 된다.
  • 이제 main() 메소드를 loadObject가 실행되도록 살짝 수정하고 실행시켜보자.
 public static void main(String[] args) {
        ManageObject manager = new ManageObject();
        String fullPath = separator+"KH"+separator+"text"+separator+"KH.obj"; //설명 참고
     //   khDTO dto = new khDTO("JEONG",20,Incheon);
     //   manager.saveObject(fullPath,dto);
         manager.loadObject(fullPath);
    }

khDTO 에서 toString을 구현해 놓았기 때문에 다음과 같이 읽어 들인 객체의 내용이 출력될 것이다.

khDTO [name= Jeong, age = 20 , address = inchen]

물론 이러한 출력 결과를 얻으려면 khDTO 클래스에서 Serializable을 implements 해야 한다.

2. SerialVersionUID 는 Serializable과 항상 함께해야한다.

바로 위 예제 코드를 다시 한번 사용해서 SerialVersionUID가 무엇인지 알아보자.

아래와 같이 khDTO 클래스에 새로운 변수를 하나 추가해보자.

public class khDTO implements Serializable {
    private String bloodType = "A";
}

이렇게 수정하고 바로 위 객체를 읽어오는 메소드인 loadObject를 실행해보자. 결과는 다음과 같다.

java.io.InvalidClass Exception : e.serial.khDTO; local class incompatible : ... //중략

SerialVersionUID 가 다르다는 InvalidClassException 예외 메시지가 출력된다. 이쯤에서 SerialVersionUID 가 무엇인지 알아보자.

SerialVersionUID란?

SerialVersionUID은 해당 객체의 버전을 명시하는데 사용한다. 만약 A라는 서버에서 B라는 서버로 khDTO 라는 클래스의 객체를 전공한다고 가정해보자. 전송하는 A 서버에 khDTO 라는 클래스가 있어야 하고, 전송을 받는 B 서버에도 khDTO 라는 클래스가 있어야 한다. 그래야만 그 클래스의 객체임을 알아차리고 그 데이터를 받을 수 있다.

그런데 만약 A 서버가 갖고 있는 khDTO 클래스의 변수는 3개인데, B서버가 갖고있는 khDTO 클래스의 변수는 4개인 상황이 발생하면 어떻게될까? 이러한 상황이 발생하면 자바에서는 다른 클래스로 인식하게 된다.

따라서 각 서버(여기서는 A서버와 B서버)들이 쉽게 해당 객체가 같은지 , 다른지를 확인할 수 있도록 하기 위해서 필요한 것이 SerialVersionUID 이다.

static final long serialVersionUID = 1L;

이런식으로static final long 으로 선언해야하며, 변수명도 반드시 serialVersionUID로 선언해야 한다.

이제 SerialVersionUID에 대해서 알아보았으니, 위에서 InvalidClassException 예외가 발생했던 클래스를 아래와 같이 수정하자.

public class khDTO implements Serializable {
    static final long serialVersionUID =1L;
    private String bloodType = 1L;
    //중간 생략
    @Override
    public STring toString() {
        //toString 구현 생략
    }
}

이제 앞에서 주석처리 해놓았던 manager.saveObject(fullPath,dto); 메소드를 주석을 푼 후 다시 실행해보자.

그러면 아래와 같이 정상적으로 출력될 것이다.

Write Success
khDTO [name = Jeong, age = 20, address = incheon , bloodType = A ]

이제 마지막으로, SerialVersionUID 을 위 예제 처럼 지정해 놓은 상태에서 저장되어 읽는 객체가 다르다면 어떻게 될까?

private String bloodType 변수를 private String bloodTypes 로 변경한 다음에 manager.saveObject(fullPath,dto); 메소드를 다시 주석처리 한 후 main 메소드를 실행시켜보자.

Write Success
khDTO [name = Jeong, age = 20, address = incheon , bloodTypes = null ]

즉, 변수의 이름이 바뀌면 저장되어 있는 객체에서 찾지 못하므로, 해당 객체는 null로 처리된다.

지금까지 살펴본 예제를 보면 Serializable을 구현해 놓은 상태에서 SerialVersionUID 을 명시적으로 지정하면 변수가 변경되거나 추가되더라도 InvalidClassException 예외가 발생하지 않는다.

하지만 만약에 이렇게 Serializable을 구현한 객체의 내용이 추가되거나 변경되었는데도 아무런 예외가 발생하지 않으면 운영 상황에서 데이터가 꼬이게 되어 큰 문제를 발생시킬수도있다.

 

3. 마지막으로 transient

이제 정말 마지막이다. 💪

transient에 대해서 알아보자. 아래 예시 코드 처럼 khDTO의 변수에 transient라는 예약어를 추가할 수 있다.

transient private int  age;

이 상태에서 앞에서 구현했던 saveObject() 메소드를 실행하게 되면 아래와 같은 결과를 가져온다.

Write Success
khDTO [name = Jeong, age = 0, address = incheon , bloodType = A ]

분명 age의 값을 20으로 지정하고 저장했다. 하지만 읽어낸 값을 보면 0이 출력되었다.

transient는 이 예제만 보고도 충분히 눈치챌 수 있는 기능이다. 바로 transient 을 선언한 변수는 Serializable의 대상에서 제외된다.

다시 말해서 해당 객체는 저장 대상에서 제외되어 버린다. 즉, 이 기능은 보안상 중요한 변수나 꼭 저장해야 할 필요가 없는 변수에 사용하면 된다.


이렇게 직렬화에 대해서 알아보았지만, 사실 여러 가지 문제(보안/유지 보수성/테스트 등등) 때문에 자바에서는 직렬화를 지양한다고 한다. 하지만 꼭 알고 있어야 하는 내용이기에, 정리해 본다.

참고 자료


우아한 형제들 기술블로그

자바의 신 VOL.2 . 27장 : Serializable과 NIO도 살펴 봅시다.