gRPC + Spring Boot — Protocol Buffers (Protobuf)

2026-05-03확률과 통계 마스터 노트

gRPC + Spring Boot 마스터 노트 시리즈 2편. Protobuf 메시지 정의 문법, 13 기본 타입과 Java 매핑, 필드 번호의 결정적 의미, repeated·map·nested message·oneof·enum 활용, 스키마 진화 규칙(필드 추가·삭제 안전선), Well-Known Types, Java 코드 생성 흐름까지.

이 글은 gRPC + Spring Boot 마스터 노트 시리즈의 두 번째 편입니다. 1편(기초)에서 gRPC 큰 그림을 봤다면, 이번엔 그 토대 — Protocol Buffers.

.proto 파일 한 번 정의 → Java·Go·Python 자동 생성. 다만 필드 번호·스키마 진화 규칙이 함정. 이 둘이 운영 사고를 가르는 자리.

처음 Protobuf가 어렵게 느껴지는 이유

처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, **필드 번호 (= 1, = 2)**가 왜 필요한지 막연합니다. 둘째, 스키마 진화 규칙이 미세해 보입니다.

해결법은 한 가지예요. "필드 번호 = 와이어 ID, 절대 변경 X" 한 줄. 이름은 바꿔도 OK, 번호는 영원히 고정. 이 인식만 잡으면 모든 진화 규칙이 자연스럽습니다.

proto3 — 기본 문법

syntax = "proto3";

package com.example.user;
option java_package = "com.example.user.proto";
option java_multiple_files = true;

message User {
  string id = 1;
  string name = 2;
  int32 age = 3;
  bool active = 4;
}

핵심:

  • syntax = "proto3" — 버전 (proto3 권장)
  • package — Protobuf 네임스페이스
  • option java_package — Java 패키지
  • option java_multiple_files = true — 메시지마다 별도 파일

13 기본 타입

Protobuf Java 비트
double double 64
float float 32
int32 int 32 (varint)
int64 long 64 (varint)
uint32 int (unsigned) 32
uint64 long (unsigned) 64
sint32 int (efficient negative) 32
sint64 long 64
fixed32 int (4 bytes) 32
fixed64 long (8 bytes) 64
bool boolean 1
string String (UTF-8) 가변
bytes ByteString 가변

여기서 시험 함정이 하나 있어요. int32 vs sint32. 음수 자주 = sint32 효율 ↑. 양수만 = int32. fixed32 = 큰 양수만 (압축 안 함).

필드 번호 — 와이어 ID

message User {
  string id = 1;        // 와이어 ID 1
  string name = 2;      // 와이어 ID 2
  int32 age = 3;        // 와이어 ID 3
}

직렬화된 바이트:

[tag1] [id 값]
[tag2] [name 값]
[tag3] [age 값]

여기서 정말 중요한 시험 함정 — 필드 번호 = 와이어 ID, 절대 변경 X. 이름은 바꿔도 OK·번호는 영원. 변경 시 기존 데이터 deserialize 깨짐.

예약 번호

message User {
  reserved 4, 5, 8 to 11;
  reserved "old_name", "deprecated";
  
  string id = 1;
  string name = 2;
  int32 age = 3;
  // 필드 4, 5, 8~11은 사용 X (예약)
}

옛 필드 삭제 시 번호 예약. 다른 필드가 그 번호 못 씀. 호환성 유지.

번호 범위

1~15:    1 byte (자주 쓰는 필드)
16~2047: 2 bytes
2048~:   3+ bytes
19000~19999: 예약 (사용 X)

자주 쓰는 필드 = 1~15 권장 (효율).

repeated — 배열

message User {
  string id = 1;
  repeated string emails = 2;
  repeated Tag tags = 3;
}

message Tag {
  string name = 1;
}
User user = User.newBuilder()
    .setId("1")
    .addEmails("a@x.com")
    .addEmails("b@x.com")
    .addTags(Tag.newBuilder().setName("admin").build())
    .build();

