본문으로 건너뛰기
AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
gRPC Proto 스키마 설계와 버전 관리 개념도
GRPC

gRPC Proto 스키마 설계와 버전 관리 실전 가이드

By AICosmus
2026년 06월 24일 12 Min Read
1

왜 .proto 스키마 설계가 그렇게 중요할까?

gRPC를 사용하는 프로젝트에서 가장 과소평가되는 영역이 바로 .proto 파일의 스키마 설계와 버전 관리입니다. REST API에서 JSON 스키마를 대충 만들어도 어느 정도 동작하는 것과 달리, gRPC의 Protocol Buffers는 이진 직렬화 포맷이기 때문에 한 번 잘못 설계하면 서비스 전체가 중단될 수 있습니다. 실제로 마이크로서비스 환경에서 proto 스키마 변경 하나가 연쇄적인 배포 실패를 일으킨 사례는 셀 수 없이 많습니다.

특히 여러 팀이 협업하는 환경에서는 proto 파일이 곧 서비스 간 계약(Contract)입니다. 이 계약이 명확하지 않거나 버전 관리가 되지 않으면, 서버를 먼저 업데이트한 팀과 클라이언트를 나중에 업데이트한 팀 사이에 통신 장애가 발생합니다. 이번 글에서는 처음부터 올바르게 proto 스키마를 설계하는 방법과, 서비스를 중단하지 않으면서 안전하게 API를 진화시키는 실전 전략을 상세히 다루겠습니다.

Proto 파일 기본 구조와 작성 컨벤션

파일 헤더의 필수 요소

모든 .proto 파일은 반드시 세 가지 요소로 시작해야 합니다. syntax 선언, package 지정, 그리고 필요한 import 구문입니다. 이 세 요소를 일관되게 유지하는 것만으로도 많은 문제를 예방할 수 있습니다.

syntax = "proto3";

package mycompany.user.v1;

option go_package = "github.com/mycompany/proto-gen/user/v1;userv1";
option java_package = "com.mycompany.user.v1";

import "google/protobuf/timestamp.proto";
import "google/protobuf/field_mask.proto";

여기서 주목할 점은 package에 버전 번호(v1)를 포함시킨다는 것입니다. 이는 Google의 API 설계 가이드에서도 권장하는 패턴으로, 나중에 호환성이 깨지는 대규모 변경이 필요할 때 v2 패키지를 별도로 만들어 점진적으로 마이그레이션할 수 있게 해줍니다. 처음부터 v1을 붙여두면 미래의 자신에게 큰 선물을 하는 셈입니다.

네이밍 컨벤션의 중요성

Protocol Buffers에는 공식적으로 권장되는 네이밍 규칙이 있습니다. 이 규칙은 단순한 미학의 문제가 아니라, 코드 생성기(Code Generator)가 각 언어에 맞는 네이밍으로 자동 변환하는 기반이 됩니다.

  • 메시지(Message): PascalCase — UserProfile, OrderRequest
  • 필드(Field): snake_case — user_name, created_at
  • Enum 타입: PascalCase — OrderStatus
  • Enum 값: SCREAMING_SNAKE_CASE, 타입명을 접두어로 — ORDER_STATUS_UNSPECIFIED, ORDER_STATUS_PENDING
  • 서비스(Service): PascalCase + “Service” 접미사 — UserService
  • RPC 메서드: PascalCase 동사+명사 — GetUser, ListOrders, CreatePayment

특히 Enum 값에 타입명을 접두어로 붙이는 규칙은 자주 간과됩니다. Proto3에서 Enum 값은 같은 패키지 내에서 전역 스코프를 가지기 때문에, 접두어 없이 PENDING이라고만 쓰면 다른 Enum의 PENDING과 충돌할 수 있습니다.

파일 구성 전략

