본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[Java] Kryo 직렬화 라이브러리

Kryo는 Java 진영의 빠르고 효율적인 바이너리 객체 그래프 직렬화 프레임워크이다. Kryo 프로젝트는 고속, 경량의 사용하기 쉬운 API를 목적으로 만들어졌다. 이 프로젝트는 파일, 데이터베이스 또는 네트워크를 넘어 객체를 유지에 유용하다.

 

Kryo는 또한 깊고 얕은 복사/clone 기능를 수행할 수 있다. 바이너리 변환이 아닌 객체에서 객체로 직접 복사하는 기능이다.


Kryo 클래스는 자동으로 직렬화를 수행한다. 출력 및 입력 클래스는 바이트 버퍼 방식으로 처리하고, 선택적으로 스트림 플러시 처리가 가능하다.

import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;
import java.io.*;

public class HelloKryo {
    static public void main(String[] args) throws Exception {
        Kryo kryo = new Kryo();
        kryo.register(SomeClass.class);
        
        SomeClass object = new SomeClass();
        object.value = "Hello Kryo!";
        
        Output output = new Output(new FileOutputStream("file.bin"));
        kryo.writeObject(output, object);
        output.close();
        
        Input input = new Input(new FileInputStream("file.bin"));
        SomeClass object2 = kryo.readObject(input, SomeClass.class);
        input.close();
    }
    static public class SomeClass {
        String value;
    }
}

IO

 - 카이로의 데이터 입출력은 Input, Output 클래스를 통해 이루어진다. 이 클래스들은 thread safe하지는 않다. 

Output

Ouput 클래스는 바이트 배열 버퍼에 데이터를 쓰는 OutputStream 방식이다. 바이트 배열이 차면, 즉시 버퍼가 사용된다. 출력이 OutputStream으로 주어진 경우, 버퍼가 가득차면 해당 바이트를 스트림에 플러시한다. 그렇지 않으면 버퍼를 자동으로 증가시킬 수도 있다.

 

Output은 효과적으로 primitives, string을 바이트로 쓰는 많은 기능 메소드를 제공한다. DataOutputStream, BufferedOutputStream, FilterOutputStream, ByteArrayOutputStream과 유사한 기능을 모두 하나의 클래스에서 제공하고 있다.

 

Output, Input은 ByteArrayOutputStream의 모든 기능을 제공하기 때문에, Output을 ByteArrayOutputStream으로 플러시할 이유는 거의 없다.

 

Output은 OutputStream에 쓸때 바이트로 버퍼링된다. 그래서 쓰기가 완료된 이후에 flush나 close를 호출하여 버퍼링된 바이트를 OutputStream에 기록해야 한다. Output으로 OutputSTream이 제공되지 않는다면 flush나 close를 호출할 필요가 없다. 많은 스트림과 달리, Output 인스턴스는 positions을 설정한다거나 새 바이트 배열 또는 스트림을 설정하여 재사용할 수 있다.

 

Ouput 생성자에 아무런 인수를 넘기지 않으면 초기화되지 않은 Ouput을 생성하며, Ouput을 사용기 전에 먼저 Ouput setBuffer를 호출해주어야 한다.

Input

Input 클래스는 바이트 배열 버퍼에서 데이터를 읽는 InputStream이다. 바이트 배열에서 읽기를 원하는 경우, 이 버퍼를 직접 설정할 수 있다. 입력에 InputStream이 주어지면 버퍼의 모든 데이터가 읽혔을때 입력은 스트림에서 버퍼를 채운다.

 

Input은 바이트로부터 primitives, string을 효율적으로 읽기위한 많은 메소드를 제공하고 있다. DataInputStream, BufferedInputStream, FilterInputStream, ByteArrayInputStream과 유사한 기능을 모두 하나의 클래스로 제공한다.

 

Input은 ByteArrayInputStream에 대한 기능적인 부분을 모두 제공하고 있기 때문에 ByteArrayInputStream으로부터 Input을 읽을 필요가 거의 없다.

 

Input의 close를 호출하면, Input의 InputStream이 존재한다면 InputStream도 close된다. 또한 InputStream에서 읽지 않는다면 close를 호출할 필요가 없다. 많은 스트림과 달리 입력 인스턴스는 위치와 제한을 설정하거나 새 바이트 배열 또는 입력스트림을 설정하여 재사용할 수 있다.

 

Input 생성자에 아무런 인수를 넘기지 않으면 초기화되지 않은 Input 객체를 생성하게 되며, Input 클래스 사용전에 Input setBuffer를 호출해주어야만 한다.

ByteBuffers

ByteBufferOuput과 ByteBufferInput 클래스는 바이트 배열이 아닌 ByteBuffer를 사용하는 것을 제외하고는 Input/Output과 동일하게 작동한다.

Unsafe buffers

UnsafeOutput, UnsafeInput, UnsafeByteBufferOutput, UnsafeByteBufferInput 클래스는 대응되는 non-unsafe 기능과 동작은 일치한다. 단, unsafe 클래스들은 더높은 성능을 위해 sun.misc.Unsafe 패키지를 많은 케이스에서 이용하고 있다. 이 클래스를 사용하려면 Util.unsafe가 true 설정이여야 한다.

 

Unsafe buffers의 단점은 직렬화를 수행하는 시스템의 기본 엔디안이나 숫자 유형 표현이 직렬화 데이터에 영향을 미친다는 것이다. 예를 들어 데이터가 x86에 쓰여지고, SPARC에서 읽혀진다면 역직렬화(deserialization)에 실패하게 될것이다. 또한, 데이터가 unsafe buffer로 쓰여졌다면, 이 데이터는 반드시 unsafe buffer로 읽어야만 한다.

 

unsafe buffer의 가장 큰 성능 차이는 가변 길이 인코딩을 적용하지 않은 사이즈가 큰 primitive array에서 드러난다. unsafe buffer나 특정 필드(FieldSerializer 사용시)에만 가변길이 인코딩을 비활성화 할 수도 있다.

Variable length encoding

