나의 작은 시리얼라이저 구현기

asdf·2022년 8월 17일
0
post-thumbnail

융합프로젝트 과목에서 자바로 수강신청 어플리케이션을 만드는 것이 과제로 주어졌었다.

그 중에서 Client와 Server가 통신하는 모듈을 구현했던 것 중에서도 직렬화/역직렬화 하는 것을 자동화 했던 경험이 있다. 자바 직렬화를 흉내내서 만들었었는데, 이를 기록 및 설명하기 위해 구현 프로그램을 변경하여, 서버에 존재하는 책 정보를 클라이언트로 전송해주는 간단한 프로그램에서 사용하는 프로토콜과 직렬화/역직렬화 모듈을 구현해 본다.

크고 웅장하고 강력한 자바 직렬화 기능보다는 작고 삐걱거리는 나의 시리얼라이저 구현기 지금 시작한다.

요구사항

  • Client와 Server 간의 통신을 위한 프로토콜을 스스로 정해야함
  • JAVA에서 제공하는 직렬화(Serializable)를 사용 불가
  • 서버에서 클라이언트로 Book 객체 하나를 전송 해야함
  • Book 객체는 조회수를 기록하고, 조회수가 10이상일 경우 인기책으로 분류함

프로토콜을 직접 정의하여야하고, 데이터를 전송가능한 형태(바이너리)로 변환해야한다.

또한 여러 기능적 요구사항을 충족해야한다.

통신 프로토콜과 데이터를 전송가능한 형태로 바꾸는 것, 즉 직렬화에 대해서 간단하게 알아보고 실제 구현으로 넘어가보도록한다.

사전지식

통신 프로토콜(Communication protocol)

: 두 개이상의 개체간의 정보를 주고받기위해 정한 규칙

구성요소로는 구문(Syntax), 의미(Semantics), 타이밍(Timing)가 있고,

형태로는 텍스트기반과, 바이너리 기반이 있다.

직렬화(Serialization)란?

: 자료구조나 객체의 상태를 저장, 전송, 복원 가능한 형식으로 변환하는 과정

직렬화 포멧 예시) JSON, XML, YAML 등..

💡 프로토콜이 뭐지? 프로토콜의 개념을 처음 학습했을 때, 코드상에 어떻게 적용되는지 정확히 알지 못해 이해가 어려웠다. 그러나 융합프로젝트 과목에서 직접 프로토콜을 정의하고 코드로 구현해보며, 이해가 깊어졌다. 혹시 프로토콜이 무엇인지 잘 이해가 가지 않는다면 직접 정의하고, 구현해보기를 추천한다.

대략적 구조와 우리가 집중할 것


자바 객체를 입력으로 받아 바이트 배열로 반환하는 Serializer, 바이트 배열을 입력받아 자바 객체를 반환하는 Deserializer를 중점적으로 오늘 글을 전개 해 나간다.

서점 프로그램의 전체적인 구조는 다음과 같다 참고해서 글을 읽기 바란다.

통신 모듈 ver 0

프로토콜을 먼저 정의해보고, 주요 클래스인 Data 클래스와 Book클래스를 구현한다.

프로토콜 정의

우리 시스템에서 사용할 프로토콜의 이름은 MyPt이며, TCP/IP에서 어플리케이션 계층에서 동작하는 프로토콜이다. MyPt는 바이너리 기반으로 정의하였고, 바이트 스트림의 구조는 다음과 같다.

  • Type : 요청과 응답 구분
    • Request(0x00)
    • Response(0x01)
  • Code
    • Type이 Request일 때
      • Book(0x00)
    • Type이 Response일 때
      • Success(0x00)
      • Fail(0x01)
  • Body Length : 가변적인 Body의 크기를 알기 위한 필드

구현

package serialization.initial;

public class Data {
    //TYPE : 바이트배열의 첫번째 인덱스 항목
    public static final byte REQUEST = 0;
    public static final byte RESPONSE = 1;

    //CODE : 바이트배열의 두번째 인덱스 항목
    //REQUEST일 때 : 요청할 엔티티 정보
    public static final byte ENTITY_BOOK = 0;
    //RESPONSE일 때 : 상태코드
    public static final byte STATUS_SUCCESS = 0;
    public static final byte STATUS_FAIL = 1;

    //헤더 크기정보
    public static final byte HEADER_LEN = 6;

    private byte[] byteArr;

    public Data(byte type, byte code){
        byteArr = new byte[HEADER_LEN];
        byteArr[0] = type;
        byteArr[1] = code;
    }

