나만의 작은 도서관

[TIL][C++] 250516 MMO 서버 개발 19일차: Protobuf keyword 재정리: Package, Optional, Oneof, Reserved 본문

Today I Learn

[TIL][C++] 250516 MMO 서버 개발 19일차: Protobuf keyword 재정리: Package, Optional, Oneof, Reserved

pledge24 2025. 5. 16. 23:21
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

 

Protobuf keyword 재정리: Package, Optional, Oneof

package

  • package는 이름 충돌을 예방하기 위한 키워드이다.
syntax = "proto3"

package Protocol;

message S_TEST { ... }
  • 위와 같이 .proto 파일을 작성하면 protoc를 거쳐 나온 xxx.pb.cc 와 xxx.pb.h에서 package이름으로 namespace가 만들어지고 message들은 해당 namespace 안에 정의되어 있게 된다(C++기준)
  • 프로젝트 내에선 아래와 같이 사용할 수 있다.
// package Protocol에 의해 namespace가 Protocol인 S_TEST 클래스 존재
Protocol::S_TEST pkt;

optional

  • optional 키워드는 필드 앞에 붙일 수 있으며, optional 필드는 데이터를 선택적으로 추가할 수 있는, 즉, 데이터가 없을 수 있다.
  • proto3에선 모든 필드는 선택적(optional)이기 때문에 optional을 붙이지 않아도 데이터가 없을 수 있다. 그럼 optional 키워드를 따로 붙일 필요가 없지 않으냐 할 수 있는데, optional 키워드를 붙였을 때 해당 필드에 추가적인 함수가 생긴다는 차이가 있다.
message S_TEST
{
	int32 num1 = 1;
	optional int32 num2 = 2;	
}
  • 위 메시지에서 num1과 num2는 int32 타입의 정수이다. num2의 경우 num1과 달리 optional이 붙었는데, 위에서도 말했지만 모든 필드는 선택적이기 때문에 num1과 num2 둘 다 데이터가 들어있을 거란 보장은 없다.
Protocol::S_TEST pkt;
pkt.has_num1(); // 안 됨
pkt.has_num2(); // 됨
  • 이 둘의 차이점은 has_이름() 함수의 존재 차이이다. protobuf에서 string, bytes, bool, 숫자 타입, message 타입, enum 타입들에서 기본값을 가진다. 따라서, 데이터가 들어가지 않았다면 기본값으로 초기화된다.
  • 문제는 기본값과 설정한 값이 같으면(ex. int32 타입의 필드를 0으로 설정) 값만으로는 설정했는지 알 수 없다. 이를 구분하기 위해 has_이름() 함수가 존재하며, optional 키워드를 붙인 경우 해당 함수가 추가된다.
Protocol::S_TEST pkt;
pkt.has_num2(); // false
pkt.set_num2(0);
pkt.has_num2(); // true
  • 몇몇 타입들은 optional 키워드를 붙이지 않아도 자동 적용되어 has_이름() 함수를 가진다. 해당 타입들은 아래와 같다.
    • 메시지 타입
    • Oneof 내부의 모든 필드
    • map 타입
    • repeated 타입
  • 반대로 optional 키워드를 붙여줘야 하는 타입은 아래와 같다.
    • 스칼라 타입
      • int32
      • float
      • bool
      • string
      • 기타 등등…

Oneof

  • Oneof는 둘러싼 여러 필드 중에 하나만 설정되도록 하는 기능이며, 각 필드는 메모리를 공유한다.
message Result {
  oneof result_type {
    string text_result = 1;
    int32 number_result = 2;
    bool boolean_result = 3;
  }
}
  • 위 message에서 result_type에 존재하는 세 필드 중 text_result를 설정한 다음, number_result를 설정했다면 number_result 필드만 값을 가지고, text_result는 값이 존재하지 않는다. 왜냐하면 OneOf는 하나의 필드만 데이터를 가지므로, 마지막에 설정한 필드만이 데이터를 가지기 때문이다.

 

Reserved

  • reserved 키워드는 필드 번호나 필드 이름을 예약하여 해당 메시지 내에서 사용하지 못하도록 막는 것이다.
// password_hash 삭제전
message UserProfile {
  int32 id = 1;
  string name = 2;
  string email = 3;
  string phone_number = 4;
  string password_hash = 5;
}

// password_hash 삭제후 올바른 접근
message UserProfile {
  int32 id = 1;
  string name = 2;
  string email = 3;
  string phone_number = 4;
  reserved 5; // password_hash 필드 번호 예약
  reserved "password_hash"; // 필드 이름도 예약
  
  string profile_picture_url = 6; // 새 필드는 다른 번호 사용
}

필드 번호를 reserved로 막는 이유

  • 버전 호환성 문제를 방지하기 위함이다. reserved를 사용하지 않고 새 버전에서 같은 번호로 다른 의미의 필드를 만들면 데이터 해석이 꼬인다. ⇒ 메모리 오염도 추가 발생
    • 어찌 보면 reinterpret_cast를 사용했을 때 발생하는 문제와 비슷하다 할 수 있다.
  • 예시는 아래와 같다.
// 문제가 발생하는 예제

// 구버전 .proto파일
message Player {
  int32 rank = 1;
}

// 신버전 .proto파일
message Player {
  string name = 1; // rank가 쓰던 번호를 name이 재사용함 (❌ 위험)
}

클라가 구버전을 이용해 rank = 97을 보냄
서버가 신버전을 이용해 97을 name으로 해석 -> "a"
=> 의도치 않은 작동 발생(메모리 오염)
reserved를 이용한 올바른 예제

// 구버전 .proto파일
message Player {
  int32 rank = 1;
}

// 신버전 .proto파일
message Player {
	reserved 1;
	reserved "rank"
  string name = 2; // rank가 쓰던 번호를 name이 재사용함 (❌ 위험)
}

클라가 구버전을 이용해 rank = 97을 보냄
서버가 신버전을 이용했을때 예약된 필드 번호 1로 데이터가 온 것을 감지
=> 오류 발생(버그 예방)