핵심 요약
BCD는 각 10진 자릿수를 4비트로 저장하므로 일반 이진 정수와 같은 방식으로 읽으면 값이 달라집니다.
검수 범위
결제나 일반 앱이 항상 BCD를 쓴다는 오해를 피하고, 레거시 장비·시계·센서 프로토콜처럼 명세에 BCD가 적힌 경우로 범위를 제한했습니다.
프로토콜 디코딩 실무
앱 개발에서 “진법 표기”는 수학 공부용 주제가 아니라, 디바이스·서버·파일 포맷이 던져주는 바이트를 사람이 해석 가능한 값으로 복원하는 실무 역량에 가깝습니다. BLE 특성값, USB/Serial 패킷, IoT 센서 프레임, NFC 태그 데이터, 바이너리 로그 파일처럼 원천 데이터가 바이트 배열로 들어오는 순간부터 “이 1바이트를 10진 정수로 볼지, BCD로 볼지, 부호 있는 값으로 볼지”를 잘못 잡으면 값이 그럴듯하게 보이면서도 의미가 완전히 어긋나는 오류가 발생합니다.
BCD 데이터 등장
현장에서 BCD(Binary-Coded Decimal)는 특히 시간·계측 장치에서 자주 마주칩니다. BCD는 10진수 한 자리(0~9)를 4비트로 담아서, 예를 들어 12를 0001 0010으로 표현합니다. 이런 구조는 “표시 장치나 레거시/임베디드 친화적인 규약”에서 흔한데, 앱은 이 값을 일반 2진 정수로 착각해 읽는 순간 바로 오동작이 시작됩니다.
센서 값 해석 오류
가장 흔한 실수는 “BCD인데 일반 정수로 읽는” 케이스입니다. 예를 들어 장치가 온도를 BCD로 0x25로 보내면 사람이 기대하는 값은 25인데, 앱이 이를 일반 정수로 읽으면 0x25는 37이 되어 버립니다. 이 오류는 UI에 숫자가 ‘그럴듯하게’ 찍히기 때문에 QA 단계에서 한동안 놓치기도 합니다.
아래는 Flutter(Dart)에서 BLE로 수신한 1바이트를 BCD로 해석하는 예시입니다.
int bcdToInt(int b) {
final hi = (b >> 4) & 0x0F;
final lo = b & 0x0F;
if (hi > 9 || lo > 9) {
throw FormatException('Invalid BCD byte: 0x${b.toRadixString(16)}');
}
return hi * 10 + lo;
}
int intToBcd(int n) {
if (n 99) throw RangeError('0..99 only');
final hi = n ~/ 10;
final lo = n % 10;
return (hi << 4) | lo;
}
이런 형태의 변환 함수를 만들어 두면, “데이터가 BCD인지 여부”만 확정되면 이후 로직은 안정적으로 유지됩니다.
시간 레지스터 복원
RTC(실시간 시계)나 일부 장치 프로토콜은 초·분·시를 BCD로 보냅니다. 예를 들어 0x23 0x59 0x48 같은 바이트가 “23시 59분 48초”일 수 있는데, 앱이 이를 그대로 10진수로 출력하면 35, 89, 72처럼 말이 안 되는 값이 됩니다. 또한 상위 비트에 플래그가 섞여 있는 장치도 있어, 마스킹을 빼먹으면 특정 시간대에만 값이 깨지는 형태로 나타납니다.
아래는 “플래그 마스킹 + BCD 변환”까지 포함한 예시입니다.
class ClockHms {
final int h, m, s;
ClockHms(this.h, this.m, this.s);
@override
String toString() => '${h.toString().padLeft(2, '0')}:'
'${m.toString().padLeft(2, '0')}:'
'${s.toString().padLeft(2, '0')}';
}
// 예: [sec, min, hour] 순서로 BCD가 온다고 가정
ClockHms parseRtcHms(List bytes) {
final sec = bytes[0] & 0x7F; // 예: 최상위 비트가 CH 플래그일 수 있음
final min = bytes[1] & 0x7F;
final hour = bytes[2] & 0x3F; // 예: 12/24h 플래그가 섞일 수 있음
return ClockHms(bcdToInt(hour), bcdToInt(min), bcdToInt(sec));
}
실제 개발에서 중요한 포인트는 “BCD 변환 전에 반드시 비트 플래그를 마스킹해야 하는지”를 프로토콜 문서에서 확인하는 습관입니다.
16진 로그 가독성
2진수를 그대로 출력하면 눈이 피로해지고, 데이터 비교가 어렵습니다. 앱 디버깅에서 바이트 배열은 대부분 16진수로 찍어보는 편이 훨씬 빠릅니다. 특히 한 자리가 4비트라서 바이트(8비트)가 정확히 2자리로 떨어지기 때문에, 패킷 덤프를 읽을 때 구조가 깔끔해집니다.
Flutter에서 Uint8List를 16진 덤프 문자열로 만드는 예시는 다음과 같습니다.
String hexDump(List bytes) {
return bytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
}
// 사용 예:
// debugPrint(hexDump(value)); // "0a 1f 25 9c ..."
여기서 padLeft(2, '0')를 빼먹으면 0a가 a로 찍히고, 패킷 길이를 눈으로 맞추는 과정에서 계속 실수가 납니다. 실제 디버깅 효율이 크게 떨어지는 대표적인 “작은 오류”입니다.
엔디안 혼동 위험
진법 자체보다 더 자주 터지는 문제는 멀티바이트 정수의 바이트 순서(엔디안)입니다. 예를 들어 2바이트 0x01 0x02는 빅엔디안이면 258이고, 리틀엔디안이면 513입니다. 장치와 앱이 엔디안을 다르게 해석하면 값이 일정한 비율로 “이상하게 커지거나 작아지는” 형태로 나타납니다.
Dart에서 엔디안 지정까지 포함해 16비트 정수를 읽는 예시는 아래와 같습니다.
import 'dart:typed_data';
int readU16(Uint8List bytes, int offset, Endian endian) {
final bd = ByteData.sublistView(bytes, offset, offset + 2);
return bd.getUint16(0, endian);
}
이 코드를 적용할 때는 “프로토콜이 엔디안을 명시하는지”가 핵심이고, 명시가 없으면 샘플 데이터를 가지고 양쪽 엔디안으로 계산해 봤을 때 말이 되는 값이 어느 쪽인지로 판별하는 경우가 많습니다.
부호 해석 함정
센서 값이 1바이트 또는 2바이트로 오는데, 그 값이 음수가 될 수 있는 경우가 있습니다. 예를 들어 가속도나 온도 오프셋이 signed로 정의되어 있는데 앱이 unsigned로 읽으면 음수 영역이 큰 양수로 튀어 버립니다. 이 문제는 값이 특정 구간에서만 깨지기 때문에 “현장 데이터에서만 재현”되는 악성 버그로 자주 분류됩니다.
Dart에서 1바이트 signed 값을 읽는 간단한 방법은 다음과 같습니다.
int toInt8(int b) {
final x = b & 0xFF;
return x >= 0x80 ? x - 0x100 : x; // 0x80~0xFF를 -128~-1로 복원
}
BCD와 결합되면 더 복잡해집니다. “BCD는 자리수 표현”이므로 음수 표현을 별도 플래그로 주는 프로토콜도 있고, 보수 표현을 쓰는 프로토콜도 있으니, 이 경우는 반드시 규약대로 분리해 처리해야 합니다.
숫자 리터럴 오해
개발 중 로그나 테스트 코드에서 숫자를 직접 박아 넣는 순간에도 오류가 납니다. 0x12를 12로 착각하는 실수는 실제로 매우 흔하고, 반대로 문서에 10진수로 적힌 12를 0x12로 넣어 18을 보내 버리는 실수도 자주 나옵니다. 또한 일부 언어/환경에서는 숫자 앞에 0을 붙인 리터럴이 특별한 의미로 해석되는 경우가 있어, 테스트 상수는 가능하면 “명시적인 접두사(0x, 0b 등)”를 쓰거나 주석으로 진법을 고정해 두는 편이 안전합니다.
크로스플랫폼 공용 유틸
실무에서는 Flutter, iOS(Swift), Android(Kotlin)처럼 플랫폼이 갈라져 있어도 “바이트 해석 로직은 동일”해야 합니다. 그래서 BCD 변환, hex dump, 엔디안 변환 같은 함수는 공용 유틸로 만들고, 테스트 벡터(입력 바이트와 기대 결과)를 고정해 검증하는 방식이 품질을 크게 올립니다.
Android(Kotlin)에서 BCD 1바이트를 정수로 바꾸는 예시는 다음과 같습니다.
fun bcdToInt(b: Int): Int {
val hi = (b shr 4) and 0x0F
val lo = b and 0x0F
require(hi <= 9 && lo <= 9) { "Invalid BCD: 0x${b.toString(16)}" }
return hi * 10 + lo
}
iOS(Swift)에서도 개념은 같습니다.
func bcdToInt(_ b: UInt8) throws -> Int {
let hi = Int((b >> 4) & 0x0F)
let lo = Int(b & 0x0F)
guard hi <= 9, lo <= 9 else {
throw NSError(domain: "BCD", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid BCD"])
}
return hi * 10 + lo
}
이런 함수들이 있으면, 이후에는 “이 필드는 BCD다/이 필드는 signed int16이다/이 필드는 little-endian이다”처럼 스펙을 코드에 반영하는 작업으로 안정적으로 이어집니다.
디버깅 재현 전략
진법/인코딩 오류는 UI에서 ‘그럴듯한 숫자’로 보이는 경우가 많아, 단순 화면 테스트로는 놓치기 쉽습니다. 이 유형의 버그는 바이트 덤프를 기준으로 재현 가능한 테스트를 만들어 두면 급격히 줄어듭니다. 예를 들어 “수신 바이트가 0x25일 때 화면에는 25가 보여야 한다” 같은 테스트 벡터를 정해두면, BCD를 일반 정수로 읽는 실수는 즉시 잡힙니다. 엔디안도 마찬가지로, 0x01 0x02를 기대값 258 또는 513 중 하나로 고정해 두면 런타임에서 애매하게 흔들릴 여지가 없어집니다.
결론
앱 개발에서 진법과 BCD, 16진 덤프는 이론이 아니라 “바이트를 올바른 의미의 값으로 복원하는 과정”을 위한 실무 도구입니다. BCD를 일반 정수로 읽거나, 엔디안을 반대로 해석하거나, signed/unsigned를 혼동하는 순간 값이 그럴듯하게 출력되면서도 실제 의미는 틀어지는 버그가 발생합니다. 따라서 수신 데이터는 먼저 규격에 따라 BCD 여부, 비트 플래그 마스킹 필요 여부, 엔디안, 부호 규칙을 확정한 뒤 변환해야 하며, 변환 유틸을 공용화하고 테스트 벡터로 검증하는 방식이 가장 안정적입니다. 디버깅 단계에서는 16진 덤프로 원본 바이트를 고정해 재현성을 확보하면, 화면에서 숫자가 “대충 맞아 보이는” 유형의 오류를 빠르게 제거할 수 있습니다.
검증 기준과 참고 자료
이 글은 2026-09-08에 아래 공식 문서와 공개 기술 문서를 기준으로 내용과 용어를 다시 검수했습니다. 제품 버전과 기기 제조사에 따라 화면 이름은 달라질 수 있으므로 실제 화면과 공식 문서를 함께 확인하세요.