본문 바로가기

Java/Spring

Protocol Buffer 원리로 배우는 고성능 직렬화, 역직렬화 전략! Protocol Buffer 예제 테스트(구글이 쓰는 이유가 있었네)

프로토콜 버퍼가 무엇인가

프로토콜 버퍼(Protocol Buffer = protobuf)란 직렬화 데이터 구조다. (XML, JSON과 유사)

직렬화 데이터 구조를 알려면 직렬화(Serialization)가 뭔지 알아야한다. 꽤 긴데 천천히 설명해보겠다.

우선, 컴퓨터가 데이터를 저장할 때 결국은 0과 1을 나타내는 비트(bit)로 표현해야 한다는 것을 알아야한다.

데이터를 파일에 쓰든 네트워크에 쓰든 결국에는 0과 1인 비트로 표현해야 한다.

요약하면 "데이터 표현(저장) = 바이트(1Byte = 8bit)"다.

UTF-8 문자열 인코딩을 생각해보자. 만약 "hello"라는 문자 데이터를 다른 서버에 보낼 때 어떻게 될까?

h,e,l,l,o 각각에 대한 인코딩을 통해 16진수로 표현하면 68(h) 65(e) 6C(l) 6C(l) 6F(o)로 표현되겠다.

이것이 문자열 직렬화다.

이것만 직렬화가 아니다. 객체 직렬화도 있다. (정확히는 marshalling/unmarshalling이라 해야할 것 같고... binding... 애매합니다.) -> 엄밀히 말하면 바이트 스트림으로 만드는게 직렬화다.

XML과 JSON이 그런 형태다.

{"name":"jeong-pro"} 라는 json 포맷의 문자열은 어떤 객체A를 json 포맷으로 직렬화했을 때라고 할 수 있다.

객체 자체를 JSON형식으로 표현(직렬화)하고 직렬화된 것을 UTF-8을 이용해서 다시 바이트로 직렬화하는 것이다. (잘못된 지식으로 오해의 소지가 있을 수 있습니다. 정확하지 않은 부분은 고칠 수 있게 코멘트 부탁드립니다.)

빙빙 돌아왔는데 결국 프로토콜 버퍼도 데이터를 직렬화 시켰을 때 표현되는 포맷이라는 것이다.

프로토콜 버퍼의 원리

프로토콜 버퍼가 뭔지 이제야 알 것 같은데 갑자기 원리가 나와서 당황할 수도 있다.

왜 프로토콜 버퍼를 쓰는지를 알기 전에 원리를 알면 장단점이 딱 보이기 때문에 원리부터 설명한다.

프로토콜 버퍼가 포맷이라고 했는데 어떤 형식인지 알아본다. (그 전에 JSON부터 알아 볼 거임)

우선 Person클래스를 만들고 그 객체를 JSON포맷으로 만든다고 쳐보자 그러면 아래와 같을 것이다.

{
	"userName":"Martin",
	"favouriteNumber":1337,
	"interests":["daydreaming","hacking"]
}

데이터 크기를 보면 공백을 빼도 총 82byte를 사용했다.

그러면 프로토콜 버퍼를 사용했을 때는 어떨까? (그림에서 볼 수 있듯 33byte를 썼다.)

출처 : https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html

message Person {
    required string user_name        = 1;
    optional int64  favourite_number = 2;
    repeated string interests        = 3;
}

위의 그림은 프로토콜 버퍼가 어떻게 직렬화하는지 원리에 대한 설명이다.

위의 코드는 .proto파일로 그림에 사용된 객체 설명이라고 보면된다.

그림의 설명처럼 핵심은 userName같은 불필요한 속성값을 숫자(1,2,3,...)로 대체해버린 것이다.

데이터의 최초 1바이트를 5bit , 3bit로 나눠서 앞에 5bit는 proto파일에 달아놓은 태그(번호)를 나타내는 것으로 비트로 "00001" 이렇게 하면 1번으로 지정한 속성이라는 것이고, 뒤에 3bit로는 타입을 표현해서 비트로 "010"이 String임을 알려준다. 그래서 처음 1바이트에는 메타 정보가 들어가는 것이고, 그 다음인 두 번째 바이트는 뒤로 이어질 데이터의 길이를 알려준다.