IO 클래스는 가변 길이 int(varint), long(varlong) 값에 대한 입출력 기능을 제공한다. 이는 각 바이트의 8번째 비트를 사용하여 더 많은 바이트가 있는지를 표시하는데, varint는 1-5byte를 사용하고 varlong의 경우 1-9 바이트를 사용한다. 가변 길이 인코딩의 비용은 보다 비싸지만 직렬화된 데이터를 보다 작게 만들수 있다.

 

가변길이 값을 쓸때 이값은 양수 도는 음수 값에 모두 최적화될 수 있다. 예를 들어, 양수 값에 최적화되었을때 0~127은 1바이트, 128~16383은 2바이트 등으로 작성된다. 그러나 절대값이 작은 음수가 5바이트로 가장 나쁜 경우다. 양수에 최적화되지 않은 경우, 이러한 범위는 절반으로 감소한다. 예를 들어 -64 ~ 63은 1바이트, 64~8191과 -65~-8192는 2바이트 등으로 쓴다.

 

Input, Output 버퍼는 고정 크기 또는 가변 길이 입출력 메소드를 모두 제공하고 있다. 버퍼가 고정된 크기 또는 가변 길이 값을 쓰는지 여부를 결정할 수 있도록 하는 방법도 있다. 이를 통해 직렬화 코드는 고정된 크기를 사용할 경우 출력 결과가 과도하게 커질 가능성이 있는 일반적인 값에 가변 길이 인코딩을 사용할 수 있게 할 수 있다.

Method Description
writeInt(int) Writes a 4 byte int.
writeVarInt(int, boolean) Writes a 1-5 byte int.
writeInt(int, boolean) Writes either a 4 or 1-5 byte int (the buffer decides).
writeLong(long) Writes an 8 byte long.
writeVarLong(long, boolean) Writes an 1-9 byte long.
writeLong(long, boolean) Writes either an 8 or 1-9 byte long (the buffer decides).

모든 값에 대해 가변 길이 인코딩을 비활성화하려면 writeVarInt, writeVarLong, readVarInt, readVarLong 메서드를 재정의하면 된다.

Chunked encoding

Chunked encoding은 일부 데이터의 길이, 그 다음에 데이터를 쓰는 것에 유용할 수 있다. 데이터의 길이를 미리 알 수 없는 경우에는 모든 데이터를 버퍼링하여 길이를 결정해야 하고, 그 다음에 데이터를 쓸 수 있다. 이를 위해 하나의 큰 버퍼를 사용하면 스트리밍이 되지 않으며, 이상적이지 않은 큰 버퍼를 필요하게 될 수도 있다.

 

Chunked encoding은 작은 버퍼를 사용함으로써 이 문제를 해결할 수 있다. 버퍼가 가득차면, 그만큼의 길이가 출력되고, 데이터가 쓰여지는데, 이것이 하나의 데이터 조각이다. 버퍼는 clear되고 더이상 쓸 데이터가 없을때까지 위의 과정이 반복된다. 길이가 0인 청크는 청크의 끝을 나타낸다.

 

Kryo는 chunked encoding 기능을 제공한다. OutputChunked는 청크데이터 출력에 사용된다. Output 클래스를 확장버전이기 때문에, 데이터 출력에 모든 편의 기능을 포함하고 있다. OutputChined 버퍼가 가득 차면, 다른 OutputStream으로 청크를 플러시하는 방식이다. endChunks 메소드는 chunk set의 마지막을 표시하기 위해 사용된다.

OutputStream outputStream = new FileOutputStream("file.bin");
OutputChunked output = new OutputChunked(outputStream, 1024);
// Write data to output...
output.endChunks();
// Write more data to output...
output.endChunks();
// Write even more data to output...
output.close();

chunked data를 읽기 위해서는, InputChunked를 사용해야 한다. InputChunked는 Input 클래스의 확장버전이므로, 데이터를 읽기위한 편의 메소드를 동일하게 모두 제공한다. 데이터를 읽어 들일때 chunk set의 마지막 도달이 곧 데이터 끝 도달을 의미한다. nextChunks 메서드는 현재 chunk set에서 모든 데이터를 읽지 않았았더라도 다음 청크 set으로 진행하는 기능의 메소드이다.

InputStream inputStream = new FileInputStream("file.bin");
InputChunked input = new InputChunked(inputStream, 1024);
// Read data from first set of chunks...
input.nextChunks();
// Read data from second set of chunks...
input.nextChunks();
// Read data from third set of chunks...
input.close();

Buffer performance

일반적인 입출력은 우수한 성능을 제공한다. 교차 플랫폼 비호환성이 허용된다면, unsafe buffers는 원시타입 배열에서 특히 보다 나은 성능을 보여준다. ByteBufferOutput과 ByteBufferInput은 상대적으로 성능이 떨어지지만, 직렬화의 결과가 ByteBuffer가 되어야하는 상황이라면 허용될만한 수준이다.

가변 길이 인코딩은 고정 길이 설정에 비해 느리다. 특히 사용하는 데이터가 많을 경우 더욱 느려진다.

chunked encoding은 중간 버퍼를 사용하기 때문에 모든 바이트에 대하여 본사본을 하나더 추가하게 된다. 이러한 조건이 하나라면 허용될만한 수준이지만, reentrant serializer을 사용할 경우, serializer는 각 object를 위한 OutputChunked 혹은 InputChunked를 생성해야만 할 것이다. 직렬화 수행중에 버퍼들을 할당하고 수거하는 작업은 성능에 부정적인 영향을 미칠 수 있다.


Reading and Writing Objects

Kryo는 입출력 객체들을 위한 3개의 메소드 집합을 가지고 있다.

 

구체적인 클래스를 알수없고 객체가 null일 수 있는 경우:

kryo.writeClassAndObject(output, object);

Object object = kryo.readClassAndObject(input);
if (object instanceof SomeClass) {
    // ...
}

구체적인 클래스를 알수있고 객체가 null일 수 있는 경우:

kryo.writeObjectOrNull(output, object);

SomeClass Object = kry.readObjectOrNull(input, SomeClass.class);

구체적인 클래스를 알수있고 객체가 null이 될수 없는 경우:

kryo.writeObject(output, object);