프로젝트가 커지면 .proto 파일을 어떻게 분리할지가 중요한 결정이 됩니다. 실전에서 검증된 구성 방식은 다음과 같습니다.

  • 서비스별 파일 분리: 하나의 .proto 파일에 하나의 service 정의. user_service.proto, order_service.proto 식으로 분리합니다.
  • 공통 메시지 별도 관리: 여러 서비스가 공유하는 메시지 타입은 common/ 디렉토리에 모아둡니다. common/pagination.proto, common/money.proto 같은 식입니다.
  • 디렉토리 구조는 패키지와 일치: package mycompany.user.v1이면 파일 경로도 mycompany/user/v1/user_service.proto로 맞춥니다.

이런 구성이 중요한 이유는, proto 파일 간의 import 관계가 곧 서비스 간 의존성 관계를 반영하기 때문입니다. 파일 구조가 체계적이면 의존성 방향도 자연스럽게 정리됩니다.

Protocol Buffers 필드 번호 와이어 인코딩 구조 - 버전 관리

메시지와 필드 설계 핵심 원칙

필드 번호: 한 번 쓰면 영원히 그 번호

Protocol Buffers에서 필드 번호(field number)는 와이어 포맷에서 필드를 식별하는 유일한 수단입니다. 필드 이름은 직렬화된 바이너리에 포함되지 않으며, 오직 번호만이 데이터를 식별합니다. 이것이 proto 스키마 설계에서 가장 핵심적인 개념입니다.

message User {
  string user_id = 1;      // 이 '1'이 와이어에서 이 필드를 식별
  string email = 2;        // '2'는 영원히 email에 귀속
  string name = 3;
  int32 age = 4;
  // 5~15는 자주 쓸 필드를 위해 예비로 남겨둠
  string address = 16;
  string phone = 17;
}

필드 번호 1~15는 와이어 포맷에서 1바이트만 사용하고, 16~2047은 2바이트를 사용합니다. 따라서 가장 자주 전송되는 필드에 1~15 범위의 번호를 할당하는 것이 성능상 유리합니다. 대역폭이 제한된 IoT 환경이나 초고빈도 호출 서비스에서는 이 차이가 체감될 수 있습니다.

절대로 하지 말아야 할 것은 한 번 사용한 필드 번호를 다른 필드에 재사용하는 것입니다. 예를 들어 필드 번호 4번이었던 age를 삭제한 뒤, 같은 4번을 birth_year에 할당하면 구 버전 클라이언트가 보낸 age 데이터를 신 버전 서버가 birth_year로 잘못 해석하는 치명적인 버그가 발생합니다.

스칼라 타입 선택 시 흔한 실수

proto3에서 타입 선택은 단순해 보이지만, 몇 가지 함정이 있습니다.

정수 타입: int32와 int64는 음수를 포함할 때 비효율적입니다. 음수 값이 예상되면 sint32/sint64를 사용하세요. 반대로 항상 양수인 값(ID, 카운트 등)에는 uint32/uint64가 적합합니다. 단, JavaScript 클라이언트와 통신할 때는 주의가 필요합니다. JavaScript의 Number 타입은 2^53까지만 정밀하게 표현하므로, 큰 정수를 주고받아야 한다면 string으로 보내거나 int64를 쓰되 JSON 매핑에서 문자열로 처리하는 방식을 사용합니다.

시간 표현: 절대로 int64에 Unix timestamp를 직접 넣지 마세요. 밀리초인지 초 단위인지 혼란을 야기합니다. Google에서 제공하는 google.protobuf.Timestamp를 사용하면 이런 모호함이 사라집니다.

import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";

message Event {
  string event_id = 1;
  google.protobuf.Timestamp created_at = 2;   // 시점
  google.protobuf.Duration ttl = 3;           // 기간
}

기본값 함정: Proto3에서 모든 스칼라 타입에는 기본값(zero value)이 있습니다. int32의 기본값은 0, string은 빈 문자열, bool은 false입니다. 문제는 이 기본값과 “필드가 설정되지 않음”을 구분할 수 없다는 점입니다. 사용자가 실제로 나이를 0으로 입력한 건지, 아니면 나이 필드를 아예 보내지 않은 건지 알 수 없습니다.

