사전 프로토콜 버퍼
포맷

프로토콜 버퍼

gabury1고친 사람 github-actions[bot]

프로토콜 버퍼는 프로그램끼리 주고받는 데이터를 작은 바이트열로 바꿔 줍니다. 주고받을 데이터의 모양은 미리 파일 하나에 적어 두고 양쪽이 나눠 가집니다. 바이트에는 칸 이름 대신 번호만 실립니다. 그래서 글자로 적는 포맷보다 크기가 작습니다.

쉽고 빠른 이해

프로토콜 버퍼는 서비스끼리 주고받는 데이터를 바이트로 바꾸는 규칙입니다. 이름 「유미」와 나이 3 을 담은 사용자 한 명이 10바이트가 됩니다.

글자로 적는 포맷은 보낼 때마다 name · age 같은 칸 이름을 함께 싣습니다. 같은 사용자를 그렇게 적으면 25바이트입니다. 양쪽이 칸의 모양을 미리 약속해 두면 이름은 안 보내고 칸마다 정한 번호만 보냅니다.

어떻게 도나:

  1. 파일 하나에 칸마다 이름 · 타입 · 번호를 적습니다
  2. 전용 컴파일러가 그 파일을 읽어 언어마다 코드를 만들어 줍니다
  3. 그 코드가 객체를 바이트로 바꿉니다. 받은 바이트는 다시 객체로 되돌립니다

서비스끼리 안쪽에서 오가는 데이터에 주로 씁니다. 브라우저가 바로 읽는 응답이나 사람이 열어 보는 설정에는 글자 포맷을 씁니다.

대가는 바이트만 봐서는 사람이 못 읽는다는 점입니다. 약속한 파일이 없으면 받는 쪽도 번호 2 가 무슨 칸인지 모릅니다.

상세

이 절은 사용자 한 명을 담은 데이터 하나를 끝까지 따라갑니다. 파일에 모양을 적는 데서 시작합니다. 그 파일로 코드를 만들고 데이터를 바이트 10개로 바꿉니다. 끝으로 그 바이트를 한 칸씩 뜯어 봅니다.

끝에서는 모양이 바뀌어도 옛 프로그램과 계속 주고받는 방법, 이 포맷이 못 담는 것, 쓰는 때와 안 쓰는 때를 봅니다.

이름과 하는 일

프로토콜 버퍼는 Protocol Buffers 를 우리말로 옮긴 이름입니다. 줄여서 Protobuf 라고도 부릅니다. 구글이 만들어 공개했습니다.

하는 일은 직렬화입니다. 직렬화는 메모리에 있는 객체를 전송하거나 저장할 수 있게 바이트열로 바꾸는 일입니다. 받은 바이트를 다시 객체로 되돌리는 일은 역직렬화라고 부릅니다.

칸 이름을 매번 싣는 글자 포맷

직렬화하는 방법은 여럿입니다. 흔히 쓰는 JSON(JavaScript Object Notation, 자바스크립트 객체 표기법)은 값을 사람이 읽는 글자로 적습니다.

JSON
{"name":"유미","age":3}

이 한 줄은 25바이트입니다. 값인 「유미」와 3 이 차지하는 것은 7바이트뿐입니다. 나머지 18바이트는 칸 이름과 따옴표 · 쌍점 같은 구분 기호입니다.

서비스끼리 같은 모양의 데이터를 수없이 주고받으면 칸 이름도 매번 같이 실려 갑니다. 받는 쪽은 글자를 한 자씩 읽어서 3 이라는 글자를 숫자 3 으로 바꿔야 합니다. 프로토콜 버퍼는 양쪽이 모양을 미리 약속해서 이 두 비용을 줄입니다.

모양을 적는 proto 파일

주고받을 데이터의 모양을 스키마라고 부릅니다. 어떤 칸이 있고 칸마다 어떤 타입의 값이 들어가는지를 적은 설계도입니다. 양쪽이 같은 스키마를 가지고 있어야 한쪽이 쓴 바이트를 다른 쪽이 읽을 수 있습니다.

프로토콜 버퍼는 스키마를 확장자가 .proto 인 파일에 적습니다. 이 파일을 proto 파일이라고 부릅니다. 아래는 user.proto 라는 파일의 내용입니다.

proto
syntax = "proto3";

message User {
  string name = 1;
  int32 age = 2;
}