    public Data(byte type, byte code, byte[] data){
        byteArr = new byte[HEADER_LEN+data.length];
        byteArr[0] = type;
        byteArr[1] = code;
        System.arraycopy(Serializer.intToBytes(data.length), 0,byteArr, 2, 4);
        System.arraycopy(data, 0, byteArr, 6, data.length);
    }
...생략
}

바이트 배열을 Wrapping한 Data클래스이다. 프로토콜에서 정의한 바이트 스트림 블럭 별 의미를 알기 쉽게 정적 변수들을 선언하였고, 바이트 배열에서 필요한 정보를 쉽게 얻을 수 있는 메소드를 제공한다.

package serialization.initial;

public class Book {
    private int serialNum;
    private String name;
    private int price;
    private int hit;

    public Book(int serialNum, String name, int price) {
        this.serialNum = serialNum;
        this.name = name;
        this.price = price;
    }

    public boolean isPopular(){
        if(hit>10){
            return true;
        }else{
            return false;
        }
    }

    public void retrieve(){
        hit += 1;
    }

    @Override
    public String toString() {
        return "Book{" +
                "serialNum=" + serialNum +
                ", name='" + name + '\'' +
                ", price=" + price +
                ", hit=" + hit +
                '}';
    }
}

서버와 클라이언트가 주고 받을 Book객체의 클래스이다. 요구사항

Book 객체는 조회수를 기록하고, 조회수가 10이상일 경우 인기책으로 분류함

을 만족시키기위해 조회할 때마다 조회수가 올라가는 것을 표현하려고 retrieve() 메소드를 구현하였고, 조회수가 10이상일 때부터 인기있는 책이라는 것을 표현하기 위해 isPopular() 메서드를 구현하였다. 다른 요구사항에 다른 멤버변수가 외부로 공개될 필요가 없음으로 getter를 따로 만들지 않았다.

통신 모듈 ver 1

이진수의 해석

데이터가 실제 네트워크 상으로 전송 될 때는 이진수 형태이다. MyPt 프로토콜로 데이터를 네트워크 상으로 보낸다면 이런 형태가 될 것이다. 이것을 해석해보자.

프로토콜의 헤더는 6바이트의 크기를 가지는 것으로 정의하였음으로, 헤더와 바디의 구분을 할 수 있다. 또한 헤더에서 맨 앞부분 1바이트는 타입을 의미하는 것으로, 두번째 1바이트는 코드를 의미하는 것으로, 나머지 4바이트는 바디의 크기를 의미하는 것으로 미리 정하였기 때문에 알 수 있었다. 이러한 정의를 토대로 헤더를 해석하면, Response타입과 Success코드를 가지며 body length는 64이다.라는 정보를 알 수 있다.

계속해서 body 부분을 해석해보자. body부분의 앞의 1바이트는 무엇을 의미하는가? 혹은 4바이트는 무엇을 의미하는가? 우리는 알 수 없다. 이번에는 다음과 같은 정보를 가지고 해석해보자.

body에 실려있는 객체는 Book 객체이고,
Book 클래스의 멤버변수가 선언되어있는 순서대로 바이트 배열로 변환한 것이다. Book 객체의 멤버변수는
1. int 타입의 serialNum의 이름을 가진다.
2. String 타입의 name의 이름을 가진다.
3. int 타입의 price의 이름을 가진다.
4. int 타입의 hit의 이름을 가진다.

“Book 객체의 멤버변수를 선언되어있는 순서대로 바이트 배열로 변환”을 토대로 body부분의 데이터들은 Book객체의 멤버 변수를 순서대로 의미함을 알 수 있다. 그러므로 body의 처음 4바이트(자바의 int형은 4바이트)는 serialNum의 값이다. 그리고 그 다음은 String 타입의 name의 값일 것이다. 그런데 몇바이트까지가 String의 값일까?

크기가 고정되어 있는 원시 타입의 변수와 달리, 크기가 고정되어있지 않은 참조 타입 변수는 크기 정보가 별도로 필요하다. String도 참조 타입 변수이기 때문에 크기 정보가 별도로 필요하다. 추가로 이런 정보가 있다고 해보자.

참조타입 변수의 앞에는 int타입의 참조타입 변수의 크기를 나타내는 데이터가 존재한다.

💡 자바에서 ‘참조 타입 변수’는 객체의 reference를 값으로 갖는 변수를 의미합니다. 그러나 여기서 직렬화를 수행 할 때는 reference의 값을 직렬화 하는 것이 아니라, reference가 가리키는 객체의 값들을 직렬화 하는 것을 말합니다. 즉, 어떤 객체 A가 다른 객체 B를 composition하고 있고 객체 A를 직렬화 한다면, 객체 B도 직렬화 되어 객체 A의 바이트 배열에 속하게 됩니다.