이 문제를 해결하려면 Wrapper 타입이나 optional 키워드를 사용합니다.

import "google/protobuf/wrappers.proto";

message UpdateUserRequest {
  string user_id = 1;
  
  // 방법 1: optional 키워드 (proto3에서 권장)
  optional string name = 2;           // has_name()으로 설정 여부 확인 가능
  optional int32 age = 3;
  
  // 방법 2: Wrapper 타입 (레거시 호환용)
  google.protobuf.StringValue nickname = 4;  // null이면 미설정
  
  // 방법 3: FieldMask 활용 (부분 업데이트에 최적)
  google.protobuf.FieldMask update_mask = 10;
}

oneof와 map의 전략적 활용

oneof는 여러 필드 중 정확히 하나만 설정될 때 사용합니다. 결제 수단처럼 상호 배타적인 선택지를 모델링하기에 완벽합니다.

message PaymentMethod {
  oneof method {
    CreditCard credit_card = 1;
    BankTransfer bank_transfer = 2;
    MobilePayment mobile_payment = 3;
  }
}

message CreditCard {
  string card_number = 1;
  string expiry = 2;
}

message BankTransfer {
  string bank_code = 1;
  string account_number = 2;
}

message MobilePayment {
  string provider = 1;   // "kakaopay", "naverpay" 등
  string token = 2;
}

map 타입은 키-값 쌍이 필요할 때 유용하지만, 사용 시 주의점이 있습니다. map의 키는 정수 또는 문자열만 가능하고, 값에는 다른 map을 중첩할 수 없습니다. 또한 map 필드의 순서는 보장되지 않으므로, 순서가 중요한 데이터에는 repeated 메시지를 사용해야 합니다.

message UserPreferences {
  // 좋은 사용: 동적 키-값 메타데이터
  map<string, string> metadata = 1;
  
  // 피해야 할 사용: 순서가 중요한 경우
  // map<int32, Step> steps = 2;  // ← 순서 보장 안 됨!
  
  // 대신 이렇게:
  repeated Step steps = 2;  // ← 순서 보장
}
gRPC 스키마 호환성 규칙 안전한 변경 vs 위험한 변경

하위 호환성을 지키는 스키마 진화 전략

호환성이 깨지는 변경 vs 안전한 변경

gRPC 서비스를 운영하다 보면 스키마를 변경해야 하는 상황이 반드시 옵니다. 이때 가장 중요한 원칙은 기존 클라이언트가 새 서버와, 새 클라이언트가 기존 서버와 문제없이 통신할 수 있어야 한다는 것입니다. 이를 각각 하위 호환성(Backward Compatibility)과 상위 호환성(Forward Compatibility)이라고 합니다.

안전한 변경(하위 호환성 유지):

  • 새 필드 추가: 새로운 필드 번호로 추가. 구 클라이언트는 이 필드를 무시합니다.
  • 새 Enum 값 추가: 기존 값은 유지한 채 새 값을 추가합니다.
  • 새 RPC 메서드 추가: 기존 메서드에 영향 없이 서비스에 새 메서드를 추가합니다.
  • 새 서비스 추가: 같은 서버에 새 service를 등록합니다.
  • 필드를 oneof로 이동: 단, 해당 oneof에 기존 필드 하나만 들어가 있어야 합니다.

호환성이 깨지는 변경(절대 하지 말 것):

  • 필드 번호 변경: 기존 데이터가 완전히 다른 필드로 해석됩니다.
  • 필드 타입 변경: int32를 string으로 바꾸면 디코딩 실패가 발생합니다.
  • 필드 번호 재사용: 삭제된 필드의 번호를 새 필드에 할당하면 데이터 오염이 일어납니다.
  • required 필드 추가(proto2 한정): 구 클라이언트가 이 필드를 모르므로 검증 실패합니다.
  • 기존 RPC 시그니처 변경: 요청/응답 메시지 타입을 바꾸면 통신이 끊깁니다.
  • Enum 기본값(0번) 변경: 0번 값의 의미를 바꾸면 미설정 필드의 해석이 달라집니다.