첫 줄은 이 파일이 따르는 문법 판을 밝힙니다. message 로 묶은 한 덩이가 메시지입니다. 주고받는 데이터 한 단위입니다. 자바의 클래스 하나에 해당합니다.

User 메시지에는 필드가 둘 있습니다. 필드는 메시지 안의 칸 하나입니다. 필드마다 타입과 이름을 적습니다.

= 1 과 = 2 는 기본값이 아닙니다. 필드마다 붙이는 필드 번호입니다. 바이트에는 필드 이름 대신 이 번호가 실립니다. 그래서 한 번 정한 번호는 바꾸지 않습니다.

파일에서 코드를 만드는 컴파일러

proto 파일은 모양만 적은 문서라 그 자체로는 실행되지 않습니다. 이 파일을 읽어 언어별 코드를 만들어 주는 것이 프로토콜 버퍼 컴파일러인 protoc 입니다. 설계도를 읽어 코드를 뽑아내는 이 일을 코드 생성이라고 부릅니다.

만들어진 코드에는 User 클래스가 들어 있습니다. 그 객체를 바이트로 바꾸고 되돌리는 메서드도 같이 들어 있습니다. 자바라면 이렇게 씁니다.

Java
User u = User.newBuilder()
    .setName("유미")
    .setAge(3)
    .build();
byte[] b = u.toByteArray();    // 10바이트
User back = User.parseFrom(b);
back.getName();                // "유미"
back.getAge();                 // 3

toByteArray 가 직렬화입니다. parseFrom 은 역직렬화입니다. 바이트를 한 칸씩 쓰고 읽는 코드는 컴파일러가 만들었습니다. 사람이 손으로 쓸 일이 없습니다.

같은 proto 파일로 여러 언어의 코드를 만들 수 있습니다. 주문 서비스는 자바로, 추천 서비스는 파이썬으로 짜여 있어도 둘은 같은 바이트를 주고받습니다. 두 서비스의 코드가 한 파일에서 나왔으므로 칸의 타입과 번호가 어긋날 일이 없습니다.

바이트 10개를 한 칸씩 뜯어 보기

위 코드가 만든 10바이트를 16진수로 적으면 이렇습니다.

0A 06 EC 9C A0 EB AF B8 10 03

바이트열은 필드마다 「태그 → 값」 순서로 이어집니다. 태그는 뒤따르는 값이 몇 번 필드인지를 알려 주는 한 바이트입니다. 태그는 값의 종류도 함께 알려 줍니다. 그 방법은 다음 절에서 봅니다.

문자열 값은 내용이 몇 바이트인지를 먼저 적습니다. 내용은 그 뒤에 UTF-8(Unicode Transformation Format 8-bit)로 적습니다. UTF-8 은 유니코드 글자를 바이트로 적는 방식입니다.

바이트 뜻
0A 태그 · 1번 필드이고 길이가 앞에 붙는 값
06 길이 · 뒤의 6바이트가 값
EC 9C A0 EB AF B8 값 · 「유미」를 UTF-8 로 적은 6바이트
10 태그 · 2번 필드이고 정수 값
03 값 · 3

표 어디에도 name 이나 age 라는 글자가 없습니다. 번호 1 과 2 만 있습니다. 그 번호가 무슨 칸인지는 받는 쪽이 자기 proto 파일을 보고 압니다.

태그 한 바이트에 든 두 가지

태그에는 필드 번호와 와이어 타입이 함께 들어 있습니다. 와이어 타입은 뒤따르는 값을 몇 바이트 읽어야 하는지 알려 주는 종류 번호입니다. 태그 값은 「필드 번호 × 8 + 와이어 타입」으로 만듭니다.

0A 는 10진수로 10 입니다. 1 × 8 + 2 이므로 1번 필드입니다. 와이어 타입은 2 입니다. 10 은 10진수로 16 입니다. 2 × 8 + 0 이므로 2번 필드입니다. 와이어 타입은 0 입니다.

지금 쓰이는 와이어 타입은 넷입니다.

와이어 타입 값을 읽는 법 이것을 쓰는 필드 타입
0 끝을 알리는 비트가 나올 때까지 읽는다 int32 · int64 · bool · enum
1 고정 8바이트 double · fixed64
2 길이를 먼저 읽고 그만큼 읽는다 string · bytes · 안에 든 메시지
5 고정 4바이트 float · fixed32