SomeClass object = kryo.readObject(input, SomeClass.class);

 

사용하기에 적절한 serializer를 찾은다음 이를 사용하여, 객체를 직렬화하거나 역직렬화하면 된다. Serializer는 재귀 직렬화를 위해서도 이러한 메소드를 호출할 수 있다. 동일한 object와 순환 참조에 대한 다중 참조는 Kryo에 의하여 자동으로 처리된다.

 

Kryo 클래스는 객체를 읽고 쓰는 메소드 외에도, serializer를 등록하거나 클래스 식별자를 효율적으로 읽고 쓰는 방법을 제공하며, null 처리가 불가능한 Serializers에 대해서 null 핸들링을 제공하며, 객체 참조를 읽고 쓰는 처리(설정이 활성화된 경우)를 할 수 있다. 이를 통해 Serializers는 직렬화 작업에만 집중할 수 있게 된다.

Round trip

Kryo API를 테스트하고 탐색하는 동안, 객체를 바이트로 쓰고, 바이트를 다시 객체로 읽는 작업도 편리하다.

Kryo kryo = new Kryo();

// Register all classes to be serialized.
kryo.register(SomeClass.class);

SomeClass object1 = new SomeClass();

Output output = new Output(1024, -1);
kryo.writeObject(output, object1);

Input input = new Input(output.getBuffer(), 0, output.position());
SomeClass object2 = kryo.readObject(input, SomeClass.class);

위 예제에서 출력은 용량이 1024바이트인 버퍼로 시작한다. 출력에 더 많은 바이트를 쓸 경우 버퍼의 크기는 제한없이 커지게 된다. OutputStream이 제공되지 않았기 때문에 Output을 닫을 필요는 없다. 입력은 출력의 byte[] 버퍼에서 직접 읽는다.

Deep and shallow copies

Kryo는 한 객체에서 다른 객체 직접할당을 통하여 깊은 복사, 얕은 복사 기능을 지원한다. 이것은 바이트로 직렬화하고 객체로 돌아가는것보다 더 효율적이다.

Kryo kryo = new Kryo();
SomeClass object = ...
SomeClass copy1 = kryo.copy(object);
SomeClass copy2 = kryo.copyShallow(object);

사용중인 모든 serializer는 복사를 지원해야 하며, Kryo와 함께 제공된 모든 Serializer는 복사를 제공한다. 직렬화와 마찬가지로 복사를 할때도 참조설정이 활성화된 경우엔 동일한 객체에 대한 다중 참조와 순환 참조는 Kryo에 의해 자동으로 처리된다. 오직 복사를 위해서만 Kryo를 사용하는 경우, registration을 비활성화할 수도 있다.

 

Kryo getOriginalToCopyMap은 객체 그래프를 복사한 뒤에 새로운 객체가 old 객체의 map을 얻게한다. map은 Kryo reset에 의해 자동으로 clear되며, Kryo setAutoReset이 false일때만 유용하다.

References

기본적으로 참조는 사용할 수 없다. 이는 같은 참조 객체가 객체 그래프상에 여러번 나타나게 되면 여러번 출력되게되고, 다른 객체로 여러번 deserialized가 일어날수 있음을 의미한다. 참조가 비활성화된 경우에 순환 참조는 serialization에 실패하게 된다. 참조 기능은 Kry setReference 혹은 setCopyReference으로 활성화되거나 비활성화될 수 있다.

 

참조 기능이 활성화되면 각각의 객체가 객체 그래프에 처음 나타날때, varint가 작성되어진다. 동일한 객체 그래프 안에서 다시 나타나는 객체 참조는 varint 데이터만 기록되는 방식이다. 이로써 deserialize 이후에도 객체 그래프의 참조 모형이 모두 복원되는 것이다. Kryo References 기능을 통해 serializer에서 참조기능을 사용할 수 있다.

 

References 기능을 활성화하는 것은 읽거나 쓰여진 모든 객체를 추적해야 하기때문에 성능에 영향을 미친다.

ReferenceResolver

ReferenceResolver는 입출력 객체에 대해서 추적 처리를 지원하고, int형 참조 ID를 제공한다. reference resolver를 지정하지 않으면 MapReferenceResolver가 기본으로 사용된다.

 

1. 출력 객체를 추적하기 위해서 Kryo의 IdentityObjectIntMap을 사용한다. 이런 종류의 map은 get 연산은 매우 빠르며, put을 통해 할당 작업을 한다. 대량의 객체의 put은 다소 느릴수 있다.

2. HashMapReferenceResolver는 출력 객체를 추적하기 위해 HashMap을 이용한다. 이런 종류의 map도 put을 통한 할당이 이루어지고, 객체수가 매우 많은 객체 그래프의 경우 더나은 성능을 보여준다.

3. ListReferenceResolver는 ArrayList를 사용해서 출력 객체를 추적한다. 상대적으로 적은 객체 그래프에 대해서는 map을 사용할때보다 빠르기도 하다(일부 테스트에서는 약 15% 빠름). 이미 쓰여진 객체를 찾기위해서는 선역으로 lookup 하기때문에 객체 수가 많은 그래프에서는 사용해서는 안된다.

 

ReferebceResolver useReference(Class)는 오버라이드할 수 있다. userReference 메서드는 해당 클래스가 reference를 지원하는지 여부에 따른 boolean 값을 리턴한다. reference를 지원하지 않는다면, 그 타입의 객체는 varint reference ID를 따로 기록하지 않게 된다. 어떤 클래스가 references가 필요하지 않고, 객체 그래프에서 자주 나타나지 않는다면, 해당 클래스에 대해 reference 비활성화를 통해 직렬화 비용을 대폭 감소시킬 수도 있다. 기본 reference resolver는 모든 원시타입 wrapper 클래스와 enum형에 대해서는 false를 반환한다. String이나 Wrapper 클래스들에서 false를 반환하는 것이 일반적이다.

public boolean userReferences(Class type) {
    return !Util.isWrapperClass(type) & !Util.isEnum(type) && type != String.class;
}

Reference limits

