Programming/Java

Java 직렬화, 역직렬화 방법 Serializable interface

Jan92 2021. 12. 13. 22:32

'자바 직렬화(Serialization)와 역직렬화(Deserialization)'

 

자바 시스템 내부에서 사용되는 객체(Object) 또는 데이터(Data)를 외부 자바 시스템에서도 사용할 수 있도록 바이트(byte) 형태로 데이터를 변환하는 기술을 '직렬화(Serialization)'라고 합니다.

반대로 바이트로 변환된 데이터를 원래대로 객체(Object)나 데이터(Data)로 변환하는 기술을 '역직렬화(Deserialization)'라고 합니다.

 

시스템적으로 본다면 JVM(Java Virtual Machine 이하 JVM)의 메모리에 상주(힙 또는 스택)되어 있는 객체 데이터를 바이트 형태로 변환하는 기술이 '직렬화'이며, 직렬화된 바이트 형태의 데이터를 객체로 변환해서 JVM에 상주시키는 것을 '역직렬화'라고 합니다.

 

직렬화, 역직렬화 코드 예시는 다음과 같습니다.

 

@Getter
@ToString
public class Member implements Serializable {

  private String name;
  private String email;
  private int age;

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

'Member Class'

 

  public static void main(String[] args) throws IOException {

    // Member 객체 생성
    Member member = new Member("Jan", "jan@gmail.com", 999);
    byte[] serializedMember;

    // 객체 직렬화
    try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
      try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
        oos.writeObject(member);
        serializedMember = baos.toByteArray();
      }
    }
    // 직렬화된 byte[] 값을 base64 encoding
    String base64EncodedMember = Base64.getEncoder().encodeToString(serializedMember);

    // base64 encoding된 값을 decoding
    byte[] base64DecodedMember = Base64.getDecoder().decode(base64EncodedMember);

    // 직렬화된 byte[] 값을 역직렬화
    try (ByteArrayInputStream bais = new ByteArrayInputStream(base64DecodedMember)) {
      try (ObjectInputStream ois = new ObjectInputStream(bais)) {
        Object objectMember = ois.readObject();

        // 역직렬화 된 Member 객체
        Member deserializedMember = (Member) objectMember;
      } catch (ClassNotFoundException e) {
        e.printStackTrace();
      }
    }

'직렬화, 역직렬화 예시'

 

직렬화에는 'java.io.ObjectOutputStream' 패키지가 사용되며 스트림에 객체를 출력하는 역할을 하며, ObjectOutputStream 객체의 writeObject() 메서드는 객체를 직렬화 후에 스트림으로 흘려보내는 기능을 합니다.

 

역직렬화에는 'java.io.ObjectInputStream' 패키지가 사용되며 스트림으로부터 객체를 입력하는 역할을 하는데요. ObjectInputStream 객체의 readObject() 메서드는 스트림에서 객체를 역직렬화 하여 읽어오고 값을 미리 선언한 알맞은 객체 변수에 저장하는 기능을 합니다.

 

 

***

대부분 OS의 프로세스 구현은 서로 다른 가상 메모리 주소 공간(Virtual Address Space, VAS)을 갖기 때문에 Object 타입의 참조값(주소 값) 데이터 인스턴스를 전달할 수 었습니다.

만약 전달한다고 하더라도 서로 다른 메모리 공간에서는 전달된 참조값이 무의미하기 때문에 서로 다른 메모리 공간 사이의 데이터 전달을 위해서는 메모리 공간의 주소 값이 아닌 바이트(byte) 형태로 직렬화(변환)된 객체 데이터를 전달하고, 사용하는 쪽에서는 역직렬화 하여 사용하는 방법이 사용됩니다.

 

***

base64가 사용되는 이유가 궁금할 수 있는데요.

base64는 1byte 단위로 쪼개진 데이터가 기본 아스키 문자가 아닌 컨트롤 기호 등으로 오인받아 오류가 생길 수 있기 때문에 사용되기도 하고, 직렬화된 객체 데이터를 주고받는데 상호 간 사용하는 캐릭터 셋이 다를 경우가 생길 수 있기 때문에 사용합니다.

base64는 바이너리 데이터를 텍스트로 다루고 싶을 때 보편적으로 사용할 수 있는 방식으로 캐릭터 셋 간의 호환성이 있는 128자의 문자인 아스키코드로 표현하는 방식입니다.

 

 


 

 

@Getter
@ToString
public class Member implements Serializable {

  private String id;
  // transient
  private transient String password;
  private Address address;

  public Member(String id, String password, Address address) {
   this.id = id;
   this.password = password;
   this.address = address;
  }
}

'Member Class'

 

@ToString
public class Address implements Serializable {

  private String addressDetail;
  private String zipCode;

  public Address(String addressDetail, String zipCode) {
    this.addressDetail = addressDetail;
    this.zipCode = zipCode;
  }
}

'Address Class'

 

'자바 직렬화를 위한 조건'

 

자바 직렬화를 위한 조건으로는 자바 기본(Primitive) 타입과 java.io.Serializable 인터페이스를 상속받아야 한다는 것입니다.

 

* 원시 타입(Primitive Type) - 정수, 실수, 문자, 논리 리터럴 등의 실제 데이터 값을 저장하는 타입 (boolean, char, byte, short, int, long, float, double)

* String의 경우 기본 타입은 아니지만 이미 java.io.Serializable를 implements 하고 있기 때문에 직렬화가 가능합니다.

 