name의 값을 읽어오기 위해 먼저, 앞의 4바이트로 String의 크기정보를 가져온다. 그리고 그 크기 정보만큼의 바이트로 String의 값 정보를 읽어온다. 이후 price와 hit는 serialNum와 동일하게 진행되어 값을 읽어온다.

위에서 중간 중간에 제공한 정보와 데이터의 크기 정보를 가진 바이트들은 body에 있는 이진수에 대한 정보를 가지고있다. 이러한 것을 ‘메타 데이터'라고 부른다. header는 이러한 메타 데이터가 사전에 정의되어 있지만, body의 메타데이터는 변화가 잦기 때문에 사전에 정의될 수 없다. body의 메타데이터는 외부에서 획득해야함을 알 수 있다.

구현

자 이제, 위에서 생각해본 내용을 바탕으로 구현을 진행해보자.

package serialization.ver1;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class Serializer {
    public static final int SIZE_LEN = 4;

    public static byte[] bookToBytes(Book book){
        List<byte[]> byteList = new ArrayList<>();

        int size = 0;
        byte[] serialNumBytes = intToBytes(book.getSerialNum());
        size += serialNumBytes.length;
        byteList.add(serialNumBytes);

        byte[] nameBytes = stringToBytes(book.getName());
        size += nameBytes.length;
        byteList.add(nameBytes);

        byte[] priceBytes = intToBytes(book.getPrice());
        size += priceBytes.length;
        byteList.add(priceBytes);

        byte[] hitBytes = intToBytes(book.getHit());
        size += hitBytes.length;
        byteList.add(hitBytes);

        byte[] res = new byte[size];

        int cursor = 0;
        for(byte[] b : byteList){
            System.arraycopy(b, 0, res, cursor, b.length);
            cursor += b.length;
        }

        return res;
    }
...생략
}

Serializer 클래스는 객체나 값들을 바이트 배열로 변환해주는 Util Class이다.

그 중에서 Book 객체를 바이트 배열로 변환해주는 bookToBytes() 메소드를 살펴보자.

아까 위에서 이진수에서 body부분을 해석할 때, 메타 데이터라고 하는 정보들이 어디선가 툭툭 등장했다. bookToBytes() 메소드의 구현에서 메타 데이터는 어디에서 와서 어떻게 존재할까? Book 클래스의 정보 → 구현자의 머리 → 코드의 하드코딩 순서로 메타데이터는 왔고 존재한다. 구현을 보게 되면, Book 객체의 멤버 변수의 타입과 순서를 고려해서 코드가 작성되어있다. 코드는 잘 작성된 것 처럼보이고 또, 잘 작동된다. 그러나 중간에 사람을 거친 메타 데이터를 이용한 직렬화 및 역직렬화는 여러 문제점이 존재한다. 이 문제점들은 조금 있다가 살펴보기로하자.

package serialization.ver1;

import java.nio.charset.StandardCharsets;

public class Deserializer {
    public static final int SIZE_LEN = 4;

    public static Book bytesToBook(byte[] bytes){
        int cursor = 0;

        int serialNum = bytesToInt(bytes, cursor);
        cursor += 4;

        String name = bytesToString(bytes, cursor);
        cursor += name.getBytes(
							StandardCharsets.UTF_8).length + SIZE_LEN;

        int price = bytesToInt(bytes, cursor);
        cursor += 4;

        int hit = bytesToInt(bytes, cursor);
        cursor += 4;

        return new Book(serialNum, name, price, hit);
    }
...생략
}

Deserializer 클래스는 바이트 배열을 객체로 변환해주는 Util Class이다.

바이트 배열을 Book 클래스로 변환해주는 bytesToBook() 메소드를 살펴보면, Serializer의 bookToBytes()와 동일한 구조를 가짐을 알 수 있다. 이 메소드 역시 bookToBytes()와 같이 메타 데이터가 사람을 거쳐왔다.

package serialization.ver1;

public class Book {
...생략
    
    public Book(int serialNum, String name, int price, int hit) {
        this.serialNum = serialNum;
        this.name = name;
        this.price = price;
        this.hit = hit;
    }
...생략

    public int getSerialNum() {
        return serialNum;
    }

    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }

    public int getHit() {
        return hit;
    }
..생략
}

Serializer에서 Book 객체의 멤버변수를 얻어와야하기 때문에 각 멤버변수별 getter를 추가 하였고, Deserializer에서 복원한 데이터들로 객체를 생성해야하기 때문에 모든 멤버를 인자로 가지는 생성자를 추가하였다.

주요 클래스들을 모두 작성하였고, 서점 프로그램을 실행하였을 때, Book 객체를 주고 받는 것을 성공하였다. 모든 요구사항을 성공적으로 구현하고 프로젝트를 끝내려고 했을 때 갑자기 새로운 요구사항이 등장한다.

