본문 바로가기

Java

자바 직렬화 Serialization

[ 그룹2 스터디 ]

 

목차

  • 직렬화란?  
  • 직렬화의 장점
  • 객체 전송의 단계 ( Marshalling, Unmarshalling )
  • 여러가지 다른 데이터의 직렬화 ( 문자열 직렬화, 이진 직렬화 )
  • 자바 직렬화   
  •         - 동작 방법
  •         - 구현 방법
  •         - 버전 관리 원리 
  •         - serialVersionUID 
  •         - 사용하는 함수와 예시 코드
  •         - 예약어 transient
  •         - 자바 직렬화가 사용되는 경우

 

더 나아가

  •  base64 encoding 이란
  •  JVM 의 메모리 구조

 

 

 

직렬화란?

객체의 직렬화는 객체의 내용을 바이트 단위로 변환하여 파일 또는 네트워크를 통해서 스트림(송수신)이 가능하도록 하는 것을 의미한다. 

 

자바의 I/O 처리는, 정수, 문자열 바이트 단위의 처리만 지원했었다. 따라서 복잡한 내용을 저장/복원 하거나, 네트워크로 전송하기 위해서는 객체의 멤버변수의 각 내용을 일정한 형식으로 만들어(이것을 패킷이라고 한다) 전송해야 했다.
객체직렬화는 객체의 내용(구체적으로는 멤버변수의 내용)을 자바 I/O가 자동적으로 바이트 단위로 변환하여, 저장/복원하거나 네트워크로 전송할 수 있도록 기능을 제공해준다. 즉 개발자 입장에서는 객체가 아무리 복잡하더라도, 객체직렬화를 이용하면 객체의 내용을 자바 I/O가 자동으로 바이트 단위로 변환하여 저장이나 전송을 해주게 된다. 
자바에서 직렬화는 자동으로 처리해주는 것이기 때문에, 운영체제가 달라도 전혀 문제되지 않는다. 객체를 직렬화할때 객체의 맴버변수가 다른 객체(Serializable 인터페이스를 구현한)의 레퍼런스 변수인 경우에는 레퍼런스 변수가 가리키는 해당 객체까지도 같이 객체직렬화를 해버린다.
또 그 객체가 다른 객체를 다시 가리키고 있다면, 같은 식으로 색체 직렬화가 계속해서 일어나게 된다. 이것은 마치 객체직렬화를 처음 시작한 객체를 중심으로 트리 구조의 객체직렬화가 연속적으로 일어나는 것이다. 

 

 

직렬화의 장점

객체 내용을 입출력형식에 구애받지 않고 객체를 파일에 저장함으로써 영속성을 제공한다.
객체를 네트워크를 통해 손쉽게 교환할 수 있다.

 

객체 전송의 단계 
객체를 분해하여 전송하기 위해서는 직렬화(Serialization) 되어야 한다.

 

객체를 전송하기 위해서는 3가지 단계를 거친다.
(1) 직렬화된 객체를 바이트 단위로 분해한다. (marshalling)
(2) 직렬화 되어 분해된 데이터를 순서에 따라 전송한다.
(3) 전송 받은 데이터를 원래대로 복구한다. (unmarshalling) 

 

Marshalling
- 마샬링(Marshalling)은 데이터를 바이트의 덩어리로 만들어 스트림에 보낼 수 있는 형태로 바꾸는 변환 작업을 뜻한다. 
- 자바에서 마샬링을 적용할 수 있는 데이터는 원시 자료형과 객체 중에서 Serializable 인터페이스를 구현한 클래스로 만들어진 객체이다.
- 객체는 원시 자료형과 달리 일정한 크기를 가지지 않고 객체 내부의 멤버 변수가 다르기 때문에 크기가 천차만별로 달라진다. 이런 문제점을 처리할 수 있는게 ObjectOutputStream 클래스이다. 

 

 

직렬화 
- 마샬링으로 바이트로 분해된 객체는 스트림을 통해 전송될 준비가 되었다. 앞에서 언급한대로 객체를 마샬링하기 위해서는 Serializable 인터페이스를 구현한 클래스로 만들어진 객체에 한해서만 마샬링이 진행될 수 있다.

 

Serializable 인터페이스는 아무런 메소드가 없고 단순히 자바 버추얼 머신에게 정보를 전달하는 의미만을 가진다.
*직렬화가 가능한 객체의 조건
(1) 기본형 타입(boolean, char, … )은 직렬화가 가능
(2) Serializable 인터페이스를 구현한 객체여야 한다.
(3) 해당 객체의 멤버들 중에 Serializable 인터페이스가 구현되지 않은 객체가 존재해서는 안된다.
(4) transient가 사용된 멤버는 전송되지 않는다. (보안 변수 : null 전송)

 

Unmarshalling
언마샬링은 객체 스트림을 통해서 전달된 바이트 덩어리를 원래의 객체로 복구하는 작업이다. 이 작업을 제대로 수행하기 위해서는 반드시 어떤 객체 형태로 복구할지 형 변환을 정확하게 해주어야 한다. 

 