Reference Resolver는 단일 객체 그래프에서 reference의 갯수 제한을 가지고 있다. Java 배열 인덱스는 Integer.MAX_VALUE 값만큼의 제한을 가지고 있다. 따라서 array 기반한 데이터구조를 사용하는 Reference Resolver가 20억개 이상의 객체를 직렬화할때는 NegativeArraySizeException을 발생시킬 수도 있다. Kryo는 int class ID를 사용하기 때문에 단일 객체 그래프안에서의 최대 참조 갯수 제한은 양수 정수 전체범위로 제한된다.

Context

Kryo getContext는 사용자 데이터 저장을 위한 map을 리턴한다. Kryo 인스턴스는 모든 직렬화기에서 사용할 수 있으므로 이 데이터는 모든 serializer에서 쉽게 액세스할 수 있다.

 

Kryo getGraphContext도 거의 비슷하지만, 각 객체그래프가 serialize/deserialize 할때 지워진다. 현재 객체 그래프에만 관련된 상태를 관리할 수 있다. 예를 들어, 이것은 클래스가 객체 그래프에 처음 나타났을때 일부 스키마 데이터를 쓰는데 사용할 수 있을 것이다. CompatibleFieldSerializer에서 예를 참고할 수 있다.

Reset

기본적으로, Kryo는 모든 객체 그래프 직렬화가 완료되면 reset을 호출하고 있다. reset은 class resolver로부터 class name 등록해제하고, reference resolver로부터 객체 직렬화, 역직렬화 이전에 reference 정보를 해제하며, 그래프 컨텍스트를 clear한다. Kryo setAutoReset(false)를 사용하여 rest 자동 호출을 비활성화 할 수 있으며, 해당 상태는 여러 객체 그래프를 포함할 수 있게 된다.


Serializer framework

Kryo는 직렬화 편의 기능을 제공하는 프레임워크다. 프레임워크 자체적으로 데이터의 스키마나 쓰거나 읽을 데이터가 무엇인지에 대해서는 강제하지 않는다. Serializers는 pluggable하며, 무엇을 읽고 쓸것인지에 대한 결정을 내릴 뿐이다. 많은 직렬화들은 다양한 방법으로 데이터를 읽고 쓸수 있도록 제공된다. 제공되는 직렬화기는 대부분의 object들을 읽고 쓰는 것이 가능하지만, cutom serializer로 일부, 완전히 교체하는 것도 가능은 하다.

Registration

Kryo가 객체의 인스턴스를 쓰기 시작하면, 먼저 object class의 식별자를 기록하게 된다. 기본적으로 Kryo가 읽거나 써야할 모든 클래스들은 사전에 등록되어야 한다. Registration은 int형 class ID와 class에 사용할 직렬화기와 클래스를 인스턴스화하는데 사용되는 obejct instatiator를 제공한다.

Kryo kryo = new Kryo();
kryo.register(SomeClass.class);
Output output = ...
SomeClass object = ...
kryo.writeObject(output, object);

deserialization 하는 동안, 등록된 클래스들은 그들이 serialization 하는동안에 가졌던 같은 ID 값을 갖게된다. register될때 클래스는 가능한 다음 순번중 가장 적은 interget ID값을 할당받는다. 이건 등록된 클래스간 순서를 의미하기 때문에 중요한 정보이다. class ID는 선택적으로 명시적으로 지정이 가능하기 때문에 순서를 재조정할 수도 있다. 

Kryo kryo = new Kryo();
kryo.register(SomeClass.class, 9);
kryo.register(AnotherClass.class, 10);
kryo.register(YetAnotherClass.class, 11);
this.register(Integer.TYPE, new IntSerializer());
this.register(String.class, new StringSerializer());
this.register(Float.TYPE, new FloatSerializer());
this.register(Boolean.TYPE, new BooleanSerializer());
this.register(Byte.TYPE, new ByteSerializer());
this.register(Character.TYPE, new CharSerializer());
this.register(Short.TYPE, new ShortSerializer());
this.register(Long.TYPE, new LongSerializer());
this.register(Double.TYPE, new DoubleSerializer());
this.register(Void.TYPE, new VoidSerializer());

Class ID -1, -2는 예약돼있다. 기본적으로 0-8까지는 원시타입과 스트링에서 사용하고 있고, 이 아이디는 변경 가능하다. 이 ID는 양수일때 최적화된 varint로 작성되므로, 절대값이 작은 양의 정수일때 가장 효율적이며, 음수 ID는 효율적으로 직렬화되지 않는다.

ClassResolver

ClassResolver는 바이트 읽고 쓰는 처리의 클래스 표현에 대한 내용이다. 기본 구현은 대부분의 경우 충분하지만, 클래스가 등록되었을때 발생하는 일이나 직렬화 중에 등록되지 않은 클래스와 마주하게 되는 경우, 클래스를 나타내기 위해 읽고 쓰는 작업 등을 사용자 정의 값으로 대체할 수도 있다. 

Optional Registration

Kryo는 class를 전면적으로 등록하지 않고 직렬화가 되도록 구성할 수도 있다.

Kryo kryo = new Kryo();
kryo.setRegistrationRequired(false);
Output output = ...
SomeClass object = ...
kryo.writeObject(output, object);

등록, 미등록 클래스들을 혼용하여 사용할 수 있다. 이때 등록되지 않은 클래스는 크게 두가지 단점이 존재한다.

  1. 클래스의 인스턴스를 생성하기 위한 deserialization를 허용하기 때문에 보안에 영향을 끼칠 수 있다. 생성자나 소멸자 실행중에 side effect가 있는 클래스의 경우 악용이 될 수 있다.

  2. 등록되지 않은 클래스가 객체 그래프에 처음 나타날때는 varint 클래스 ID를 사용하는 대신에, 정규화된 클래스 이름(qualifed class name)을 쓰게 된다. 동일한 객체 그래프 내에서 같은 클래스가 또다시 나타났을때는 varint를 사용하여 작성이 된다. 직렬화 결과 사이즈를 줄이기 위해서는 짧은 패키지 이름을 고려해야 한다.

 