Book객체를 여러개 한번에 전송 해야함
Book객체를 상속한 SpecialBook 객체를 전송 해야함
int형의 num과 String형의 manager 멤버 변수를 가지는 SerialNumber 클래스를 만들 것
Book객체의 멤버 변수인 int형의 serialNum를 SerialNumber 클래스 타입으로 변경 할 것

전송해야할 객체의 종류와 수가 늘었다. 전송할 객체종류가 하나 늘 때마다 메소드를 두개씩 계속해서 만들어야한다니 끔직하다. 코딩이 하기 싫어진다. 각 객체에 맞는 메소드 말고 모든 객체에 범용적으로 사용할 수 있는 메소드를 만들 수는 없을까?

통신 모듈 ver2

사람을 거친 메타 데이터의 문제점

사람을 거친 메타 데이터를 활용한 직렬화/역직렬화 구현은 여러 문제점이 존재한다.

전송할 객체종류 하나당, 메소드 두개가 구현되어하는 문제

객체의 클래스의 정보를 구현자가 인지하고, 그 정보에 맞게 직렬화/역직렬화 메소드를 구현해야한다. 메소드의 내부 구현은 클래스의 멤버 변수 정보와 강하게 결합 되어 있기 때문에 클래스의 멤버 변수 정보가 변경 되면 Serializer의 메소드, Deserializer의 메소드, Server측의 클래스, Client측의 클래스가 변경 되어야한다. 즉, 매우 변경에 취약하다. 또한 바이너리 형태의 형식을 가지기 때문에 디버깅하기가 매우 어렵다.

전송할 객체에 불필요한 메소드와 생성자가 추가되어야하는 문제

처음에 만든 Book 클래스는 요구사항을 구현하는데 필요한 최소한의 메소드와 생성자만이 존재했다. 그러나 이전에 구현한 Serializer와 Deserializer을 사용하기 위해서 불필요한 getter가 추가되어 캡슐화가 깨졌고, 불필요한 생성자가 추가되어 불변식 유지에 문제가 생길 위험에 쳐했다.

Reflection in JAVA

자바에서 제공하는 Reflection API를 활용해서 이전까지의 문제를 해결할 수 있다.

Reflection은 Compile time에 클래스, 인터페이스, 메서드, 필드의 정보를 가지지 않고 Runtime에 획득한 정보로 클래스, 인터페이스, 메서드, 필드에 대한 정보를 알 수 있다. 또한 메서드를 호출할 수 있고, 클래스를 인스턴스화 시킬 수 있다.

클래스의 이름만 알고 있다면, 클래스의 정보를 런타임에 읽어올 수 있기 때문에, 사람이 직접 클래스의 구조에 맞게 클래스당 메서드를 작성하지 않아도 된다. 메타 데이터를 획득하는 과정에서 사람이 빠지게 됨으로써 하나의 메서드로 모든 객체가 사용할 수 있게 되었다. 이로 인해 첫번째 문제를 해결할 수 있다.

또한 Reflection API를 사용하면, 클래스의 접근제어자를 무시하고 멤버변수의 값에 접근가능하다. 이로 인해 불필요한 getter가 생기는 문제도 해결할 수 있다.

Reflection을 활용한 Serializer/Deserializer

Reflection을 활용하여 범용적인 직렬화/역직렬화 메소드를 만들기 위해서는 추가적인 정보가 하나 필요하다. 클래스의 이름을 통해 클래스의 메타 데이터를 동적으로 얻어 온다. 따라서, 클래스의 이름은 body에 필수적으로 존재 해야한다. 클래스 이름은 String으로 표현됨으로, String의 크기 정보가 필요하다. 이것을 고려하여 역직렬화 하는 과정을 기술하면 다음과 같다.

  1. body 부분에서 처음 4바이트를 가져와서 int형으로 복원한다 이것은 클래스 이름(String)의 크기이다.
  2. 1번에서 획득한 정수 정보 만큼의 바이트를 가져와서 String형으로 복원한다. 이것이 클래스 이름(String)이다.
  3. 2번에서 획득한 클래스 이름으로 해당 클래스의 정보를 획득한다. (멤버 변수의 구조 및 순서)
  4. 3번에서 획득한 정보를 토대로 바이트 배열을 객체로 복원한다.