String과 같이 기본형이 아닌 객체도 멤버로 포함하여 직렬화가 가능합니다. (예시에 Address Class) 대신에 멤버로 사용되는 객체의 class도 Serializable Interface를 implement 해야 한다는 조건이 있습니다.

 

Member Class 멤버 중 password에는 'transient 예약어'가 걸려있는 것을 볼 수 있는데요. transient 예약어를 사용하여 선언된 변수는 Serializable 대상에서 제외됩니다. (패스워드처럼 보안상 중요한 변수 혹은 저장할 필요가 없는 변수에 대해 사용됩니다.)

 

 

 

Serializable interface

Serializable 인터페이스는 인터페이스 내부에 아무것도 없는 특이한 모습을 볼 수 있는데요. Serializable interface는 어떤 멤버 변수나 메서드도 가지고 있지 않은 '마커 인터페이스(marker interface)'입니다. 마커 인터페이스는 해당 인터페이스를 구현한 클래스가 특정 capability를 가진다고 표시(mark) 해두기 위한 용도로 쓰입니다.

 

 


 

 

'Java 직렬화의 사용과 JSON, CSV, XML 형태의 직렬화'

 

자바 직렬화 외에도 데이터를 문자열 형태로 직렬화하는 방법들이 있습니다. 대표적으로 JSON, CSV, XML, 프로토콜 버퍼 등이 있는데요.

JSON, CSV, XML, 프로토콜 버퍼 등은 시스템의 고유 특성과 상관없는 범용적인 API나 데이터를 변환하여 추출할 때 많이 사용됩니다.

(표 형태의 다량의 데이터 직렬화에는 CSV, 구조적인 데이터는 XML, JSON이 사용됩니다.)

 

그렇다면 이렇게 범용적으로 사용할 수 있는 데이터 포맷들이 있는데도 자바 직렬화를 사용하는 이유는 무엇일까요?

자바 직렬화를 쓰는 이유는 자바 시스템 간의 데이터 교환을 위해서 이며, '자바'라는 말이 들어간 것처럼 자바 시스템에서 사용하기에 최적화되어 있기 때문입니다.

복잡한 데이터 구조를 가진 클래스 객체라도 앞서 본 직렬화의 기본 조건만 지킨다면 큰 작업 없이 바로 직렬화, 역직렬화가 가능하다는 장점이 있습니다.

 

자바 직렬화는 JVM 메모리에서만 상주되어 있는 객체 데이터를 그대로 영속화(Persistence)가 필요할 때 사용되며, 시스템이 종료되더라도 없어지지 않는 장점이 있고, 네트워크로 전송도 가능합니다.

* 서블릿 세션(Servlet Session), 캐시(Cache), 자바 RMI(Remote Method Invocation)에서 사용됩니다.

 

 

 

'자바 직렬화의 단점'

 

자바 직렬화에는 단점도 존재합니다. 데이터를 직렬화 한 다음 해당 데이터의 원본 클래스에 속성을 추가하고, 이전에 직렬화된 데이터를 역직렬화 하면 java.io.InvalidCalssException 예외가 발생합니다. 

이 문제의 원인은 각 시스템에서 사용하고 있는 모델의 버전이 다르기 때문에 발생하는데요. 외에도 직렬화, 역직렬화 시스템 자체가 다른 경우에도 예외가 발생할 수 있습니다.

이런 경우 해결 방안은 버전 간의 모델 호환성을 유지하기 위해 'SerialVersionUID'를 정의하는 것입니다.

(SerialVersionUID에 대해서는 아래에서 조금 더 자세하게 보겠습니다.)

 

* 멤버 변수가 추가되는 것과 반대로 제외될(빠질) 경우 Exception 대신 null 값이 들어갑니다.

 

 

비슷한 문제로 직렬화 타입 체크가 엄격합니다. String -> StringBuilder, int -> long 변경 등에도 역직렬화 과정에서 Exception이 발생합니다. 또한 데이터의 사이즈가 크다는 단점도 있습니다. 같은 객체를 json 형태로 직렬화 한 것에 비해서 2배 정도의 크기를 가집니다.

때문에 일반적인 메모리 기반의 캐시에서는 데이터를 저장할 수 있는 용량의 한계가 있기 때문에 JSON 형태로 직렬화하여 용량을 효율적으로 사용하기도 합니다.

 

 

 

'SerialVersionUID'

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    private static final long serialVersionUID = 362498820763181265L;
}

(예시 HashMap)

 

SerialVersionUID는 객체의 해시 코드로 직렬화될 때 객체에 표시되는 식별자 역할을 합니다.

직렬화되는 클래스가 명시적으로 SerialVersionUID를 선언하지 않으면 직렬화시 자동으로 생성되어 직렬화 내용에 포함됩니다. 이처럼 클래스에 SerialVersionUID가 정의되지 않은 경우에는 컴파일러 실행에 따라 변경될 수 있는 클래스의 세부 사항에 매우 민감한 반면에, 클래스 내에 SerialVersionUID를 정의할 경우 클래스 내용이 변경되어도 클래스 버전은 업데이트되지 않으므로 직렬화 시에는 SerialVersionUID를 정의할 것이 권장됩니다.

 

* serialVersionUID를 지정하지 않으면 클래스에서 필드를 추가하거나 수정할 때 serialVersionUID가 새 클래스에 대해서 생성되고, 이전 직렬화된 객체가 다르기 때문에 이미 직렬화된 객체를 복구할 수 없고 java.io.InvalidClassException 에러가 발생하게 됩니다.

 

* static final long으로 선언해야 하며, 이름도 serialVersionUID로 선언해야 자바에서 인식 가능합니다.