copy를 위해서만 Kryo를 사용하는 경우에는, registration 기능을 비활성화 할 수 도 있다. Kryo setWarnUnregisteredClasses 옵션을 통해서 등록되지 않은 클래스 발생하면 로깅되도록 설정 할 수 있다. 이를 통해 등록되지 않은 클래스들의 목록을 쉽게 얻을 수 있다. Kryo의 unregisteredClassMessage를 재정의하여 사용자 정의 로그 메시지를 남게하거나 다른 작업을 수행하게 할 수도 있다.

Default Serializers

클래스가 등록되면 직렬화기 인스턴스를 선택적으로 지정할수 있다. 역직렬화되는 동안 등록된 클래스는 직렬화시 사용했던 serializer와 동일한 serializer를 가지고 있어야 한다. 

Kryo kryo = new Kryo();
kryo.register(SomeClass.class, new SomeSerializer());
kryo.register(AnotherClass.class, new AnotherSerializer());

Serializer를 지정하지 않았거나 등록되지 않은 클래스가 발견되었을때, serializer는 클래스를 serializer에 매핑하는 "default serializer"목록에서 자동으로 선택된다. default serializer가 많다고 직렬화 성능에 영향을 주지 않기 때문에 기본적으로 Kroy는 다양한 JRE 클래스들에 대해 50개 이상의 기본 serializer를 가지고 있다.

 

default serializer를 추가하는 방법:

Kryo kryo = new Kryo();
kryo.setRegistrationRequired(false);
kryo.addDefaultSerializer(SomeClass.class, SomeSerializer.class);

Output output = ...
SomeClass object = ...
kryo.writeObject(output, object);

위와 같이 하면 SomeClass 또는 SomeClass를 확장하거나 구현하는 모든 클래스가 등록될때 SomeSerializer 인스턴스가 생성된다. Default Serializer는 보다 구체적인 클래스에 먼저 일치하도록 정렬되지만, 그렇지 않으면 추가된 순서대로 매칭된다. 추가된 순서는 인터페이스와 관련이 있을 수 있다.

 

클래스와 일치하는 Default Serializer가 없으면 global default serializer가 사용된다. global default serializer는 기본적으로 FieldSerializer로 설정되지만 변경할 수 있다. 일반적으로 global serializer는 다양한 타입 처리가 가능하다.

Kryo kryo = new Kryo();
kryo.setDefaultSerializer(TaggedFieldSerializer.class);
kryo.register(SomeClass.class);

위 코드에서는 SomeClass와 일치하는 기본 시리얼라이저가 없는 경우 TaggedFieldSerializer가 사용된다. 클래스는 또한 DefaultSerializer 어노테이션을 사용할 수도 있다.

@DefaultSerializer(SomeClassSerializer.class)
public class SomeClass {
    // ...
}

최대한의 유연성 보장을 위해, Kryo getDefaultSerializer를 Serializer를 선택하고 인스턴스화하는 커스텀 로직을 구현하여 재정의할 수 있다.

Serializer factories

addDefaultSerializer(Class, Class) 메소드는 Serializer의 구성을 허용하지 않는다. Serializer 클래스 대신 Serializer 팩토리를 설정할 수 있어 factory에서 각 Serializer 인스턴스를 생성하고 구성할수도 있다. Factory는 일반적인 Serializer를 제공하며, 종종 getConfig 메서드를 통해 serializer 추가 설정을 하기도 한다.

Kry kryo = new Kryo();

TaggedFieldSerializerFactory defaultFactory = new TaggedFieldSerializerFactory();
defaultFactory.getConfig().setReadUnkownTagData(true);
kryo.setDefaultSerializer(defaultFactory);

FieldSerializerFactory someClassFactory = new FieldSerializerFactory();
someClassFactory.getConfig().setFieldsCanBeNull(false);
kryo.register(SomeClass.class, someClassFactory);

Serializer factory는 isSupported(Class) 메소드를 가지고 있는데, 이 메소드는 어떤 클래스가 다른 방식으로 매칭이 되더라도 클래스를 처리하는것을 거부할수 있도록 한다. 이를 통해 factory에서는 다중 인터페이스를 확인하거나, 이를 다른 로직 구현으로 적용할 수 있게 되는 것이다.

Object creation

일부 직렬화기는 특정 클래스를 위한 것이지만, 다른 직렬화기들은 다른 여러 클래스를 직렬화하기도 한다. 직렬화기는 Kryo newInstance(Class)를 사용하여 모든 클래스의 인스턴스를 생성할 수 있다. 이 작업은 클래스에 대한 등록을 조회한 다음 registrationdml ObjectInstantiator를 사용하여 수행된다. instantiator는 등록시에 지정할 수 있다.

Registration registration = kryo.register(SomeClass.class);
registration.setInstantiator(new ObjectInstantiator<SomeClass>() {
    public SomeClass newInstance () {
        return new SomeClass("some constructor arguments", 1234);
    }
});

registration에 등록된 instantiator가 없으면, Kryo newInstantiator가 제공된다. 객체 생성을 커스터마이즈하기 위해서는 Kery newInstantiator를 재정의하거나 InstantiatorStrategy를 공급하면 된다.

InstantiatorStrategy

Kryo는 DefaultInstantiatorStrategy를 제공하며, 이는 인수없는 생성자를 호출하는 ReflectASM을 사용하여 objects를 생성하고 있다. 이것이 불가능 하다면 reflection을 사용하여 인수없는 생성자를 호출하게 될것이다. 만약 또다시 실패하게 된다면, 그것은 예외를 던지거나 fallback InstantiatorStrategy를 사용하게 된다. Reflection은 setAccessible을 사용하므로, 인수없는 생성자는 Kryo에서 public api에 영향을 주지 않고도 클래스 인스턴스를 생성할수 있는 좋은 방법이다.

 