추가된 요구사항인 객체 배열 복원에서는 하나의 과정이 더 추가되어야 한다.

  1. body 부분에서 처음 4바이트를 가져와서 int형으로 복원한다 이것은 클래스 이름(String)의 크기이다.
  2. 1번에서 획득한 정수 정보 만큼의 바이트를 가져와서 String형으로 복원한다. 이것이 클래스 이름(String)이다.
  3. 다음 4바이트 만큼을 가져와서 int형으로 복원한다. 이것은 배열에 존재하는 객체의 개수를 알려준다.
  4. 2번과 3번에서 획득한 클래스 이름으로 해당 클래스의 정보를 획득한다. (멤버 변수의 구조 및 순서)
  5. 4번에서 획득한 정보를 토대로 바이트 배열을 객체로 복원한다.

위의 내용을 코드로 확인해보자.

구현

package serialization.ver2;

import java.lang.reflect.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Deserializer {
    public static final int SIZE_LENGTH = 4;

    public static Object bytesToObjectArr(byte[] bytes) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        int cursor = 0;
        int classNameSize = bytesToInt(bytes, 0);
        cursor += SIZE_LENGTH;

        String className = new String(
                bytes, cursor, classNameSize
        ).trim();

        cursor += classNameSize;

        int count = bytesToInt(bytes, cursor);
        cursor += SIZE_LENGTH;

        className = className.replace("[L","");
        Class clazz = Class.forName(className);
        Object objectArr = Array.newInstance(clazz, count);

        for(int i=0; i<count; i++){
            int clsNameSize = bytesToInt(bytes, cursor);
            cursor += SIZE_LENGTH;

            String objClassName = new String(
                    bytes, cursor, clsNameSize
            ).trim();
            cursor += classNameSize;

            Class objClass = Class.forName(objClassName);
            Constructor constructor = objClass.getDeclaredConstructor();
            Object obj = constructor.newInstance();

            for(Field f : getAllFields(objClass)){
                int length = bytesToInt(bytes, cursor);
                cursor += SIZE_LENGTH;
                setData(obj, f, bytes, cursor, length);
                cursor += length;
            }

            Array.set(objectArr, i, obj);
        }

        return objectArr;
    }

    public static Object bytesToObject(byte[] bytes) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        int cursor = 0;
        int classNameSize = bytesToInt(bytes, 0);
        cursor += SIZE_LENGTH;

        String className = new String(
                bytes, cursor, classNameSize
        ).trim();

        cursor += classNameSize;

        Class clazz = Class.forName(className);
        Constructor constructor = clazz.getDeclaredConstructor();
        Object obj = constructor.newInstance();

        for(Field f : getAllFields(clazz)){
            int length = bytesToInt(bytes, cursor);
            cursor += SIZE_LENGTH;
            setData(obj, f, bytes, cursor, length);
            cursor += length;
        }

        return obj;
    }

    private static List<Field> getAllFields(Class clazz){
        if(clazz==Object.class){
            return Collections.emptyList();
        }

        List<Field> result = new ArrayList<>(
										getAllFields(clazz.getSuperclass()));
        for(Field f : clazz.getDeclaredFields()){
            if(Modifier.isStatic(f.getModifiers())){
                continue;
            }

            result.add(f);
        }

        return result;
    }

    private static void setData(
            Object obj, Field f, byte[] bytes,
                    int cursor, int length)
                    throws IllegalAccessException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException {
        String typeName = f.getType().getSimpleName();
        f.setAccessible(true);

        if (typeName.contains("[")) {
            typeName = "array";
        }

        switch (typeName) {
            case "byte":
                f.setByte(obj, bytes[cursor]);
                return;
            case "char":
                f.setChar(obj, bytesToChar(bytes, cursor));
                return;
            case "short":
                f.setShort(obj, bytesToShort(bytes, cursor));
                return;
            case "int":
                f.setInt(obj, bytesToInt(bytes, cursor));
                return;
            case "long":
                f.setLong(obj, bytesToLong(bytes, cursor));
                return;
            case "String":
                f.set(obj, new String(bytes, cursor, length));
                return;
            case "array": {
                if (length == 0) {
                    f.set(obj, null);
                    return;
                }
                byte[] a = new byte[length];
                System.arraycopy(bytes, cursor, a, 0, length);
                f.set(obj, bytesToObjectArr(a));
                return;
            }
            default: {
                if (length == 0) {
                    f.set(obj, null);
                    return;
                }
                byte[] a = new byte[length];
                System.arraycopy(bytes, cursor, a, 0, length);
                f.set(obj, bytesToObject(a));
                return;
            }
        }
    }
...생략
}

코드가 많이 길어져 어려워 보이지만 생각보다 그리 어렵지않다. 차근차근 보자.

먼저 객체를 바이트 배열로 변환해주는 메서드인 bytesToObject()메서드를 보자.

        int cursor = 0;
        int classNameSize = bytesToInt(bytes, 0);
        cursor += SIZE_LENGTH;

        String className = new String(
                bytes, cursor, classNameSize
        ).trim();

        cursor += classNameSize;

        Class clazz = Class.forName(className);
        Constructor constructor = clazz.getDeclaredConstructor();
        Object obj = constructor.newInstance();