reserved 키워드로 실수 방지하기

필드를 삭제할 때는 반드시 reserved를 사용해 해당 번호와 이름을 예약해야 합니다. 이는 미래의 개발자(또는 미래의 나)가 실수로 같은 번호나 이름을 재사용하는 것을 컴파일 타임에 차단해줍니다.

message User {
  reserved 4, 8, 12 to 15;  // 이 번호들은 영원히 사용 불가
  reserved "age", "legacy_field";  // 이 이름들도 재사용 불가
  
  string user_id = 1;
  string email = 2;
  string name = 3;
  // 4번(age)은 삭제됨 — reserved로 보호
  string phone = 5;
  // ...
}

reserved를 쓰지 않으면 어떤 일이 벌어지는지 구체적으로 살펴보겠습니다. 필드 번호 4가 원래 int32 age였다고 가정합니다. 누군가 이 필드를 삭제한 뒤, 몇 달 뒤 다른 개발자가 4번에 string department를 할당합니다. 이때 아직 업데이트되지 않은 구 버전 클라이언트가 age=25를 보내면, 신 버전 서버는 이 데이터를 department 필드의 string으로 디코딩하려고 시도합니다. 타입이 다르므로 예측 불가능한 결과가 나옵니다. reserved는 이 시나리오를 원천 차단합니다.

Enum 진화 시 반드시 지켜야 할 규칙

Enum은 스키마 진화에서 특히 까다로운 영역입니다. 핵심 규칙은 두 가지입니다.

첫째, 0번 값은 항상 UNSPECIFIED로 지정합니다. Proto3에서 Enum의 기본값은 항상 0번입니다. 이 값에 실제 비즈니스 의미를 부여하면, 클라이언트가 해당 필드를 설정하지 않았을 때 의도치 않게 특정 상태가 되어버립니다.

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;  // 반드시 0번은 "알 수 없음"
  ORDER_STATUS_PENDING = 1;
  ORDER_STATUS_CONFIRMED = 2;
  ORDER_STATUS_SHIPPED = 3;
  ORDER_STATUS_DELIVERED = 4;
  ORDER_STATUS_CANCELLED = 5;
  // 새 상태 추가는 뒤에 이어서
  ORDER_STATUS_REFUNDED = 6;
}

둘째, 기존 Enum 값의 번호나 의미를 절대 변경하지 않습니다. 새 값은 항상 뒤에 추가하고, 삭제할 값은 reserved로 처리합니다. 구 버전 클라이언트가 알지 못하는 새 Enum 값을 받으면 해당 필드를 기본값(0)으로 처리하므로, 서버 측에서는 UNSPECIFIED를 적절히 핸들링하는 로직이 있어야 합니다.

Proto API 버전 관리 전략: 패키지 기반 vs 메서드 기반

서비스가 성장하면서 하위 호환이 불가능한 대규모 변경이 필요한 시점이 옵니다. 이때 두 가지 접근법이 있습니다.

패키지 기반 버전 관리는 Google이 공식 권장하는 방식입니다. 완전히 새로운 패키지(v2)를 만들고, 구 버전(v1)과 신 버전(v2)을 동시에 운영합니다.

// mycompany/user/v1/user_service.proto — 기존 버전 유지
package mycompany.user.v1;

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

// mycompany/user/v2/user_service.proto — 새 버전
package mycompany.user.v2;

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);  // 완전히 새로운 메시지 구조
  rpc GetUserProfile(GetUserProfileRequest) returns (UserProfile);  // 새 RPC
}

이 방식의 장점은 명확한 분리입니다. v1과 v2의 메시지 구조가 완전히 독립적이므로, v2를 자유롭게 설계할 수 있습니다. 단점은 두 버전의 비즈니스 로직을 동시에 유지보수해야 한다는 것입니다. 실제로는 내부적으로 공통 도메인 모델을 두고, v1과 v2의 proto 메시지를 각각 도메인 모델로 변환하는 어댑터 계층을 만드는 것이 일반적입니다.