클래스의 인스턴스가 생성되면 인스턴스의 상태, 즉 인스턴스 변수 값은 마치 생명체처럼 계속 변한다.

이러한 인스턴스의 어느 순간 상태를 그대로 저장하거나 네트워크를 통해 전송해야 할 때 직렬화를 하여 전송.

  • 객체의 상태를 영속화 하는 메커니즘
  • 객체를 다른 환경에 저장했다가 나중에 재구성 할 수 있게 만드는 과정 
  • " 인스턴스 내용을 연속 스트림으로 만드는 것 "
  • 자바의 보조 스트림인 ObjectInputStream 와  ObjectOutputStream 사용

 

여러가지 다른 데이터 직렬화 

    -  문자열 형태의 직렬화 

직접 데이터를 문자열 형태로 확인 가능한 직렬화 방법입니다. 범용적인 API나 데이터를 변환하여 추출할 때 많이 사용됩니다.
표형태의 다량의 데이터를 직렬화시 CSV가 많이 쓰이고 구조적인 데이터는 이전에는 XML을 많이 사용했으며 최근에는 JSON형태를 많이 사용하고 되고 있습니다.

 

  • CSV

데이터를 표현하는 가장 많이 사용되는 방법 중 하나로 콤마(,) 기준으로 데이터를 구분하는 방법입니다.

 

김배민,deliverykim@baemin.com,25

 

자바에서 사용방법

 

Member member = new Member("김배민", "deliverykim@baemin.com", 25);
// member객체를 csv로 변환 
String csv = String.format("%s,%s,%d",member.getName(), member.getEmail(), member.getAge()); 
System.out.println(csv);

 

예제에서는 문자열로 단순히 변경했습니다.
자바에서는 Apache Commons CSVopencsv 등의 라이브러리 등을 이용할 수 있습니다.

 

 

  • JSON

최근에 가장 많이 사용하는 포맷으로 자바스크립트(ECMAScript)에서 쉽게 사용 가능하고,
다른 데이터 포맷 방식에 비해 오버헤드가 적기 때문에 때문에 인기가 많습니다.

 

{ 
  name: "김배민",
  email: "deliverykim@baemin.com",
  age: 25 
}

 

자바에서 사용방법

위에서 CSV 처럼 문자열로 만들어 줄 수 있으나, 그렇게 사용하는 경우는 드물고

자바에서는 JacksonGSON 등의 라이브러리를 이용해서 변환할 수 있습니다.

 

  -  이진 직렬화 

데이터 변환 및 전송 속도에 최적화하여 별도의 직렬화 방법을 제시하는 구조입니다.
직렬화뿐만 아니라 전송 방법에 대한 부분도 이야기하고 있지만 여기서는 직렬화 부분만 이야기하겠습니다.
종류로는 Protocol Buffer(이하 프로토콜버퍼) Apache Avro 등이 있습니다.

 

  • 프로토콜 버퍼는 구글에서 제안한 플랫폼 독립적인 데이터 직렬화 플랫폼입니다.
  • 자바에서 사용방법
    프로토콜 버퍼는 특정 언어 또는 플랫폼에 종속되지 않는 방법을 구현하기 위해 직렬화 하기 위한 데이터를 표현하기 위한 문서가 따로 있습니다.
message Member {
  required string name = 1;
  optional string email = 2;
  required int32 age = 3;
}

 

이렇게 기술된 member.proto 문서를 프로토콜 버퍼 컴파일러를 이용해서

개발하기 원하는 언어(여기서는 자바)로 변환해야 합니다.
(프로토콜 버퍼 컴파일러는 별도로 설치하거나 GradleMaven등 의 빌드 도구를 이용하면 됩니다.)
자바로 변환하게 되면 프로토콜 버퍼 형태의 Member 클래스가 생성됩니다.

 

Member member = Member.newBuilder()
    .setAge(25)
    .setName("김배민")
    .setEmail("deliverykim@baemin.com")
    .build();
ByteArrayOutputStream baos = new ByteArrayOutputStream()
member.writeTo(baos);
// 프로토콜 버퍼 직렬화된 데이터
byte[] serializedMember = baos.toByteArray();

자바 직렬화와 다른 점은 데이터 스펙을 표현하기 위한 문서( .proto 파일 ) 가 존재하는 부분입니다.

그 이외에는 대부분 동일합니다. 그 외 여러가지 직렬화 방법이 존재.

 

그럼 다시 왜 자바 직렬화를 사용하는지 이야기해보겠습니다.

CSV, JSON, 프로토콜 버퍼 등은 시스템의 고유 특성과 상관없는 대부분의 시스템에서의 데이터 교환 시 많이 사용됩니다.
하지만 "자바 직렬화 형태의 데이터 교환은 자바 시스템 간의 데이터 교환을 위해서 존재한다."고 생각하시면 됩니다.