이런 원리로 데이터를 표현하게되면 같은 데이터여도 33byte만으로도 표현할 수 있게 되는 것이다.


프로토콜 버퍼 특징

- 프로토콜 버퍼는 왜 쓰는가?

1. 통신이 빠르기 때문이다.

같은 데이터를 보내더라도 데이터의 크기가 작으니까 같은 시간에 더 많은 데이터를 보낼 수 있다.

물론 더 빠르게 보낼 수도 있다.

2. 파싱을 할 필요가 없다.

JSON포맷으로 온 데이터를 다시 객체로 파싱해서 객체로 사용하는게 보통인데, 프로토콜 버퍼를 쓰면 바이트가 오면 그 바이트 그대로 메모리에 써버리고 객체 레퍼런스가 가리켜 버리면 끝난다. 별도의 파싱이 필요가 없는 것이다.

그렇기 때문에 또 빠르다.

- 그러면 단점은 없는가?

1. 인간이 읽기 불편하다.

JSON같은 경우는 데이터를 보면 사람이 읽기 정말 편하다. 반대로 프로토콜 버퍼가 쓴 데이터는 사람이 읽기 어렵다. proto파일이 없으면 아예 무슨 의미인지 모른다.

이것은 즉, proto파일이 반드시 있어야 한다는 것이다. 예전 XML 스키마를 대체한다고 보면 된다.

XML 스키마가 없으면 XML로 온 데이터가 자세하게 뭘 의미하는지 모르는 것과 같다.

이 문제 때문에 외부 API로 쓰이기에는 문제가 있다. (모든 클라이언트가 proto파일이 있어야하기 때문)

그래서 내부 서비스간의 데이터 교환에서 주로 쓰인다.

2. proto 문법을 배워야 한다.

프로토콜 버퍼에서 쓰이는 스키마 파일 즉, .proto 파일을 작성할 줄 알아야한다.

근데 그 문법이 다른 프로그래밍 문법과 유사하기 때문에 크게 문제되진 않지만, 또 정확하게 작성하려면 문법을 알아야한다. 문서를 보고.

그리고 proto파일을 한 번 정의했다가 필요에 의해서 변경되면 이 proto파일을 쓰는 애플리케이션은 다시 공유되어야 하고 컴파일되어야 한다는 단점이 있다.

뭐 구글 문서에는 proto파일에 속성이 업데이트되는건 무방하다고 나오는데 속성이 삭제되거나 타입이 변경되고 이러면 당연히 proto파일을 사용하는 애플리케이션 모두가 바꿔야 한다는 문제가 있다. (얻는게 있으면 잃는게 있다)


프로토콜 버퍼가 뭔지, 어떤 특징이 있는지, 원리가 무엇인지 까지 기초적인 내용을 다 알아봤다.

개발자가 언제부터 이론만 공부했던가. 코딩을 해야지.(?) 예제를 테스트해보자.

사용법과 예제

사용법은 아래와 같다.

  1. protoc 라는 컴파일러를 설치한다.
  2. .proto파일에 원하는 데이터를 작성하고 protoc 컴파일러로 컴파일해서 원하는 프로그래밍 언어로 source generate를 한다.
  3. 원하는 프로그래밍 언어에서 프로토콜 버퍼 API라이브러리를 적용해서 API를 사용한다.

protoc라는 컴파일러는 c++기반이고 c++소스를 오픈소스로 제공하니 빌드해서 사용하면 된다.

근데 그럴필요없이 깃허브에 운영체제에 따른 컴파일러를 다운로드하게 해놨으니 그걸 받아서 사용하는게 편하다.

https://github.com/protocolbuffers/protobuf/releases/tag/v3.8.0

위 링크는 3.8.0 버전에 대한 링큰데 원하는 버전의 protoc를 받도록 하자. 필자는 win64.zip 파일을 받아서 압축을 풀고 끝냈다. (1단계 끝)

