프로젝트의 시작#
마이크로시스템과 상위 애플리케이션 간의 원격 프로시저 호출(RPC)을 지원하기 위한 경량 프레임워크 WindRPC (Micro Interconnect & Network Dispatch)의 개발 이야기입니다.
발단#
빛나리(Bitnari) 프로젝트(모니더 후면 앰비언트 라이트)를 진행하면서 다양한 통신 프로토콜 방식을 검토하던 중이었습니다.
하드웨어(Arm Cortex-M / Zephyr RTOS)와 PC 애플리케이션(C#, JS) 간 통신을 구성함에 있어 Protocol Buffers (Protobuf)를 꼭 적용해보고 싶었습니다.
Protocol Buffer의 장점#
Protocol Buffer는 이종 환경 통신에서 다음과 같은 뛰어난 장점들을 제공합니다.
- 언어 및 플랫폼 독립성: C/C++ 펌웨어, C# 데스크톱 앱, JS/TS 웹 환경에 구애받지 않고 일관된 데이터 구조를 공유할 수 있습니다.
- 바이너리 직렬화 및 고성능: JSON/XML처럼 키(Key) 문자열을 매번 전송하지 않고 필드 태그 기반의 효율적인 바이너리로 인코딩되어, 전송 대역폭 절감과 빠른 직렬화/역직렬화 속도를 자랑합니다.
- 타입 안전성과 하위 호환성: 필드 번호 기반 구조를 통해 필드 추가/수정 시 발생할 수 있는 데이터 꼬임이나 타입 오류를 방지하고 안정적인 버전 호환성을 보장합니다.
하지만 막상 임베디드 통신 프로토콜에 직접 도입하려고 하니 현실적인 벽에 부딪히게 되었습니다.
우선 표준 gRPC는 HTTP/2 기반의 무거운 인프라를 필요로 하여, RAM과 Flash 메모리가 극도로 제한적인 마이크로컨트롤러(MCU) 및 NanoPB 환경에서는 구동할 수 없었습니다.
결국 초기 타협안으로 Protobuf를 단순히 데이터 구조체(메시지) 전달용으로만 쓰는 것을 우선 고려하였습니다.
초기 개념 구상#
그럼에도 불구하고 데이터 메시지만 단순 전달하기보다 RPC 형태라도 사용할 방법은 없을까? 라는 고민과 욕심 끝에, 단순한 RPC를 직접 구현하기로 마음을 먹고 BLE (Bluetooth Low Energy) GATT 아키텍처의 구조에서 힌트를 얻고자 했습니다.
BLE는 프로파일 하위에 서비스(Service)와 특성(Characteristic)을 두고, 특성에 속성/권한(Read, Write, Write with No Response, Notify, Indicate 등)을 부여하여 동작합니다.
초기에는 BLE의 이 개념을 그대로 가져와 프로토콜을 구현해 보려 했으나, 실제 분석해 보니 BLE의 GATT 모델은 RPC 형태의 양방향 통신에는 어울리지 않았습니다.
- 상태 중심(Attribute-Centric)의 한계: BLE 특성은 디바이스의 ‘상태(State)나 변수’를 읽고 쓰거나 단방향으로 이벤트를 전달하는(Push) 데 특화되어 있습니다.
- 트랜잭션/시퀀스 매핑의 어려움: 특정 요청 패킷에 대해 대응하는 응답 패킷을 시퀀스 번호(Transaction ID)로 짝지어 비동기 처리하는 ‘양방향 함수 호출(RPC)’ 패턴을 직접 표현하기에는 한계가 있었습니다.
따라서 BLE의 구조를 그대로 따르기보다는, “서비스와 특성의 계층 관계” 및 “속성(Property)에 따른 동작 분류” 아이디어만 차용하여 RPC에 맞게 재해석했습니다.
계층 구조#
- Service(서비스) 하위에 데이터 변수 대신 Procedure(함수 프로시저)를 배치
함수 동작 특성 분류#
- 요청 & 응답 (Request-Response): 클라이언트가 요청을 보내면 서버가 결과를 반환하는 일반적인 양방향 RPC
- 요청 전용 (One-Way / Write Without Response): 응답을 기다리지 않고 빠르게 명령만 전송하는 형태 (예: LED 스트리밍 데이터 전송)
- 비동기 알림 (Asynchronous Notification / Notify): 클라이언트에서 별도 요청 없이도 센서 상태 변경이나 디바이스 이벤트 발생 시 서버가 클라이언트로 데이터를 즉시 푸시하는 알림
RPC 스펙 디스크립터의 부재#
기본적인 메시지 동작 특성과 교환 방식을 결정하고 나니 또 다른 큰 난관에 부딪혔습니다. 바로 “RPC 스펙 자체를 기술할 디스크립터(Descriptor)가 없다"는 것이었습니다.
- gRPC
.proto구문 사용 불가: 표준 Protobuf 파일(.proto)은 내부에service와rpc키워드로 인터페이스를 정의하지만, 이는 gRPC 전용 문법이므로 gRPC를 쓸 수 없는 NanoPB/임베디드 환경에서는 사용이 불가 했습니다. gRPC를 똑같이 쓰고 별도 파서를 만든는 것도 방법이지만, 자칫하면 혼동을 줄 수 있기 때문에 이 방법은 피하기로 합니다. - 커스텀 문법/파서 비용: 완전히 새로운 문법(IDL)과 문법 분석 파서를 처음부터 직접 만드는 것은 개발 공수와 유지보수 비용이 너무 컸습니다.
YAML 기반 RPC 스펙 디스크립터 (user_spec.yml)#
사람이 읽고 쓰기 쉽고 파이썬 등에서 즉시 객체화(Parsing)하기 용이한 YAML 포맷을 RPC 스펙 디스크립터 형식으로 채택했습니다.
수동으로 .proto 파일이나 C 헤더, C# 클래스를 별도로 일일이 작성할 필요 없이, 단 하나의 user_spec.yml 파일만 작성하면 되는 구조를 고려 하였습니다.
Core Spec과 User Spec의 분리#
스펙 디스크립터를 설계하면서 또 하나 중요하게 고려한 점은 Core Spec(코어 명세)과 User Spec(유저 명세)을 명확히 분리하는 것이었습니다.
왜 굳이 둘을 나누었을까요?
프레임워크 자체의 기본 동작에 필요한 시스템 서비스(Ping, 디바이스 정보 조회 등)와 사용자가 실제 애플리케이션 개발에 사용하는 비즈니스 로직(Wi-Fi 설정, LED 스트리밍 등)이 섞이게 되면, 추후 프레임워크가 업데이트될 때 사용자 정의 RPC ID와 충돌이 발생하거나 코드가 복잡해질 위험이 있었습니다.
그래서 WindRPC는 다음과 같이 영역을 나누기로 결정했습니다.
- Core Spec (코어 영역): 프레임워크 차원에서 제공하는 필수 서비스 영역입니다. Service ID 1~6번 대를 전용 영역(Reserved)으로 할당하여 핑(Ping)이나 기본 장치 상태 조회 등 시스템 공통 RPC와 모든 서비스에서 공용으로 사용할 메시지나 Enum을 정의합니다.
- User Spec (유저 영역): 개발자가 실제 구현하고자 하는 서비스 영역입니다. Service ID 7번 이후의 영역을 자유롭게 사용하며, 개발자는 자신이 필요한 로직만
user_spec.yml파일 하나에 정의하여 관리하게 됩니다.
이렇게 스펙을 분리함으로서 프레임워크의 하위 호환성과 독립성을 유지하면서도, 개발자는 내부 시스템 구조를 깊게 신경 쓸 필요 없이 유저 명세서만 작성하면 되도록 하고 싶었습니다.
user_spec.yml 작성 기본 규칙#
- 메타데이터 (Metadata): 프로젝트명(
project), 스펙 버전(version) 정의. - 열거형 (Enums): 상태 코드 및 상수 enum 정의.
- 구조체 (Structs): 필드 타입(
string,uint32,bytes등)과 정적 메모리 제약(max_length등)을 명시한 메시지 구조체 정의. - 서비스 및 RPCs (Services & RPCs):
- Service ID: 16비트 네임스페이스 고유 식별자 지정 (유저 영역: 7번부터 사용).
- RPC ID: 서비스 내 고유 함수 식별자 지정.
- 요청/응답/알림 매핑:
request,response메시지 타겟 지정.
# user_spec.yml 실제 작성 구조 예시
package: bitnari
info:
title: "Bitnari Control Specification"
version: "1.0.0"
services:
- id: 7
name: led_control
messages:
- name: PixelColor
fields:
- { number: 1, name: r, type: uint32 }
- { number: 2, name: g, type: uint32 }
- { number: 3, name: b, type: uint32 }
- name: PixelData
fields:
- number: 1
name: colors
type: PixelColor
property: repeated
nanopb: { max_count: 64 }
rpcs:
- id: 1
name: display_pixels
type: REQUEST_ONLY
request: PixelData
- id: 8
name: power_manager
messages:
- name: PowerStatus
fields:
- { number: 1, name: voltage_mv, type: uint32 }
- { number: 2, name: is_charging, type: bool }
rpcs:
- id: 1
name: get_power_status
type: REQUEST_RESPONSE
request: types.Empty
response: PowerStatus
- id: 2
name: charging_alert
type: NOTIFICATION
event: PowerStatus
디스크립터 정리 및 상호 검증#
디스크립터를 정리하고 proto를 생성하는 코드를 작성했습니다.
초기에는 디스크립터 파싱 오류나 복잡한 검증 규칙보다는, 작성한 YAML 명세에서 표준 .proto 파일이 정상적으로 잘 생성되는지 자체를 우선적으로 확인했습니다.
그리고 이렇게 생성된 .proto를 기반으로 NanoPB로 변환된 C 코드와 C#으로 변환된 클래스 코드 사이에 실제로 데이터를 주고받으면서, MCU와 데스크톱 애플리케이션 양단 간에 패킷 직렬화 및 역직렬화가 오류 없이 잘 동작하는지 상호 검증을 수행했습니다.
서로 다른 환경(C MCU vs C# .NET)에서 동일한 .proto 명세로 생성된 바이너리 데이터 교환에 문제가 없는 것을 확인하면서, 디스크립터 기반 코드 자동화 방식을 기본으로 확장을 고려하게 되었습니다.
Nanopb .options 파일 자동 생성과 사유#
디스크립터(user_spec.yml)로부터 .proto 파일 생성기를 작성할 때, 표준 .proto 파일뿐만 아니라 Nanopb 전용 .options 파일도 함께 자동으로 생성하도록 개발했습니다.
.options 파일을 함께 생성해야 했던 핵심 사유는 마이크로컨트롤러(MCU) C 언어 환경의 특수성 때문이었습니다.
- 동적 메모리(Heap) 배제와 Zero-Heap 달성: 표준 Protobuf 사양에서 가변 길이 문자열(string)이나 가변 배열(repeated) 필드는 C 언어로 변환 시 포인터 동적 할당(
malloc/free)이나 필드 단위 디코딩 콜백(pb_callback_t)을 필요로 합니다. 그러나 RAM 메모리가 극도로 제한된 MCU 환경에서는 힙(Heap) 동적 메모리 할당을 사용할 경우 메모리 파편화나 런타임 수신 크래시 위험이 상존합니다. nanopb:제약 명시 및 .options 자동 추출: YAML 디스크립터 작성 시 필드 속성에 정적 메모리 제약(nanopb: { max_size: 64, max_count: 32 })을 명시하도록 하고, 파이썬 제너레이터가 이를 읽어user_service.options파일을 자동으로 동적 생성하게 만들었습니다.- 정적 C 구조체로의 변환: Nanopb 컴파일러(
protoc)는 이 .options 파일을 참조하여 C 헤더(.pb.h) 및 코드(.pb.c) 생성 시 개별 필드의 동적 메모리 포인터 대신char buffer[64];나uint32_t colors[32];같은 고정 크기의 정적 C 배열 구조체로 깔끔하게 치환해 줍니다. (다만 초기 중첩 메시지 파싱을 위한 메시지 레벨의 oneof 디코딩 콜백 순회 문제는 이후 Flat 아키텍처 전환 전까지 여전히 과제로 남아 있었습니다.)
결과적으로 개발자가 수동으로 .options 파일이나 C 헤더 구조체를 작성할 필요 없이, YAML 디스크립터 정의 하나만으로 필드 단위 힙 메모리를 0바이트 사용하는 Zero-Heap 정적 메모리 C 구조체가 자동으로 생성되도록 프레임워크의 기틀을 마련했습니다.
정리하며#
빛나리 프로젝트에서 Protocol Buffer를 활용하고자 했던 욕심으로 결국 RPC 설계와 구현을 고려하면서, 빛나리 프로젝트는 배보다 배꼽이 더 커저버렸습니다.
다음 포스팅에서는 proto 메시지 구조와 서버 코드 자동 생성 작업에 대한 이야기를 해보려 합니다.