DefaultInstantiatorStrategy는 Kryo로 객체를 생성하는데 권장된다. 그것은 자바코드에서 처럼 생성자를 실행한다. 대안적으로 외부언어 메커니즘이 객체 생성에 사용되기도 한다. Objenesis StdInstantiatorStrategy는 JVM 특정 API를 사용하여 생성자를 전혀 호출하지 않고 클래스의 인스턴스를 생성한다. 이것을 사용하는 것은 위험이 있다. 왜냐하면 대부분의 클래스들은 생성자 호출을 통해 객체 생성이 된다는 것을 가정하고 때문이다. 생성자를 뛰어넘고 객체를 생성하면 객체가 초기화되지 않거나 유효하지 않은 상태로 남아있게 될 가능성이 있다. 클래스들은 생성자를 통해 만들어지도록 설계되어져 있기도 한다. 

 

Kryo는 가급적 DefaultInstantiatorStrategy를 먼저 사용하여 구성될수 있도록 시도하며, 필요한경우 StdInstantiatorStrategy로 폴백 시도를 한다.

kryo.setInstantiatorStrategy(new DefaultInstantiatorStrategy(new StdInstantiatorStrategy()));

또다른 옵션은 SerializingInstantiatorStrategy를 사용하는 것이다. SerializingInstantiatorStrategy은 Java의 내장된 직렬화 메커니즘을 사용하여 인스턴스를 생성한다. 이를 사용하면, 클래스는 Java.io.Serializable을 implement해야 하며, 슈퍼클래스를 호출하는 인수없는 생성자를 구현해야 한다. 이것 또한 생성자를 우회하는 방식이라 StdInstantiatorStrategy와 같은 이유로 위험하다.

kryo.setInstantiatorStrategy(new DefaultInstantiatorStrategy(new SerializaingInstantiatorStrategy()));

Override create

또는 일부 일반적인 serializer들은 특정 타입에 대한 사용자 정의 객체 생성을 목적으로 Kryo newInstance를 호출하는 대신 메소드를 오버라이드할 수 있는 기능을 제공하기도 한다. 

kryo.register(SomeClass.class, new FieldSerializer(kery, SomeClass.class) {
    protected T create (Kryo kryo, Input input, Class<? extends T> type) {
        return new SomeClass("some constructor arguments", 1234);
    }
});

일부 Serializer는 writeHeader 메소드를 제공한다. 이 메소드는 적절한 시점에 생성에 필요한 데이터를 쓰기 위한 용도로 오버라이드 된다.

static public class TreeMapSerializer extends MapSerializer<TreeMap> {
    protected void writeHeader (Kryo kryo, Output output, TreeMap map) {
        kryo.writeClassAndObject(output, map.comparator());
    }
    
    protected TreeMap create (Kryo kryo, Input input, Class<? extends TreeMap> type, int size) {
        return new TreeMap((Comparator)kryo.readClassAndObject(input));
    }
}

Serializer가 writeHeader를 제공하지 않으면, create를 위한 writing data는 write 메소드 안에 두면 된다.

static public class SomeClassSerializer extends FieldSerializer<SomeClass> {
    public SomeClassSerializer (Kryo kryo) {
        super(kryo, SomeClass.class);
    }
    public void write (Kryo kryo, Output output, SomeClass object) {
        output.writeInt(object.value);
    }
    protected SomeClass create (Kryo kryo, Input input, Class<? extends SomeClass> type) {
        return new SomeClass(input.readInt());
    }
}

Final Classes

Serializer가 값에 대해서 예상되는 클래스를 알고 있다 하더라도, 값의 구체화된 클래스가 final이 아니라면, serializer는 먼저 클래스 id를 기록하고 값을 작성해야만 한다. final class는 변형이 일어나지 않으므로 효과적으로 Serialize할 수 있다. Kryo isFinal은 클래스가 final인지 여부를 결정하는데 사용된다. 이 메서드는 final이 아닌 타입 경우에도 참을 반환하도록 재정의할 수도 있다.

 

예를 들면, 애플리케이션이 ArrayList를 광범위하게 사용하지만 ArrayList 하위 클래스를 사용하지 않는 경우, ArrayList를 final로 처리하면 FieldSerializer가 ArrayList 필드당 1-2 바이트로 저장할 수 있다.

Closures

Kryo는 몇가지 주의사항을 포함하여 java.io.Serializable을 implement하는 Java 8+ closure를 직렬화할 수 있다. 한 JVM에서 직렬화된 클로저가 다른 JVM에서는 역직렬화 되는것에 실패할 수도 있다.

 

Kryo isClosures는 클래스가 closure인지 여부를 결정하기 위해 사용된다. closure를 serialize하기 위해서는 아래 클래스들을 반드시 등록해주어야 하며, 추가적으로 closure capturing class도 등록해야 한다.

 

ClosureSerializer.ClousClosureSerializer.Closure, SerializedLambda, Object[], Class

kryo.register(Object[].class);
kryo.register(Class.class);
kryo.register(SerializedLambda.class);
kryo.register(ClosureSerializer.Closure.class, new ClosureSerializer());
kryo.register(CapturingClass.class);

Callable<Integer> closure1 = (Callable<Integer> & java.io.Serializable)(() -> 72363);

Output output = new Output(1024, -1);
kryo.writeObejct(output, closure1);

Input input = new Input(output.getBuffer(), 0, output.position());
Callable<Integer> closure2 = (Callable<Integer>)kryo.readObject(input, ClosureSerializer.Closure.class);

Compression and encryption

Kryo는 stream을 지원하므로, 모든 직렬화된 바이트에서 압축 또는 암호화를 사용하는 것은 사소한 부분이다.

OutputStream outputStream = new DeflaterOutputStream(new FileOutputStream("file.bin"));
Output output = new Output(outputStream);
Kryo kryo = new Kryo();
kryo.writeObject(output, object);
output.close();

필요한 경우, Serializer를 사용하여 객체 그래프의 바이트 부분 집합에 대해서만 바이트를 압축하거나 암호화할 수도 있다. 예를 들어 DeflateSerializer 또는 BlowfishSerializer를 참고하면 이 Serializer들은 바이트를 인코딩하고 디코딩하기 위해 다른 Serializer를 감싸고 있다.


Implementing a serializer

Serializer 추상클래스를 상속받아 객체 바이트 변환 과련 메소드를 재정의할 수 있다.

public class ColorSerializer extends Serializer<Color> {
    public void write(Kryo kryo, Output output, Color color) {
        output.writeInt(color.getRGB());
    }
    
