클라우드 Mac에서 검증하는 iOS JSON Pointer 경로 해석
서버는 검증 오류가 발생한 위치를 /items/0/name으로 알려 주는데, iOS 클라이언트에서는 엉뚱한 필드가 강조 표시되는 경우가 있습니다. 원인은 UI 바인딩이 아니라 경로 해석기가 빈 키를 버리거나, 01을 배열 인덱스로 처리하거나, 필드 누락과 유효한 null 값을 구분하지 못하는 데 있을 때가 많습니다. 클라우드 Mac에서는 독립 실행형 Swift 스크립트로 이러한 동작 규칙을 먼저 확정한 뒤, 테스트 데이터를 앱의 테스트 타깃으로 옮길 수 있습니다. 전체 UI 흐름을 실행할 필요는 없습니다.
1. 경로 해석 규칙부터 확정하기
이 글에서는 RFC 6901의 JSON 문자열 형식을 구현합니다. 빈 문자열은 문서 전체를 가리키며, 그 외의 경로는 반드시 /로 시작해야 합니다. #으로 시작하는 URI 프래그먼트 형식은 지원하지 않습니다. 데이터를 수정하는 기능도 아니며, JSONPath 쿼리 언어와도 다릅니다.
경로를 나눌 때는 빈 구간을 그대로 유지해야 합니다. /는 빈 키를 가리키고, /a/는 객체 a 안의 빈 키를 가리킵니다. 따라서 끝의 슬래시를 임의로 제거하면 안 됩니다. 객체의 멤버 이름은 항상 문자열로 비교하고, 현재 노드가 실제로 배열일 때만 숫자를 인덱스로 해석합니다.
경로가 존재하고 그 값이 null이면 읽기에 성공한 것입니다. 경로가 존재하지 않으면 실패입니다. 이 두 결과를 하나의 옵셔널 값으로 표현하지 마세요.
사용자에게 표시하는 오류 메시지는 최종적으로 “필드를 찾을 수 없습니다”로 통일할 수 있습니다. 하지만 테스트에서는 문법 오류, 멤버 누락, 인덱스 오류, 노드 타입 오류를 구분해 두어야 API 계약이 바뀌었을 때 원인을 추적하기 쉽습니다.
2. 엄격한 이스케이프 처리와 노드 순회 구현하기
다음 코드를 PointerCheck.swift로 저장합니다. Foundation만 사용하므로 별도 패키지를 설치할 필요 없이 현재 선택된 툴체인으로 실행할 수 있습니다. 이스케이프 처리에서는 ~0과 ~1만 허용하며, 각각 ~와 /로 복원합니다.
import Foundation
enum PointerError: Error {
case syntax
case missing
case index
case scalar
}
func decodeToken(_ token: Substring) throws -> String {
var result = ""
var cursor = token.startIndex
while cursor < token.endIndex {
let character = token[cursor]
cursor = token.index(after: cursor)
if character != "~" {
result.append(character)
continue
}
guard cursor < token.endIndex else {
throw PointerError.syntax
}
switch token[cursor] {
case "0": result.append("~")
case "1": result.append("/")
default: throw PointerError.syntax
}
cursor = token.index(after: cursor)
}
return result
}
func resolve(_ pointer: String, in document: Any) throws -> Any {
if pointer.isEmpty {
return document
}
guard pointer.first == "/" else {
throw PointerError.syntax
}
let tokens = try pointer.dropFirst()
.split(separator: "/", omittingEmptySubsequences: false)
.map { try decodeToken($0) }
var current = document
for token in tokens {
if let object = current as? [String: Any] {
guard let value = object[token] else {
throw PointerError.missing
}
current = value
} else if let array = current as? [Any] {
let digits = token.utf8
guard !digits.isEmpty,
digits.allSatisfy({ $0 >= 48 && $0 <= 57 }),
token == "0" || !token.hasPrefix("0"),
let index = Int(token),
index < array.count else {
throw PointerError.index
}
current = array[index]
} else {
throw PointerError.scalar
}
}
return current
}
~0을 먼저 치환한 뒤 ~1을 치환하는 방식은 사용할 수 없습니다. 원래 입력이 ~01이라면 슬래시가 아니라 리터럴 문자열 ~1로 복원되어야 하기 때문입니다. 각 구간을 순서대로 스캔하면 단독으로 나타나는 물결표나 ~2도 거부할 수 있어, 잘못된 경로가 아무 경고 없이 “수정”되는 일을 막을 수 있습니다.
배열 처리 분기에서는 ASCII 숫자만 허용하고, 양수·음수 부호와 공백, 그 밖의 숫자 문자는 거부합니다. Int 변환 실패도 인덱스 오류로 처리해야 하며, 강제 언래핑을 사용하면 안 됩니다. 반면 객체의 "01"은 유효한 키 이름이므로 배열 규칙을 적용해서는 안 됩니다.
3. 작은 테스트 데이터로 혼동하기 쉬운 경계 조건 검증하기
이어서 다음 검증 코드를 추가합니다. 테스트 데이터에는 빈 키, 특수 문자가 포함된 키, 배열, 스칼라, null이 함께 들어 있습니다. 각 단언문은 하나의 결과만 검증합니다.
let source = #"{"a/b":{"~key":[null,7]},"":9,"~1":11,"01":12}"#
let document = try JSONSerialization.jsonObject(
with: Data(source.utf8),
options: [.fragmentsAllowed]
)
let nullValue = try resolve("/a~1b/~0key/0", in: document)
precondition(nullValue is NSNull)
let emptyKey = try resolve("/", in: document)
precondition((emptyKey as? NSNumber)?.intValue == 9)
let escapedKey = try resolve("/~01", in: document)
precondition((escapedKey as? NSNumber)?.intValue == 11)
let numericKey = try resolve("/01", in: document)
precondition((numericKey as? NSNumber)?.intValue == 12)
let root = try resolve("", in: document)
precondition(root is [String: Any])
let rejected = [
"a", "#/a", "/bad~2", "/bad~",
"/absent", "/a~1b/~0key/01",
"/a~1b/~0key/-", "/a~1b/~0key/2",
"/a~1b/~0key/999999999999999999999999999999",
"/a~1b/~0key/1/child"
]
for pointer in rejected {
var failed = false
do {
_ = try resolve(pointer, in: document)
} catch is PointerError {
failed = true
}
precondition(failed, "Unexpected resolution success")
}
루트가 배열인 데이터와 스칼라인 데이터도 추가합니다. 루트가 스칼라여도 빈 경로로 읽으면 성공해야 하지만, 그 아래로 더 내려가려는 읽기는 반드시 실패해야 합니다. 문자열 비교는 입력 그대로 유지하고, 대소문자를 자동으로 통일하거나 Unicode 정규화를 적용하지 않습니다. 그러면 다른 멤버를 읽게 될 수 있기 때문입니다.
4. 예제 실행에 그치지 않고 클라우드 빌드에 연결하기
SSH 세션에서 다음 명령을 실행합니다.
set -eu
xcrun --find swift
xcrun swift PointerCheck.swift
스크립트는 성공하면 출력 없이 종료되고, 단언에 실패하면 0이 아닌 종료 코드를 반환합니다. 최적화 컴파일로 인해 검사가 제거될 수 있는 방식으로 이 검증 스크립트를 실행하지 마세요. 실제 앱에 통합할 때는 경로 해석기를 공용 모듈에 두고, 단언문을 테스트 타깃의 단언문으로 옮긴 다음 프로젝트에서 기존에 사용하던 테스트 실행 경로로 수행합니다.
독립 실행형 스크립트가 검증하는 것은 Foundation 데이터 트리에서의 읽기 동작입니다. iOS 타깃의 통합 테스트를 대신할 수는 없습니다. 앱이 사용자 정의 디코딩 모델을 직접 순회한다면 그 모델에도 동일한 테스트 데이터를 구성해야 합니다. 두 표현 방식이 저절로 같은 동작을 한다고 가정해서는 안 됩니다.
입력을 외부 API에서 받는다면 경로 해석기에 전달하기 전에 문서 크기, 경로 길이, 구간 수에도 제한을 두어야 합니다. 제한값은 서비스에서 다루는 필드 규모에 맞춰 정하고, 상한값과 상한을 초과한 값 모두 테스트해야 합니다. 예제의 짧은 경로를 리소스 사용량의 한계로 간주해서는 안 됩니다.
5. 실패하면 추측해서 우회하지 말고 입력 확인하기
경로를 찾지 못했을 때는 먼저 오류 유형, 구간 번호, 현재 노드 타입을 기록합니다. 업무 데이터 문서 전체를 로그에 남기지는 않습니다. 경로 자체에도 사용자 데이터가 들어 있을 수 있으므로, 민감 정보를 제거한 뒤에만 빌드 첨부 자료에 포함해야 합니다.
그다음 서버가 제공한 경로가 JSON Pointer인지 다른 경로 문법인지 확인하고, 추가 URL 디코딩을 거쳤는지도 점검합니다. 이 글에서 지원하는 문자열 형식에서 퍼센트 기호는 일반 문자일 뿐이므로, 임의로 한 번 더 디코딩하면 안 됩니다.
마지막으로, 수정 과정에서 확인한 실패 입력은 모두 고정된 테스트 데이터 모음에 추가합니다. 검증 기준은 “비슷한 필드를 최대한 찾아내는 것”이 아니라, 유효한 경로는 정확히 읽고 잘못된 경로는 명확히 실패하는 것이어야 합니다. 그래야 오류 표시, 설정 읽기, 필드 매핑이 하나의 안정적인 경로 규칙을 공유할 수 있습니다.
자주 묻는 질문
빈 경로와 슬래시 하나는 같은 의미인가요?
아닙니다. 빈 경로는 문서 전체를 반환합니다. 슬래시 하나는 루트 객체에서 이름이 빈 문자열인 멤버를 가리키며, 해당 멤버가 없으면 실패해야 합니다.
배열 인덱스로 01이나 하이픈을 사용할 수 있나요?
이 읽기 전용 해석기는 둘 다 거부합니다. 0 또는 앞에 0이 없는 십진 정수만 허용합니다. 하이픈은 읽을 수 있는 요소가 아니며 범위 초과와 정수 오버플로도 실패로 처리합니다.
내 파이프라인으로 클라우드 Mac 검증하기
ArmRDP는 가상 머신이 아닌 전용 물리 Mac mini를 제공합니다. 하루 단위로 대여해 툴체인과 빌드 결과를 먼저 확인한 뒤, 팀에 맞는 대여 기간을 선택하세요.
클라우드 Mac 대여하기