본문으로 건너뛰기
Background Image
  1. 프로젝트/
  2. WindRPC/

WindRPC 개발기 4 - 멀티 클라이언트 자동화와 Flat 아키텍처 완성

·6 분· loading · loading ·
WindRPC - 이 글은 시리즈의 일부입니다.
부분 : 이 글

멀티 클라이언트 자동화와 Flat 아키텍처 완성
#

마이크로시스템과 상위 애플리케이션 간의 원격 프로시저 호출(RPC)을 지원하기 위한 경량 프레임워크 WindRPC 개발 이야기의 네 번째 편입니다.

https://github.com/micro-artwork/windrpc


들어가며
#

약 8개월에 가까운 공백 이후 작업을 재개하게 되었습니다. 이 기간 동안에 AI 에이전트가 좀 더 강력하게 발전함에 따라 많은 도움을 받으며 기존 WindRPC의 C 서버 로직을 대폭 최적화하고, 통합 단위 테스트 코드를 하나씩 구축해 나가기 시작했습니다.

로직 최적화와 멀티 클라이언트 SDK 자동 생성
#

기존에는 데스크톱 클라이언트를 C# (WinForms) 기반으로만 우선 개발하고 있었습니다. 따라서 Protobuf 라이브러리가 만들어준 C# 클래스 메시지만 가져다가 시리얼 포트로 전송하고 응답을 받는 수준의 단순한 방식만 고려했었습니다.

휴지기 이전보다는 적극적으로 에이전트를 사용하게 되면서 UI/UX 개발 속도가 비약적으로 올라가면서, 데스크톱 클라이언트를 C#뿐만 아니라 웹 기술 기반의 일렉트론(Electron)으로 전환하는 방안을 적극적으로 검토하게 되었습니다.

일렉트론 환경을 도입하려면 JavaScript/TypeScript 기반의 클라이언트 통신 라이브러리가 필수적이었습니다. 통신을 위해서 proto 파일 기반으로 메시지를 생성하고, 해당 메시지만 잘 전송하면 MCU에서 적절한 응답을 해주기 때문에 어려운 것은 없었습니다.

다만, C# 환경만 고려하다가 추가로 JS/TS 환경을 고려하는 것은 일시적인 부가적인 일이지만 여러 언어의 클라이언트를 개발 할 시 일관화되고 안정적인 개발은 쉽지 않겠다는 생각이 들었습니다.

그래서 C 서버 코드뿐만 아니라 C#과 JS/TS 환경의 클라이언트 SDK까지 통째로 자동 생성하는 것이 맞겠다는 결론을 내렸고, 디스크립터(user_spec.yml) 하나로부터 양쪽 언어의 클라이언트 통신 SDK(WindRpcClient.cs, WindRpcClient.js)를 선택적으로 생성하도록 자동화 도구를 확장했습니다.

// 생성된 Electron JS/TS 클라이언트 SDK 사용 예시
import { WindRpcClient, decodePowerManagerPowerStatus } from './windrpc/WindRpcClient.js';

const client = new WindRpcClient();

// 1. 시리얼/바이너리 수신 바인딩
serialPort.on('data', (chunk) => {
    client.receiveBytes(chunk, (notification) => {
        // 서버 비동기 푸시 알림 처리
        if (notification.rpcId === 0x0882) {
            const alert = decodePowerManagerPowerStatus(notification.payload);
            console.log('서버 푸시 알림 수신:', alert);
        }
    });
});

// 2. RPC 요청-응답 호출 (비동기 async/await)
async function fetchPowerStatus() {
    const responseFrame = await client.sendRequest(
        0x0801, // (Service ID 8, RPC ID 1)
        new Uint8Array(0),
        (frame) => serialPort.write(frame),
        3000 // 타임아웃 (ms)
    );
    return decodePowerManagerPowerStatus(responseFrame.payload);
}

풀 코드 생성과 아키텍처 재고
#