여기서 중요한 질문 "그럼 자바에서도 CSV, JSON을 사용하면 되지 자바 직렬화를 써야 되는 이유가 있나요?"

사실 이 질문은 이 글을 쓰고 있는 큰 이유이기도 합니다.


그래서 먼저 결론을 이야기하자면 정답은 없습니다."목적에 따라 적절하게 써야 한다."라고 정도 이야기할 수 있을 것 같습니다.

 

여기부터는 자바 직렬화 

자바 직렬화란 ?

  • 자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에서도 사용할 수 있도록 바이트(byte) 형태로 데이터 변환하는 기술
  • 바이트로 변환된 데이터를 다시 객체로 변환하는 기술은 (역직렬화)
  • 시스템적으로 이야기하자면 JVM(Java Virtual Machine 이하 JVM)의 메모리에 상주(힙 또는 스택)되어 있는 객체 데이터를 바이트 형태로 변환하는 기술과
    직렬화된 바이트 형태의 데이터를 객체로 변환해서 JVM으로 상주시키는 형태를 같이 이야기합니다.

사용 방법

일반 Address 객체

 

public class Address {
    private int houseNumber;

    //getters and setters
}

 

 위의 클래스를 직렬화 시도하면 NotSerializableException 발생

 

@Test(expected = NotSerializableException.class)
public void whenSerializing_ThenThrowsError() throws IOException {
    Address address = new Address();
    address.setHouseNumber(10);
    FileOutputStream fileOutputStream = new FileOutputStream("yofile.txt");
    try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream)) {
        objectOutputStream.writeObject(address);
    }
}

 

직렬화 방법

자바 기본(primitive) 타입과 java.io.Serializable 인터페이스를 상속받은 객체는 직렬화 할 수 있는 기본 조건을 가집니다.

 

Serializable에 대해서 알아보기

public interface Serializable {
}

Serializable의 인터페이스를 보면 메소드가 하나도 없는 것을 볼 수 있습니다. 

이러한 인터페이스를 마커 인터페이스 라고 한다.

 

마커 인터페이스 같은 경우에는 컴파일 시점에 발견할 수 있다는 큰 장점이 있다. 그리고 또한 적용 범위를 좀 더 세밀하게 지정할 수 있다. 마커 어노테이션 만큼 유연하게 확장할 수는 없다는 단점이 있다.

 

아무런 구현해야 할 메소드도 없는 이 인터페이스가 도대체 왜 있는 것일까요?

 

우선 직렬화하려는 클래스에 Serializable인터페이스를 구현하여 해당 클래스가 직렬화가 가능함을 알려주어야 합니다. (Serializble 인터페이스는 태그 인터페이스로 직렬화가 가능함만을 알려줄뿐 아무 메소드도 오버라이딩을 강제하지 않습니다.)

 

public class Main { 
    public static void main(String[] args) {
        Car car1 = new Car();
        FileOutput fileStream = new FileOutputStream("file.ser");
        ObjectOutputStream objStream = new ObjectOutputStream(fileStream); // 위에서 생성한 fileStream객체를 인자로 받는 ObjectOutputStream 객체를 생성
        objStream.writeObject(car1);    // car1 객체 직렬화
        objStream.close();
    }
}

 

File로 객체의 상태를 저장하기 위해 FileOutputSteam을 생성한 뒤 객체를 저장할 수 있는 객체인 ObjectOutputStream을 생성하며 방금 생성한 FileOutputStream을 인자로 전달합니다. 그후 ObjectOutputStream을 이용하여 file에 객체의 정보를 저장합니다.

 

* 데코레이터패턴

- 객체의 변경사항 없이 객체의 행위를 추가하거나 변경하기 위해 또 다른 객체로 감싸는 디자인패턴.

Java의 Stream들은 데코레이터 패턴을 사용하여 디자인이 되어있습니다. 객체지향을 준수하기 위해서 입니다. 바로 각 객체가 가지고 있는 목적에 합당한 일만을 하도록 하기 위해서죠. 따라서 ObjectOutputStream 같은 클래스는 FileOutputStream 같이 파일에 접근 가능한 스트림과 연결하여 사용해야 합니다.

 

 

 

개발을 하다 보면 아래와 같은 경우가 존재합니다.

  • 생성한 객체를 파일로 저장할 일이 있을 수도 있습니다.
  • 저장한 객체를 읽을 일이 생길 수도 있습니다.
  • 다른 서버에서 생성한 객체를 받을 일도 생길 수 있습니다.

 

이럴 때 꼭 필요한 것이 Serializable 입니다. 우리가 만든 클래스가 파일에 읽거나 쓸 수 있도록 하거나, 다른 서버로 보내거나 받을 수 있도록 하려면 반드시 이 인터페이스를 구현해야 합니다.

 

Serializable 인터페이스를 구현한 클래스들을 보면 serialVersionUID라는 값을 지정해주는 것을 본 적이 있을 것입니다.

 

