ByteData 파서가 맞는지는 예제 숫자 하나가 아니라 “같은 원시 패킷을 어떤 오프셋·부호·엔디안으로 읽었는가”로 검증해야 합니다. 이번에는 임의 설명 대신 8바이트 센서 프레임을 고정하고 Dart VM과 JavaScript 빌드에서 같은 코드를 실행했습니다.
2026년 10월 4일 Flutter 3.47.5에 포함된 Dart 3.13.4에서 16개 조건을 검사했습니다. 패킷 A5 02 2E FB 34 12 80 00의 온도는 -1234, 시퀀스는 4660, 플래그는 0x80으로 읽혔고 두 실행 환경에서 모두 통과했습니다.
먼저 패킷 표부터 코드 옆에 둔다
| 오프셋 | 길이 | 필드 | 계약 | 원시 바이트 |
|---|---|---|---|---|
| 0 | 1 | magic | UInt8 | A5 |
| 1 | 1 | version | UInt8 | 02 |
| 2 | 2 | temperature | Int16 LE, ×0.01 | 2E FB |
| 4 | 2 | sequence | UInt16 LE | 34 12 |
| 6 | 1 | flags | UInt8 bit field | 80 |
| 7 | 1 | reserved | UInt8 | 00 |
필드 표에 폭·부호·엔디안·스케일이 함께 없으면 구현자가 추측하게 됩니다. 파서는 표의 각 행을 그대로 옮기고, 스케일은 정수 해석이 끝난 뒤 적용합니다.

온도 두 바이트를 세 번 읽어 차이를 고정했다
final data = ByteData.sublistView(packet);
final raw = data.getInt16(2, Endian.little); // -1234
final wrongSign = data.getUint16(2, Endian.little); // 64302
final wrongOrder = data.getInt16(2, Endian.big); // 12027
final celsius = raw / 100; // -12.34
부호만 틀리면 -1234가 64302가 되고, 바이트 순서까지 틀리면 12027이 됩니다. 세 값 모두 정상적인 정수라 컴파일러는 문제를 알려 주지 않습니다. 따라서 “그럴듯한 범위인가”만 보지 말고 테스트 패킷의 기대값을 정확히 고정해야 합니다.
부분 뷰의 오프셋은 원본이 아니라 뷰 기준이다
ByteData.sublistView(packet, 2, 8)로 payload만 보면 길이는 6바이트이고 온도 오프셋은 0, 시퀀스 오프셋은 2가 됩니다. 원본 패킷의 오프셋 2를 부분 뷰에 다시 적용하면 다른 필드를 읽습니다.
final payload = ByteData.sublistView(packet, 2, 8);
payload.getInt16(0, Endian.little); // -1234
payload.getUint16(2, Endian.little); // 4660
파서 함수가 전체 프레임을 받는지 payload 뷰를 받는지 API 이름과 주석으로 분명히 하세요. 두 방식을 섞으면 테스트 패킷이 길어질수록 오프셋 오류를 찾기 어렵습니다.
경계값은 쓰고 다시 읽는 왕복으로 확인한다
Int16 경계 -32768, -1, 0, 32767을 little-endian으로 기록한 뒤 다시 읽었습니다. 각각 00 80, FF FF, 00 00, FF 7F로 저장됐고 원래 값으로 돌아왔습니다. UInt16은 0, 32768, 65535를 big-endian으로 왕복 검사했습니다.
실제 센서에서 경계값이 잘 나오지 않더라도 테스트에서는 직접 만들어야 합니다. 특히 0x7FFF / 0x8000 양쪽을 넣으면 signed·unsigned 전환 오류를 바로 드러낼 수 있습니다.
잘린 패킷은 기본값으로 숨기지 않는다
길이 8인 뷰에서 오프셋 7부터 UInt16을 읽으면 2바이트가 필요하므로 범위를 벗어납니다. VM과 JavaScript 빌드 모두 오류가 발생했고 테스트에서 이를 잡았습니다. 런타임별 구체적인 오류 타입 이름에 의존하기보다, 파싱 전에 필요한 최소 길이를 확인하고 잘린 프레임을 별도 결과로 반환하는 편이 이식성이 좋습니다.
SensorFrame parse(Uint8List packet) {
if (packet.length != 8) {
throw FormatException('expected 8 bytes, got ${packet.length}');
}
final data = ByteData.sublistView(packet);
return SensorFrame(
temperatureCelsius: data.getInt16(2, Endian.little) / 100,
sequence: data.getUint16(4, Endian.little),
flags: data.getUint8(6),
);
}
증상에서 잘못된 축을 찾는 순서
| 관찰값 | 비교 호출 | 다음 판단 |
|---|---|---|
| 음수가 6만대 양수 | getInt16 vs getUint16 |
부호 계약 확인 |
| 값의 자릿수까지 다름 | Endian.little vs Endian.big |
바이트 순서 확인 |
| 두 필드가 서로 바뀜 | 전체 뷰 vs 부분 뷰 오프셋 | 기준 오프셋 확인 |
| 마지막 프레임만 예외 | 수신 길이와 최소 길이 | 잘림·프레이밍 확인 |
재현 범위와 제외한 항목
- Dart VM과
dart compile js -O2결과를 Node.js에서 실행했습니다. Android·iOS 실기기와 BLE 플러그인은 사용하지 않았습니다. - 패킷은 재현용으로 만든 8바이트 예제이며 실제 제조사 프로토콜이 아닙니다.
- 스트림에서 프레임 경계를 찾는 로직, CRC 알고리즘, 패킷 손실 복구는 검증하지 않았습니다.
- 오류 타입의 문자열은 런타임과 최적화에 따라 달라질 수 있어 계약으로 삼지 않았습니다.
바이트 계약과 구현 글을 구분해서 읽기
signed·unsigned와 2의 보수 자체를 언어 중립적으로 확인하려면 바이트 해석 오류 예방을, 일반적인 정수 범위는 비트로 정수를 표현하는 방법에서 분리해 다룹니다.