서버 코드에 이어 C# 및 일렉트론(JS/TS) 클라이언트 SDK 초안까지 자동 생성하는 데 성공하면서, 통신 전 과정의 풀 코드 생성(Full-Code Generation)이 가능해졌습니다.

전 과정 자동 생성이 가능해진 순간, 초안을 만든 후 최적화를 고민하면서 아키텍처에 대해 근본적인 질문을 던지게 되었습니다.

“어차피 모든 코드를 도구가 자동으로 만들어낸다면, 기존에 사람이 읽기 편하도록 유지했던 중첩(Nested) Protobuf 메시지 구조를 고집할 필요가 과연 있을까?”

초기에는 사람이 .proto 파일이나 메시지 구조를 읽고 이해하기 쉽도록 ClientMessage 하위에 oneof 구문을 겹겹이 쌓은 중첩 구조를 만들었습니다. proto 자체가 데이터를 나타내는 구조이지만 그 자체로 디스크립터가 되길 원했기 때문입니다. 하지만 그 한계로 인해서 YAML 스펙 디스크립터를 생성했고 사실상 해당 스펙이 proto의 역할을 대신하고 있었고 proto는 단순히 데이터 구조를 담는 그릇 이상의 역할은 하지 않게 되었습니다.

결론적으로 모든 코드가 자동 생성되는 방향으로 정해졌다면, 사람이 읽기 쉬운 메시지 구조를 유지할 필요성이 전혀 없다는 것이 됩니다.

궁극적으로 내부 로직이나 API가 어떻게 돌아가던지(블랙박스) 결국 유저 입장에서는 생성된 함수(또는 메서드)에 대한 기대 동작과 역할을 알고, 이를 호출하고 응답을 받으면 되었기 때문입니다.

하이브리드 패킷 포맷 (6바이트 헤더 + Protobuf 페이로드)
#

최적화를 위해 메시지 구조를 훨씬 간결하게 단순화하고, 마이크로컨트롤러(MCU) 서버에서도 NanoPB 디코딩 콜백이 발생하지 않도록 정적 메모리 크기를 가정한 방식을 고민했습니다.

그 결과 중첩 메시지를 피하고 ‘하이브리드(Hybrid) 패킷 구조’와 Flat 아키텍처를 고안하게 되었습니다.

[ WindRPC 6바이트 바이너리 헤더 + Protobuf 페이로드 구조 ]

+-------------------+-------------------+-------------------+-----------------------+
|  RPC ID (2 Byte)  |  Seq ID (2 Byte)  | Payload Len (2B)  | Protobuf Payload      |
+-------------------+-------------------+-------------------+-----------------------+
| (Service << 8)    | Transaction ID    | Raw Payload Bytes | Serialized Data Bytes |
|   | RPC ID        |                   | Length            |                       |
+-------------------+-------------------+-------------------+-----------------------+

이전에는 패킷 전체를 Protobuf 메시지로 감싸는 대신, 새로운 방식은 패킷 전면부는 6바이트 고정 크기의 일반 바이너리 헤더로 구성하고 뒤따르는 페이로드 영역만 단순한 Protobuf 메시지로 처리하는 하이브리드 형태입니다.

흔한 패킷 구조인데 페이로드 부분만 Protobuf를 쓰는 특징만 가지고 있습니다. 데이터 영역은 복잡한 데이터를 직렬화(serialization)/역직렬화(deserialization) 함에 있어 검증된 Protobuf를 사용해서 불필요한 개발이나 검증을 줄이고 간단한 헤더 파싱으로 호출할 프로시저를 분류하고 응답할 수 있도록 한 구조입니다.

  • RPC ID (2 Byte): 서비스 ID와 RPC ID를 조합한 16비트 고유 숫자 식별자 ((service_id « 8) | rpc_id)
  • Sequence ID (2 Byte): 요청과 응답을 짝지어 비동기 매칭하기 위한 트랜잭션 시퀀스 번호
  • Payload Length (2 Byte): 후속으로 이어지는 Protobuf 바이너리 데이터의 길이
  • Protobuf Payload: 개별 프로시저의 파라미터나 리턴 값만 담고 있는 단순한 Flat Protobuf 메시지