클래스 이름를 통해서 해당 클래스의 메타데이터를 가지고있는 Class 클래스를 가져온다. Class 클래스를 통해 생성자를 가져오고 생성자를 통해 객체를 생성한다. Class 클래스의 getDeclaredConstructor()메서드는 해당 클래스의 인자가 없는 생성자를 획득한다. 이를 통해 객체를 생성하기 때문에 직렬화/역직렬화 되는 객체의 클래스는 인자가 없는 생성자를 하나 필수적으로 가져야한다.


        for(Field f : getAllFields(clazz)){
            int length = bytesToInt(bytes, cursor);
            cursor += SIZE_LENGTH;
            setData(obj, f, bytes, cursor, length);
            cursor += length;
        }

        return obj;
	}
    private static List<Field> getAllFields(Class clazz){
        if(clazz==Object.class){
            return Collections.emptyList();
        }

        List<Field> result = new ArrayList<>(getAllFields(clazz.getSuperclass()));
        for(Field f : clazz.getDeclaredFields()){
            if(!Modifier.isStatic(f.getModifiers())){
		            result.add(f);
            }
        }

        return result;
    }

클래스의 모든 멤버 변수의 정보를 가져오기 위해 getAllFields() 메소드를 호출한다. 멤버 변수의 정보는 Field 객체에 저장되어 있다. getAllFields() 메소드를 살펴보면, 해당 클래스의 상위 클래스를 인자로 넣는 getAllFields() 메소드를 재귀적으로 호출함을 알 수 있다. 상속된 모든 멤버 변수의 정보를 가져오기 위함이다. 이로 인해서 추가요구사항

Book객체를 상속한 SpecialBook 객체를 전송 해야함

을 만족할 수 있게 되었다.

클래스의 모든 멤버 변수 정보를 획득 한 후, Field 리스트를 순회하면서 바이트 배열을 멤버 변수에 타입에 맞게 복원한 후 객체에 저장한다. 이것을 setData() 메소드에서 수행한다. setData() 메소드를 자세히 살펴보자.

 private static void setData(Object obj, Field f, byte[] bytes, int cursor, int length) throws IllegalAccessException, ClassNotFoundException, InvocationTargetException, NoSuchMethodException, InstantiationException {
        String typeName = f.getType().getSimpleName();
        f.setAccessible(true);

        if(typeName.contains("[")){
            typeName = "array";
        }

        switch(typeName){
            case "byte":
                f.setByte(obj, bytes[cursor]);
                return;
            case "char":
                f.setChar(obj, bytesToChar(bytes, cursor));
                return;
            case "short":
                f.setShort(obj, bytesToShort(bytes, cursor));
                return;
            case "int":
                f.setInt(obj, bytesToInt(bytes, cursor));
                return;
            case "long":
                f.setLong(obj, bytesToLong(bytes, cursor));
                return;
            case "String":
                f.set(obj, new String(bytes, cursor, length));
                return;
            case "array":{
                if(length==0){
                    f.set(obj, null);
                    return;
                }
                byte[] a = new byte[length];
                System.arraycopy(bytes, cursor, a, 0, length);
                f.set(obj, bytesToObjectArr(a));
                return;
            }
            default:{
                if(length==0){
                    f.set(obj, null);
                    return;
                }
                byte[] a = new byte[length];
                System.arraycopy(bytes, cursor, a, 0, length);
                f.set(obj, bytesToObject(a));
                return;
            }
        }
    }

Field형 객체로 부터 타입에 대한 정보를 얻어서, 각 타입에 맞게 바이트배열을 변환하는 구현이다. 여기서 주목 할 점은, case문에서 array와 default문에 해당하는 내용이다. array는 배열을 의미하고, default문에서는 참조 변수 타입을 의미한다. 이것을 통해 멤버 변수로 배열이나 참조변수가 존재하여도, 재귀적으로 역직렬화 함을 알 수 있다. 추가 요구사항인

int형의 num과 String형의 manager 멤버 변수를 가지는 SerialNumber 클래스를 만들 것
Book객체의 멤버 변수인 int형의 serialNum를 SerialNumber 클래스 타입으로 변경 할 것

를 만족 할 수 있게 되었다.

