프로토콜 디코딩 실무
앱 개발에서 “진법 표기”는 수학 공부용 주제가 아니라, 디바이스·서버·파일 포맷이 던져주는 바이트를 사람이 해석 가능한 값으로 복원하는 실무 역량에 가깝습니다. 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 < 0 || 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(Listbytes) { 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진 덤프로 원본 바이트를 고정해 재현성을 확보하면, 화면에서 숫자가 “대충 맞아 보이는” 유형의 오류를 빠르게 제거할 수 있습니다.
FAQ
BCD인지 일반 2진 정수인지 어떻게 구분하나요?
가장 확실한 방법은 프로토콜/데이터시트를 확인하는 것입니다. 문서에 “각 자릿수를 4비트로 표현한다”, “0x25가 25를 의미한다”처럼 자리 기반 표현을 설명하면 BCD일 가능성이 높습니다. 문서가 불명확하면 샘플 값 몇 개를 확보해 일반 정수 해석과 BCD 해석을 각각 적용해 보고, 의미가 성립하는 쪽을 선택하되 이후에도 테스트 벡터로 고정해 재발을 막는 것이 안전합니다.
BCD를 일반 정수로 읽으면 어떤 증상이 나오나요?
대표적으로 0x25가 25가 아니라 37로 보이는 식의 오차가 발생합니다. 문제는 이런 값이 UI에서 그럴듯하게 보이기 때문에 한동안 발견되지 않고, 특정 구간에서만 “값이 이상하게 튄다”는 형태로 보고되는 경우가 많습니다. 시간(HH:MM:SS)처럼 범위가 명확한 값은 60을 넘는 분/초가 나오거나, 24를 넘는 시간이 찍히는 식으로 빠르게 드러나기도 합니다.
RTC 시간 데이터에서 자주 터지는 오류는 무엇인가요?
BCD 변환 전에 플래그 비트를 마스킹하지 않아 값이 특정 순간에만 깨지는 경우가 많습니다. 예를 들어 초/분/시 레지스터의 최상위 비트에 동작 플래그가 섞여 있는데 이를 그대로 BCD로 바꾸면 정상 시간대에는 그럴듯해 보이다가 특정 조건에서만 비정상 값이 나옵니다. 시간 데이터는 특히 “마스킹 → BCD 변환” 순서가 중요합니다.
엔디안 오류는 어떤 패턴으로 나타나나요?
2바이트 이상 값에서 크기가 일관되게 커지거나 작아지는 형태로 나타납니다. 예를 들어 0x01 0x02를 기대했는데 258이 아니라 513처럼 계산되면 엔디안이 뒤집혔을 가능성이 큽니다. 센서 원시값, 길이 필드, 카운터 값에서 특히 자주 발생하며, 샘플 프레임을 기준으로 빅/리틀 두 가지로 계산해 비교하면 빠르게 판별할 수 있습니다.
signed/unsigned 혼동은 왜 발견이 늦나요?
음수 영역에 진입하기 전까지는 값이 정상처럼 보이기 때문입니다. 예를 들어 signed int8/int16로 와야 하는 값이 unsigned로 처리되면, 특정 순간부터 갑자기 200대·60000대처럼 큰 값으로 튀면서 “간헐적 이상치”로 보입니다. 이런 유형은 실데이터에서만 재현되는 경우가 많아, 부호 규칙을 초기부터 명확히 고정하는 것이 중요합니다.
16진 덤프를 로그로 남겨야 하는 이유가 뭔가요?
진법/인코딩 버그는 화면 숫자만 보면 원인을 역추적하기 어렵습니다. 반면 16진 덤프는 “원본 바이트”를 그대로 고정해 주므로, 동일 입력을 재현하고 파서 로직을 단계별로 검증할 수 있습니다. 특히 바이트 경계가 중요한 프로토콜에서는 16진 덤프가 사실상 표준적인 디버깅 언어 역할을 합니다.
테스트 벡터는 어떤 방식으로 만들면 좋나요?
입력 바이트 배열과 기대 결과값을 1:1로 묶어 “이 바이트가 들어오면 이 숫자가 나와야 한다”를 고정하면 됩니다. 예를 들어 BCD 0x25는 25, 리틀엔디안 0x01 0x02는 513처럼 케이스를 명시해 두면, 리팩터링이나 플랫폼별 구현 차이로 해석이 흔들리는 일을 크게 줄일 수 있습니다. 핵심은 샘플을 적게라도 “확정값”으로 박아 두는 것입니다.
숫자 리터럴(0x, 0b 등) 실수는 어떻게 예방하나요?
문서가 10진수인지 16진수인지부터 명시적으로 맞추는 것이 우선입니다. 코드에서는 상수에 진법을 분명히 드러내고, 주석으로 단위를 함께 남기는 습관이 효과적입니다. 또한 테스트 데이터는 가능한 한 “바이트 배열”로 정의해 두면, 사람이 0x12를 12로 착각하는 류의 실수를 구조적으로 줄일 수 있습니다.
Flutter와 Kotlin/Swift에서 같은 데이터를 다르게 해석하는 문제는 왜 생기나요?
플랫폼별 기본 타입의 부호, 바이트 접근 방식, 엔디안 처리 방식이 다르기 때문입니다. 예를 들어 Kotlin의 Byte는 signed이며, Swift의 UInt8/Int8 변환도 명시가 필요합니다. 그래서 BCD 변환, signed 변환, 엔디안 읽기 같은 로직은 공용 유틸 패턴으로 통일하고, 동일 테스트 벡터로 각 플랫폼을 검증하는 방식이 가장 확실합니다.
값이 “대충 맞아 보이는데”도 버그일 수 있나요?
이 분야에서 가장 위험한 유형이 바로 그 케이스입니다. BCD/엔디안/부호 오류는 종종 숫자가 완전히 엉망이 아니라 “그럴듯한 다른 숫자”로 보이기 때문에, 사용자가 문제를 느끼기 전까지 발견되지 않습니다. 원본 바이트 덤프를 기준으로 변환 단계를 검증하고, 경계값(0, 9/10, 59/60, 음수 전환 지점 등)을 테스트로 고정해 두는 것이 실무적으로 가장 안전한 대응입니다.