프로토콜 설계 원칙

ProtocolEmbeddedSystem Design

펌웨어와 앱 사이, 디바이스와 서버 사이에 바이너리 프로토콜을 설계하는 일은 흔하다. 초기에는 잘 동작하지만 버전이 올라가면서 한쪽만 업데이트되거나, 예상 못한 데이터가 들어오면 깨지기 시작한다. 이 문제들은 대부분 설계 단계에서 원칙을 지키면 예방할 수 있다.


Postel’s Law — 보내는 건 엄격하게, 받는 건 관대하게

“Be conservative in what you send, be liberal in what you accept.” — Jon Postel (RFC 761)

TCP/IP를 설계한 Jon Postel이 제안한 원칙이다. 인터넷이 이렇게 많은 구현체 사이에서 동작할 수 있는 이유 중 하나다.

실무에서 자주 발생하는 문제:

// 펌웨어 v2에서 status 필드에 새로운 값 0x03을 추가했다
typedef enum {
    STATUS_IDLE   = 0x00,
    STATUS_ACTIVE = 0x01,
    STATUS_ERROR  = 0x02,
    STATUS_SLEEP  = 0x03,  // v2 추가
} status_t;

앱이 아직 v1이면 0x03을 받았을 때 파서가 깨진다. 이런 코드가 문제다:

/* 나쁜 예 — 알 수 없는 값에 크래시 */
void handle_status(uint8_t status) {
    switch (status) {
    case 0x00: set_idle(); break;
    case 0x01: set_active(); break;
    case 0x02: set_error(); break;
    default:
        assert(0);  // "이 값은 올 수 없다" — 하지만 온다
    }
}

/* 좋은 예 — 모르는 값은 무시하거나 안전하게 처리 */
void handle_status(uint8_t status) {
    switch (status) {
    case 0x00: set_idle(); break;
    case 0x01: set_active(); break;
    case 0x02: set_error(); break;
    default:
        log_warn("unknown status: 0x%02X", status);
        break;  // 무시하고 계속 동작
    }
}

송신 측은 엄격하게: 스펙에 정의된 값만 보낸다. 정의되지 않은 필드에 쓰레기 값을 넣지 않는다. Reserved 필드는 0으로 채운다.

수신 측은 관대하게: 알 수 없는 값을 받아도 크래시하지 않는다. 해석할 수 없는 필드는 건너뛴다.

단, 관대함에도 한계가 있다. 보안 컨텍스트에서 잘못된 입력을 받아들이면 취약점이 된다. 관대한 파싱은 기능 동작에 적용하고, 인증/인가/무결성 검증에는 엄격해야 한다.


Extensibility — 필드를 추가해도 기존 파서가 깨지지 않는 구조

프로토콜은 반드시 바뀐다. 처음부터 확장을 고려하지 않으면 v2에서 호환성이 깨진다.

Version Byte

가장 단순한 방법. 패킷 헤더에 버전 번호를 넣는다.

┌──────┬─────┬────┬────────┬─────────────┬───────┐
│ SYNC │ VER │ ID │ LENGTH │ PAYLOAD     │ CRC   │
│ 0xAA │ 1B  │ 1B │  2B    │ LENGTH 바이트│  2B   │
└──────┴─────┴────┴────────┴─────────────┴───────┘

수신 측은 자기가 지원하지 않는 버전이면 패킷 전체를 건너뛰거나 에러를 반환한다.

if (pkt.version > SUPPORTED_VERSION) {
    send_nack(ERR_UNSUPPORTED_VERSION);
    return;
}

TLV (Type-Length-Value)

필드를 고정 위치가 아닌 타입-길이-값 묶음으로 보낸다. 새 타입을 추가해도 기존 파서는 길이만큼 건너뛰면 된다.