메서드 기반 버전 관리는 소규모 변경에 적합합니다. 같은 서비스에 새 메서드를 추가하고, 구 메서드를 deprecated로 표시합니다.

service UserService {
  // 구 메서드: deprecated 주석으로 표시
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {
    option deprecated = true;
  };
  
  // 신 메서드: 새로운 요청/응답 메시지 사용
  rpc GetUserV2(GetUserV2Request) returns (GetUserV2Response);
}

일반적인 권장 사항으로는, 소규모 점진적 변경에는 메서드 기반, 전면 재설계가 필요하면 패키지 기반을 사용합니다. 두 가지를 혼합할 수도 있으니 프로젝트 상황에 맞게 판단하면 됩니다.

gRPC 실전 설계 패턴 래퍼 페이지네이션 FieldMask

실전 설계 패턴과 안티패턴

Request/Response 래퍼 패턴

gRPC에서 가장 널리 사용되는 설계 패턴은 모든 RPC 메서드에 전용 Request/Response 메시지를 만드는 것입니다. 이것이 권장되는 이유가 명확합니다.

// ❌ 안티패턴: 원시 타입을 직접 사용
service UserService {
  rpc GetUser(google.protobuf.StringValue) returns (User);
}

// ✅ 권장: 전용 Request/Response 메시지
service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
  rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
  rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
}

message GetUserRequest {
  string user_id = 1;
}

message GetUserResponse {
  User user = 1;
}

전용 메시지를 사용하면 나중에 요청에 새 필드를 추가하거나 응답 구조를 확장할 때 하위 호환성을 깨지 않고 자유롭게 할 수 있습니다. 예를 들어 GetUserRequest에 include_deleted = 2 같은 옵션 필드를 추가해도 기존 클라이언트는 전혀 영향받지 않습니다.

페이지네이션 패턴

목록 조회 API에서 페이지네이션은 거의 필수입니다. gRPC에서 권장되는 패턴은 커서 기반(cursor-based) 페이지네이션입니다.

message ListOrdersRequest {
  int32 page_size = 1;            // 페이지 크기 (기본값: 서버 결정)
  string page_token = 2;          // 이전 응답의 next_page_token
  string filter = 3;              // 선택적 필터링 (예: "status=SHIPPED")
  string order_by = 4;            // 정렬 (예: "created_at desc")
}

message ListOrdersResponse {
  repeated Order orders = 1;
  string next_page_token = 2;     // 다음 페이지 토큰 (빈 문자열이면 마지막 페이지)
  int32 total_size = 3;           // 전체 개수 (비용이 크면 생략 가능)
}

이 패턴의 핵심은 page_token이 불투명(opaque) 문자열이라는 점입니다. 클라이언트는 토큰의 내부 구조를 알 필요 없이, 서버가 내려준 값을 그대로 다음 요청에 넘기면 됩니다. 서버 측에서는 이 토큰에 커서 정보를 인코딩(예: Base64로 감싼 JSON)하여 구현합니다. 오프셋 기반 페이지네이션보다 대규모 데이터셋에서 성능이 훨씬 안정적입니다.

부분 업데이트와 FieldMask 패턴

REST에서 PATCH 요청에 해당하는 부분 업데이트는 gRPC에서 google.protobuf.FieldMask를 사용합니다. 이 패턴을 쓰면 클라이언트가 정확히 어떤 필드만 변경하고 싶은지 명시할 수 있습니다.

import "google/protobuf/field_mask.proto";

message UpdateUserRequest {
  User user = 1;  // 변경할 값이 담긴 User 객체
  google.protobuf.FieldMask update_mask = 2;  // 어떤 필드를 업데이트할지
}

// 사용 예:
// update_mask: { paths: ["name", "email"] }
// → user 객체의 name과 email만 업데이트, 나머지 필드는 그대로 유지

