Up and Running for Protobuf

Up and Running for Protobuf

다른 시스템과 데이터를 주고 받는다거나 용량이 큰 데이터를 효율적으로 저장하기 위해서는 데이터를 압축하거나 다른 효율적인 방법이 필요하다. 인터넷을 통해 데이터를 효율적으로 주고 받기 위해 많은 사람들이 고민을 해 왔고 그 중 요즘 Defacto Standard로 사용되는 것이 Google에서 만든 Protocol Buffer(a.k.a Protobuf)라는 것이다.

Protocol Buffer는 language 중립적이고 데이터를 Serialize할 때 쉽게 확장 가능한 구조를 제공한다는 특징을 가지고 있다. Protocol Buffer를 사용하기 위해서는 몇가지 설치가 필요하다. 먼저 Protocol Compiler(확장자 .proto 파일을 컴파일하기 위해 사용됨)와 사용하고자 하는 language에 맞는 Protobuf Runtime을 먼저 설치해야 한다. 여기서는 Java Runtime을 사용하도록 한다.

처음 시작할 때 부담없이 따라할 수 있는 자료로 Protocol Buffer Github 페이지를 추천한다. 여기에서 필요한 설치와 Quick Start와 관련된 Reference를 만날 수 있다.

Protocol Compiler 설치

Protobuf Compiler는 C++로 작성되어 있어 직접 Compile해서 설치할 것이 아니라면 간단하게 아래의 링크에서 설치버전을 받아 설치하면 된다.

  • https://github.com/protocolbuffers/protobuf/releases

Linux Ubuntu(20.04)의 경우는 protoc-25.1-linux-x86_64.zip 버전을 다운받으 그냥 풀기만 하면 설치가 끝난다. 이 글을 작성하는 시점(2023.12.18)에서의 버전이 25.1이니 이후 버전을 설치하면 문제없이 동작할 것이다.

설치 후 정상 동작여부를 확인해 보려면 아래와 같이 콘솔에서 버전정보(libprotoc 25.1)를 확인해 보기 바란다.

skanto:~/protoc-25.1-linux-x86_64$ pwd
/home/skanto/protoc-25.1-linux-x86_64
skanto:~/protoc-25.1-linux-x86_64$ ls
bin  include  readme.txt  sample
skanto:~/protoc-25.1-linux-x86_64$ bin/protoc --version
libprotoc 25.1

ProtoBuf Runtime 설치

Protobuf는 다양한 언어를 Runtim으로 지원하지만 여기서는 Java언어로 된 Runtime설치를 설명한다. Java Runtime 설치는 아래 링크를 참조하면 되고 Maven 또는 Gradle을 이용하여 동적으로 Library를 Include하면 쉽게 설치 가능하다(인터넷 연결 필수).

  • https://github.com/protocolbuffers/protobuf/tree/main/java

위 링크로 들어가면 Maven과 Gradle에서 사용하는 방법을 설명하며 Gradle의 경우 아래와 같이 build.gradle 파일의 dependency 블럭에 implementation ‘com.google.protobuf:protobuf-java:3.25.0’ 을 추가해 주면 끝난다.

...
dependencies {
    // Use JUnit Jupiter for testing.
    testImplementation 'org.junit.jupiter:junit-jupiter:5.9.1'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    implementation 'com.google.guava:guava:31.1-jre'

    // https://github.com/protocolbuffers/protobuf/tree/main/java
    implementation 'com.google.protobuf:protobuf-java:3.25.0'	    
}
...

이렇게 하면 Protocol Buffer를 활용하기 위한 기본적인 설치작업은 마무리 된다.

간단한 Sample로 배워보기

Protobuf는 한 번만 사용해보면 전체 그림이 머리에 들어올 것이다. 여기서는 Quick Start로 간단한 예제를 통해 그 과정을 설명한다. 자세한 내용은 Protocol Buffer Basics: Java를 참고하기 바란다.

Protocol Buffer를 사용하려면 아래의 기본적인 단계를 거친다.

  1. .proto 파일을 통해 메시지 포맷을 정의한다.
  2. Protocol Buffer Compiler를 이용하여 .proto파일을 컴파일 한다.
  3. Protocol Buffer API를 이용하여 메시지를 Read, Write한다.

샘플 소스코드를 직접 받으려면 Github페이지를 참고하기 바란다. 하지만 샘플이 그리 길지 않으니 타이핑해서 직접 손으로 작성해보길 추천한다.

메시지 포맷 정의

