Dev

자바 직렬화(Java Object Serialization)에 유연성 더하기

prostars 2021. 12. 11. 17:59

자바 객체를 영속화하는 방법의 하나로 자바 직렬화를 사용할 있다. 단순하게는 Serializable 인터페이스를 구현하거나 확장성 있는 방법으로는 Externalizable 인터페이스를 구현하는 것을 선택할 있고, 자바 직렬화에 종속되지 않는 다른 방법을 선택할 수도 있다.

 

일단, Serializable 인터페이스를 구현한 클래스의 인스턴스가 외부 저장소에 영속화되면 호환성을 유지하면서 해당 클래스의 필드를 수정하기는 어렵다. (https://docs.oracle.com/en/java/javase/11/docs/specs/serialization/version.html) Serializable 대신 Externalizable 인터페이스를 구현하면 객체 직렬화 단계에서 객체 스트림에 읽고 쓰는 방식의 세부적인 구현을 있기에 호환성을 확보하기 용이하다. (두 방식의 세부적인 차이와 readObject, writeObject 를 재정의하는 것은 논외로 하자.)

 

Externalizable 을 사용하더라도 객체 스트림에 해당 클래스의 필드를 순차적으로 직접 쓰면 해당 필드의 타입 변경이나 삭제는 어렵다. 또한, 해당 클래스(이후 TestEntity)가 외부 저장소에 직렬화되고 나면 이제 TestEntity 는 쉽게 버리거나 클래스 명을 바꾸거나 다른 패키지로 옮기는 등의 리팩터링은 할 수 없다. 외부 저장소에 직렬화된 객체를 역직렬화 하려면 클래스 명은 물론 패키지까지 동일한 클래스가 있어야 한다. serialVersionUID 도 당연히 동일해야 한다. 그럼에도 Externalizable 은 개발 편의성이 좋아서 흔히 사용되는 인터페이스다.

 

글에서는 멀티 노드가 외부에 캐싱하는 운영 환경에서 캐싱 대상인 TestEntity 필드 타입 변경이 필요한 상황을 가정한다. TestEntity 는 위에서 언급한 바이트 스트림에 직접 순차적으로 쓰는 방식으로 Externalizable 을 구현한 클래스다. 다양한 방법이 있겠지만, 코드 수정 영역을 TestEntity 로 국한하기 위해서 Externalizable 을 유지한 상태에서 TestEntity 의 직렬화 방식을 유지보수가 용이한 방식으로 바꾸는 것으로 마무리한다. 바꿀 방식으로 CBOR(https://cbor.io) 사용할 것이다. CBOR 에 대한 설명은 하지 않는다. 다른 방식을 사용해도 작업 내용은 비슷하다.

예제

예제 구성은 외부 저장소로 memcached(https://memcached.org) 사용하고, client library 로 spymemcached(https://github.com/couchbase/spymemcached) 사용한다. memcached 는 직접 받아서 실행해도 되지만, 예제 코드에 포함된 docker-compose.yml 을 사용하면 편하다. 예제 코드가 여러 개의 프로젝트로 나누어진 이유는 위에서 언급한 클래스 명이 다르면 역직렬화 없기 때문이다. 예제 코드는 Groovy 로 작성했다. Java 와 흡사하지만 간결하여 프로토타이핑이나 테스트 코드 작업에 좋다. 자바에 익숙하다면 읽고 실행하는 무리가 없을 것이다. 예제는 최대한 단순하게 구현했고, 캐시 만료 시간은 설정하지 않았다.

  • AddingFlexibilityToJava Serialization (https://github.com/prostars/AddingFlexibilityToJavaSerialization)
    • LikeSerializableV1 (TestEntity V1 의 salary 필드 타입은 Integer 다.)
      • TestEntity V1 을 캐시에 쓰고, 읽는다.
    • LikeSerializableV2 (TestEntity V2 의 salary 필드 타입은 Long 이다.)
      • TestEntity V2 를 캐시에서 읽으려다 실패한다.
    • CBORV2Fingerprint
      • TestEntity 를 지문으로 구분하여 V2 는 CBOR 로 읽고, V1 은 기존 방식으로 읽고 쓴다.
    • CBORV3Fingerprint
      • TestEntity V2 를 지문과 함께 CBOR 로 쓰고, 지문으로 구분하여 V2 는 CBOR 로 읽고 V1 은 기존 방식으로 읽는다.
    • CBORV4Fingerprint
      • TestEntity 를 지문으로 구분하여 V2 는 CBOR 로 V1 은 기존 방식으로 읽고, V2 를 지문없이 CBOR 로 쓴다.
    • CBORV5
      • TestEntity V2 를 CBOR 로 읽고, 쓴다.
    • CBORV6 (TestEntity V3 의 salary 필드 타입은 Double 이다.)
      • TestEntity V3 를 CBOR 로 읽고, 쓴다.

LikeSerializableV1

먼저, TestEntity 의 필드 타입을 변경하는 상황을 확인한다. 아래는 Externalizable 인터페이스를 구현한 LikeSerializableV1 의 TestEntity 클래스 V1 이다.

class TestEntity implements Externalizable {
    private static final long serialVersionUID = 88468757223408586L

    Integer salary
    Long bonus
    
    Integer setSalary(final Integer salary) {
        assert salary > 0
        this.salary = salary
    }

    void writeExternal(final ObjectOutput out) {
        out.writeInt(salary)
        out.writeLong(bonus)
    }

    void readExternal(final ObjectInput objectInput) {
        salary = objectInput.readInt()
        bonus = objectInput.readLong()
    }
}

LikeSerializableV1 을 실행하면 TestEntity V1 객체를 캐시에 영속화한다.

LikeSerializableV2

아래는 Externalizable 인터페이스를 구현한 LikeSerializableV2 의 TestEntity V2 클래스다. TestEntity 의 salary 필드 타입을 Integer 에서 Long 으로 변경한다. (이하 코드는 설명에 필요한 부분만 표시한다.)

class TestEntity implements Externalizable {
    ...
    Long salary
    ...
    Long setSalary(final Long salary) {
        ...
    }
    
    void writeExternal(final ObjectOutput out) {
        out.writeLong(salary)
        ...
    }

    void readExternal(final ObjectInput objectInput) {
        salary = objectInput.readLong()
        ...
    }
}

LikeSerializableV2 를 실행해보면, 역직렬화에 실패하고 예외가 발생한다. salary 를 스트림에서 읽어올 때 4 bytes 가 아니라 8 bytes 를 읽었기에 bonus 를 스트림에서 읽을 때 전체 길이를 초과하여 읽다가 발생하는 에러다. salary 에 브레이크 포인트를 잡아보면 salary 자체는 쓰레기 값이지만 채워는 진다. 예외는 bonus 를 채우기 위해 스트림을 읽다가 발생한다. 

버전 분기

이제 TestEntity 의 V1, V2 에 따른 버전 관리가 필요하다. 역직렬화할 읽을 타입에 따라서 스트림에서 4 bytes 를 읽을지 8 bytes 를 읽을지 구분할 방법이 필요하다. 서버 버전 설정이나 기준 타임스탬프 다양한 방법이 있겠지만, 여기서는 작업 대상 필드가 양수만 받는다는 점을 이용해서 1byte 지문을 추가하고 이것으로 구분할 것이다.

작업 순서

코드를 보기 전에 작업 순서를 정리하자. 단순한 예제와 달리 운영 시스템에는 수많은 아이템이 캐싱되어 서비스 중일 것이다. 규모가 있는 서비스라면 캐시를 모두 날리고 새로 시작하기는 서비스 점검을 걸더라도 쉽지 않다. 서비스의 지속성을 위해 여러 단계를 거친다. 

                1. 지문을 인식하고 호환성을 유지하면서 읽을 있는 V2 모든 노드의 안정 버전에 적용한다.
                  여기서 말하는 안정 버전은 블루 그린이나 카나리 배포 방식을 사용한다고 했을 , 롤백했을 경우에도 롤백 버전에 지문 인식 구현이 포함된 상태를 말한다.
                2. 문을 추가하여 직렬화하는 writeExternal 구현을 적용한 V3 모든 노드에 배포한다.
                  이때, 일부 노드에 V1 남아있다면 헬게이트가 열릴 것이다.
                3. 캐시 만료 시간을 기다린다.
                  모든 노드는 TestEntity  직렬화에 지문을 추가할 것이고, 역직렬화에 salary 를 Long 으로 읽을 것이다.
                  시에서 히트되지 않은 V1 expiration time 지나면 사라지고, 캐시에 영속된 TestEntity 객체는 V3 상태로 지문과 함께 salary 가 Long 타입일 것이다. 이제 불필요해진 지문을 제거하는 1 뒷정리를 해야 한다.
                4. writeExternal 구현에서 지문 추가를 삭제하고, readExternal 구현에서 지문 위치에 지문이 있으면 해당 byte 버리고, 없으면 해당 byte 포함해서 Long 으로 읽는 처리를 V4 모든 노드에 배포한다.
                5. 캐시 만료 시간을 기다린다.
                  캐시에서 히트되지 않은 V3 대한 expiration time 지나면 사라지고, 캐시에 영속된 TestEntity 객체는 V4 상태로 지문 없이 salary 가 Long 타입일 것이다 불필요해진 지문 구분 처리를 제거하는 2 뒷정리를 해야 한다
                6. readExternal 에서 지문 처리를 제거한 V5 모든 노드에 배포한다.

이 과정을 영속화 대상 클래스가 수정될 때마다 할 수는 없으므로, 직렬화하는 방식을 유동성 있는 방식으로 변경한다. 여기선 CBOR 을 적용한다. 캐시의 만료 시간이 지나기 전에 캐시에 있는 모든 TestEntity 이 교체될 수도 있겠지만, 개런티 하기 어렵기 때문에 캐시 만료 시간을 다음 단계로 가는 기준으로 삼는다. 이 순서에서 생략한 것들이 있는데 점진적인 적용 처리와 로깅이다. 지문 처리에 실수가 있으면 캐시가 오염될 수 있기 때문에 배포 순간 100% 적용이 아닌 0% 시작해서 10%, 20% 등으로 점진 적용을 하는 기능과 해당 부분 로깅 on/off 기능이 함께 준비되어 있으면 좋다. 추가로 영속화 대상의 크기가 달라지면서 캐시의 아이템 할당 크기도 변경되니 관련 내용도 같이 체크하는 것이 좋다.

CBORV2Fingerprint

작업 순서 정리를 정리했으니 다음 순서를 진행한다. 아래는 CBORV2Fingerprint 의 Externalizable 인터페이스를 구현한 TestEntity 클래스다.

class TestEntity implements Externalizable {
    ...
    private static final cborFactory = new CBORFactory()
    ...
    void writeExternal(final ObjectOutput out) {
        out.writeInt(salary.toInteger())
        ...
    }

    void readExternal(final ObjectInput objectInput) {
        final fingerprint = objectInput.readByte()
        if (fingerprint == 0xF0 as Byte) {
            final parser = cborFactory.createParser(objectInput as InputStream)
            parser.nextToken()
            while (parser.nextToken() != JsonToken.END_OBJECT) {
                final fieldName = parser.getCurrentName()
                parser.nextToken()
                switch (fieldName) {
                    case 'salary': salary = parser.getNumberValue(); break
                    case 'bonus': bonus = parser.getNumberValue(); break
                    default: println("cannot found field name : $fieldName"); break
                }
            }
        } else {
            def bytes = ByteBuffer.allocate(4)
            bytes.put(0, fingerprint)
            objectInput.read(bytes.array(), 1, 3)
            salary = bytes.getInt()
            bonus = objectInput.readLong()
        }
    }
}

위와 같이 readExternal 을 수정한다. 구현이 단순하므로 부연은 생략한다. 아직 writeExternal 에 지문과 CBOR 쓰는 코드를 추가하면 된다. 현재 시점에서 지문과 CBOR 로 쓰면 LikeSerializableV1 를 사용하는 노드는 readExternal , 지문을 포함해서 salary 를 읽으면서 salary 는 이상한 음수 값이 되고, bonus  쓰레기 값이 된다. 위험한 예외 발생 없이 역직렬화 자체는 성공한다는 것이다. 이것이 위에서 언급했던 헬게이트다.

운영 중인 시스템의 배포 과정에서 V1, V2가 공존하는 시간 없이 V1 에서 V2로 모든 노드가 한 번에 교체되는 경우는 드물다. 한동안은 V1 을 캐시에 쓰는 노드와 V2 를 캐시에 쓰는 노드가 공존할 것이다. 그렇기 때문에 읽는 부분에서 호환성 유지를 위해 지문을 처리 기능을 추가한 readExternal 이 모든 노드에 적용된 후에 writeExternal 에 지문 쓰기를 추가해야 한다. 단계를 줄이자고 readExternal 와 writeExternal 를 한 번에 수정하고 적용하려면 지문을 쓰는 코드도 버전 분기가 필요해서 지저분하고 복잡해진다. 

CBORV2Fingerprint 를 실행하면, TestEntity 를 지문으로 구분하여 V2 는 CBOR 로 읽고, V1 기존 방식으로 읽고 쓴다.

CBORV3Fingerprint

다시 다음 순서를 진행한다. 아래는 CBORV3Fingerprint 의 Externalizable 인터페이스를 구현한 TestEntity 클래스다.

class TestEntity implements Externalizable {
    ...
    void writeExternal(final ObjectOutput out) {
        out.writeByte(0xF0)
        final generator = cborFactory.createGenerator(out)
        generator.writeStartObject()
        generator.writeNumberField('salary', salary)
        generator.writeNumberField('bonus', bonus)
        generator.close()
    }
    ...   
}

위와 같이 writeExternal 에 지문과 CBOR 로 쓰는 코드를 추가한다. CBORV3Fingerprint 를 실행하면 TestEntity V2 를 지문과 함께 CBOR 로 쓰고, 지문으로 구분하여 V2 는 CBOR 로 읽고 V1 기존 방식으로 읽는다.

CBORV4Fingerprint

다시 다음 순서를 진행한다. 아래는 CBORV4Fingerprint 의 Externalizable 인터페이스를 구현한 TestEntity 클래스다.

class TestEntity implements Externalizable { 
    ...
    void writeExternal(final ObjectOutput out) {
        final generator = cborFactory.createGenerator(out)
        ...
    }
    
    void readExternal(final ObjectInput objectInput) {
        final pis = new PushbackInputStream(objectInput as InputStream)
        final fingerprint = pis.read() as Byte
        if (fingerprint != 0xF0 as Byte)
            pis.unread(fingerprint)

        final parser = cborFactory.createParser(pis as InputStream)
        parser.nextToken()
        while (parser.nextToken() != JsonToken.END_OBJECT) {
            final fieldName = parser.getCurrentName()
            parser.nextToken()
            switch (fieldName) {
                case 'salary': salary = parser.getNumberValue(); break
                case 'bonus': bonus = parser.getNumberValue(); break
                default: println("cannot found field name : $fieldName"); break
            }
        }
    }    
}

위와 같이 writeExternal 에서 지문을 쓰는 코드를 제거하고, 이제 Integer 타입의 salary 는 캐시에 없으므로 readExternal 에서 지문을 처리하는 방식을 수정한다. CBORV4Fingerprint 를 실행하면 TestEntity 를 지문으로 구분하여 V2 는 CBOR 로 V1 기존 방식으로 읽고, V2 를 지문 없이 CBOR 로 쓴다.

CBORV5

다시 다음 순서를 진행한다. 아래는 CBORV5 의 Externalizable 인터페이스를 구현한 TestEntity 클래스다.

class TestEntity implements Externalizable { 
    ...
    void writeExternal(final ObjectOutput out) {
	...
    }

    void readExternal(final ObjectInput objectInput) {
        final parser = cborFactory.createParser(objectInput as InputStream)
	...
    }
}

이제 캐시에 지문이 포함된 CBOR 없으므로, 위와 같이 readExternal 에서 지문을 처리하는 코드를 제거한다. CBORV5 를 실행하면 TestEntity V2 를 CBOR 로 읽고 쓴다. 이렇게 모든 단계의 진행이 끝났다. TestEntity 의 salary 필드 타입 변경과 함께 직렬화 방식도 서비스 중단 없이 변경했다.

CBORV6

마지막으로 Serializable 구현처럼 객체 스트림에 해당 클래스의 필드를 순차적으로 직접 쓰는 대신 적당한 데이터 포맷을 선택하여 사용했을 때의 이점을 확인해보자. 아래는 CBORV6 의 Externalizable 인터페이스를 구현한 TestEntity 클래스다.

class TestEntity implements Externalizable { 
    ...
    Double salary
    ...
    Double setSalary(final Double salary) {
        ...
    }
    ...
}

위와 같이 TestEntity 의 salary 타입을 Double 로 수정한다. CBORV6 을 실행하면 TestEntity V3 를 CBOR 로 읽고 쓴다.

타입 처리는 CBOR 대행한다. CBORV5 과 CBORV6 을 번갈아 실행해보면 다른 과정 없이 바로 타입 변경이 가능한 것을 확인할 있다.