FieldMask 없이 부분 업데이트를 구현하면, 기본값(빈 문자열, 0)이 전달된 것이 “이 값으로 변경해주세요”인지 “이 필드는 건드리지 마세요”인지 구분할 수 없는 문제가 생깁니다. FieldMask는 이 모호함을 완전히 해소합니다.

에러 상세 정보: google.rpc.Status와 에러 디테일

gRPC의 기본 에러 모델은 상태 코드(Status Code)와 메시지 문자열로 구성되는데, 실제 서비스에서는 이것만으로 충분하지 않을 때가 많습니다. 클라이언트가 에러 원인을 프로그래밍적으로 구분해 적절히 대응해야 할 때, Google의 에러 상세(Error Details) 모델을 활용합니다.

import "google/rpc/status.proto";
import "google/rpc/error_details.proto";

// 서버 측 에러 응답 구성 예시 (Go 기준 의사 코드):
// 입력 검증 실패 시:
//   status = INVALID_ARGUMENT
//   details = [BadRequest {
//     field_violations: [
//       { field: "email", description: "이메일 형식이 올바르지 않습니다" },
//       { field: "age", description: "나이는 0보다 커야 합니다" }
//     ]
//   }]

자주 사용하는 에러 디테일 타입으로는 BadRequest(입력 검증 오류), PreconditionFailure(사전 조건 미충족), RetryInfo(재시도 가능 시 대기 시간), ResourceInfo(어떤 리소스가 문제인지) 등이 있습니다. 이 패턴을 적용하면 클라이언트가 상태 코드만 보고도 적절한 에러 핸들링을 할 수 있어 사용자 경험이 크게 향상됩니다.

흔한 안티패턴 5가지

실전에서 자주 목격하는 proto 설계 안티패턴을 정리했습니다. 이 실수들은 초기에는 문제가 없어 보이지만, 서비스가 성장하면서 심각한 기술 부채가 됩니다.

1. 거대 메시지(God Message): 하나의 메시지에 수십 개의 필드를 넣는 것. 특히 응답 메시지가 비대해지면 불필요한 데이터까지 항상 전송됩니다. 관련 필드를 하위 메시지로 분리하고, 필요한 경우 FieldMask로 반환 필드를 제한하세요.

2. 범용 래퍼(Generic Wrapper): message GenericRequest { string method = 1; bytes payload = 2; } 같은 구조. gRPC의 강타입 이점을 완전히 포기하는 것이며, 코드 생성의 혜택도 받지 못합니다.

3. Enum 대신 문자열 사용: 상태값이나 타입 구분을 string으로 처리하면 오타로 인한 버그, 검증 누락, 자동완성 불가 등의 문제가 생깁니다. 유한한 선택지는 반드시 Enum으로 정의하세요.

4. 중첩 깊이 과다: 3~4단계 이상 깊이 중첩된 메시지는 코드 생성 결과물이 복잡해지고 사용성이 떨어집니다. 2단계까지를 기본으로, 최대 3단계를 넘기지 않는 것이 좋습니다.

5. Request/Response 재사용: CreateUserRequest를 UpdateUser에도 재사용하는 것. 두 작업의 필수/선택 필드가 다른 경우가 대부분이므로, 각 RPC 메서드에 맞는 전용 메시지를 만드세요.

대규모 프로젝트에서의 Proto 관리 전략

Proto 저장소 전략: 모노레포 vs 전용 저장소

여러 서비스가 proto 파일을 공유해야 할 때, 파일 관리 방식을 결정하는 것은 팀 생산성에 직접적인 영향을 미칩니다.

전용 Proto 저장소(Proto Repository): 모든 .proto 파일을 하나의 별도 저장소에 모으고, 각 서비스가 이를 의존성으로 가져가는 방식입니다. Google, Uber, Lyft 등 대규모 조직에서 채택하는 패턴입니다.

  • 장점: proto 변경의 리뷰와 승인이 중앙화됨, 호환성 검사를 CI에서 강제 가능, 코드 생성 파이프라인을 한 곳에서 관리
  • 단점: 서비스 개발과 proto 변경의 PR이 분리되어 개발 속도가 느려질 수 있음