SerialVersionUID, 클래스의 버전관리
직렬화하면 내부에서 자동으로 SerialVersionUID라는 고유의 번호를 생성하여 관리한다. 이 UID는 직렬화와 역직렬화 할 때 중요한 역할을 한다. 이 값이 맞는지 확인 후 처리하기 때문이다. 만약 이 SerialVersionUID 값이 맞지 않는다면 오류를 출력한다.
객체 생성 당시의 UID와 현재 변경한 이후의 UID는 내부에서 자동으로 생성되어 관리되므로 UID가 달라 오류가 발생할 수 있다. 이러한 문제로 Java에서는 SerialVersionUID를 직접 선언하고 관리하는 방식을 적극 권장하고 있다. 

 

serialVersionUID는 Java 직렬화 및 역직렬화 할때 필요한 버전 정보입니다. 만약 객체를 직렬화하고 클래스에 멤버변수가 추가된다면 java.io.InvalidClassException 예외가 발생하게 됩니다. Java 직렬화 스펙을 살펴보면 아래와 같습니다.

  • serialVersionUID 필수 값은 아닙니다.
  • 호환 가능한 클래스는 serialVersionUID 값이 고정되어 있습니다.
  • serialVersionUID가 선언되어 있지 않으면 클래스의 기본 해쉬값을 사용합니다.

위의 스펙을 살펴보면 변경에 취약한 클래스가 변경되면 역직렬화 시에 예외가 발생할 수 있으니 개발자가 serialVersionUID 값을 직접 관리해주어야 혼란을 줄일 수 있다는 것을 의미하기도 합니다.

하지만 그럼에도 불구하고 또 다른 문제가 발생할 수 있습니다.

만약 기존의 직렬화된 객체의 멤버 변수의 타입이 바뀐다면 마찬가지로 java.io.InvalidClassException 예외가 발생합니다. 이걸 보면 Java 직렬화는 상당히 타입에 엄격하다는 것을 알 수 있습니다.

즉, 특별한 문제가 없으면 Java 직렬화 버전 serialVersionUID 값은 개발 시 직접 관리해줘야 합니다. 값이 동일하다면 멤버 변수 및 메서드 추가는 크게 문제가 되지 않습니다. 또한 멤버 변수 제거 및 이름 변경은 오류는 발생하지 않고 데이터는 누락됩니다.

 

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    private static final long serialVersionUID = 362498820763181265L;
}

 

예를들어, HashMap 클래스를 보면 위와 같은 변수를 볼 수 있습니다. 이렇게 Serializable 인터페이스를 구현한 후에는 위와 같이 serialVersionUID라는 값을 지정해 주는 것을 권장합니다.
(만약 별도로 지정하지 않으면, 자바 소스가 컴파일될 때 자동으로 생깁니다.)

 

static final long serialVersionUID = 1L;

 

위와 같이 반드시 static final long으로 선언해야 하며, 변수명도 serialVersionUID로 선언해 주어야 자바에서 인식을 할 수 있습니다.

 

 

자바 직렬화 방법과 예시 코드

자바 직렬화는 방법은 java.io.ObjectOutputStream 객체를 이용

 

public class Person implements Serializable {
    private static final long serialVersionUID = 1L;
    private int age;
    private String name;

    // getters and setters
}

 

객체를 저장해보기

 

import java.io.Serializable;

public class SerialDTO implements Serializable {
    private String booName;
    private int bookOrder;
    private boolean bestSeller;
    private long soldPerDay;

    public SerialDTO(String booName, int bookOrder, boolean bestSeller, long soldPerDay) {
        this.booName = booName;
        this.bookOrder = bookOrder;
        this.bestSeller = bestSeller;
        this.soldPerDay = soldPerDay;
    }

    @Override
    public String toString() {
        return "SerialDTO{" +
                "booName='" + booName + '\'' +
                ", bookOrder=" + bookOrder +
                ", bestSeller=" + bestSeller +
                ", soldPerDay=" + soldPerDay +
                '}';
    }
}

 

DTO 클래스를 저장해보는 예제를 해보겠습니다.

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

public class ManageObject {
    public static void main(String[] args) {
        ManageObject manage = new ManageObject();
        String fullPath = "/Users/choejeong-gyun/Documents/test.md";

        SerialDTO dto = new SerialDTO("God of Java", 1, true, 100);
        manage.saveObject(fullPath, dto);
    }

