ByteData 기반 패턴
Flutter에서 음수 표현 문제는 대개 Uint8List와 ByteData 사이를 오가며 값을 읽고 쓸 때 발생합니다. Dart는 int가 가변 정밀도라서 “정수 자체가 오버플로우로 망가지는” 경우는 상대적으로 적지만, 네트워크·파일·BLE처럼 고정 폭(8/16/32비트) 데이터를 다룰 때는 결국 “n비트 2의 보수로 해석하느냐”가 핵심이 됩니다. 따라서 실무에서는 ByteData.getInt16/getUint16/getInt32/getUint32 같은 API를 기준으로 엔디안과 signed/unsigned를 명시적으로 고정하는 방식이 가장 안전합니다.
Uint8List 해석 오류
Flutter에서 흔한 실수는 Uint8List를 단순히 인덱싱해서 정수로 조립하면서 signed/unsigned와 엔디안을 암묵적으로 처리해버리는 것입니다. 예를 들어 16비트 온도 값이 2의 보수(Int16)로 오는데 이를 bytes[0] | (bytes[1] << 8)처럼 직접 조립하면, 부호 확장과 마스킹을 정확히 처리하지 않는 한 특정 값에서 음수가 양수로, 양수가 음수로 보이기 쉽습니다. 해결은 조립 로직을 흩뿌리지 말고 ByteData로 감싼 뒤 getInt16 또는 getUint16으로 읽는 습관을 팀 표준으로 두는 것입니다.
final bd = ByteData.sublistView(bytes); final v = bd.getInt16(offset, Endian.little); // 스펙이 Int16 LE라면 이렇게 고정
Endian 지정 누락
BLE나 센서 프로토콜은 Little-Endian이 많고, 일부 네트워크/파일 포맷은 Big-Endian이 많습니다. Flutter에서 엔디안 지정이 누락되면 기본값(플랫폼/구현에 좌우되는 것이 아니라 API에서 요구되는 엔디안 선택)을 제대로 의식하지 못해 “값이 일정 배수로 틀어지는” 문제가 발생합니다. 이 상태에서 signed까지 틀리면 디버깅이 매우 어려워집니다. 해결은 “읽는 순간 엔디안을 무조건 적는다”를 규칙으로 두고, getInt16(offset, Endian.little)처럼 호출부에서 스펙을 드러내는 방식이 가장 확실합니다.
Int16 부호 변환 실수
센서 값은 종종 “스케일된 정수”로 옵니다. 예를 들어 온도가 value/100처럼 정의되어 있는데, 원시 값이 Int16(2의 보수)인데도 UInt16로 읽어버리면 음수 온도가 큰 양수로 보입니다. 반대로 UInt16인데 Int16으로 읽으면 32768 이상 구간이 음수로 바뀝니다. 해결은 “스펙의 signed 여부를 먼저 확정하고 그 다음 스케일을 적용”하는 순서를 고정하는 것입니다. 즉, 스케일링을 하기 전에 이미 잘못 읽은 값에 나눗셈을 해봤자 결과는 더 그럴듯하게 틀어질 뿐입니다.
비트 필드 추출 함정
Dart의 비트 연산은 int 기준으로 동작하며, 값이 음수일 때 >>는 산술 시프트로 부호 비트를 유지합니다. 비트 필드 추출은 부호와 무관한 경우가 많으므로, 원시 값을 signed로 읽었더라도 필드 파싱 전에는 반드시 마스크로 양수 영역으로 정규화하는 편이 안전합니다. 예를 들어 16비트 레지스터에서 특정 비트를 뽑을 때는 value & 0xFFFF로 폭을 고정한 뒤 시프트와 마스크를 적용하면 부호 확장으로 인한 오염을 차단할 수 있습니다.
크로스 플랫폼 브릿지 혼선
Flutter에서 네이티브(Android/iOS)와 값을 주고받는 경우(MethodChannel, FFI, BLE 플러그인 내부)에는 “네이티브 쪽의 타입 폭과 부호”가 Flutter로 넘어오면서 깨지는 일이 많습니다. 특히 Android(Java/Kotlin)의 byte는 signed이고, iOS(Swift)의 UInt8/Int8은 구분이 엄격하며, 플러그인이 중간에서 List<int>로 변환해 주는 과정에서 의도치 않게 값이 바뀌거나 범위가 재해석되는 경우가 있습니다. 해결은 채널 경계에서 데이터는 가급적 “바이트 배열(Uint8List) 그대로” 넘기고, 해석은 Flutter 한 곳 또는 네이티브 한 곳으로 “단일 책임”을 고정하는 것입니다.
체크섬 불일치 원인
Flutter에서 CRC/체크섬이 틀리는 문제는 대개 바이트를 int로 올리는 과정에서 마스킹이 누락되어 발생합니다. Dart는 Uint8List의 각 원소가 이미 0~255이지만, 연산 중간에 다른 소스에서 들어온 값(List<int> 등)이 음수 범위를 포함하고 있거나, ~(bitwise NOT) 같은 연산 이후 폭이 무제한으로 확장되어 기대와 다른 결과가 나올 수 있습니다. 해결은 체크섬 계산을 할 때마다 중간 결과를 특정 폭으로 강제하는 것입니다. 예를 들어 8비트 단위는 & 0xFF, 16비트는 & 0xFFFF, 32비트는 & 0xFFFFFFFF로 단계별 마스킹을 넣어 “의도한 폭”을 유지시키는 방식이 안정적입니다.
표준 유틸 설계
실무에서 재발을 막는 가장 좋은 방법은 파싱 함수를 표준화해 “호출부에서 실수할 여지”를 제거하는 것입니다. readInt16LE, readUint32BE, writeInt16LE 같은 형태로 엔디안과 signed가 함수명에 박히면, 개발자는 스펙을 코드로 강제할 수 있고 리뷰에서도 빠르게 검증할 수 있습니다. 이 유틸 내부에서는 ByteData.sublistView를 사용해 불필요한 복사 없이 읽고, 필요 시 폭 마스킹과 스케일링의 순서를 고정해두면 안정성이 크게 올라갑니다.
int readInt16LE(Uint8List bytes, int offset) {
final bd = ByteData.sublistView(bytes);
return bd.getInt16(offset, Endian.little);
}
int readUint16LE(Uint8List bytes, int offset) {
final bd = ByteData.sublistView(bytes);
return bd.getUint16(offset, Endian.little);
}
int readInt32BE(Uint8List bytes, int offset) {
final bd = ByteData.sublistView(bytes);
return bd.getInt32(offset, Endian.big);
}
int readUint32BE(Uint8List bytes, int offset) {
final bd = ByteData.sublistView(bytes);
return bd.getUint32(offset, Endian.big);
}
경계값 검증 관행
Flutter에서도 “경계값”이 모든 문제의 출발점인 건 동일합니다. Int16이라면 32767과 -32768, UInt16이라면 65535, Int8이라면 127과 -128 같은 값이 들어왔을 때 올바르게 해석되는지 자동 테스트로 박아두면, 플러그인 교체나 리팩터링 이후에도 문제가 조기에 잡힙니다. 특히 BLE/센서 앱은 실제 환경에서만 특정 값이 나오기 때문에, 테스트에서 의도적으로 경계를 만들어 넣는 것이 장애 비용을 크게 줄입니다.
실전 문제 대응 흐름
Flutter에서 값이 갑자기 “큰 음수” 또는 “큰 양수”로 튀면, 우선 원본 바이트를 16진수로 찍고, 스펙 기준으로 엔디안과 signed를 확정한 뒤 ByteData.getIntX/getUintX로 동일 해석을 재현하는 절차가 가장 빠릅니다. 그 다음 스케일링(나누기/곱하기)은 마지막에 적용해야 하고, 이 순서가 바뀌면 “그럴듯한 값”이 나와서 오히려 원인 파악이 늦어집니다.