.proto 확장자를 가진 빈 파일을 하나 만들고 아래의 내용을 추가한다. 여기서는 addressbook.proto 라는 파일로 만든다. 내용을 보면 대충 알겠지만 Serialize하려는 자료의 구조를 정의하는 message를 추가하고 이 message에서 필요로 하는 필드를 name과 type을 활용하여 정의한다.

syntax = "proto2";

package tutorial;

option java_multiple_files = true;
option java_package = "com.example.tutorial.protos";
option java_outer_classname = "AddressBookProtos";

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

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    optional string number = 1;
    optional PhoneType type = 2 [default = PHONE_TYPE_HOME];
  }

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

위 정의 내용 중 알아두면 도움이 되는 필드 몇 개를 소개하자면…

  • package 선언은 프로젝트간 충돌을 방지하기 위해 사용하며 Java언어를 사용할 경우 아래에 있는 java_package 옵션을 명시적으로 기술하지 않을 경우 이 package 이름을 사용함. Java언어가 아니더라도 Protocol Buffer 내에서의 namespace 충돌방지 뿐만 아니라 Java 이외의 언어에서도 활용될 수 있으므로 package 는 계속 유지하는 것이 좋다.
  • java_outer_classname 옵션은 wrapper 클래스의 이름을 지정한다. 이 옵션을 지정하지 않을 경우 파일명을 CamelCase로 해서 Wrapper클래스 이름으로 지정한다. 예를 들어 파일이름이 “my_proto.proto”일 경우 wrapper 클래스 이름은 MyProto가 된다.
  • java_multiple_files 옵션이 true일 경우 메시지 각각의 .java 파일을 생성하도록 한다. (false일 경우 과거 방식처럼 Wrapper 클래스의 inner class로 각각의 class가 생성됨)
  • 데이터 타입으로 boolint32floatdoublestring등이 지원함, 다른 message 타입을 이용해서 구조를 정의하는 것도 가능함
  • ” = 1″, ” = 2″와 같이 표시된 것은 필드를 binary 인코딩 할 때 unique한 tag로 활용한다. 참고로 tag번호가 1~15까지는 그 이상(16이상) 인코딩할 때 보다 1바이트 적게 사용하므로 효율적이다.
  • 필드 Modifiers
    • optional 필드 값을 반드시 설정해야 하는 것은 아니다, 만약 생략하면 default값이 사용됨(숫자일 경우 0, String일 경우 공백, boolean일 경우 false)
    • repeated 순서가 보장되며 가변 횟수만큼 반복됨(0도 가능)
    • required 필수로 값을 지정해야 함. 만약 값을 지정하지 않을 경우 Runtime시 메시지를 만들려고 할 때 “RuntimeException”이 발생하며, binary를 파싱할 경우에는 “IOException”이 발생함
      중요: required로 지정하면 이후 변경이 어려우므로 데이터의 변경 가능성, 확장성을 잘 고려해서 설정해야 함

Protocol Buffer 컴파일

이제 .proto파일을 정의 했으므로 이 파일을 이용하여 Compile하면 java파일이 각각 생성된다. 이렇게 생성된 java클래스로 AddressBook 메시지를 활용할 수 있다. 컴파일은 아래와 같이 실행한다.

protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto
  • -I=$SRC_DIR: .proto파일이 있는 디렉터리를 지정한다($SRC_DIR). 복수개의 디렉터리를 지정할 수 있다.
  • -java_out=$DST_DIR: 생성될 java파일이 위치할 디렉터리를 지정한다. 해당 디렉터리 하위에 패키지 폴더가 생성된다.
  • $SRC_DIR/addressbook.proto: 컴파일 할 .proto파일을 지정한다. -I 옵션으로 디렉터리를 설정하고 해당 리텍터리에 .proto파일 있다면 .proto파일 이름만 명시하면 된다.(디렉터리명 생략)

Protocol Buffer API를 활용한 메시지 생성과 관련 API에 대한 좀 더 상세한 설명은 여기를 참고하기 바란다.

Message 작성

이제 컴파일을 통해 생성된 Java 클래스를 활용하여 메시지를 만들어 보자. 전체 과정은 크게 두 단계로 나눌 수 있다. 먼저, 생성된 클래스를 이용하여 메시지 객체를 만드는 부분이고, 다음으로 생성된 메지지를 OutputStream으로 write하는 부분으로 나눈다.

아래 예제는 파일에서 binary stream을 읽어와 Protobuf Message객체를 만들고 새로운 객체를 추가한 후 다시 파일로 write한다.