    public void saveObject(String fullPath, SerialDTO 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 (Exception e) { 
            e.printStackTrace();
        } finally {
            if (oos != null) {
                try {
                    oos.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            if (fos != null) {
                try {
                    fos.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
  • 자바에서는 ObjectOutputStream 클래스를 사용하면 객체를 저장할 수 있습니다. ObjectInputStream을 사용하면 저장해놓은 객체를 읽을 수 있습니다.
  • 위의 코드에서도 FileOutputStream 객체를 만든 후에 ObjectOutputStream의 매개변수로 넘겼습니다. 이렇게 하면 해당 객체는 파일에 저장됩니다.
  • writeObject()를 통해서 매개변수로 넘어온 객체를 저장합니다.

 

그리고 파일을 확인해보면 파일에 객체가 저장이 된 것을 볼 수 있습니다.

 

객체를 읽어보기

import java.io.*;

public class ManageObject {
    public static void main(String[] args) {
        ManageObject manage = new ManageObject();
        String fullPath = "/Users/choejeong-gyun/Documents/test.md";
        manage.loadObject(fullPath);
    }

    public void loadObject(String fullPath) {
        FileInputStream fis = null;
        ObjectInputStream ois = null;
        try {
            fis = new FileInputStream(fullPath);
            ois = new ObjectInputStream(fis);
            Object obj = ois.readObject();
            SerialDTO dto = (SerialDTO)obj;
            System.out.println(dto);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (ois != null) {
                try {
                    ois.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
        if (fis != null) {
            try {
                fis.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
SerialDTO{booName='God of Java', bookOrder=1, bestSeller=true, soldPerDay=100}

 

그러면 위와 같이 파일에 저장된 객체 정보를 읽을 수 있습니다. 그리고 이번에 SerialDTO 클래스의 필드를 하나 추가한 후에 위의 코드를 다시 실행해보겠습니다.

 

java.io.InvalidClassException: FileIO.SerialDTO; local class incompatible: stream classdesc serialVersionUID = -358710248991570103, local class serialVersionUID = 1424372278057927306
    at java.base/java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:689)
    at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1982)
    at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1851)
    at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2139)
    at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1668)
    at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:482)
    at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:440)
    at FileIO.ManageObject.loadObject(ManageObject.java:49)
    at FileIO.ManageObject.main(ManageObject.java:12)

 

그러면 위와 같은 결과가 나옵니다. 위에서 볼 수 있듯이 serialVersionUID 값이 다르다는 에러 메세지가 출력됩니다. 이렇게 객체 형태가 변경되면 컴파일시 serialVersionUID가 다시 생성되기 때문에 이러한 문제가 발생하게 됩니다.

 

 

transient라는 예약어는 Serializable과 떨어질 수 없는 관계

transient private int bookOrder;

SerialDTO 클래스에 transient 라는 예약어를 추가한 후에 다시 객체를 파일에 저장하고 읽어오는 코드를 실행해보겠습니다.

Write Success
SerialDTO{booName='God of Java', bookOrder=0, bestSeller=true, soldPerDay=100}

그러면 객체를 생성할 때 bookOrder에 1을 넣었지만 결과에는 0이 나오는 것을 볼 수 있습니다.

0이 나오는 이유가 무엇일까요?

 

객체를 저장하거나, 다른 JVM으로 보낼 때, transient 예약어를 사용하여 선언한 변수는 Serializable의 대상에서 제외됩니다.

그러면 뭐하러 이것을 사용하나 싶을 수 있지만, 패스워드와 같이 보안상 중요한 변수나 꼭 저장해야 할 필요가 없는 변수에 대해서는 transient를 사용할 수 있습니다.

 

 

 

직렬화 참고하기

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

이러한 상속 관계가 있을 때, SuperUserInfo 클래스를 직렬화 했지만, 하위 클래스인 UserInfo 클래스도 직렬화가 가능하게 됩니다.
UserInfo를 직렬화하면 부모 클래스의 name, password도 같이 직렬화가 됩니다.

 

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

하지만 위와 같이 부모 클래스가 직렬화를 구현하지 않았다면 자식 클래스에서 직렬화할 때 name, password는 직렬화 대상에서 제외됩니다.

 

public class UserInfo implements Serializable {
    int age;

    Object object = new Object();  // Object 객체는 직렬화할 수 없다. 
}

위의 코드에서 UserInfo 클래스는 Serializable을 구현하고 있어서 직렬화 할 수 있다고 생각할 수 있지만 직렬화를 시도하면 java.io.NotSerializableException이 발생합니다.

이유가 무엇일까요? 바로 Object 객체 때문입니다. 위의 예제에서 보았듯이 부모 클래스에서 Serializable을 구현하고 있다면 자식 클래스도 직렬화가 가능했습니다.


Object는 모든 클래스의 최고 조상이기 때문에 이 클래스가 Serializable을 구현하다면 모든 클래스들이 직렬화가 가능했을 것입니다.

그렇기 때문에 Object 클래스는 Serializable을 구현하지 않아 직렬화를 할 수 없습니다.

 

public class UserInfo implements Serializable {
    int age;

    Object object = new String("abc");  
}

하지만 위와 같이 다형성을 이용한 코드는 직렬화를 할 수 있습니다. 인스턴스 변수의 타입은 Object의 타입이지만 실제로 저장된 객체는 직렬화가 가능한 String 인스턴스이기 때문에 직렬화가 가능합니다.

 

인스턴스 변수의 타입이 아닌 실제로 연결된 객체의 종류에 의해서 결정된다는 것을 알아두면 좋을 것 같습니다.

objectOutputStream.writeObject(객체);  와  Person p2 = (Person) objectInputStream.readObject(); 로 

 

직렬화 예시 1 )

Person p = new Person();
p.setAge(20);
p.setName("Joe");
FileOutputStream fileOutputStream = new FileOutputStream("yofile.txt");
try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream)) {
    objectOutputStream.writeObject(p);
}

FileInputStream fileInputStream = new FileInputStream("yofile.txt");
try ( ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream)) {
    Person p2 = (Person) objectInputStream.readObject();
    assertEquals(p2.getAge(), p.getAge());
    assertEquals(p2.getName(), p.getName());;
}

 

객체를 직렬 화하여 바이트 배열(byte []) 형태로 변환하는 코드

직렬화 예시 2)

  
      Member member = new Member("김배민", "deliverykim@baemin.com", 25);
        byte[] serializedMember;
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
                oos.writeObject(member);
                // serializedMember -> 직렬화된 member 객체 
                serializedMember = baos.toByteArray();
            }
        }
        // 바이트 배열로 생성된 직렬화 데이터를 base64로 변환
        System.out.println(Base64.getEncoder().encodeToString(serializedMember));
}

 

