클라우드 Mac에서 iOS 16진수 코덱 계약 검증하기
Windows에서 비즈니스 코드를 작성한 뒤 iOS 프로젝트를 클라우드 Mac으로 옮겨 빌드할 때는 인터페이스의 16진수 필드도 코드와 함께 검증해야 합니다. 장치 메시지, 해시 다이제스트, 바이너리 식별자에는 이런 표현이 자주 쓰입니다. 문자열이 정상적으로 보인다고 해서 디코딩한 바이트까지 올바른 것은 아닙니다. 특히 잘못된 문자를 무시하거나, 선행 0을 잃거나, 불완전한 입력을 자동으로 보완해 다른 데이터로 바꾸는 동작을 막아야 합니다.
이 글에서는 독립 실행형 Swift 스크립트로 바이트 단위의 계약을 정의한 다음, 이를 프로젝트 테스트에 통합하는 방법을 설명합니다. 네트워크 서비스나 서명 자료가 필요하지 않으므로 아카이브 전에 실행하기에 적합합니다.
입력과 출력 계약부터 정의하기
디코더는 ASCII 문자 0–9, a–f, A–F만 허용하며, 두 문자마다 하나의 바이트를 나타냅니다. 인코더는 항상 소문자를 출력하고, 바이트마다 정확히 두 자리를 사용합니다. 대문자와 소문자는 동등하게 취급할 수 있지만 공백, 줄바꿈, 0x 접두사, 전각 문자는 이 프로토콜에 포함되지 않습니다.
하위 계층에서 빈 문자열은 빈 데이터를 뜻합니다. 비즈니스 필드가 빈 값을 허용하지 않는다면 비즈니스 유효성 검사에서 거부해야 합니다. 범용 디코더가 필드별 규칙까지 암묵적으로 처리하게 해서는 안 됩니다. 입력 상한은 디코딩한 바이트 수를 기준으로 정하고, 경계값도 허용된다는 점을 명확히 합니다.
엄격한 디코딩과 사용자 입력 정리는 별개의 책임입니다. 공백이 포함된 데이터를 붙여넣을 수 있게 하려면 먼저 UI 어댑터 계층에서 명시적으로 변환한 뒤 엄격한 디코더에 전달해야 합니다. 기계 간 인터페이스에서는 자동 보정을 기본 동작으로 삼지 마세요.
또한 16진수는 바이트를 표현하는 방식일 뿐, 암호화나 무결성 보호, 출처 인증을 제공하지 않습니다. 디코딩 검증을 통과했다는 사실만으로 메시지를 신뢰할 수는 없습니다.
잘못된 입력을 거부하는 디코더와 고정 자릿수 인코더 구현하기
다음 내용을 HexContract.swift로 저장합니다. 이 구현은 먼저 길이와 허용 용량을 검사한 다음 출력 공간을 할당합니다. 문자는 바이트 단위로 판별하며, 변환에 실패한 항목을 건너뛸 수 있는 compactMap은 사용하지 않습니다. 테스트에서 실패 원인을 구분할 수 있도록 별도의 오류 타입도 정의합니다.
import Foundation
enum HexError: Error, Equatable {
case invalidLimit
case oddLength
case tooLarge
case invalidCharacter
}
func nibble(_ c: UInt8) throws -> UInt8 {
switch c {
case 48...57: return c - 48
case 65...70: return c - 65 + 10
case 97...102: return c - 97 + 10
default: throw HexError.invalidCharacter
}
}
func decodeHex(_ text: String, maxBytes: Int) throws -> Data {
guard maxBytes >= 0 else { throw HexError.invalidLimit }
let count = text.utf8.count
guard count % 2 == 0 else { throw HexError.oddLength }
guard count / 2 <= maxBytes else { throw HexError.tooLarge }
var iterator = text.utf8.makeIterator()
var output = Data()
output.reserveCapacity(count / 2)
while let high = iterator.next() {
guard let low = iterator.next() else {
throw HexError.oddLength
}
let a = try nibble(high)
let b = try nibble(low)
output.append((a << 4) | b)
}
return output
}
func encodeHex(_ data: Data) -> String {
let alphabet = Array("0123456789abcdef".utf8)
var output = [UInt8]()
for byte in data {
output.append(alphabet[Int(byte >> 4)])
output.append(alphabet[Int(byte & 15)])
}
return String(decoding: output, as: UTF8.self)
}
여기서는 사용자에게 보이는 문자 수가 아니라 UTF-8 바이트 수를 사용합니다. ASCII가 아닌 입력은 반드시 실패해야 합니다. 오류를 판정하는 순서도 계약의 일부입니다. 음수 상한을 먼저 확인하고, 이어서 홀수 길이, 용량 초과를 검사한 뒤 마지막으로 허용 문자 집합을 검사합니다.
상한 검사에서 maxBytes * 2를 계산하지 않으므로 상한값을 곱할 때 발생할 수 있는 오버플로를 피합니다. 이 검사가 제한하는 것은 출력 메모리 할당이지 요청 수신 과정이 아닙니다. 인터페이스에서는 요청 본문을 읽을 때도 크기를 제한해야 합니다. 무제한 길이의 문자열을 먼저 받아 놓고 디코더가 메모리를 절약해 줄 것이라고 기대해서는 안 됩니다.
고정 테스트 벡터와 거부 사례 추가하기
다음 테스트를 같은 파일 뒤에 추가합니다. 고정 벡터는 선행 0과 대소문자 처리를 확인합니다. 모든 단일 바이트 값에 대한 테스트에서는 표준 서식 지정 결과를 독립적인 기준으로 사용합니다. 인코더와 디코더가 같은 오류를 가지고 있어도 왕복 변환만 성공하는 상황을 방지하기 위해서입니다.
func reject(
_ text: String,
limit: Int = 16,
expected: HexError
) {
do {
_ = try decodeHex(text, maxBytes: limit)
preconditionFailure("Expected rejection")
} catch let error as HexError {
precondition(error == expected, "Unexpected error")
} catch {
preconditionFailure("Unexpected error type")
}
}
let fixed = try decodeHex("000aFf", maxBytes: 3)
precondition(fixed == Data([0, 10, 255]))
precondition(encodeHex(fixed) == "000aff")
let empty = try decodeHex("", maxBytes: 0)
precondition(empty.isEmpty)
for value in 0...255 {
let reference = String(format: "%02x", value)
let decoded = try decodeHex(reference, maxBytes: 1)
precondition(decoded == Data([UInt8(value)]))
precondition(encodeHex(Data([UInt8(value)])) == reference)
}
reject("f", expected: .oddLength)
reject("0g", expected: .invalidCharacter)
reject("0x00", expected: .invalidCharacter)
reject("00\n0", expected: .invalidCharacter)
reject("00", expected: .invalidCharacter)
reject("0000", limit: 1, expected: .tooLarge)
reject("", limit: -1, expected: .invalidLimit)
let sequence = Data((0...255).map { UInt8($0) })
let restored = try decodeHex(encodeHex(sequence), maxBytes: 256)
precondition(restored == sequence)
길이가 홀수인 입력은 명확하게 실패해야 합니다. f를 임의로 0f나 f0로 해석해서는 안 됩니다. 잘못된 문자가 끝부분에 있는 경우도 테스트해, 구현이 이미 디코딩한 앞부분을 반환하지 않는지 확인해야 합니다. 보안 관련 필드에서는 실패 시 전체 처리가 실패해야 하며, 계속 사용할 수 있는 부분 결과를 제공해서는 안 됩니다.
클라우드 Mac에서 실행하고 프로젝트에 통합하기
먼저 현재 개발 도구를 사용할 수 있는지 확인한 다음 스크립트를 실행합니다.
xcrun --find swift
xcrun swift HexContract.swift
성공하면 스크립트는 아무것도 출력하지 않고 종료 코드 0으로 끝납니다. 어설션 실패나 처리되지 않은 오류가 발생하면 0이 아닌 종료 코드를 반환합니다. 검사를 비활성화하는 최적화 모드로 이 스크립트를 실행하지 말고, 파이프라인에서도 종료 코드를 무시하는 처리를 덧붙이지 마세요.
독립 실행형 스크립트가 검증하는 것은 현재 macOS의 Swift 구현입니다. 프로젝트에 정식으로 통합할 때는 코덱을 앱의 공유 모듈에 넣고, 위 테스트 벡터를 XCTest로 옮깁니다. 검사 코드는 XCTAssertEqual과 XCTAssertThrowsError로 바꾸고 기존 iOS 테스트 타깃에서 실행합니다. 그래야 타깃 멤버십, 조건부 컴파일, 실제 호출 경로의 문제까지 함께 발견할 수 있습니다.
기존 구현이 있다면 테스트 전용 복사본을 따로 유지하지 마세요. 테스트가 프로덕션 모듈을 직접 임포트하게 해야 합니다. 그렇지 않으면 스크립트는 통과해도 앱은 여전히 이전 디코더를 사용할 수 있습니다.
경계 조건 체크리스트로 회귀 검증 마무리하기
병합 전에는 각 항목을 확인합니다. 값이 0인 바이트가 00으로 인코딩되는지, 빈 입력이 필드 규칙에 맞는지, 최대 허용 용량은 통과하는지, 상한을 한 바이트 초과하면 거부하는지, 잘못된 입력에서 어떤 데이터도 반환하지 않는지 점검합니다. 무작위 데이터에만 기대지 말고 결과를 재현할 수 있는 긴 바이트 시퀀스도 추가합니다.
대소문자가 섞인 입력을 허용해도 되지만, 원본 문자열을 그대로 고유 키로 사용해서는 안 됩니다. aa와 AA는 같은 바이트를 나타냅니다. 정규화해 저장해야 한다면 먼저 엄격하게 디코딩한 뒤 일관된 형식으로 다시 인코딩합니다. 검증 전에 문자열의 대소문자를 변환하는 것으로 바이트 검사를 대신하지 마세요.
마지막으로 도구 체인 버전, 실패한 테스트 사례 이름, 테스트 종료 결과를 남기면 됩니다. 실제 장치 메시지나 토큰을 다룰 때는 전체 입력을 로그에 기록하지 말고, 고정된 합성 벡터로 문제를 재현합니다. 검증의 목표는 모든 문자열을 어떻게든 변환하는 것이 아닙니다. 유효한 바이트는 정확하게 왕복 변환되고, 허용 범위를 벗어난 입력은 명확히 중단되도록 하는 것입니다.
자주 묻는 질문
공백을 지우거나 홀수 길이에 0을 채워야 하나요?
기계 인터페이스에서는 기본적으로 거부해야 합니다. 수동 입력 화면에서 보정을 허용한다면 엄격한 디코더 앞의 별도 어댑터에서 처리합니다.
인코딩 후 디코딩하는 왕복 테스트만으로 충분한가요?
아닙니다. 두 함수가 같은 결함을 가질 수 있습니다. 고정 벡터, 모든 단일 바이트, 잘못된 입력 거부와 선행 0 검사도 필요합니다.
빌드 작업에 맞춰 전용 Mac을 대여하세요.
가까운 노드를 선택한 다음, 프로젝트에 필요한 메모리와 저장 공간, 이용 기간을 확인하세요. 가상 머신이 아닌 물리 머신 한 대를 단독으로 사용하며, 실제 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.