import com.example.tutorial.protos.AddressBook;
import com.example.tutorial.protos.Person;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintStream;

class AddPerson {
  // This function fills in a Person message based on user input.
  static Person PromptForAddress(BufferedReader stdin,
                                 PrintStream stdout) throws IOException {
    Person.Builder person = Person.newBuilder();

    stdout.print("Enter person ID: ");
    person.setId(Integer.valueOf(stdin.readLine()));

    stdout.print("Enter name: ");
    person.setName(stdin.readLine());

    stdout.print("Enter email address (blank for none): ");
    String email = stdin.readLine();
    if (email.length() > 0) {
      person.setEmail(email);
    }

    while (true) {
      stdout.print("Enter a phone number (or leave blank to finish): ");
      String number = stdin.readLine();
      if (number.length() == 0) {
        break;
      }

      Person.PhoneNumber.Builder phoneNumber =
        Person.PhoneNumber.newBuilder().setNumber(number);

      stdout.print("Is this a mobile, home, or work phone? ");
      String type = stdin.readLine();
      if (type.equals("mobile")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_MOBILE);
      } else if (type.equals("home")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_HOME);
      } else if (type.equals("work")) {
        phoneNumber.setType(Person.PhoneType.PHONE_TYPE_WORK);
      } else {
        stdout.println("Unknown phone type.  Using default.");
      }

      person.addPhones(phoneNumber);
    }

    return person.build();
  }

  // Main function:  Reads the entire address book from a file,
  //   adds one person based on user input, then writes it back out to the same
  //   file.
  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.err.println("Usage:  AddPerson ADDRESS_BOOK_FILE");
      System.exit(-1);
    }

    AddressBook.Builder addressBook = AddressBook.newBuilder();

    // Read the existing address book.
    try {
      addressBook.mergeFrom(new FileInputStream(args[0]));
    } catch (FileNotFoundException e) {
      System.out.println(args[0] + ": File not found.  Creating a new file.");
    }

    // Add an address.
    addressBook.addPerson(
      PromptForAddress(new BufferedReader(new InputStreamReader(System.in)),
                       System.out));

    // Write the new address book back to disk.
    FileOutputStream output = new FileOutputStream(args[0]);
    addressBook.build().writeTo(output);
    output.close();
  }
} 

Message 읽기

아래 예제는 위에서 작성했던 Message를 파일로 부터 읽어 화면으로 그 내용을 출력한다.

import com.example.tutorial.protos.AddressBook;
import com.example.tutorial.protos.Person;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintStream;

class ListPeople {
  // Iterates though all people in the AddressBook and prints info about them.
  static void Print(AddressBook addressBook) {
    for (Person person: addressBook.getPeopleList()) {
      System.out.println("Person ID: " + person.getId());
      System.out.println("  Name: " + person.getName());
      if (person.hasEmail()) {
        System.out.println("  E-mail address: " + person.getEmail());
      }

      for (Person.PhoneNumber phoneNumber : person.getPhonesList()) {
        switch (phoneNumber.getType()) {
          case PHONE_TYPE_MOBILE:
            System.out.print("  Mobile phone #: ");
            break;
          case PHONE_TYPE_HOME:
            System.out.print("  Home phone #: ");
            break;
          case PHONE_TYPE_WORK:
            System.out.print("  Work phone #: ");
            break;
        }
        System.out.println(phoneNumber.getNumber());
      }
    }
  }

  // Main function:  Reads the entire address book from a file and prints all
  //   the information inside.
  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.err.println("Usage:  ListPeople ADDRESS_BOOK_FILE");
      System.exit(-1);
    }

    // Read the existing address book.
    AddressBook addressBook =
      AddressBook.parseFrom(new FileInputStream(args[0]));

    Print(addressBook);
  }
}

Eclipse에서 외부 툴로 Compiler 명령 활용 하기

콘솔을 통해 매번 Proto Buffer Compiler 명령을 사용하면 번거로울 수 있다. 개발툴로 Eclipse를 사용할 경우 아래와 같이 자주 사용하는 명령어를 외부 명령어로 등록해 두면 향후 활용하기가 편리하다

Run > External Tools > External Tools Configurations… 메뉴를 선택하면 아래와 같은 화면을 볼 수 있다.

여기서 Compiler명령어(Locations) 위치와 현재 디렉터리(Working Directory) 그리고 위에서 설명했던 Compiler 옵션을 Arguments 란에 등록해 주면 된다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다