iOS UUID 계약 테스트: 엄격한 파싱과 바이트 순서 검증
같은 주문 레코드인데 API는 UUID를 대문자로 반환하고 로컬 캐시는 소문자로 저장하는 반면, 중복 제거 로직은 문자열을 그대로 비교하는 경우가 있습니다. 다른 경로에서는 UUID를 바이너리로 저장했다가 다시 읽으니 앞쪽 몇 그룹의 바이트 순서가 달라지기도 합니다. 이런 문제는 클라우드 Mac에서 빌드 전 검사에 포함하면 시뮬레이터를 실행하지 않고도 확인할 수 있습니다. 먼저 고정된 Swift 테스트 벡터로 입력, 식별자 비교, 직렬화 계약을 확정한 다음 iOS 프로젝트에서도 같은 규칙을 사용하도록 구성합니다.
형식과 비즈니스 정책부터 분리하기
UUID는 128비트 식별자입니다. 일반적인 텍스트 표현은 16진수 문자 다섯 그룹으로 이루어지며, 각 그룹의 길이는 8, 4, 4, 4, 12입니다. 하이픈은 정해진 위치에 네 번 들어가지만, 이런 형태라고 해서 반드시 무작위로 생성된 버전 4 UUID인 것은 아닙니다.
먼저 세 가지 규칙을 명확히 정해야 합니다. 대문자를 허용할지, 앞뒤 공백을 허용할지, 버전을 제한할지입니다. 이 글에서는 대소문자를 모두 허용하되 공백, 중괄호, 하이픈이 없는 형식, 비 ASCII 문자는 거부합니다. 무작위 식별자를 명시적으로 요구하는 필드에만 버전 4와 해당 변형 검사를 적용합니다.
‘파싱할 수 있는가’는 라이브러리의 기능이고, ‘시스템에 받아들일 것인가’는 프로토콜의 결정입니다. 파서가 느슨하게 허용하는 동작을 그대로 API 계약으로 삼지 마세요.
모든 비트가 0인 UUID도 별도로 정책을 정해야 합니다. 일반적인 텍스트 형식에는 부합할 수 있지만, 그렇다고 자동으로 ‘미설정’을 나타내는 대체 값으로 사용해서는 안 됩니다. 선택적 필드에는 명시적인 null 의미를 사용하는 편이 좋습니다.
클라우드 Mac에서 고정 테스트 벡터 실행하기
사용할 개발 도구 체인이 선택된 클라우드 Mac을 준비하고, 아래 내용을 uuid-contract.swift로 저장합니다. 이 스크립트는 Foundation만 사용하며 프로젝트, 네트워크, 난수에 의존하지 않습니다. 여기서 precondition은 테스트 실패 시 스크립트 실행을 중단하기 위한 것이며, 앱에서 외부 입력을 처리하는 용도로 사용하면 안 됩니다.
import Foundation
func parseUUID(_ text: String, requireV4: Bool = false) -> UUID? {
let bytes = Array(text.utf8)
guard bytes.count == 36 else { return nil }
let separators: Set<Int> = [8, 13, 18, 23]
for (index, byte) in bytes.enumerated() {
if separators.contains(index) {
guard byte == 45 else { return nil }
} else {
let isHex = (48...57).contains(byte)
|| (65...70).contains(byte)
|| (97...102).contains(byte)
guard isHex else { return nil }
}
}
guard let value = UUID(uuidString: text) else { return nil }
if requireV4 {
let raw = value.uuid
guard raw.6 >> 4 == 4, raw.8 & 0xC0 == 0x80 else {
return nil
}
}
return value
}
let valid = "00112233-4455-4677-8899-aabbccddeeff"
let parsed = parseUUID(valid, requireV4: true)!
precondition(parsed.uuidString.lowercased() == valid)
precondition(parseUUID(valid.uppercased()) == parsed)
let invalid = [
" " + valid,
valid + "\n",
"{" + valid + "}",
valid.replacingOccurrences(of: "-", with: ""),
valid.replacingOccurrences(of: "a", with: "a")
]
for input in invalid {
precondition(parseUUID(input) == nil)
}
let otherVersion = "00112233-4455-5677-8899-aabbccddeeff"
precondition(parseUUID(otherVersion) != nil)
precondition(parseUUID(otherVersion, requireV4: true) == nil)
let wrongVariant = "00112233-4455-4677-0899-aabbccddeeff"
precondition(parseUUID(wrongVariant) != nil)
precondition(parseUUID(wrongVariant, requireV4: true) == nil)
let raw = parsed.uuid
let wire: [UInt8] = [
raw.0, raw.1, raw.2, raw.3,
raw.4, raw.5, raw.6, raw.7,
raw.8, raw.9, raw.10, raw.11,
raw.12, raw.13, raw.14, raw.15
]
precondition(wire == [
0x00, 0x11, 0x22, 0x33,
0x44, 0x55, 0x46, 0x77,
0x88, 0x99, 0xaa, 0xbb,
0xcc, 0xdd, 0xee, 0xff
])
xcrun swift uuid-contract.swift를 실행합니다. 성공하면 아무것도 출력하지 않고, 실패하면 0이 아닌 종료 코드를 반환합니다. 파이프라인에서는 이 종료 코드가 그대로 유지되어야 합니다. 도구 체인을 업데이트한 뒤 다시 실행해 비교할 수 있도록 xcrun swift --version 결과도 함께 기록합니다.
식별자 비교 경로를 하나로 통일하기
앱 내부에서는 가능한 한 API에서 받은 원문 문자열이 아니라 UUID 값을 비교합니다. 데이터베이스에 텍스트 키를 저장해야 한다면 엄격한 파싱에 성공한 뒤 uuidString.lowercased()를 호출해, 하이픈이 포함된 소문자 표현 하나로 통일합니다.
파싱하기 전에 하이픈을 삭제하거나 앞뒤 공백을 제거하지 마세요. 이런 ‘보정’은 상위 시스템의 프로토콜 오류를 감추고, 원래라면 거부해야 할 두 입력이 같은 키로 합쳐지게 할 수도 있습니다. 사용자 인터페이스에서 붙여넣기를 허용한다면 정리 단계를 명시적으로 제공할 수는 있지만, API 계층에서 이를 아무 알림 없이 그대로 적용해서는 안 됩니다.
기존 데이터를 마이그레이션할 때는 먼저 대소문자 정규화 후 발생하는 충돌을 집계해야 합니다. 예를 들어 두 레코드의 UUID가 영문자 대소문자만 다르다고 해서 어느 한쪽을 바로 덮어쓰면 안 됩니다. 먼저 비즈니스상 연결 관계를 확인한 다음 병합하거나 비정상 레코드를 격리하고, 마이그레이션 결과를 보존합니다.
바이너리 교환 시 전체 바이트 순서 확인하기
위 테스트 벡터는 UUID 텍스트의 16진수 문자 순서대로 16바이트를 나열합니다. 이 순서는 현재 머신의 정수 저장 방식에 따라 결정되는 것이 아니라, 전송 프로토콜의 일부로 명시되어야 합니다.
처음 여덟 바이트를 네이티브 정수로 읽은 뒤 그대로 쓰지 마세요. 또한 어떤 API가 식별자를 GUID라고 부른다는 이유만으로 이 프로토콜과 메모리 레이아웃이 같다고 가정해서도 안 됩니다. 서로 다른 언어 간에 데이터를 주고받을 때는 양쪽 모두 같은 고정 텍스트에서 완전히 동일한 16진수 바이트열을 출력해야 합니다.
다시 읽을 때도 독립적인 샘플 사용하기
‘직접 인코딩한 뒤 직접 디코딩하는’ 테스트만으로는 부족합니다. 양쪽에서 같은 실수를 하면 왕복 테스트는 여전히 통과하기 때문입니다. 수신 측은 고정된 16바이트 샘플을 직접 사용하고, 복원된 텍스트가 원본 테스트 벡터와 같은지 단언해야 합니다. 이어서 15바이트와 17바이트 입력이 반드시 거부되는지도 테스트합니다. 0을 채워 넣거나 잘라내서는 안 됩니다.
실제 파싱 경로에 검사 연결하기
이 스크립트는 규칙의 예시를 검증합니다. 실제로 적용할 때는 파싱 함수를 공유 모듈로 옮겨 API 디코딩, 캐시 읽기와 쓰기, 데이터 가져오기 도구에서 모두 같은 함수를 호출하도록 합니다. 해당 테스트 벡터는 테스트 타깃에 넣어, 검사 스크립트는 통과하지만 앱은 여전히 별도의 느슨한 경로를 사용하는 상황을 방지합니다.
네 가지 테스트 그룹을 갖추세요. 유효한 대소문자 표현은 같은 식별자로 처리되어야 하고, 허용되지 않는 문자와 공백은 거부되어야 하며, 버전 제한은 지정된 필드에만 적용되어야 하고, 바이너리 샘플은 바이트 단위로 일치해야 합니다. 모든 그룹에서 반환 결과를 확인해야 하며, 단순히 ‘크래시가 나지 않았다’는 것만 단언해서는 안 됩니다.
실패 로그에는 필드명, 실패 유형, 샘플 번호만 기록하고 실제 비즈니스 식별자는 출력하지 않습니다. 도구 체인 업데이트, 서버 측 직렬화 라이브러리 교체, 과거 데이터 가져오기 작업 시에는 먼저 이 계약 테스트를 다시 실행합니다. 실패 지점을 텍스트, 정책, 바이트 순서 중 한 계층으로 좁히는 편이 캐시와 API 사이를 오가며 원인을 추측하는 것보다 훨씬 직접적입니다.
자주 묻는 질문
모든 UUID를 버전 4로 제한해야 하나요?
아닙니다. API가 버전 4 무작위 식별자를 명시적으로 요구할 때만 제한하세요. 일반 형식 검사와 허용 버전 정책은 분리해야 합니다.
대소문자가 다르면 다른 UUID인가요?
아닙니다. 유효한 값을 파싱한 뒤 하이픈이 포함된 소문자 형식을 키로 사용하세요. 원본 문자열은 진단용으로만 보관합니다.
다음 빌드에는 전용 Mac을 사용해 보세요.
가상 머신이 아닌 클라우드 Mac을 선택해 기존 Xcode와 CI 워크플로를 그대로 유지하세요. 프로젝트 요구 사항에 맞춰 머신을 선택할 수 있으며, 실제 사용 가능 여부는 콘솔에 표시되는 실시간 정보를 기준으로 합니다.