Flat 아키텍처의 장점과 단일 표준으로의 정립
#

이 하이브리드 Flat 구조는 기존 중첩 방식 대비 커다란 성능적 이점을 제공해 주었습니다.

  1. O(1) 룩업 테이블 기반의 초고속 분기

    • 더 이상 프로시저 분류를 위해 메시지를 콜백으로 순회하거나 태그를 일일이 비교할 필요가 없어졌습니다.
    • MCU는 수신된 6바이트 헤더의 RPC ID 2바이트만 읽어 C 언어 룩업 테이블(Lookup Table)에서 O(1) 시간 복잡도로 즉시 해당 함수를 디스패치합니다.
  2. NanoPB 디코딩 콜백 배제와 정적 메모리(Zero-Heap) 고정

    • 파라미터와 리턴 메시지만 생성하되 내부적으로 정적 메모리 제약(nanopb: max_count / max_length)을 강제했습니다.
    • NanoPB에 의해 페이로드 부분만 한 번에 디코딩 및 인코딩하면 되므로, 복잡한 콜백 없이 동적 메모리 할당(malloc/free)이 전혀 없는 정적 구조체를 활용하게 되어 전반적인 연산량이 크게 줄어들었습니다.

초기에는 기존 중첩 방식인 Nested 버전과 새로운 하이브리드 방식인 Flat 버전을 사용자가 선택할 수 있도록 두 가지 생성 모드를 모두 지원했습니다.

그러나 실제 빛나리(Bitnari) 프로젝트에 Flat 버전을 기본으로 채용하여 테스트하고 운영해 본 결과, 훨씬 안정적인 코드 생성과 운영이 가능함을 확인했습니다.

신규 컨셉이 어느 정도 문제가 없다고 판단된 시점에서, 과거의 유산이었던 Nested 방식을 과감하게 코드 베이스에서 완전히 없애버리고 Flat 하이브리드 아키텍처만을 단일 표준으로 정립하여 WindRPC 초기 버전을 정식 완성했습니다.


정리하며
#

이후 별도 테스트 스크립트를 만들어서 문제가 있는지 확인할 수 있도록 하여, 유지 보수에 문제가 없도록 하였습니다. 육아와 여러가지 일로 많은 시간을 쓰기 어렵겠지만 적어도 빛나리 프로젝트를 진행하며 WindRPC 개발과 유지보수를 이어갈 생각입니다.

그리고 이후 진행할 프로젝트도 WindRPC로 통신 로직을 구성할 예정입니다.

WindRPC 탄생 비화와 작업 변화 과정에 대한 개발기는 여기 까지입니다. 이후 포스팅은 사용 방법이라던가 개발 과정 이슈 등 빠진 내용이 생각이 날 때 정리해볼까 합니다.

WindRPC - 이 글은 시리즈의 일부입니다.
부분 : 이 글

관련 글

WindRPC 개발기 3 - RPC 서버 구현 및 생성
·5 분· loading · loading
RPC 서버 구현 및 생성 # 마이크로시스템과 상위 애플리케이션 간의 원격 프로시저 호출(RPC)을 지원하기 위한 경량 프레임워크 WindRPC 개발 이야기의 세 번째 편입니다.
WindRPC 개발기 2 - Proto 초기 설계와 C 서버 코드 자동화
·5 분· loading · loading
Proto 초기 설계와 C 서버 코드 자동화 # 마이크로시스템과 상위 애플리케이션 간의 원격 프로시저 호출(RPC)을 지원하기 위한 경량 프레임워크 WindRPC 개발 이야기의 두 번째 편입니다.
WindRPC 개발기 1 - 프로젝트의 시작
·7 분· loading · loading
프로젝트의 시작 # 마이크로시스템과 상위 애플리케이션 간의 원격 프로시저 호출(RPC)을 지원하기 위한 경량 프레임워크 WindRPC (Micro Interconnect & Network Dispatch)의 개발 이야기입니다.