마지막으로 배열을 직렬화하는 bytesToObjectArr() 메소드를 살펴보자.

  public static Object bytesToObjectArr(byte[] bytes) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        int cursor = 0;
        int classNameSize = bytesToInt(bytes, 0);
        cursor += SIZE_LENGTH;

        String className = new String(
                bytes, cursor, classNameSize
        ).trim();

        cursor += classNameSize;

        int count = bytesToInt(bytes, cursor);
        cursor += SIZE_LENGTH;

        className = className.replace("[L","");
        Class clazz = Class.forName(className);
        Object objectArr = Array.newInstance(clazz, count);

        for(int i=0; i<count; i++){
            int clsNameSize = bytesToInt(bytes, cursor);
            cursor += SIZE_LENGTH;

            String objClassName = new String(
                    bytes, cursor, clsNameSize
            ).trim();
            cursor += classNameSize;

            Class objClass = Class.forName(objClassName);
            Constructor constructor = objClass.getDeclaredConstructor();
            Object obj = constructor.newInstance();

            for(Field f : getAllFields(objClass)){
                int length = bytesToInt(bytes, cursor);
                cursor += SIZE_LENGTH;
                setData(obj, f, bytes, cursor, length);
                cursor += length;
            }

            Array.set(objectArr, i, obj);
        }

        return objectArr;
    }

bytesToObject() 메소드와 비슷하게 진행되나, 객체의 개수정보인 count가 추가 됨을 알 수 있다. 객체를 복원하는 것으로 내부적으로 bytesToObject()를 사용하는 것을 알 수 있다. 배열을 역직렬화 하는 것을 마지막으로 추가된 모든 요구사항을 만족하였다.

package serialization.ver2;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

public class Serializer {
    public static final int SIZE_LENGTH = 4;

    public static byte[] objectArrToBytes(Object[] objs) throws IllegalAccessException, IllegalArgumentException{
        if(objs.length==0){
            throw new IllegalArgumentException();
        }

        List<byte[]> byteList = new LinkedList<>();
        int size = 0;

        String className = objs.getClass().getName().replace(";","");
        byte[] classNameBytes = className.getBytes(StandardCharsets.UTF_8);

        byteList.add(intToBytes(classNameBytes.length));
        byteList.add(classNameBytes);

        size += SIZE_LENGTH;
        size += classNameBytes.length;

        int count = 0;

        try{
            for(Object obj : objs){
                byte[] objBytes = objectToBytes(obj);

                count++;
                size+=objBytes.length;
                byteList.add(objBytes);
            }
        }catch(NullPointerException e){
        }

        byteList.add(2, intToBytes(count));

        byte[] result = new byte[size+SIZE_LENGTH];
        int cursor = 0;
        for(byte[] b : byteList){
            System.arraycopy(b, 0, result, cursor, b.length);
            cursor+=b.length;
        }

        return result;
    }

    public static byte[] objectToBytes(Object obj) throws IllegalAccessException {
        List<byte[]> byteList = new LinkedList<>();
        int size = 0;

        String className = obj.getClass().getName().replace(";","");
        byte[] classNameBytes = className.getBytes(StandardCharsets.UTF_8);

        byteList.add(intToBytes(classNameBytes.length));
        byteList.add(classNameBytes);

        size += SIZE_LENGTH;
        size += classNameBytes.length;

        try{
            for(Field f : getAllFields(obj.getClass())){
                byte[] fieldBytes = getBytesFrom(obj, f);
                byte[] fieldLength = intToBytes(fieldBytes.length);

                byteList.add(fieldLength);
                byteList.add(fieldBytes);
                size+=fieldBytes.length+SIZE_LENGTH;
            }

        }catch(NullPointerException e){
        }

        byte[] result = new byte[size];
        int cursor = 0;
        for(byte[] b : byteList){
            System.arraycopy(b, 0, result, cursor, b.length);
            cursor+=b.length;
        }

        return result;
    }

    private static List<Field> getAllFields(Class clazz){
        if(clazz==Object.class){
            return Collections.emptyList();
        }

        List<Field> result = new ArrayList<>(getAllFields(clazz.getSuperclass()));
        for(Field f : clazz.getDeclaredFields()){
            if(!Modifier.isStatic(f.getModifiers())){
							result.add(f);
            }
        }

        return result;
    }

    private static byte[] getBytesFrom(Object obj, Field f) throws IllegalAccessException {
        String typeName = f.getType().getSimpleName();
        f.setAccessible(true);

        if(typeName.contains("[")){
            typeName = "array";
        }

        switch(typeName){
            case "byte":
                return new byte[]{f.getByte(obj)};
            case "char":
                return charToBytes(f.getChar(obj));
            case "short":
                return shortToBytes(f.getShort(obj));
            case "int":
                return intToBytes(f.getInt(obj));
            case "long":
                return longToBytes(f.getLong(obj));
            case "String":
                try{
                    return new String((String)f.get(obj)).getBytes(StandardCharsets.UTF_8);
                }catch(NullPointerException e){
                    return new byte[0];
                }
            case "array":
                try{
                    return objectArrToBytes((Object[])f.get(obj));
                }catch(NullPointerException | IllegalArgumentException e){
                    return new byte[0];
                }
            default:
                try{
                    return objectToBytes(f.get(obj));
                }catch(NullPointerException e){
                    return new byte[0];
                }
        }
    }
...생략
}