List<String> emails = user.getEmailsList();

여기서 시험 함정이 하나 있어요. repeated는 배열·List. proto2의 optional·required는 proto3에서 제거 (required 영구 제거).

map — 키-값

message User {
  map<string, string> attributes = 1;
}
User user = User.newBuilder()
    .putAttributes("role", "admin")
    .putAttributes("dept", "eng")
    .build();

Map<String, String> attrs = user.getAttributesMap();

내부적으로 repeated MapEntry 메시지. 키 = 정수·string·bool만.

nested message

message User {
  string id = 1;
  Address address = 2;

  message Address {
    string street = 1;
    string city = 2;
  }
}
User.Address address = User.Address.newBuilder()
    .setStreet("123 Main")
    .setCity("Seoul")
    .build();

User user = User.newBuilder()
    .setId("1")
    .setAddress(address)
    .build();

또는 별도 메시지로 분리도 OK.

enum

message User {
  Status status = 1;

  enum Status {
    UNKNOWN = 0;        // 첫 enum 값 = 0 필수
    ACTIVE = 1;
    INACTIVE = 2;
    BANNED = 3;
  }
}

여기서 시험 함정이 하나 있어요. 첫 enum 값 = 0 필수. 보통 UNKNOWN 또는 UNSPECIFIED. 기본값으로 사용.

oneof — 여러 중 하나

message Result {
  oneof outcome {
    string success_message = 1;
    string error_message = 2;
    int32 code = 3;
  }
}

세 필드 중 하나만 설정 가능. 다른 것 설정 시 기존 자동 클리어.

Result r = Result.newBuilder().setSuccessMessage("OK").build();
r.getOutcomeCase();   // SUCCESS_MESSAGE

용도 — 응답에서 성공·실패 구분, 다양한 형태의 데이터.

Well-Known Types — 표준 메시지

Google이 제공하는 표준:

import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/any.proto";
import "google/protobuf/wrappers.proto";

message Event {
  google.protobuf.Timestamp created_at = 1;
  google.protobuf.Duration timeout = 2;
  google.protobuf.StringValue optional_field = 3;
}

service Service {
  rpc Ping (google.protobuf.Empty) returns (google.protobuf.Empty);
}
Type Java
Timestamp java.time.Instant
Duration java.time.Duration
Empty (빈 메시지)
Any 임의 메시지 (다형성)
StringValue·Int32Value·BoolValue String/Integer/Boolean (nullable)

여기서 정말 중요한 시험 함정 — proto3 기본값은 nullable 아님. string 빈 값 = "", int32 = 0. nullable 필요 = Wrapper 타입 (StringValue 등).

Service 정의

service UserService {
  rpc GetUser (UserRequest) returns (User);                           // Unary
  rpc ListUsers (Empty) returns (stream User);                        // Server Streaming
  rpc CreateUsers (stream User) returns (CreateResult);               // Client Streaming
  rpc Chat (stream Message) returns (stream Message);                 // Bidirectional
}

stream 키워드로 streaming 명시.

스키마 진화 — 호환성 규칙

안전한 변경 (Backward Compatible)

// V1
message User {
  string id = 1;
  string name = 2;
}

// V2 — 안전
message User {
  string id = 1;
  string name = 2;
  int32 age = 3;       // 새 필드 추가 OK
  string email = 4;
}

옛 클라이언트가 V2 데이터 받음 → 모르는 필드 무시. 옛 V1 데이터를 V2가 받음 → 새 필드는 기본값.

안전한 삭제

// V3
message User {
  reserved 2;
  reserved "name";
  
  string id = 1;
  // string name = 2;     ← 삭제·번호 예약
  int32 age = 3;
  string email = 4;
}

번호 예약하면 미래 충돌 X.

위험한 변경

변경 결과
필드 번호 변경 데이터 깨짐
타입 변경 (호환 X) 데이터 깨짐
repeated ↔ 단일 변경 깨짐
필드 이름 변경 OK (번호 같으면)