역직렬화 예시 3)

 // 직렬화 예제에서 생성된 base64 데이터 
    String base64Member = "...생략";
    byte[] serializedMember = Base64.getDecoder().decode(base64Member);
    try (ByteArrayInputStream bais = new ByteArrayInputStream(serializedMember)) {
        try (ObjectInputStream ois = new ObjectInputStream(bais)) {
            // 역직렬화된 Member 객체를 읽어온다.
            Object objectMember = ois.readObject();
            Member member = (Member) objectMember;
            System.out.println(member);
        }
    }

 

 

- 자바 직렬화 

자바 직렬화 형태의 데이터 교환은 자바 시스템 간의 데이터 교환을 위해서 존재한다.

자바 직렬화는 자바 시스템에서 개발에 최적화되어 있습니다.
복잡한 데이터 구조의 클래스의 객체라도 직렬화 기본 조건만 지키면 큰 작업 없이 바로 직렬화를 가능합니다. 물론 역직렬화도 마찬가지입니다.

 

자바 직렬화가 사용되는 경우

  • 서블릿 세션 (Servlet Session)
    서블릿 기반의 WAS(톰캣, 웹로직 등)들은 대부분 세션의 자바 직렬화를 지원하고 있습니다.
    물론 단순히 세션을 서블릿 메모리 위에서 운용한다면 직렬화를 필요로 하지 않지만,
    파일로 저장하거나 세션 클러스터링, DB를 저장하는 옵션 등을 선택하게 되면 세션 자체가 직렬화가 되어 저장되어 전달됩니다.
    (그래서 세션에 필요한 객체는 java.io.Serializable 인터페이스를 구현(implements) 해두는 것을 추천합니다.)
    참고로 위 내용은 서블릿 스펙에서는 직접 기술한 내용이 아니기 때문에 구현한 WAS 마다 동작은 달라질 수 있습니다.
  • 캐시 (Cache)
    자바 시스템에서 퍼포먼스를 위해 캐시(EhcacheRedisMemcached, …)
    라이브러리를 시스템을 많이 이용하게 됩니다.
    자바 시스템을 개발하다 보면 상당수의 클래스가 만들어지게 됩니다.
    예를 들면 DB를 조회한 후 가져온 데이터 객체 같은 경우 실시간 형태로 요구하는 데이터가 아니라면
    메모리, 외부 저장소, 파일 등을 저장소를 이용해서 데이터 객체를 저장한 후 동일한 요청이 오면 DB를 다시 요청하는 것이 아니라 저장된 객체를 찾아서 응답하게 하는 형태를 보통 캐시를 사용한다고 합니다.
    캐시를 이용하면 DB에 대한 리소스를 절약할 수 있기 때문에 많은 시스템에서 자주 활용됩니다. (사실 이렇게 간단하진 않습니다만 간단하게 설명했습니다.)
    이렇게 캐시 할 부분을 자바 직렬화된 데이터를 저장해서 사용됩니다. 물론 자바 직렬 화만 이용해서만 캐시를 저장하지 않지만 가장 간편하기 때문에 많이 사용됩니다.
  • 자바 RMI(Remote Method Invocation)
    최근에는 많이 사용되지 않지만 자바 직렬화를 설명할 때는 빠지지 않고 이야기되는 기술이기 때문에 언급만 하고 넘어가려고 합니다.
    자바 RMI를 간단하게 이야기하자면 원격 시스템 간의 메시지 교환을 위해서 사용하는 자바에서 지원하는 기술입니다.
    보통은 원격의 시스템과의 통신을 위해서 IP와 포트를 이용해서 소켓통신을 해야 하지만 RMI는 그 부분을 추상화하여 원격에 있는 시스템의 메서드를 로컬 시스템의 메서드인 것처럼 호출할 수 있습니다.
    원격의 시스템의 메서드를 호출 시에 전달하는 메시지(보통 객체)를 자동으로 직렬화 시켜 사용됩니다.
    그리고 전달받은 원격 시스템에서는 메시지를 역직렬화를 통해 변환하여 사용됩니다.
    자세한 내용은 작은 책 한 권 정도의 양이 되기 때문에 따로 한번 찾아보시는 것을 추천드립니다.

 

 

