Flutter 앱 개발 음수 표현

ByteData 기반 패턴

Flutter에서 음수 표현 문제는 대개 Uint8ListByteData 사이를 오가며 값을 읽고 쓸 때 발생합니다. 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로 동일 해석을 재현하는 절차가 가장 빠릅니다. 그 다음 스케일링(나누기/곱하기)은 마지막에 적용해야 하고, 이 순서가 바뀌면 “그럴듯한 값”이 나와서 오히려 원인 파악이 늦어집니다.

결론

Flutter에서 음수 표현(2의 보수) 관련 버그는 대부분 “바이트 배열을 정수로 해석하는 경계 지점”에서 발생합니다. 특히 Uint8List를 직접 조립하거나 엔디안과 signed/unsigned를 명확히 고정하지 않으면, 값이 특정 구간에서만 갑자기 음수로 보이거나 큰 양수로 튀는 형태로 나타나 디버깅이 어려워집니다. 가장 실무적인 해법은 ByteData 기반으로 getInt16/getUint16/getInt32/getUint32를 사용해 스펙(비트 폭, 부호, 엔디안)을 코드에 고정하고, 비트 필드 추출과 체크섬 같은 연산에서는 필요한 폭으로 마스킹해 부호 확장과 폭 확장을 차단하는 것입니다. 또한 네이티브 브릿지나 플러그인 경계에서는 해석 책임을 한쪽으로 통일하고, 경계값 테스트를 자동화해 재발을 구조적으로 막는 방식이 비용 대비 효과가 가장 큽니다.

FAQ

Flutter에서 음수 표현 문제가 가장 자주 터지는 지점은 어디인가요?

대부분 네트워크·파일·BLE·센서처럼 원본 데이터가 Uint8List로 들어오는 구간에서 터집니다. 이때 “이 값이 Int16인지 UInt16인지”, “Little-Endian인지 Big-Endian인지”가 스펙대로 고정되지 않으면 값이 정상처럼 보이다가 경계값에서만 급격히 깨집니다.

Uint8List를 직접 조립하면 왜 위험한가요?

직접 조립은 마스킹, 시프트, 엔디안, signed 해석 순서를 모두 개발자가 실수 없이 처리해야 하므로 오류 확률이 높습니다. 특히 상위 비트가 1이 되는 구간에서 부호 확장이나 폭 확장 문제로 값이 음수/양수로 뒤집히는 일이 흔합니다. ByteData.getIntX/getUintX로 읽으면 이런 실수를 구조적으로 줄일 수 있습니다.

ByteData로 읽을 때 Int와 Uint는 어떻게 구분해야 하나요?

프로토콜 스펙이 부호 정수(Int16/Int32 등)인지, 부호 없는 정수(UInt16/UInt32 등)인지에 따라 선택해야 합니다. 음수 온도, 가속도처럼 음수가 나올 수 있는 값은 보통 getInt16 같은 signed API를 쓰고, 길이·카운터·마스크 값처럼 음수가 의미 없는 값은 getUint16/getUint32로 읽는 것이 일반적입니다. 스케일링(나누기/곱하기)은 올바르게 읽은 뒤 마지막에 적용해야 합니다.

Endian을 실수하면 어떤 증상이 나오나요?

값이 일정 배수로 틀어지거나 완전히 다른 범위의 값처럼 보입니다. 여기에 signed 해석까지 틀리면 큰 양수와 큰 음수가 번갈아 나와 원인 파악이 더 어려워집니다. 해결은 읽는 모든 지점에서 Endian.little 또는 Endian.big를 명시하고, 함수명이나 유틸로 엔디안을 강제하는 것입니다.

Dart의 int는 오버플로우가 없는데도 왜 문제가 생기나요?

Dart의 int는 가변 정밀도라 일반 산술에서 오버플로우가 덜하지만, 문제는 “원본 데이터가 고정 폭(n비트) 정수”라는 점입니다. 프로토콜은 Int16/UInt16처럼 폭이 고정되어 있는데 앱에서 그 폭을 강제하지 않으면 비트 연산 결과가 의도한 범위를 벗어나거나 부호 확장이 섞여 잘못된 값이 만들어질 수 있습니다. 비트 연산 중간에는 & 0xFF, & 0xFFFF, & 0xFFFFFFFF 같은 폭 고정을 습관화하는 편이 안전합니다.

비트 필드 추출에서 부호 확장은 어떤 상황에서 문제를 만들까요?

원시 값을 signed로 읽었는데, 그 값을 그대로 >>로 시프트하면 음수에서는 상위가 1로 채워지는 산술 시프트가 적용되어 결과가 오염될 수 있습니다. 비트 필드 추출은 대개 부호와 무관하므로, 먼저 폭을 마스크로 고정한 뒤 시프트와 마스크로 필드를 뽑는 방식이 안전합니다.

체크섬이나 CRC가 맞지 않을 때 음수 문제가 원인일 수 있나요?

그럴 가능성이 큽니다. 특히 바이트를 int로 올리는 과정에서 음수 값이 섞이거나, ~ 같은 연산 후 폭이 무제한으로 확장되면 기대한 8/16/32비트 결과와 달라집니다. 체크섬 계산은 단계마다 의도한 폭으로 마스킹해 중간 결과를 고정하면 재현성과 일관성이 좋아집니다.

MethodChannel이나 플러그인 경계에서 값이 달라지는 이유는 무엇인가요?

네이티브(Android/iOS) 쪽의 타입 폭과 부호 체계가 Flutter로 넘어오는 과정에서 재해석되기 때문입니다. 일부 플러그인은 바이트를 List<int>로 변환해 전달하면서 signed/unsigned 의미가 흐려지거나, 개발자가 중간에서 임의 변환을 추가하면서 값이 변질됩니다. 가장 안전한 방법은 가능한 한 Uint8List 그대로 전달하고, 해석 책임을 Flutter 또는 네이티브 한쪽으로 단일화하는 것입니다.

실무에서 재발을 막는 가장 효과적인 방법은 무엇인가요?

파싱을 표준 유틸로 고정하는 것이 가장 효과적입니다. readInt16LE, readUint32BE처럼 함수명에 폭·부호·엔디안을 포함시키면 호출부에서 실수할 여지가 줄고, 리뷰에서 스펙 준수 여부를 빠르게 확인할 수 있습니다. 여기에 경계값 테스트를 자동화하면 플러그인 교체나 리팩터링 이후에도 회귀를 조기에 잡을 수 있습니다.

어떤 경계값을 테스트에 반드시 포함해야 하나요?

Int8이라면 127과 -128, Int16이라면 32767과 -32768, UInt16이라면 65535, Int32라면 2,147,483,647과 -2,147,483,648처럼 부호 비트가 뒤집히는 경계값이 핵심입니다. 또한 0, 1, -1은 거의 모든 프로토콜/연산에서 의미 있는 기준값이므로 반드시 포함하는 것이 좋습니다.

 
 

댓글 남기기