비트 수를 정할 때 “지금 값이 들어간다”만 확인하면 나중에 필드가 조용히 잘립니다. 필요한 범위, 비트 배치, 범위 초과 처리, 바이트 순서를 하나의 계약으로 묶어야 저장 파일과 네트워크 패킷이 같은 값을 복원합니다.
2026년 10월 5일 Node.js 24.19.0에서 16비트 헤더를 설계하고 가능한 65,536개 필드 조합을 모두 패킹한 뒤 복원했습니다. 왕복과 16비트 범위를 포함한 131,079개 조건이 통과했습니다. 이 글은 signed 해석보다 “여러 필드를 몇 비트에 배치하고 초과값을 어떻게 막는가”에 집중합니다.
최댓값에서 필요한 비트 수를 계산한다
0부터 최댓값 M까지 표현하려면 일반적으로 ceil(log2(M + 1))비트가 필요합니다. 실행 결과 최댓값 7은 3비트, 8은 4비트, 255는 8비트, 256은 9비트, 1000은 10비트였습니다.
| 허용 최댓값 | 필요 비트 | 표현 가능한 범위 |
|---|---|---|
| 7 | 3 | 0~7 |
| 8 | 4 | 0~15 |
| 255 | 8 | 0~255 |
| 256 | 9 | 0~511 |
| 1000 | 10 | 0~1023 |
종류가 현재 7개라고 3비트로 정하면 값 0을 쓰는지에 따라 이미 8개 상태를 소비할 수 있습니다. 예약값과 향후 확장값까지 포함한 최댓값을 먼저 정해야 합니다.

16비트 헤더를 네 필드로 나눴다
| 비트 | 필드 | 폭 | 범위 |
|---|---|---|---|
| 15~13 | version | 3 | 0~7 |
| 12~8 | type | 5 | 0~31 |
| 7~4 | retry | 4 | 0~15 |
| 3~0 | flags | 4 | 0~15 |
word = (version << 13) |
(type << 8) |
(retry << 4) |
flags;
version 5, type 17, retry 9, flags 10은 0xB19A가 됐습니다. 다시 마스크와 오른쪽 시프트를 적용했을 때 네 값이 그대로 돌아왔습니다. 한두 예제가 아니라 8×32×16×16 전체 조합을 검사했으므로 비트 경계와 필드 간 침범을 모두 포함합니다.
마스크는 검증이 아니라 잘라내기다
입력 8·32·16·16은 각 필드가 표현할 수 있는 최댓값보다 1 큽니다. 안전한 패커는 네 입력을 모두 거부했습니다. 반대로 범위 검사 없이 시프트와 OR만 하면 상위 비트가 이웃 필드로 넘어가 복원값이 1·1·1·0이 됐습니다.
value & mask를 먼저 적용해도 초과값은 정상 범위의 다른 값과 충돌합니다. 사용자 입력이나 서버 값을 패킹할 때는 잘라내기 전에 범위를 검사하고, 프로토콜 오류로 반환해야 합니다.
엔디안은 필드 내부가 아니라 완성된 여러 바이트의 순서다
0xB19A를 2바이트로 내보내면 big-endian은 B1 9A, little-endian은 9A B1였습니다. 비트 필드를 조립하는 식은 같고, 완성된 16비트 정수를 바이트로 직렬화하는 단계에서 순서가 갈립니다.
const be = Buffer.alloc(2);
be.writeUInt16BE(word); // B1 9A
const le = Buffer.alloc(2);
le.writeUInt16LE(word); // 9A B1
명세에는 “16비트 헤더”만 적지 말고 “UInt16 Big-Endian”처럼 바이트 순서를 포함합니다. 한 바이트짜리 테스트값으로는 엔디안 오류가 드러나지 않으므로 서로 다른 두 바이트가 나오는 골든 값을 사용합니다.
오버플로 동작도 필드 계약의 일부다
12비트 카운터의 최댓값은 4095입니다. (4095 + 1) & 0xFFF는 0이 됐습니다. 패킷 시퀀스라면 의도된 순환일 수 있지만, 결제 횟수나 재시도 횟수라면 오류일 수 있습니다.
| 필드 목적 | 초과 정책 | 테스트 |
|---|---|---|
| 순환 시퀀스 | 0으로 wrap | 최댓값 다음 0 |
| 횟수 제한 | 최댓값 유지 | saturate 확인 |
| 금액·길이 | 요청 거부 | 오류 코드 확인 |
| 예약 필드 | 0만 허용 | 비트가 켜지면 거부 |
리뷰할 때 확인할 다섯 줄
- 각 필드의 최솟값·최댓값과 예약값이 적혀 있는가?
- 비트 구간이 겹치거나 비어 있는 이유가 명시됐는가?
- 패킹 전에 초과값을 거부하는가?
- 두 바이트 이상이면 엔디안이 명시됐는가?
- 최댓값 다음 동작이 wrap·saturate·error 중 무엇인가?
레이아웃을 바꿀 때는 버전 필드부터 소비한다
필드 폭을 변경하면 같은 16비트가 과거 앱과 새 앱에서 다른 뜻이 됩니다. type을 5비트에서 6비트로 늘리면서 시작 위치만 옮기면 retry와 flags도 모두 달라집니다. 이런 변경은 단순 리팩터링이 아니라 wire format 변경입니다.
| 변경 | 이전 디코더 영향 | 안전한 대응 |
|---|---|---|
| 예약값 하나 추가 | 모르는 값 거부 가능 | unknown 처리 정책 명시 |
| 필드 폭 확대 | 뒤 필드 오프셋 변경 | 새 version과 별도 레이아웃 |
| 엔디안 변경 | 전체 word 오해석 | 기존 버전 유지, 새 버전만 적용 |
| 초과 정책 변경 | 같은 입력 결과가 달라짐 | 버전별 골든 벡터 보관 |
이번 헤더는 version이 3비트라 0~7을 표현합니다. 그 공간도 무한하지 않으므로 “버전 값만 올리면 된다”가 아니라 각 버전의 디코더와 제거 시점을 문서화해야 합니다. 새 인코더가 만든 대표 바이트를 구버전 디코더에도 넣어 명시적으로 거부하는지 확인하면 오해석보다 안전합니다.
골든 벡터는 숫자와 바이트를 함께 보관한다
테스트에는 필드 객체, 16비트 word, 직렬화된 바이트를 한 묶음으로 둡니다. 예제의 경우 {5,17,9,10} ↔ 0xB19A ↔ B1 9A가 하나의 골든 벡터입니다. 객체 왕복만 검사하면 엔디안을 놓치고, 바이트만 검사하면 필드 마스크 오류의 원인을 찾기 어렵습니다.
검증 범위
- Node.js 정수 비트 연산과 Buffer 직렬화를 Windows x64에서 실행했습니다.
- JavaScript 비트 연산은 32비트 범위로 변환되므로 32비트보다 넓은 필드는 BigInt나 전용 바이트 API가 필요합니다.
- 실제 BLE·파일 포맷은 사용하지 않았고 예제 헤더는 검증용 설계입니다.
- signed 필드 해석은 signed·unsigned 바이트 해석에서 별도로 검증했습니다.