┌──────┬──────┬──────────┐┌──────┬──────┬──────────┐
│ TYPE │ LEN  │ VALUE    ││ TYPE │ LEN  │ VALUE    │ ...
│ 1B   │ 1B   │ LEN 바이트││ 1B   │ 1B   │ LEN 바이트│
└──────┴──────┴──────────┘└──────┴──────┴──────────┘
/* TLV 파서 — 모르는 타입은 건너뛴다 */
uint16_t offset = 0;
while (offset + 2 <= payload_len) {
    uint8_t type = payload[offset];
    uint8_t len  = payload[offset + 1];

    if (offset + 2 + len > payload_len) break;  // 잘린 TLV

    switch (type) {
    case TLV_TEMPERATURE:
        if (len == 2) temp = read_le16(&payload[offset + 2]);
        break;
    case TLV_HUMIDITY:
        if (len == 1) humidity = payload[offset + 2];
        break;
    default:
        /* 모르는 타입 → 건너뛴다 (Postel's Law) */
        break;
    }
    offset += 2 + len;
}

패킷 프로토콜 파서에서 다룬 고정 포맷 파서와 비교하면, TLV는 유연하지만 파싱 오버헤드가 있다. 필드가 자주 바뀌는 프로토콜에 적합하다.

Reserved Fields

향후 확장을 위해 예약 필드를 둔다. 송신 측은 0으로 채우고, 수신 측은 무시한다.

typedef struct {
    uint8_t  cmd;
    uint8_t  flags;
    uint16_t payload_len;
    uint8_t  reserved[4];  // 향후 사용. 0으로 채움
    uint8_t  payload[];
} packet_header_t;

Reserved 필드를 나중에 활용할 때 version byte와 함께 쓰면 안전하다. “version 2부터 reserved[0]은 priority 필드”처럼 정의한다.


Idempotency — 같은 명령을 두 번 보내도 결과가 같다

네트워크는 불안정하다. BLE 연결이 끊겼다가 재연결되면 앱은 마지막 명령이 전달되었는지 알 수 없다. 재전송했을 때 두 번 실행되면 문제다.

멱등하지 않은 설계:

// "조명 밝기 10% 올려" — 두 번 보내면 20% 올라간다
CMD_BRIGHTNESS_UP, delta=10

멱등한 설계:

// "조명 밝기를 70%로 설정" — 두 번 보내도 70%다
CMD_BRIGHTNESS_SET, value=70

멱등성을 보장할 수 없는 동작(결제, 잠금 해제 등)에는 시퀀스 번호를 사용한다:

typedef struct {
    uint8_t  cmd;
    uint16_t seq;      // 시퀀스 번호
    uint8_t  payload[];
} command_t;

/* 수신 측 */
static uint16_t last_seq = 0;

void handle_command(const command_t *cmd) {
    if (cmd->seq == last_seq) {
        // 중복 명령 — 이전 응답을 재전송
        resend_last_response();
        return;
    }
    last_seq = cmd->seq;
    execute(cmd);
}

Statelessness — 가능한 한 Stateless, 불가피하면 명시적으로

Stateless 프로토콜은 각 메시지가 독립적이다. 이전 메시지를 기억할 필요가 없으므로 구현이 단순하고, 재연결 후에도 바로 동작한다.

Stateless 예시 — 센서 데이터 보고:

// 매 전송마다 모든 정보를 포함
typedef struct {
    uint8_t  sensor_id;
    uint32_t timestamp;
    int16_t  temperature;  // 0.01도 단위
    uint8_t  battery_pct;
} sensor_report_t;

수신 측은 이전 메시지 없이 이 한 패킷만으로 현재 상태를 알 수 있다.

Stateful이 불가피한 경우 — OTA 펌웨어 업데이트:

파일을 분할 전송할 때는 상태가 필요하다. 이때는 상태를 명시적으로 관리한다:

typedef struct {
    uint8_t  cmd;        // OTA_DATA
    uint32_t offset;     // 파일 내 위치 — 순서에 의존하지 않음
    uint16_t chunk_len;
    uint8_t  data[];
} ota_chunk_t;