서비스 내 Proto(Service-local Proto): 각 서비스 저장소 안에 해당 서비스의 .proto 파일을 두는 방식입니다. 소규모 팀이나 초기 프로젝트에 적합합니다.

  • 장점: 서비스 코드와 proto가 함께 버전 관리됨, 변경-빌드-테스트 사이클이 빠름
  • 단점: 서비스 간 proto 공유 시 복사-붙여넣기가 발생하기 쉬움

실용적인 권장 사항은 이렇습니다. 서비스가 3개 이하이고 팀이 소규모이면 서비스 내 관리로 시작하고, 서비스가 늘어나면 전용 저장소로 전환하세요. 처음부터 전용 저장소를 구축하면 오버엔지니어링이 될 수 있습니다.

buf: 현대적인 Proto 관리 도구

최근 gRPC 생태계에서 빠르게 채택되고 있는 buf 도구는 proto 파일 관리의 거의 모든 고민을 해결해줍니다. protoc(기존 Protocol Buffers 컴파일러)의 불편한 점을 대폭 개선한 도구입니다.

buf lint: proto 파일의 스타일과 설계 규칙을 자동으로 검사합니다. 네이밍 컨벤션 위반, Enum 0번 값 누락, 서비스 이름 접미사 등을 잡아냅니다.

# buf.yaml
version: v2
lint:
  use:
    - STANDARD           # Google 스타일 가이드 기반 규칙
    - COMMENTS           # 공개 요소에 주석 강제
  except:
    - PACKAGE_VERSION_SUFFIX  # 필요에 따라 예외 설정

buf breaking: 가장 강력한 기능으로, 이전 버전 대비 하위 호환성이 깨지는 변경을 자동으로 감지합니다. CI 파이프라인에 통합하면 실수로 호환성을 깨는 PR을 머지 전에 잡을 수 있습니다.

# CI에서 실행:
buf breaking --against ".git#branch=main"

# 출력 예시:
# user/v1/user_service.proto:15:3: Field "4" on message "User" changed type 
#   from "int32" to "string".
# user/v1/user_service.proto:22:1: Previously present field "5" with name 
#   "phone" on message "User" was deleted without a reserve.

buf generate: 코드 생성을 선언적 설정 파일로 관리합니다. 여러 언어의 코드를 한 번에 생성할 수 있고, 원격 플러그인을 사용하면 로컬에 protoc 플러그인을 설치할 필요도 없습니다.

# buf.gen.yaml
version: v2
plugins:
  - remote: buf.build/grpc/python
    out: gen/python
  - remote: buf.build/grpc/go
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/grpc/java
    out: gen/java

buf를 도입하면 proto 관리의 생산성과 안정성이 눈에 띄게 향상됩니다. 새 프로젝트라면 처음부터, 기존 프로젝트라면 점진적으로 도입하는 것을 강력히 권장합니다.

CI/CD에서의 호환성 게이트

Proto 스키마 변경이 운영 사고로 이어지는 것을 방지하려면, CI 파이프라인에 호환성 검사를 필수 게이트로 넣어야 합니다. 구체적인 파이프라인 구성은 다음과 같습니다.

단계 1 — Lint: buf lint로 스타일 규칙 검사. 네이밍 컨벤션, 필수 주석, Enum 기본값 등을 확인합니다.

단계 2 — Breaking Change Detection: buf breaking --against origin/main으로 main 브랜치 대비 호환성 검사. 깨지는 변경이 있으면 CI 실패.

단계 3 — Code Generation: buf generate로 모든 타겟 언어의 코드를 생성하고, 생성된 코드가 컴파일되는지 확인합니다.

단계 4 — 생성된 코드 커밋 검증: 생성된 코드가 저장소에 커밋되는 방식이라면, 로컬에서 생성한 결과와 CI에서 생성한 결과가 동일한지 diff 검사를 합니다. 이를 통해 “코드는 생성했는데 커밋하지 않음” 같은 실수를 방지합니다.