와이어 타입 0 이 가변 길이 정수입니다. 작은 수일수록 바이트를 적게 쓰는 정수 표기입니다. 한 바이트에 값을 7비트씩 싣습니다. 남은 맨 윗비트로는 다음 바이트가 더 있는지를 표시합니다.

그래서 0 부터 127 까지는 한 바이트로 끝납니다. 그보다 큰 수는 두 바이트 이상이 됩니다. 나이나 개수처럼 작은 값이 흔하므로 늘 4바이트를 쓰는 표기보다 대개 작아집니다.

모르는 번호를 건너뛰는 읽기

서비스를 고치다 보면 스키마도 바뀝니다. 필드를 더하거나 뺀 뒤에도 옛 프로그램과 새 프로그램은 계속 주고받아야 합니다. 이때 와이어 타입이 쓰입니다.

옛 프로그램이 새 필드 3번이 든 바이트를 받았다고 해 봅시다. 태그를 읽으면 번호 3 이 자기 proto 파일에 없다는 것을 압니다. 그래도 와이어 타입을 보면 값이 몇 바이트인지는 압니다. 그만큼 건너뛰고 다음 태그를 읽습니다.

flowchart TD
    T["태그를 읽는다"] --> Q{"내 proto 파일에 있는 번호인가"}
    Q -->|있다| F["그 필드에 값을 담는다"]
    Q -->|없다| S["와이어 타입으로 값의 길이를 알아내 건너뛴다"]
    F --> N["다음 태그로 간다"]
    S --> N

옛 프로그램이 새 데이터를 이렇게 읽어 내는 성질을 상위 호환이라고 부릅니다.

반대로 새 프로그램이 옛 데이터를 받으면, 바이트에 없는 필드는 기본값으로 채웁니다. 숫자는 0, 문자열은 빈 문자열, 참거짓은 거짓입니다. 새 프로그램이 옛 데이터를 읽어 내는 이 성질은 하위 호환입니다. 필드를 더하는 변경은 상위 호환과 하위 호환을 다 지킵니다.

필드 번호에 걸린 규칙

필드 이름은 바이트에 없습니다. 그래서 바이트로 주고받는 동안에는 이름을 바꿔도 오가는 데이터가 안 깨집니다.

번호는 반대입니다. 번호를 바꾸면 옛 데이터의 값이 엉뚱한 필드로 들어갑니다. 지운 필드의 번호를 새 필드에 다시 줘도 똑같이 됩니다.

그래서 지운 번호는 reserved 로 묶어 둡니다. 묶어 둔 번호를 누가 다시 쓰면 컴파일러가 코드를 만들지 않고 멈춥니다.

proto
message User {
  reserved 2;         // 지운 age 의 번호
  string name = 1;
  string email = 3;   // 새로 더한 필드
}

이 포맷이 못 담는 것

프로토콜 버퍼가 작아진 것은 담지 않기로 한 것이 있어서입니다. 그 빈틈이 실무에서 겪는 골치가 됩니다.

못 담는 것 그래서 생기는 일
필드 이름과 타입 바이트만 봐서는 번호와 값만 보인다. 사람도 도구도 proto 파일이 있어야 읽는다
「0 을 보냈다」와 「안 보냈다」의 차이 기본값인 필드는 바이트에 안 실린다. 그래서 둘이 같은 바이트가 된다
메시지가 끝나는 곳 바이트열에 끝 표시가 없다. 메시지 둘을 이어 붙이면 하나로 합쳐져 읽힌다

첫 줄은 장애를 볼 때 아픕니다. 로그나 네트워크 패킷에 찍힌 바이트를 열어 봐도 16진수만 보입니다. 읽으려면 맞는 판의 proto 파일을 찾아 도구에 넣어야 합니다.

둘째 줄은 나이를 0 으로 채워 보낸 것과 아예 안 채운 것이 받는 쪽에서 똑같이 0 으로 읽힌다는 뜻입니다. 둘을 갈라야 하면 필드 앞에 optional 을 붙입니다. 그러면 값이 채워졌는지가 따로 기록됩니다.

셋째 줄 때문에 메시지를 여러 개 이어 보낼 때는 길이를 따로 적어야 합니다. 메시지를 실어 나르는 쪽이 메시지마다 바이트 수를 앞에 붙입니다.