직렬화시 생각해야 할 점

 

버전관리

당연한 말이지만 어떠한 객체를 직렬화 한 후 다시 역직렬화 하기 위해서는 직렬화 한 객체의 Class가 존재해야 합니다. 하지만 객체가 외부에 저장되어 있는 동안 Class가 변한다면 어떻게 될까요. Class가 변경되어도 역직렬화가 가능한 경우가 있지만 불가능한 경우도 있습니다. 따라서 이런때를 대비할 수 있는 방법이 존재해야 할 것입니다. 우선 어떠한 변경이 역직렬화를 가능/불가능 하게 만드는지를 파악해보겠습니다.

 

역직렬화에 문제를 주는 경우

- 인스턴스 변수를 삭제하는 경우

- 인스턴스 변수의 유형을 변경하는 경우

- transient로 지정하지 않았던 인스턴스 변수를 transient로 지정한 경우

- 클래스를 상속계층의 위나 아래로 변경하는 경우

- Serializable 구현을 제거한 경우

- 인스턴스 변수를 클래스 변수로 변경한 경우

역직렬화에 문제를 주지 않는 경우

- 클래스에 새로운 인스턴스 변수를 추가하는 경우

- 상속 트리에 클래스를 추가하는 경우

- 상속 트리에서 클래스를 제거하는 경우

 

객체를 직렬화 할때에는 객체가 속한 Class의 버전ID번호가 찍힙니다. 그 번호를 serialVersionUID라고 부르며 앞서 말씀드린것을 바탕으로 추론할 수 있듯이 직렬화 한 후 Class가 변경된다면 serialVersionUID역시 다를 것입니다. 따라서 역직렬화 하는 경우 serialVersionUID가 다르다면 역직렬화가 불가능합니다.

- 외부에 장기간 저장되는 경우에는 java 직렬화를 지양해야 합니다. 역직렬화 대상의 클래스가 변경이 일어날 확률이 높기 때문에 해당 데이터가 쓰레기가 될 가능성이 높기 때문입니다.

- 역직렬화시 반드시 예외(역직렬화 대상 클래스의 변경이 일어날 가능성이 높으므로)가 발생할 것으로 생각하고 개발해야 합니다.

- 자주 변경되는 클래스(비지니스 데이터 저장 클래스)는 역직렬화시 버전 충돌 문제로  java 직렬화를 사용하지 않습니다.

결론

Java 직렬화를 사용할 경우 역직렬화를 할 때 예외가 생길 수 있다는 사실을 인지하고 반드시 예외 처리를 해야합니다. 또한 자주 변경되는 비즈니스적인 데이터를 Java 직렬화을 사용하지 않습니다. 긴 만료시간을 가지는 데이터는 JSON 등 다른 포맷을 사용하여 저장해야 합니다.

 

더보기

▶  여기서 잠깐 ! Base64 인코딩 이란?

더보기

 

     <  java Base64 인코딩 >

 

base64 인코딩은 byte array 를 64개의 문자로 이루어진 문자열로 변환하는 방법이다.

2진수로 표시하는 것을 bit 라고 하고

8bit 로 이루어진 것을 byte 라고 한다.

byte array 는 byte 의 배열을 의미한다. (byte[] 로 선언한다.)

byte array 는 연속된 이진수 값들을 가지고 있는데, 이 값을 사람이 읽을 수 있는 문자열로 변환이 필요할 때가 있다.

가령 암호화를 위해서는 key 가 필요한데 보통 이 key 들은 byte array 로 표현된다. 이 key 값을 특정사용자에게 제공하고 싶을 때, 이진수로 이루어진 배열로 전달하는 것 보다는 문자열(String)로 전달하는 것이 보기도 쉽고 길이도 줄어들 것이다.

문제는 'byte array 를 String 으로 어떻게 변환할 것인가?' 이다.

이 때 흔히 사용 되는 변환 방식 중에 하나가 base64 encoding 인데

byte array 를

숫자와 알파벳 대소문자 그리고 +, / 로 이루어진

64개의 문자로 mapping 하는 것이다.

base64 mapping 테이블은 아래와 같다.


바로 예제 코드를 보자.

참고로 자바8 부터 Base64 class 를 java.util package 로 공식적으로 제공한다.

 
    @Test
    public void base64Test() {
        final byte[] key = RandomUtils.nextBytes(9);
        final String keyString = Base64.getEncoder().encodeToString(key);
        System.out.println(keyString);

        final byte[] decodedKey = Base64.getDecoder().decode(keyString);
        assertArrayEquals(key, decodedKey);
    }

 

위의 소스코드는 test code 로 작성됐다.