    public Color read(Kryo kryo, Input input, Class<? extends Color> type) {
        return new Color(input.readInt());
    }
}

Serializer는 두개의 메소드만 재정의하면 된다. write 메서드는 obejct를 byte로 출력하며, read 메서드는 Input 객체를 읽어들여 새로운 object 인스턴스를 생성한다.

KryoException

직렬화에 실패하며, 객체 그래프에서 예외가 발생한 위치에 대한 직렬화 trace 정보와 함께 KryoException이 던져진다. 중첩된 Serializer의 경우 KryoException은 추가된 직렬화 trace 정보를 넘겨준다.

Object object = ...
Field[] fields = ...
for (Field field : fields) {
    try {
        // User other serializers to serialize each field.
    } catch (KryoException ex) {
        ex.addTrace(field.getName() + " (" + object.getClass().getName() + ")");
        throw ex;
    } catch (Throwable t) {
        KryoException ex = new KryoException(t);
        ex.addTrace(field.getName() + " (" + object.getClass().getName() + ")");
        throw ex;
    }
}

Stack Size

Kryo가 제공하는 Serializer들은 중첩 객체를 직렬화할때 호출 스택(call stack)을 사용한다. Kryo는 스택 호출을 최소화하지만, 매우 깊은 객체 그래프의 경우 Stack Overflow가 발생할 수 있다. 이것은 내장된 Java 직렬화를 포함한 대부분의 직렬화 라이브러리에서 공통적으로 발생하는 문제이기도 하다. 스택크기는 -Xss를 사용하여 늘릴 수 있지만, 이는 모든 스레드에 적용된다는 점에 유의해야 한다. 많은 스레드를 보유하고 있는 대형 JVM의 경우에는 많은 양의 메모리를 사용할 수 있다.

 

Kryo setMaxDepth를 사용하여 객체 그래프의 최대 깊이를 제한할 수도 있다. 이를 통해 악의적인 데이터가 스택 오버플로우를 발생시키는 것을 방지할 수 있다.

Accepting null

기본적으로 직렬화기는 null을 받을수 없다. 대신 Kryo는 null인지 null이 아닌지를 나타내는데 필요한 바이트를 작성하게 될것이다. Null을 자체적으로 처리할 수있는 직렬화기가 더 효율적일수 있으려면, Serializer setAcceptsNull(true) 설정을 해주면 된다. 직렬화기가 처리할 모든 인스턴스가 null이 될수없다는 것이 확실하다면, null 표시 바이트 쓰기를 피하는 용도로 사용할 수 있다. 


Kryo versioning and upgrading

Kryo 버저닝은 다음과 같은 주요 규칙이 적용된다.

  1. 직렬화 호환성이 깨지면 major 버전이 증가한다. 이는 이전 버전으로 직렬화된 데이터가 새버전으로는 역직렬화되지 않을 수 있음을 의미한다.

  2. 문서화된 public API의 binary나 source가 깨지면 minor 버전이 증가하게 된다. 극소수의 사용자가 영향을 받을때 버전을 증가시키지 않기 위해, 일반 용도에 거의 사용되지 않거나 의도하지 않은 공용클래스에 발생하는 경우 일부 경미한 파손이 허용되기도 한다.

 

dependency를 업그레이드하는 것은 주요한 이벤트이지만, 직렬화 라이브러리 대부분은 대다수의 dependency에 비해서 파손 가능성이 높다. Kryo를 업그레이드 할때는 버전 차이를 확인한 뒤에 새버전에 대한 테스트를 철저히 할것을 권장한다. Kryo 팀은 이를 가능한한 쉽고 안전하게 변경하기 위해 노력하고 있다.

  - 개발시 직렬화 호환성은 서로 다른 이진 형식과 기본 직렬화기에 대해 테스트된다.

  - 개발할때 binary나 source 호환성은 clirrer로 추적 가능하다.

  - 각 릴리즈에 대해 직렬화, binary, source 호환성을 보고하는 섹션이 changelog를 통해 제공되고 있다.

  - binary, source 호환성은 japi-compliance-checker를 통해 리포팅되고 있다.


Compatibility (호환성)

필요에 의해, 직렬화된 바이트를 장기간 저장해야할때는 직렬화가 클래스의 변화를 처리할때 주요 고려요인이 될수 있다. 이를 정방향 호환성(신규 클래스에 의해 직렬화된 읽기 바이트) 및 이전 버전과의 호환성 (이전 클래스에 의해 직렬화된 읽기 바이트) 이라고 한다. Kryo는 호환성을 다루는데 서로 다른 접근 방식을 취하는 몇가지 일반적인 Serializer를 제공하고 있다. 

 

forward, backward 호환성을 위한 추가적인 serializer는 손쉽게 만들수 있으며, 그 예는 직접 작성한 외부 스키마를 사용하는 Serializer가 있을 수 있다.


Serializers

Kryo는 다양한 설정 옵션과 다양한 호환성 수준을 가진 많은 직렬화기를 제공하고 있다. 추가적인 serializer들은 kryo-serializers 자매 프로젝트에서 찾을 수 있다. private API에 접근할 수 있는 serializers들을 보유하고 있다. 그렇지 않으면 모든 JVM에서 완전히 안전하지 않을 수 있다. 

FieldSerializer

FieldSerializer는 비영속 필드를 직렬화하여 작동한다. 이 직렬화기는 추가적인 설적없이 POJO, 많은 다른 클래스들에 대한 직렬화를 제공한다. 모든 비공개 필드에 대해서도 기본적으로 입출력이 이루어지기 때문에, 직렬화대상 클래스에대한 평가는 중요하다.

 

FieldSerializer는 Java 클래스 파일을 스키마로 사용하여 다른 스키마 정보없이 오직 필드 데이터만 작성하여 효율적이다. 이는 이전에 직렬화된 바이트를 무효화하지 않고 필드 유형을 추가/제거/변경할 수 없다. 필드의 이름 변경은 필드의 알파벳 순서가 변경되지 않는 경우에만 허용된다.

 