offset을 명시하면 패킷 순서가 바뀌거나 재전송이 발생해도 올바른 위치에 쓸 수 있다. 암묵적 순서(1번 다음은 2번)에 의존하는 것보다 안전하다.


Self-describing — 데이터만 보고 해석 가능

패킷을 받았을 때 별도의 문서 없이도 구조를 파악할 수 있어야 한다. Opaque blob을 지양한다.

나쁜 예 — opaque blob:

// 4바이트 blob. 무엇인지 알 수 없다
uint8_t data[4] = {0x1A, 0x00, 0x48, 0x01};

좋은 예 — 구조화된 필드:

typedef struct {
    uint8_t  type;    // 0x01 = 온도
    uint8_t  unit;    // 0x01 = 섭씨
    int16_t  value;   // 0.01도 단위, little-endian
} measurement_t;

TLV 포맷도 self-describing의 한 형태다. 각 필드가 자신의 타입과 길이를 가지고 있으므로 파서가 구조를 동적으로 해석할 수 있다.

BLE GATT Profile 설계에서 “기능별 서비스 분리 vs Serial Channel” 비교가 이 원칙과 관련된다. GATT 기반 서비스 분리는 self-describing(UUID만 보고 기능을 파악 가능), Serial Channel은 opaque(자체 프로토콜 해석 필요)에 가깝다.


Fail-safe Defaults — 알 수 없는 값은 안전한 방향으로

파서가 예상하지 못한 값을 만났을 때 기본 동작은 안전한 쪽이어야 한다.

/* 모터 제어 프로토콜 */
void handle_motor_cmd(uint8_t mode) {
    switch (mode) {
    case MODE_FORWARD:  motor_forward(); break;
    case MODE_REVERSE:  motor_reverse(); break;
    case MODE_HOLD:     motor_hold();    break;
    default:
        motor_stop();  // 모르는 모드 → 정지 (안전)
        break;
    }
}

/* 권한 제어 */
bool check_permission(uint8_t level) {
    switch (level) {
    case PERM_ADMIN: return true;
    case PERM_USER:  return has_user_access();
    default:
        return false;  // 모르는 권한 → 거부 (안전)
    }
}

원칙: 물리적 동작에서 알 수 없는 값은 정지/비활성, 보안에서 알 수 없는 값은 거부/최소 권한.


원칙 요약

원칙핵심실패 시 증상
Postel’s Law송신 엄격, 수신 관대한쪽 업데이트 시 통신 장애
Extensibility버전/TLV/예약 필드v2 배포 불가, 호환성 파괴
Idempotency절대값 설정 또는 시퀀스 번호재전송 시 이중 실행
Statelessness메시지 독립적, 상태 명시재연결 후 동작 불가
Self-describing타입/단위 명시디버깅 불가, 파서 오류
Fail-safe defaults모르면 안전한 쪽으로예외 상황에서 사고

메모

  • 이 원칙들은 BLE 커스텀 프로토콜, UART 시리얼 통신, REST API, Protocol Buffers까지 규모와 관계없이 적용된다
  • Protocol Buffers는 extensibility를 언어 레벨에서 보장한다. 알 수 없는 필드를 자동으로 보존하고, 필드 번호로 forward/backward compatibility를 유지한다
  • Martin Kleppmann의 Designing Data-Intensive Applications에서 직렬화와 스키마 진화 챕터가 이 원칙들의 분산 시스템 적용을 상세히 다룬다
  • Bluetooth SIG의 GATT Design Guidelines도 같은 원칙을 따른다. Unknown attribute는 무시, reserved 비트는 0으로 채움
  • CRC는 무결성 검증의 기본이다. 프로토콜에 CRC를 넣을 때 파라미터(poly, init, reflect)를 문서에 명확히 기록해야 한다
  • Postel’s Law에 대한 반론도 있다. 관대한 파싱이 잘못된 구현을 사실상 표준으로 고착시킬 수 있다 (RFC 9413). 보안이 중요한 프로토콜에서는 strict validation이 맞다