Proto 초기 설계와 C 서버 코드 자동화#
마이크로시스템과 상위 애플리케이션 간의 원격 프로시저 호출(RPC)을 지원하기 위한 경량 프레임워크 WindRPC 개발 이야기의 두 번째 편입니다.
들어가며#
이전 글에서는 전체적인 프로젝트의 이해를 돕기 위해 디스크립터(user_spec.yml) 명세 형식을 먼저 소개했습니다. 하지만 사실 개발의 극초기 단계에서는 디스크립터보다 표준 Protocol Buffers (.proto) 파일의 메시지 구조를 정의하는 일부터 시작했습니다.
gRPC를 사용할 수 없는 마이크로컨트롤러(MCU) 환경에서 단 하나의 Protobuf 바이너리 통화로 어떻게 다양한 서비스와 함수 프로시저를 구별하고 분기할 것인가에 대한 고민이 가장 먼저 해결되어야 했기 때문입니다.
초기 Proto 메시지 설계#
초기에 고안했던 핵심 메커니즘은 ClientMessage와 ServerMessage라는 최상위 메시지를 크게 한 쌍으로 나누는 것이었습니다.
요청을 보내는 주체와 응답을 주는 주체에 따라 전송 방향을 먼저 결정하고, 메시지 내부에는 oneof 구문을 중첩으로 사용하여 서비스(Service)와 프로시저(Procedure)를 계층적으로 분류했습니다.
- 전송 주체 구분: 최상위에 ClientMessage(클라이언트 요청용)와 ServerMessage(서버 응답 및 알림용)를 별도로 정의
- 계층적 분기: 메시지 내부의 oneof 구문 하위에 동일한 서비스 명칭과 프로시저 명칭을 배치하고, 파라미터(입력 값)와 리턴 값(결과 값)을 속성으로 매핑
이 구조는 다이어그램이나 논리적인 개념상으로는 매우 체계적이고 깔끔해 보였습니다. 하나의 Protobuf 메시지 틀 안에서 모든 서비스와 프로시저를 깔끔하게 계층화할 수 있다고 생각했기 때문입니다.
[ 초기 중첩 oneof 메시지 계층 구조 ]
1. 클라이언트 요청 패킷 (ClientMessage)
ClientMessage (최상위)
└── [oneof payload] ──► Request
├── bytes request_id
└── [oneof service]
├── common.Request
├── led.Request ──► [oneof command] ──► display_pixels (PixelData)
└── power.Request
2. 서버 응답 및 알림 패킷 (ServerMessage)
ServerMessage (최상위)
└── [oneof payload]
├── Response (요청에 대한 응답)
│ ├── bytes request_id
│ └── [oneof service] ──► common.Response / power.Response
└── Notification (비동기 이벤트 푸시)
└── [oneof service] ──► power.Notification
// 당시 정의했던 초기 코어 proto 메시지 예시 (windrpc.proto)
syntax = "proto3";
package hlt.windrpc.core;
import "hlt/windrpc/service/common.proto";
import "hlt/windrpc/service/led.proto";
import "hlt/windrpc/service/power.proto";
message ClientMessage {
oneof payload { Request request = 1; }
}
message Request {
bytes request_id = 1;
oneof service {
hlt.windrpc.service.common.Request common = 6;
hlt.windrpc.service.led.Request led = 7;
hlt.windrpc.service.power.Request power = 8;
}
}
message ServerMessage {
oneof payload {
Response response = 1;
Notification notification = 2;
}
}
message Response {
bytes request_id = 1;
oneof service {
hlt.windrpc.service.common.Response common = 6;
hlt.windrpc.service.power.Response power = 8;
}
}
// 개별 서비스 정의 예시 (led.proto)
package hlt.windrpc.service.led;
message PixelData {
repeated fixed32 colors = 1;
}
message Request {
oneof command {
PixelData display_pixels = 1;
}
}
NanoPB의 복잡성과 콜백의 늪#
하지만 개념적으로 그럴듯 해보였던 설계는 MCU C 언어 환경에서 NanoPB 라이브러리를 통해 C 구조체로 변환되면서 큰 문제에 부딪히게 되었습니다.
C 구조체 중첩과 비대해진 타입 명칭#
Protobuf 패키지명과 서비스명, 프로시저명이 중첩되다 보니, NanoPB가 생성해 낸 C 구조체 타입과 oneof 구분 태그(Tag) 이름이 극도로 길어졌습니다.
예를 들어 특정 프로시저를 호출하기 위한 태그나 구조체에 접근하려면 다음과 같이 길고 복잡한 C 멤버 변수 경로를 거쳐야 했습니다.
// 초기 구조체 접근 및 태그 설정 예시
hlt_windrpc_core_ClientMessage msg = hlt_windrpc_core_ClientMessage_init_zero;
msg.which_payload = hlt_windrpc_core_ClientMessage_request_tag;
hlt_windrpc_core_Request *req = &msg.payload.request;
req->which_service = hlt_windrpc_core_Request_led_tag;
req->service.led.which_command = hlt_windrpc_service_led_Request_display_pixels_tag;
단순히 메시지의 종류를 지정하는 것만으로도 C 코드가 너무 길어지고 가독성이 급격히 떨어졌습니다.
NanoPB 디코딩 콜백(Callback) 작성의 복잡성#
더 큰 문제는 수신받은 패킷을 디코딩하는 서버 측 C 코드를 작성할 때 발생했습니다.
NanoPB는 가변 길이 문자열(string)이나 가변 배열(repeated) 필드를 처리하기 위해 pb_callback_t라는 콜백 함수 방식을 사용합니다. 중첩된 oneof 메시지 구조를 디코딩하기 위해서는 각 단계마다 수많은 콜백 함수를 직접 구현해야 했습니다.
- 필드 단위 디코딩 콜백 함수 작성 (decode_string_callback, decode_request_id 등)
- 각 oneof 태그 분기문마다 수동으로 케이스 처리 및 바이트 스트림 읽기
- 메모리 오버플로우 방지를 위한 버퍼 및 컨텍스트 관리
이 모든 과정을 C 서버 코드에서 사람이 손으로 일일이 작성하다 보니 코드의 양이 폭발적으로 증가했습니다. C 언어 특성상 디코딩 실수가 잦았고, 중첩 구조체 조작 중 오탈자나 태그 매칭 오류로 인한 빌드 에러 및 런타임 수신 오류가 비일비재하게 발생했습니다.
C 서버 코드 자동화의 결단#
원래는 디스크립터 파싱 오류나 복잡한 검증 규칙보다는 일단 .proto 파일이 잘 만들어지고, 이를 통해 C 코드와 C# 클라이언트 간 패킷이 주고받아지는지 검증하는 것이 목적이었습니다.
그러나 서버 측 패킷 해석 및 분기 로직을 C 언어로 수동 작성하는 과정에서의 고통을 겪고 나니, 사람이 이 디코딩 코드와 핸들러 분기 로직을 직접 만드는 것은 개발 생산성과 안정성 측면에서 도저히 불가능하다는 결론에 도달했습니다.
결국 스펙 디스크립터(user_spec.yml)에서 표준 .proto 파일을 생성하는 것에 그치지 않고, MCU에서 동작할 C 서버 프레임워크 엔진 코드와 핸들러 콜백 스텁(Stub)까지 통째로 자동 생성하기로 결단했습니다.
개발자는 NanoPB의 내부 콜백이나 태그 분기 메커니즘을 전혀 알 필요 없이, 자동 생성된 C 함수 핸들러 내부에서 비즈니스 로직(예: LED 제어, 센서 값 반환)만 채워 넣으면 되는 구조를 지향하게 되었습니다.
정리하며#
처음에는 표준 .proto의 중첩 oneof 구조로 계층화를 시도했지만, 임베디드 C/NanoPB 환경에서는 복잡한 구조체와 디코딩 콜백의 늪에 빠지는 시행착오를 겪었습니다. 이후에 서버 코드를 생성을 고려하게 되었고, 이에 대한 생성기에 대한 작업 내용을 정리 해보겠습니다.