9byte 를 생성하고 base64 로 encoding 하고 출력한 뒤, 다시 decode 하여 원래값과 비교한다.

제대로 작성된 코드라면, key 와 decodedKey 가 같은 배열이어야 하기 때문에 assertArrayEquals 로 검증했다.

위의 코드는 테스트를 통과한다. base64로 encoding 과 decoding 이 잘 된다는 의미이다.

base64 문자열로 인코딩된 결과 값은 아래와 같이 출력된다.

8cGqf+MU8ssm


퀴즈! 왜 base64 encoding된 문자열의 문자 수가 12일까?

9byte 는 총 72 bit 이다. (9 x 8 이므로)

이걸 6으로 나누는 것이다. 왜냐면 (총 64개의 문자로 매핑되는데 64는 2^6 이기 때문이다)

72 / 6 = 12.

그러므로 인코딩된 문자 수는 12개가 되는 것이다.

 

출처 :  https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=myca11&logNo=221360965771

 

 

▶ 여기서 잠깐 ! JVM 메모리 구조를 아시나요?

더보기

아래는 자바 프로그램의 실행 단계입니다. 분량상 운영체제 도형은 생략하였습니다. JVM의 구조는 크게 보면, Garbage Collector, Execution Engine, Class Loader, Runtime Data Area로, 4가지로 나눌 수 있습니다

 

자바 소스 파일은 자바 컴파일러에 의해서 바이트 코드 형태인 클래스 파일이 됩니다. 그리고 이 클래스 파일은 클래스 로더가 읽어들이면서 JVM이 수행됩니다.

 

(1) Class Loader

JVM 내로 클래스 파일을 로드하고, 링크를 통해 배치하는 작업을 수행하는 모듈입니다. 런타임 시에 동적으로 클래스를 로드합니다.

 

(2) Execution Engine

클래스 로더를 통해 JVM 내의 Runtime Data Area에 배치된 바이트 코드들을 명렁어 단위로 읽어서 실행합니다. 최초 JVM이 나왔을 당시에는 인터프리터 방식이었기때문에 속도가 느리다는 단점이 있었지만 JIT 컴파일러 방식을 통해 이 점을 보완하였습니다. JIT는 바이트 코드를 어셈블러 같은 네이티브 코드로 바꿈으로써 실행이 빠르지만 역시 변환하는데 비용이 발생하였습니다. 이 같은 이유로 JVM은 모든 코드를 JIT 컴파일러 방식으로 실행하지 않고, 인터프리터 방식을 사용하다가 일정한 기준이 넘어가면 JIT 컴파일러 방식으로 실행합니다.

 

(3) Garbage Collector

Garbage Collector(GC)는 힙 메모리 영역에 생성된 객체들 중에서 참조되지 않은 객체들을 탐색 후 제거하는 역할을 합니다. 이때, GC가 역할을 하는 시간은 언제인지 정확히 알 수 없습니다.

 

(4) Runtime Data Area

JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역입니다. 이 영역은 크게 Method Area, Heap Area, Stack Area, PC Register, Native Method Stack로 나눌 수 있습니다.

 

1.  Method area 

모든 쓰레드가 공유하는 메모리 영역입니다. 메소드 영역은 클래스, 인터페이스, 메소드, 필드, Static 변수 등의 바이트 코드를 보관합니다.

 

 

2. Heap area

모든 쓰레드가 공유하며, new 키워드로 생성된 객체와 배열이 생성되는 영역입니다. 또한, 메소드 영역에 로드된 클래스만 생성이 가능하고 Garbage Collector가 참조되지 않는 메모리를 확인하고 제거하는 영역입니다.

 

 

3. Stack area 

 

 

메서드 호출 시마다 각각의 스택 프레임(그 메서드만을 위한 공간)이 생성합니다. 그리고 메서드 안에서 사용되는 값들을 저장하고, 호출된 메서드의 매개변수, 지역변수, 리턴 값 및 연산 시 일어나는 값들을 임시로 저장합니다. 마지막으로, 메서드 수행이 끝나면 프레임별로 삭제합니다.

 

 

4. PC Register

쓰레드가 시작될 때 생성되며, 생성될 때마다 생성되는 공간으로 쓰레드마다 하나씩 존재합니다. 쓰레드가 어떤 부분을 무슨 명령으로 실행해야할 지에 대한 기록을 하는 부분으로 현재 수행중인 JVM 명령의 주소를 갖습니다.

 

 

5. Native method stack

자바 외 언어로 작성된 네이티브 코드를 위한 메모리 영역입니다.

 

출처 : https://steady-coding.tistory.com/305

 

JVM 메모리 구조란? (JAVA)

안녕하세요? 코딩 중독입니다. 오늘은 JVM 메모리 구조에 대해 알아보겠습니다. JVM이란? JVM 메모리 구조를 설명하기 전에 JVM이 무엇인지 알아야 합니다. JVM은 Java Virtual Machine의 약자로, 자바 가상

steady-coding.tistory.com