여기서 정말 중요한 시험 함정 — 이름 변경은 OK, 번호는 영원. 와이어 형식은 번호 기반. 이름은 코드 생성용.

호환 가능한 타입 변경

int32 ↔ uint32 ↔ int64 ↔ uint64 ↔ bool   (정수형 사이)
sint32 ↔ sint64
fixed32 ↔ sfixed32
fixed64 ↔ sfixed64
string ↔ bytes (UTF-8 유효 시)

대부분은 변경 시 데이터 다르게 해석.

Java 코드 생성

.proto 컴파일 → Java 클래스 자동 생성:

src/main/proto/user.proto
       ↓ protoc
build/generated/.../UserOuterClass.java     # 메시지
build/generated/.../UserServiceGrpc.java     # 서비스 (gRPC)

Java API

// 빌더 패턴
User user = User.newBuilder()
    .setId("123")
    .setName("Alice")
    .addEmails("a@x.com")
    .build();

// 직렬화
byte[] bytes = user.toByteArray();

// 역직렬화
User restored = User.parseFrom(bytes);

// 변경 (불변 → 새 객체)
User updated = user.toBuilder().setAge(31).build();

여기서 시험 함정이 하나 있어요. Protobuf 객체는 불변. toBuilder()로 새 빌더 → 변경 → build()로 새 객체.

직렬화 형식

필드 1 (id="123"):
  [tag = 0x0a]   # field 1, wire type 2 (length-delimited)
  [length = 3]
  [data = "123"]

필드 3 (age=30):
  [tag = 0x18]   # field 3, wire type 0 (varint)
  [data = 30]

매우 컴팩트. 키 이름 X. 번호 + 타입만.

JSON 변환

// Protobuf → JSON
JsonFormat.printer().print(user);
// {"id": "123", "name": "Alice", ...}

// JSON → Protobuf
User.Builder builder = User.newBuilder();
JsonFormat.parser().merge(jsonString, builder);
User user = builder.build();

디버깅·로깅에 유용.

시험 직전 한 번 더 — 자주 헷갈리는 함정 모음

여기까지가 2편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • proto3 = 권장 버전
  • package (Protobuf) / java_package (Java) / java_multiple_files
  • 13 기본 타입 — int32·int64·sint32·fixed32·string·bytes·bool·...
  • int32 vs sint32 — 음수 자주 = sint32 (varint zigzag)
  • 필드 번호 = 와이어 ID, 영원히 고정
  • 1~15 = 1 byte (자주 쓰는 필드)
  • reserved = 옛 번호·이름 예약 (재사용 방지)
  • 19000~19999 = Protobuf 예약 (사용 X)
  • repeated = 배열·List
  • proto2의 optional·required는 proto3에서 제거
  • map<K, V> = 키-값 (내부 repeated MapEntry)
  • nested message — 메시지 안 메시지
  • enum — 첫 값 0 필수 (UNKNOWN)
  • oneof — 여러 필드 중 하나만
  • Well-Known Types — Timestamp·Duration·Empty·Any·Wrapper (nullable)
  • proto3 기본값 — string "", int 0, bool false (null 아님)
  • nullable 필요 = Wrapper 타입
  • Servicerpc Name (Req) returns (stream Res) (stream 키워드)
  • 스키마 진화 — 새 필드 추가 OK·삭제 시 reserved
  • 이름 변경 OK / 번호 변경 X (데이터 깨짐)
  • 호환 타입 변경 — int32↔int64·uint32↔int32 등
  • Java — 자동 코드 생성 + 빌더 패턴
  • Protobuf 객체 불변toBuilder().build()
  • JsonFormat — Protobuf ↔ JSON 변환

시리즈 다른 편

공식 문서: Protocol Buffers / Proto3 Language Guide 에서 더 깊이.

다음 글(3편)에서는 Unary RPC — 가장 기본 1:1 호출 패턴, 동기·비동기·Future 3 Stub, 데드라인·메타데이터까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!