API가 같은 레코드의 UUID를 대문자에서 소문자로 바꿨을 뿐인데 클라이언트가 두 번째 레코드를 삽입하는 경우가 있습니다. 원인은 대개 난수 생성기가 아닙니다. 문자열을 그대로 기본 키로 사용하면서 비교, 캐시, 영속화에 서로 다른 규칙을 적용하기 때문입니다. 클라우드 Mac에서는 먼저 이 입력 경계의 동작을 검증하는 독립적인 Swift 인수 테스트 스크립트를 만든 다음 iOS 프로젝트 테스트에 통합할 수 있습니다. 시뮬레이터를 실행할 필요는 없습니다.
먼저 입력 계약을 명확히 정하기
이 글에서는 표준 36바이트 텍스트 형식을 허용합니다. 16진수 문자를 길이가 각각 8, 4, 4, 4, 12인 다섯 그룹으로 나누고, 그룹 사이에는 ASCII 하이픈을 사용합니다. 입력은 대소문자 혼용을 허용하되 출력은 소문자로 통일합니다. 앞뒤 공백, 중괄호로 감싼 형식, 하이픈이 없는 형식은 거부합니다.
다른 표현 방식이 모두 잘못되었다는 뜻은 아닙니다. API에서 허용할 입력 형식을 하나로 정하는 것입니다. 기존 API가 다른 형식을 허용한다면 명시적인 호환 계층에서 처리해야 합니다. 핵심 파서가 입력을 암묵적으로 보정하게 해서는 안 됩니다.
문법적으로 유효한지와 비즈니스 규칙상 허용되는지도 구분해야 합니다. 전체가 0인 UUID는 구조적으로 유효하지만 주문이나 문서 식별자로는 금지할 수 있습니다. UUID 버전을 제한할지도 API 계약에서 정해야 합니다. 세 번째 그룹의 첫 문자가 특정 숫자가 아니라는 이유만으로 바로 오류를 반환해서는 안 됩니다.
UUID는 식별자이지 접근 자격 증명이 아닙니다. 형식이 올바르더라도 서버는 현재 사용자에게 해당 레코드를 읽을 권한이 있는지 확인해야 합니다.
실행 가능한 엄격한 파서 작성하기
노드에서 xcrun --find swift를 실행해 현재 도구 체인이 Swift를 제공하는지 확인합니다. 다음 내용을 uuid-contract.swift로 저장하세요. 코드는 먼저 바이트 구조를 검사한 뒤 Foundation으로 파싱합니다. 이렇게 하면 비표준 입력을 어느 정도까지 허용하는지에 관한 OS 버전별 동작에 의존하지 않아도 됩니다.
import Foundation
enum IdentifierError: Error {
case invalidSyntax
case zeroNotAllowed
}
func parseIdentifier(
_ text: String,
allowZero: Bool = false
) throws -> UUID {
let bytes = Array(text.utf8)
let separators: Set<Int> = [8, 13, 18, 23]
guard bytes.count == 36 else {
throw IdentifierError.invalidSyntax
}
for (index, byte) in bytes.enumerated() {
if separators.contains(index) {
guard byte == 45 else {
throw IdentifierError.invalidSyntax
}
} else {
let isHex = (48...57).contains(byte)
|| (65...70).contains(byte)
|| (97...102).contains(byte)
guard isHex else {
throw IdentifierError.invalidSyntax
}
}
}
guard let value = UUID(uuidString: text) else {
throw IdentifierError.invalidSyntax
}
let zero = UUID(
uuid: (0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0)
)
guard allowZero || value != zero else {
throw IdentifierError.zeroNotAllowed
}
return value
}
func canonical(_ value: UUID) -> String {
value.uuidString.lowercased()
}
눈에 보이는 문자 수 대신 UTF-8 바이트를 사용하는 이유는 전각 문자와 하이픈을 닮은 유니코드 기호를 명확히 배제하기 위해서입니다. 여기서 lowercased()는 이미 검증된 UUID의 출력만 처리합니다. 임의의 사용자 입력을 먼저 변환한 뒤 통과시키는 방식이 아닙니다.
고정된 테스트 데이터로 경계 확인하기
같은 파일 끝에 테스트를 이어서 추가합니다. 성공 사례에서는 값의 동등성, 정규화, 멱등성을 검증합니다. 실패 사례에서는 공백, 문자 치환, 길이 변화를 다룹니다. 고정된 잘못된 입력을 무작위 UUID로 대체하지 마세요. 실패 상황을 재현하기 어려워집니다.
let upper = "550E8400-E29B-41D4-A716-446655440000"
let lower = upper.lowercased()
let value = try parseIdentifier(upper)
precondition(value == (try parseIdentifier(lower)))
precondition(canonical(value) == lower)
let reparsed = try parseIdentifier(canonical(value))
precondition(canonical(reparsed) == lower)
let invalid = [
"",
" " + lower,
lower + "\n",
"{" + lower + "}",
lower.replacingOccurrences(of: "-", with: ""),
lower.replacingOccurrences(of: "-", with: "-"),
lower.replacingOccurrences(of: "5", with: "g"),
lower + "0"
]
for text in invalid {
do {
_ = try parseIdentifier(text)
fatalError("Invalid UUID accepted")
} catch IdentifierError.invalidSyntax {
}
}
let zero = "00000000-0000-0000-0000-000000000000"
do {
_ = try parseIdentifier(zero)
fatalError("Zero UUID accepted")
} catch IdentifierError.zeroNotAllowed {
}
_ = try parseIdentifier(zero, allowZero: true)
xcrun swift uuid-contract.swift를 실행합니다. 성공하면 아무것도 출력되지 않으며, 실패하면 프로세스가 0이 아닌 종료 코드로 종료됩니다. 코드의 빈 catch 블록은 예상한 오류가 발생했음을 뜻합니다. 다른 오류는 무시되지 않고 스크립트를 실패하게 만듭니다. XCTest로 옮긴 뒤에도 구체적인 오류를 검증하는 어서션을 사용해 이 구분을 유지해야 합니다.
데이터 경계에서 정규화하기
파싱은 네트워크 응답, 딥 링크 매개변수, 가져온 파일의 데이터가 도메인 모델로 들어오는 지점에서 수행해야 합니다. 도메인 계층은 UUID를 보관하고, 요청을 보내거나 텍스트 필드에 쓸 때만 canonical을 호출합니다. UI 계층과 캐시 계층에서 각각 문자열 치환을 수행하게 해서는 안 됩니다.
저장소 마이그레이션은 특히 주의해야 합니다. 먼저 읽기 전용 복사본에서 기존 키를 정규형에 매핑하고, 하나의 정규형에 여러 기존 키가 대응하는 사례를 집계합니다. 충돌이 발견되면 비즈니스 규칙에 따라 레코드를 병합한 다음 외래 키와 고유성 제약 조건을 갱신해야 합니다. 곧바로 일괄 소문자 변환을 하면 과거의 두 레코드를 하나로 합쳐 버릴 수 있습니다.
UUID 텍스트 정렬을 시간순 정렬로 취급하지 않기
무작위 UUID의 사전식 순서는 생성 순서를 나타내지 않습니다. 목록에 안정적인 순서가 필요하다면 명시적인 생성 시각 필드를 사용하고, 시각이 같을 때는 정규화된 식별자를 보조 정렬 키로 추가합니다. 시간 정보를 담는 UUID 방식을 채택하더라도 버전과 정렬 규칙은 별도로 정해야 합니다.
클라우드 Mac의 인수 검증 절차에 통합하기
ArmVNC 노드에서 스크립트를 실행하기 전에 커밋 식별자, sw_vers, xcrun swift --version을 기록합니다. 이 정보는 도구 체인에 따른 차이를 설명하기 위한 것이며 비즈니스 데이터를 포함하지 않습니다. 실패 사례에는 고정된 테스트 값을 사용하고, 사용자 레코드를 그대로 보관하지 마세요.
지속적 통합에 연결한 뒤에는 최소한 다음 네 가지를 확인합니다.
- 표준 형식의 텍스트를 파싱할 수 있으며, 대소문자가 달라도 같은 값을 얻는다.
- 공백, 잘못된 구분자, 16진수가 아닌 문자가 포함된 입력은 반드시 실패한다.
- 전체가 0인 값은 명시적인 정책에 따라 처리하며, 일반적인 구문 오류로 처리해 차이를 감추지 않는다.
- 기존 키를 이전하기 전에 충돌을 확인하고, 이전 전후에 연관 레코드 수가 동일하게 유지된다.
마지막으로 통합 테스트를 하나 추가합니다. 같은 엔터티를 대문자 UUID와 소문자 UUID로 각각 입력해도 저장소에는 레코드가 하나만 남고, 업데이트는 기존 레코드에 반영되는지 확인합니다. 파서가 보장하는 것은 입력 경계의 일관성입니다. 캐시, 데이터베이스, 비즈니스 업데이트 로직이 실제로 이 계약을 따르는지는 통합 테스트로 입증해야 합니다.
자주 묻는 질문
UUID의 대소문자가 다르면 다른 식별자인가요?
아닙니다. UUID 값으로 파싱한 뒤 비교하고 문자열로 저장할 때는 소문자 표준 형식으로 통일합니다. 기존 문자열 키는 정규화 후 충돌하는지 먼저 확인해야 합니다.
전체가 0인 UUID는 반드시 거부해야 하나요?
구조적으로는 유효합니다. 엔터티 식별자로 허용할지는 업무 규칙이므로 명시적인 옵션으로 만들고 허용과 거부 경로를 모두 테스트합니다.
빌드 작업은 전용 클라우드 Mac에 맡기세요
Apple Silicon 물리 노드를 선택해 한 대를 단독으로 사용하세요. macOS 그래픽 인터페이스와 터미널을 그대로 이용할 수 있습니다. 모델, 이용 기간, 노드는 주문 확인 내용을 기준으로 합니다.