그 다음에 .proto 파일을 만들어야 하는데 이것 역시 구글에서 예제로 주는 것을 사용했다. (물론 proto2버전의 예제지만 개인적으로 proto3를 사용하고 싶어서 약간 수정했다.

syntax = "proto3";

package com.tistory.jeongpro;

option java_package = "com.example.demo.domain";
option java_outer_classname = "AddressBookProtos";

message Person {
  string name = 1;
  int32 id = 2;
  string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  PhoneNumber phones = 4;
}

message AddressBook {
  Person people = 1;
}

위의 내용을 "MyAddressBook.proto"이라는 이름으로 작성했다.

이제 이 코드를 아까 설치한 protoc를 이용해서 컴파일하면 된다.

그 전에 간단하게 proto파일에 대해 설명하겠다.

syntax="proto3"는 proto2와 proto3가 있는데 proto3를 사용하겠다는 의미다.

package는 프로토파일간에 충돌(중복)을 방지하기 위해 나눈 값이다.

option으로 java_package를 줬는데 generate로 생성된 소스에 적용될 패키지명을 설정하는 것이다.

마찬가지로 option으로 java_outer_classname은 generate로 생성된 소스의 클래스 이름을 지정하는 것이다.

추가로 list, map은 어떻게 표현하는 등은 나중에 연구해보자.(지금은 처음하니까)

현재 위 그림과 같은 상태다. compile이라는 디렉토리는 그냥 빈 디렉토리로 생성한 것이고, MyAddressBook.proto파일은 위에서 만든 예제 파일이다.

"protoc --java_out=./compile ./MyAddressBook.proto"

이런 명령어를 통해서 컴파일 했다.

명령어에 주는 옵션도 다양한데 나중에 알아보고 위 내용을 설명하면 자바소스를 받으려고 --java_out 옵션을 줬고 ./compile 이라는 상대경로를 지정해서 여기다가 생성해달라고 한 것이다. 제일 뒤의 값은 이 컴파일할 파일을 설정한 것이다.

그러면 ./compile/com/example/demo/domain/AddressBookProtos.java라는 파일이 생길 것이다.

이 파일을 프로젝트에 복사하고 spring 프로젝트를 만들고 maven repository를 통해 라이브러리를 적용할 것이다.

* generate된 소스를 보면 토하고 싶다. 저렇게 작은데 2000줄이 넘는 코드가 생성된다. 무슨 코드길래 하면서 봤더니 api들이 전부 구현되어 있고, 심지어 패키지(ex java.lang.String)들도 다 적혀서 나오기 때문에 코드가 그렇게 긴 것이었다.

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>3.8.0</version>
</dependency>
<dependency>
	<groupId>com.google.protobuf</groupId>
	<artifactId>protobuf-java-util</artifactId>
	<version>3.8.0</version>
</dependency>

사실 protobuf-java만 dependency를 추가해도 된다. protobuf-java-util같은 경우는 json데이터를 protobuffer 데이터로 변경할 수 있게하고 protobuffer데이터를 json데이터로 바꿀 수 있게 할 때 쓴다.

(명확하게는 json과 protobuffer로 포맷을 변경할 수 있다는 얘기고 이게 GRPC에서 쓰는 방법이라고 한다. -출처 : 조대협님 블로그)

(GRPC에서는 결국에 통신에서는 프로토콜버퍼를 써서 유리하게 빠른 통신을 하고 정작 외부로 노출할 때는 json 포맷을 써버리는 것이다. 자세한건 필자도 모르니 일단 그렇다더라만 알고 넘어가자.)

package com.example.demo.controller;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

import org.springframework.util.StopWatch;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.domain.AddressBookProtos.AddressBook;
import com.example.demo.domain.AddressBookProtos.Person;
import com.example.demo.domain.AddressBookProtos.Person.PhoneNumber;
import com.example.demo.domain.AddressBookProtos.Person.PhoneType;
import com.fasterxml.jackson.core.JsonGenerationException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;

@RestController
public class TestController {
	private static final String PROJ_DIR = System.getProperty("user.dir"); 
	private ObjectMapper objectMapper;
	
	public TestController() {
		this.objectMapper = new ObjectMapper();
	}
	
	@GetMapping("/protobuf")
	public String protobuf() throws IOException {
		
		//create object
		PhoneNumber myPhoneNumber = PhoneNumber.newBuilder()
				.setNumber("010-1234-5678")
				.setType(PhoneType.MOBILE)
				.build();
		
		Person person = Person.newBuilder()
			.setId(1)
			.setEmail("jeong-pro@tistory.com")
			.setName("jdk")
			.setPhones(myPhoneNumber)
			.build();
		
		AddressBook addressBook = AddressBook.newBuilder()
				.setPeople(person)
				.build();
		long start = System.nanoTime();
		//serialize
		FileOutputStream fos = new FileOutputStream(PROJ_DIR + File.separator + "test.txt");
		addressBook.writeTo(fos);
		//deserialize
		FileInputStream fis = new FileInputStream(PROJ_DIR + File.separator + "test.txt");
		AddressBook inputAddressBook = AddressBook.parseFrom(fis);
		long end = System.nanoTime();
		System.err.println("proto = " + (end-start));
		return inputAddressBook.toString();
	}
	@GetMapping("/json")
	public com.example.demo.domain.myjson.AddressBook json() throws JsonGenerationException, JsonMappingException, IOException {
		//create object
		com.example.demo.domain.myjson.PhoneNumber myPhoneNumber = com.example.demo.domain.myjson.PhoneNumber.builder()
				.number("010-1234-5678")
				.type(com.example.demo.domain.myjson.PhoneType.MOBILE)
				.build();
		
		com.example.demo.domain.myjson.Person person = com.example.demo.domain.myjson.Person.builder()
			.id(1)
			.email("jeong-pro@tistory.com")
			.name("jdk")
			.phones(myPhoneNumber)
			.build();
		
		com.example.demo.domain.myjson.AddressBook addressBook = com.example.demo.domain.myjson.AddressBook.builder()
				.people(person)
				.build();
		long start = System.nanoTime();
		//serialize
		objectMapper.writeValue(new File(PROJ_DIR + File.separator + "test2.txt"), addressBook);
		//deserialize
		com.example.demo.domain.myjson.AddressBook inputAddressBook = objectMapper.readValue(new File(PROJ_DIR + File.separator + "test2.txt"), com.example.demo.domain.myjson.AddressBook.class);
		long end = System.nanoTime();
		System.err.println("json = " + (end-start));
		return inputAddressBook;
	}
	
}

컨트롤러를 위와 같이 작성했다.

json포맷은 비교를 위해서 만들었다. generate된 코드는 2000줄이 넘어가므로 옮기지 않겠다. json포맷을 위한 데이터는 아래와 같다.

package com.example.demo.domain.myjson;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AddressBook {
	private Person people;
}

[AddressBook.java]

package com.example.demo.domain.myjson;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Person {
	private String name;
	private int id;
	private String email;
	private PhoneNumber phones;
}

[Person.java]

package com.example.demo.domain.myjson;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneNumber {
	private String number;
	private PhoneType type;
}

[PhoneNumber.java]

package com.example.demo.domain.myjson;

public enum PhoneType {
	MOBILE(0),
	HOME(1),
	WORK(2);
	private final int number;
	private PhoneType(int number) {
		this.number = number;
	}
	public int getNumber() {
		return number;
	}
}

[PhoneType.java]

위 클래스들은 비교를 위해 생성한 것일 뿐이라서 protocol buffer만 테스트할 사람은 generate된 소스만 있으면 된다.

컨트롤러를 보면 builder패턴으로 객체를 만든다.

protocol buffer를 사용한 객체는 불변 객체다. (immutable) 자바 String클래스의 특징과 같다고 보면된다.

직렬화 방법은 객체.writeTo() 메서드를 쓰면 된다.

test.txt 파일의 크기는 49btye였다. test2.txt 파일의 크기는 116byte였다. (2배 이상 차이)

test.txt 파일의 내용을 아래와 같다.

/
jdkjeong-pro@tistory.com"

010-1234-5678

nanoTime으로 수행시간이 다른가도 찍어봤다. File I/O하는 것이 mapper를 이용한 것과 차이는 있지만 그냥 찍어봤다.

결과는 뭐 크게 차이는 없었다. 미비하게 protobuf가 좋긴하다. 근데 이제 대용량으로 넘어가면 proto가 확실히 유리하고 많게는 50% 이상의 성능이 좋아지는 효과를 볼 수 있다고 한다.

일단 이번에 처음 테스트한 것이니 부족한게 많아도 일단 경험해본 것으로 좋게 생각하고 마무리 한다.

 

ps. 프로토콜 버퍼만 특별하게 직렬화 매커니즘이 있는건 아니다. 유사한 것으로 apache avro가 있다.

참고 및 출처 사이트

https://www.joinc.co.kr/w/man/12/ProtocolBuffer

https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html

https://bcho.tistory.com/1182