팀에서 사용자 설정을 부분 업데이트 방식으로 바꾸면, 요청 자체가 실패하기보다는 성공 응답을 받았는데도 이전 필드가 남는 문제가 자주 발생합니다. 클라이언트는 null을 무시할 값으로 처리하지만, 서버는 삭제로 해석하는 식입니다. 클라우드 Mac의 빌드 작업에 Swift 계약 테스트를 추가하면 컴파일 성공 여부를 넘어, 양쪽이 업데이트 내용을 동일하게 해석하는지도 검증할 수 있습니다. 여기서는 JSON Merge Patch를 통한 문서 변환만 다룹니다. 운영 API에 연결하지 않으며 시뮬레이터에도 의존하지 않습니다.
업데이트 프로토콜의 네 가지 규칙 확정하기
JSON Merge Patch의 규칙은 RFC 7396에 정의되어 있습니다. 객체 패치는 필드별로 처리합니다. 패치에 없는 필드는 유지하고, 값이 null인 필드는 삭제하며, 나머지 값은 재귀적으로 적용합니다. 패치가 객체가 아니면 대상 값 전체를 교체하므로, 배열을 인덱스별로 병합하지 않습니다.
먼저 API에서 application/merge-patch+json을 사용한다는 점을 명시해야 합니다. 요청 메서드만으로 프로토콜을 판단해서는 안 됩니다. 일반 JSON 요청은 같은 PATCH 메서드를 사용하더라도 전혀 다른 비즈니스 규칙을 따를 수 있습니다.
객체 멤버의
null은 삭제를 뜻하고, 루트 패치의null은 문서 전체를 null로 바꾼다는 뜻입니다. 비즈니스 요구사항상 명시적인 null 값을 저장해야 한다면, 같은 위치의null에 값 할당과 삭제라는 두 가지 의미를 동시에 부여할 수 없습니다.
빈 객체와 빈 배열도 구분해야 합니다. 기존 객체에 {}를 적용하면 내용이 유지되지만, []는 대상 전체를 빈 배열로 교체합니다. 대상이 원래 객체가 아니었다면 빈 객체 패치를 적용한 결과는 빈 객체가 됩니다.
독립적인 Swift 병합 코어 작성하기
저장소에 ci/merge-patch-check.swift를 만들고 다음 코드를 넣습니다. Foundation으로 JSON을 파싱하고 프래그먼트 허용 옵션을 켭니다. 이렇게 하면 객체와 배열만 허용하는 대신 숫자, 문자열, 루트의 null 값도 테스트할 수 있습니다.
import Foundation
enum PatchError: Error {
case depthExceeded
case mismatch(String)
}
func parse(_ text: String) throws -> Any {
try JSONSerialization.jsonObject(
with: Data(text.utf8),
options: [.fragmentsAllowed]
)
}
func merge(
_ target: Any,
_ patch: Any,
depth: Int = 0
) throws -> Any {
guard depth <= 64 else {
throw PatchError.depthExceeded
}
guard let fields = patch as? [String: Any] else {
return patch
}
var result = target as? [String: Any] ?? [:]
for (key, value) in fields {
if value is NSNull {
result.removeValue(forKey: key)
} else {
result[key] = try merge(
result[key] ?? NSNull(),
value,
depth: depth + 1
)
}
}
return result
}
func canonical(_ value: Any) throws -> Data {
try JSONSerialization.data(
withJSONObject: value,
options: [.sortedKeys, .fragmentsAllowed]
)
}
func check(
_ name: String,
_ target: String,
_ patch: String,
_ expected: String
) throws {
let actual = try merge(try parse(target), try parse(patch))
let expectedValue = try parse(expected)
guard try canonical(actual) == canonical(expectedValue) else {
throw PatchError.mismatch(name)
}
}
깊이 제한은 이 구현에서 정한 리소스 관리 정책이지, 프로토콜 자체의 규칙이 아닙니다. 여기서는 루트 호출의 깊이가 0이며, 재귀 호출의 깊이가 64를 넘으면 실패합니다. 실제 요청을 받는 진입점에서는 요청 본문의 바이트 수와 전체 중첩 깊이도 제한해야 합니다. 배열은 그대로 교체되므로, 객체 재귀 처리에서 세는 깊이만으로는 배열 내부까지 완전히 검사할 수 없습니다.
이 병합 코어는 JSON 파싱으로 얻은 값만 받으며, 임의의 비즈니스 객체는 받지 않습니다. 날짜나 바이너리 데이터를 딕셔너리에 그대로 넣고 직렬화 단계에서 타입이 자동으로 보정되기를 기대해서는 안 됩니다.
고정 테스트 벡터로 삭제와 교체 검증하기
같은 파일에 다음 검사를 추가합니다. 각 샘플은 핵심 동작 하나를 검증하며, 실패한 테스트의 이름을 코드 리뷰에서 확인할 프로토콜 조항과 바로 연결할 수 있습니다.
try check(
"delete-and-preserve",
#"{"theme":"dark","badge":3}"#,
#"{"badge":null}"#,
#"{"theme":"dark"}"#
)
try check(
"nested-object",
#"{"ui":{"theme":"dark","scale":2}}"#,
#"{"ui":{"scale":3}}"#,
#"{"ui":{"theme":"dark","scale":3}}"#
)
try check(
"replace-array",
#"{"roles":["reader","writer"]}"#,
#"{"roles":["reader"]}"#,
#"{"roles":["reader"]}"#
)
try check("root-null", #"{"enabled":true}"#, "null", "null")
try check("empty-object", "[1,2]", "{}", "{}")
try check("empty-array", #"{"items":[1]}"#, #"{"items":[]}"#, #"{"items":[]}"#)
try check("missing-delete", "{}", #"{"absent":null}"#, "{}")
try check("scalar-root", #"{"count":1}"#, "7", "7")
비교 전에는 객체 키만 정렬하며 배열 순서는 바꾸지 않습니다. 여기서 사용하는 직렬화 결과 비교는 위의 고정 샘플에 적합한 방식입니다. 범용적인 숫자 정규화 방법이 아니며, 서로 다른 언어에서 공통으로 사용할 서명을 계산하는 데도 사용해서는 안 됩니다.
클라이언트가 필드를 일반적인 옵셔널 값으로 모델링하면, 인코딩 과정에서 ‘필드 삭제’와 ‘필드 업데이트 안 함’이 모두 필드 생략으로 처리되기 쉽습니다. 요청 구성 계층에서는 변경 없음, 삭제, 값 할당의 세 가지 상태를 명확하게 구분하고, 생성된 JSON도 별도로 검사해야 합니다. 병합 코어만 검증해서는 요청 인코딩이 올바르다고 보장할 수 없습니다.
리소스 경계와 반복 적용 검사 추가하기
같은 패치를 반복해서 적용해도 결과는 같아야 합니다. 이 검사는 배열에 항목을 잘못 추가하거나 숫자를 잘못 누적하는 구현을 찾는 데 유용합니다. 다만 API 전체에서 알림 중복 발송 같은 부수 효과가 없다는 것까지 증명하지는 못합니다.
다음 검사도 추가하여, 지나치게 깊은 객체를 조용히 잘라 내는 대신 명시적인 오류를 반환하는지 확인합니다.
let target = try parse(#"{"ui":{"scale":1}}"#)
let patch = try parse(#"{"ui":{"scale":2},"old":null}"#)
let once = try merge(target, patch)
let twice = try merge(once, patch)
guard try canonical(once) == canonical(twice) else {
throw PatchError.mismatch("idempotence")
}
var deep: Any = 1
for _ in 0..<66 {
deep = ["child": deep]
}
do {
_ = try merge(NSNull(), deep)
throw PatchError.mismatch("depth-limit")
} catch PatchError.depthExceeded {
}
놓치기 쉬운 또 다른 사례는 유효하지 않은 JSON입니다. 파싱에 실패하면 반드시 요청 처리를 중단해야 합니다. 오류를 잡은 뒤 빈 딕셔너리로 대체하여 병합을 계속하면, 손상된 입력을 정상적인 업데이트로 처리할 수 있습니다. 중복 키를 받아들일 때의 정책도 진입점에서 별도로 정해야 합니다. 서로 다른 파서가 우연히 같은 값을 남기는 동작에 의존해서는 안 됩니다.
클라우드 Mac의 커밋 검사에 통합하기
먼저 현재 도구 체인을 사용할 수 있는지 확인한 다음, 저장소 루트 디렉터리에서 실행합니다.
set -eu
xcrun --find swift
xcrun swift ci/merge-patch-check.swift
이 스크립트는 최적화로 비활성화될 수 있는 어설션을 사용하지 않습니다. 비교가 하나라도 실패하면 오류를 던지고 0이 아닌 종료 상태를 반환합니다. 시간이 오래 걸리는 앱 빌드보다 먼저 실행하고, 샘플 이름과 종료 상태만 기록하면 됩니다. 실제 사용자 설정을 로그에 남길 필요는 없습니다.
도입할 때는 세 계층에서 검증합니다. 병합 코어에서는 고정 테스트 벡터를 실행하고, 요청 구성 계층에서는 실제 인코딩 결과를 검사하며, API 통합 계층에서는 서버가 패치를 적용한 뒤의 문서를 대조합니다. 모바일 캐시에도 같은 샘플을 사용해야 합니다. 그래야 화면이 잘못된 규칙으로 낙관적 업데이트를 수행한 뒤 서버 결과로 다시 덮어써지는 일을 막을 수 있습니다. 최종 리뷰에서 살펴볼 것은 요청 한 번이 성공 응답을 반환했는지가 아니라, 각 필드의 업데이트가 무엇을 의미하는지입니다.
자주 묻는 질문
객체 필드 값을 null로 설정할 수 있나요?
패치 객체에서 null은 해당 필드 삭제를 뜻합니다. 필드를 유지하면서 null을 저장하려면 다른 프로토콜이나 명시적 작업이 필요합니다. 단, 루트 패치가 null이면 문서 전체를 null로 교체합니다.
배열을 인덱스별로 병합하나요?
아닙니다. 패치에 있는 배열은 기존 배열 전체를 교체하며 빈 배열도 동일합니다. 요소 식별자별 업데이트가 필요하면 별도의 업무 작업으로 정의해야 합니다.
팀 전용 빌드를 실행하는 Mac
Apple Silicon M4 전용 물리 Mac mini를 선택하고 팀의 툴체인과 runner 설정을 그대로 유지하세요. 일·주·월·분기 단위로 이용할 수 있으며, 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.
빌드 노드 대여하기