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 타입
- Service —
rpc Name (Req) returns (stream Res)(stream 키워드) - 스키마 진화 — 새 필드 추가 OK·삭제 시 reserved
- 이름 변경 OK / 번호 변경 X (데이터 깨짐)
- 호환 타입 변경 — int32↔int64·uint32↔int32 등
- Java — 자동 코드 생성 + 빌더 패턴
- Protobuf 객체 불변 —
toBuilder().build() - JsonFormat — Protobuf ↔ JSON 변환
시리즈 다른 편
- 1편 — 기본 개념·HTTP/2·4 RPC 모드
- 2편 — Protocol Buffers (현재 글)
- 3편 — Unary RPC
- 4편 — Server Streaming
- 5편 — Client Streaming
- 6편 — Bidirectional Streaming
- 7편 — Interceptors
- 8편 — Error Handling
- 9편 — Security
- 10편 — 고급 (Reflection·Health·LB·gRPC-Web)
공식 문서: Protocol Buffers / Proto3 Language Guide 에서 더 깊이.
다음 글(3편)에서는 Unary RPC — 가장 기본 1:1 호출 패턴, 동기·비동기·Future 3 Stub, 데드라인·메타데이터까지 풀어 갑니다.