서비스 호출까지 적는 인터페이스 정의 언어

proto 파일에는 메시지 말고 서비스도 적을 수 있습니다. 서비스는 원격에서 부를 수 있는 메서드의 묶음입니다.

proto
service UserService {
  rpc GetUser (GetUserRequest) returns (User);
}

rpc 로 시작하는 줄 하나가 메서드 하나입니다. GetUserRequest 메시지를 받아 User 메시지를 돌려준다는 뜻입니다.

이렇게 다른 컴퓨터에 있는 함수를 내 코드 안의 함수처럼 부르는 방식을 원격 프로시저 호출(RPC, Remote Procedure Call)이라고 부릅니다. 부르는 쪽은 네트워크를 직접 다루지 않고 메서드 하나를 부를 뿐입니다.

원격 호출을 하려면 부를 함수와 주고받을 데이터를 양쪽이 똑같이 알아야 합니다. 두 쪽이 다른 언어로 짜여 있어도 그렇습니다. 이것을 언어와 상관없이 적는 언어가 인터페이스 정의 언어(IDL, Interface Definition Language)입니다. proto 파일이 이 일을 합니다.

gRPC는 proto 파일로 원격 프로시저 호출을 해 주는 구글의 프레임워크입니다. 서비스 정의를 proto 파일에서 그대로 읽습니다.

컴파일러는 서비스 정의에서 스텁도 만들어 줍니다. 스텁은 클라이언트 쪽에 있는 객체입니다. 서비스와 같은 메서드를 가지고 있습니다.

클라이언트가 스텁의 메서드를 부르면 스텁이 인자를 프로토콜 버퍼 메시지로 바꿔 서버로 보냅니다. 서버가 돌려준 바이트도 스텁이 다시 객체로 바꿔 돌려줍니다.

쓰는 때와 안 쓰는 때

고르는 기준은 받는 쪽이 proto 파일을 가질 수 있느냐입니다. 가질 수 있으면 작은 바이트와 어긋나지 않는 모양을 얻습니다. 가질 수 없으면 받은 바이트를 읽을 방법이 없습니다.

상황 쓰는 포맷 까닭
서비스끼리 안쪽에서 자주 오가는 호출 프로토콜 버퍼 양쪽이 같은 proto 파일을 나눠 가질 수 있다
여러 언어로 짠 서비스가 한 데이터를 나눠 씀 프로토콜 버퍼 한 파일에서 언어마다 코드를 만든다
브라우저가 바로 받아 쓰는 공개 응답 JSON 받는 쪽이 proto 파일을 가졌다고 기대할 수 없다
사람이 열어 고치는 설정 파일 · 로그 글자 포맷 열어서 바로 읽고 고칠 수 있어야 한다

모양이 미리 안 정해진 데이터에도 맞지 않습니다. 프로토콜 버퍼는 필드를 전부 proto 파일에 먼저 적어야 쓸 수 있기 때문입니다.

관련 항목

프로토콜 버퍼를 이루는 구성 요소

proto 파일 · 메시지 · 필드 · 필드 번호 · 와이어 타입 · 열거형 · oneof · repeated 필드 · 맵 필드

프로토콜 버퍼가 맡는 변환 작업

직렬화 · 역직렬화 · 마샬링 · 인코딩

바이트열에 쓰이는 인코딩

가변 길이 정수 · UTF-8 · 16진수 · 엔디언 · 지그재그 인코딩

proto 파일에서 코드를 뽑아내는 도구

protoc · 컴파일러 · 코드 생성 · 컴파일러 플러그인 · Buf

프로토콜 버퍼로 메시지를 싣는 원격 호출 기술

gRPC · 원격 프로시저 호출 · 스텁 · 인터페이스 정의 언어 · 서비스 정의

같은 역할을 두고 겨루는 직렬화 포맷

JSON · XML · Apache Avro · Apache Thrift · MessagePack · FlatBuffers · CBOR

스키마를 바꿀 때 걸리는 규칙과 성질

스키마 진화 · 하위 호환 · 상위 호환 · 기본값 · 필드 존재 여부

프로토콜 버퍼가 속하는 상위 분류

포맷 · 이진 포맷 · 스키마 · 데이터 교환 포맷

다른 이름: Protocol Buffers · Protobuf · protobuf · 프로토버프