FieldSerializer의 호환성 결함은 많은 상황에서 허용되고 있다. (예를 들어 네트워크를 통해 데이터를 전송해야 할때) 그러나 Java 클래스가 추가 확장이 어렵기 때문에 장기적인 데이터 스토리지에는 적합하지 않을 수 있다.

Setting Description Default Value
fieldsCanBeNull false일 경우 필드 값이 null이 아니라고 가정하여 필드당 0-1바이트를 저장할 수 있다. true
setFieldsAsAccessible true일 경우 모든 비영속화 필드(private 필드 포함)가 직렬화되고 필요한 경우 Accessible로 설정 가능하다. false일 경우 Public API 필드만 직렬화된다. true
ignoreSyntheticFields true일 경우 합성 필드(scoping을 위해 컴파일러에서 생성된)가 직렬화 된다. false
fixedFieldTypes true일 경우, 모든 필드 값의 구체화된 타입이 필드 타입과 일치한다고 가정하는 것이다. 이렇게 하면 필드 값에 대한 클래스 ID를 필요가 없다. false
copyTrasient true일 경우, 모든 transient 필드가 복사된다. true
serializeTransient true일 경우, 모든 transient 필드가 직렬화된다. false
variableLengthEncoding true일 경우, int long 타입에 대해서 가변길이 인코딩을 사용한다. true
extendedFieldNames true일 경우, 필드이름에 선언 클래스 접두어가 붙는다. 이것은 하위 클래스에 슈퍼 클래스와 이름이 같은 필드가 있을때 충돌을 피하게 해준다.  false

CachedField settings

FieldSerializer는 직렬화할 필드를 지정할수 있다. Field는 제거할수 있으므로 직렬화되지 않는다. 직렬화를 보다 효율적으로 만들수 있도록 필드를 구성할 수 있다.

FieldSerializer fieldSerializer = ...
fieldSerializer.removeField("id"); // Won't be serialized.

CachedField nameField = fieldSerializer.getField("name");
nameField.setCanBeNull(false);

CachedField someClassField = fieldSerializer.getField("someClass");
someClassField.setClass(SomeClass.class, new SomeClassSerializer());

FieldSerializer annotation

어노테이션을 사용하여 각 필드에 대한 직렬화설정을 할 수도 있다.

Annotation Description
@Bind Sets the CachedField setting for any field.
@CollectionBind Sets the CollectionSerializer settings for Collection fields.
@MapBind Sets the MapSerializer settings for Map fields.
@NotNull Marks a field as never being null.
public class SomeClass {
    @NotNull
    @Bind(serializer = StringSerializer.class, valueClass = String.class, canBeNull = false)
    Object stringField;
    
    @Bind(variableLengthEncoding = false)
    int intField;
    
    @BindMap(
        keySerializer = StringSerializer.class,
        valueSerializer = IntArraySerializer.class,
        keyClass = String.class,
        valueClass = int[].class,
        keyCanBeNull = false)
    Map map;
    
    @BindCollection(
        elementSerializer = LongArraySerializer.class,
        elementClass = long[].class,
        elementsCanBeNull = false)
    Collection collection;
}

VersionFieldSerializer

VersionFieldSerializer는 FieldSerializer를 확장한 것으로 이전버전 호환성을 지원한다. 이는 이전에 직렬화된 바이트를 무효화하지 않고 필드를 추가할 수 있음을 의미한다. 필드 타입 변경, 삭제, 이름 변경은 지원하지 않는다.

 

필드가 추가될때는 이전에 직렬화된 바이트와의 호환을 위해 추가된 버전을 나타내주어야 하며, @Since(int) 어노테이션을 추가해주어야 한다. 이 어노테이션은 절대로 변경되어서는 안된다.

 

기본적으로 VersionFieldSerializer는 FieldSerializer의 설정 옵션을 그대로 상속받는다. VersionFieldSerializer는 FieldSerializer에 비해 약간의 오버헤드가 있다. (추가적인 varint 옵션)

Setting Description Default value
compatible false일 경우, 다른 버전의 객체를 읽을때 예외가 발생한다. 객체의 버전은 모든 필드의 최대 버전이다. true

TaggedFieldSerializer

TaggedFieldSerializer는 FieldSerializer를 확장한 것으로 이전버전 호환성을 지원하며, 선택적으로 신규버전 호환성을 지원한다. 이는 이전에 직렬화된 바이트를 무효화하지 않고 필드를 추가하거나 이름을 변경하고 선택적으로 제거할 수 있음을 의미한다. 필드 타입변경은 지원지 않는다.

 

@Tag(int) 어노테이션이 있는 필드만 직렬화된다. 필드의 태그값은 유니크해야 하며, 부모 클래스 및 한 클래스 모든 범위에 해당한다. 중복 태그값이 발생할경우 예외가 던져진다.

 

전후 호환성 및 직렬화 성능은 readUnknownTagData와 ChunkedEncoding 설정에 따라 달라진다. 추가적으로 필드 앞에 각 태그값에 대한 varint가 작성된다. 

 

readUnknownTagData와 chunkedEncoding 설정이 false일 경우, 필드는 제거하면 안되지만 @Deprecated 주석을 적용할수는 있다. Deprecated 필드는 이전 바이트를 읽을때는 일지만 새 바이트에 쓰지는 않는다. 필드의 이름을 바꾸거나 private 설정을 통하여 클래스의 혼잡도를 감소시킬수 있다. 

 

기본적으로 TaggedFieldSerializer는 FieldSerializer의 설정 옵션을 그대로 상속받는다.

Setting Description Default value
readUnknownTagData false일때는 알수없는 태그가 발견되면 예외가 발생하거나 chunkedEncodig이 true이면 데이터를 건너뛴다. 

true일 경우, 각 필드 값의 값앞에 클래스가 기록된다. 알수없는 태그가 발견되면 데이터를 읽으려고 시도한다. 
 
chunkedEncoding true일 경우, 알 수없는 필드 데이터를 건너뛸 수 있도록 청크 인코딩으로 필드가 작성된다. 이것은 성능에 영향을 미친다. false
chunkSize 청크 처리된 인코딩에 대한 각 청크의 최대 크기 1024