Serializer의 구현도 Deserializer의 구현과 구조는 동일하게 진행되니 Deserializer의 구현을 보면 쉽게 이해가능하다.

package serialization.ver2;

public class Book {
    private int serialNum;
    private String name;
    private int price;
    private int hit;

    public Book(int serialNum, String name, int price) {
        this.serialNum = serialNum;
        this.name = name;
        this.price = price;
    }

    public Book(){}

    public boolean isPopular(){
        if(hit>10){
            return true;
        }else{
            return false;
        }
    }

    public void retrieve(){
        hit += 1;
    }

    @Override
    public String toString() {
        return "Book{" +
                "serialNum=" + serialNum +
                ", name='" + name + '\'' +
                ", price=" + price +
                ", hit=" + hit +
                '}';
    }
}

Book 클래스에서는 불필요한 getter를 제거하였고, 불필요한 생성자를 줄였다.

추가 요구사항까지 모두 구현 완료 하였고 프로젝트는 성공적으로 마무리했다. 직렬화/역직렬화 자동화로 인해서 코드의 양이 확연히 줄었다.

이번 글의 주제였던 직렬화/역직렬화 구현에 집중하기 위해서 MyPt 프로토콜을 통해 통신하는 코드와 중요하지 않다고 생각한 코드의 변경을 생략하였다. 전체코드를 확인하여 통신이 되는 것을 확인 해보자.(코드 정리 후 공개)

아직 남은 문제점

처음에 코딩 노가다를 했어야 할 때 보다 훨씬 유연하고 편리한 통신 모듈이 만들어졌다. 그럼에도 아직 해결되지않은 몇몇 문제들이 남아있다.

  • 직접 만들지 않은 클래스 직렬화 어려움(자바 컬렉션 프레임워크 등…)

⇒ 자바 직렬화의 초창기 버전을 만들었다고 생각하면 될 것 같다. 상속, 조합, 배열 등 기본적인 요소는 직렬화 가능하나, 모든 클래스가 가능하다고는 어려울 것 같다. 어려움이 발견될 때마다 조금씩 개선한다면 자바 직렬화 처럼 성숙해질 수 있을 것같다.

💡 컬렉션 프레임워크가 직렬화가 안돼.. 융합프로젝트 과목 프로젝트 진행 할 당시에, 겪었던 어려움은 컬렉션 프레임워크가 직렬화 되지 않는 다는 것이였는데, 내부구조가 복잡했기 때문으로 기억한다. 이를 해결하기 위한 미봉책으로 모든 DTO 클래스의 컬렉션프레임워크 멤버변수를 단순 배열로 변경하여 저장하였다. 그때는 별로 좋지 못한 방법이라고 생각했다. 글 작성을 위해서 자바 직렬화에 대해서 추가적으로 공부하였는데, 자바에서 컬렉션 프레임워크를 직렬화 할 때는 배열형태로 선형으로 데이터들을 직렬화한다는 점을 알았다. 조금 다르긴 했지만, 그때 했던 방법이 아주 나쁘진 않았다.
  • 빈 생성자 필요 / 생성자를 통한 불변식 유지

⇒ 비즈니스 로직상에서는 필요없는 인자가 없는 생성자가 필요하다. Reflection에서 모든 객체를 동일하게 생성후 필드를 넣는 방식으로 구현하기 위해서이다. 범용성을 위해서는 빈 생성자가 필요하다. 또한 빈 생성자를 사용하여 생성하는 방식으로, 불변식 유지가 어려운점은 이전 통신 모듈과 비슷한 문제를 가지고 있다. 자바 직렬화에서는 각 클래스의 생성자에 맞춰서 객체를 생성하는 것인지, 아니면 내부적으로 생성자가 아닌 다른 방식으로 객체를 생성하는 메커니즘이 존재하는 것인지는 잘 모르겠다.

  • 전송할 객체의 클래스의 구조가 Client측과 Server측이 동일해야함

⇒ 각각 클라이언트와 서버 어플리케이션이 가지고있는 클래스 코드를 기반으로 클래스의 정보를 얻어오는 것이기 때문에, 양 쪽에서 가지고있는 클래스가 완벽하게 같아야 복원이 가능하다. 이것은 자바 직렬화에서 동일하다.

더 알아보기

  • 자바 직렬화
  • 다른 직렬화 포맷

0개의 댓글