이 네 단계를 모두 통과해야 PR을 머지할 수 있도록 설정하면, 스키마 변경으로 인한 운영 사고를 사전에 예방할 수 있습니다. 처음에는 번거롭게 느껴질 수 있지만, 한 번의 장애가 가져오는 비용을 생각하면 충분히 가치 있는 투자입니다.

Proto 스키마 CI 파이프라인 호환성 검사 흐름도

마무리: Proto 스키마 설계 체크리스트

지금까지 gRPC의 .proto 스키마를 올바르게 설계하고, 서비스를 중단 없이 진화시키는 전략을 살펴봤습니다. 마지막으로 실무에서 바로 활용할 수 있는 체크리스트를 정리합니다.

  • 패키지에 버전(v1) 포함: 미래의 대규모 변경을 위한 보험입니다.
  • 모든 RPC에 전용 Request/Response 메시지 사용: 원시 타입이나 공유 메시지를 직접 쓰지 마세요.
  • 필드 번호 1~15는 고빈도 필드에 할당: 와이어 크기 최적화입니다.
  • 삭제한 필드는 반드시 reserved 처리: 번호와 이름 모두 예약하세요.
  • Enum 0번은 항상 UNSPECIFIED: 기본값에 비즈니스 의미를 부여하지 마세요.
  • 기본값과 미설정을 구분해야 하면 optional 사용: 또는 Wrapper 타입을 활용하세요.
  • 시간은 google.protobuf.Timestamp: Unix timestamp 정수를 직접 쓰지 마세요.
  • buf breaking을 CI에 통합: 호환성 파괴를 머지 전에 감지하세요.

Proto 스키마는 gRPC 서비스의 기초 체력과 같습니다. 처음에 시간을 들여 탄탄하게 설계하면, 이후 서비스가 성장하고 변화할 때 그 투자가 몇 배로 돌아옵니다. 당장 새 .proto 파일을 만들어야 한다면, 이 체크리스트를 옆에 두고 하나씩 확인하면서 시작해보세요. 미래의 동료 개발자(또는 미래의 자신)가 분명 감사할 것입니다.

Photo by Youn Seung Jin on Pexels

참고 자료

  • Protocol Buffers Language Guide (proto3) — Google 공식 문서 — Proto3 문법, 필드 규칙, 스키마 진화 원칙을 다루는 공식 레퍼런스
  • Google Cloud API Design Guide — Versioning — Google이 권장하는 API 버전 관리 전략과 하위 호환성 유지 원칙

Tags:

API버전관리bufgRPCProtocol Buffers스키마설계
작성자

AICosmus

Follow Me
다른 기사
LLM 구조와 KV 캐시 개념 일러스트
Previous

Transformer 구조를 알면 KV 캐시는 당연해진다 — LLM 추론 속도의 비밀

터미널에서 AI 코딩 에이전트를 사용하는 모습
Next

[opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 1/12화: opencode란? 벤더 락인 없는 터미널 AI 코딩 에이전트

댓글 1개
  1. gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus 댓글:
    2026년 07월 19일, 8:06 오전

    […] gRPC Proto 스키마 설계와 버전 관리 실전 가이드 […]

    답글

답글 남기기 응답 취소

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

최신 글

  • [opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 1/12화: opencode 에이전트 아키텍처 완전 해부 — 2026 Primary·Subagent 5계층 구조
  • Kotlin 코루틴 핵심 5가지 개념과 실전 활용법
  • [opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 12/12화: opencode 로컬 모델 완전 가이드 2026 — Ollama·에어갭·규제 환경 도입 체크리스트
  • LLM 파인튜닝 실전 5단계 — 2026 LoRA 완벽 가이드
  • 금융 앱 생체인증 작동 원리, 지문·얼굴 보안 5단계 완전 해부

최신 댓글

  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전의 Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리 - AICosmus
  2. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  3. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  4. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  5. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  • About
  • Contact
  • Disclaimer
  • Privacy - Policy
  • Terms of Service
Copyright 2026 — AICosmus. All rights reserved. Blogsy WordPress Theme