사용자가 iOS 앱을 업데이트한 뒤에도 기기에는 이전 버전이 저장한 JSON 캐시가 남아 있을 수 있습니다. 서버의 최신 응답이 정상적으로 디코딩된다고 해서 오래된 캐시까지 읽을 수 있는 것은 아닙니다. Codable 모델에 필드를 추가한 뒤 새 응답만 테스트했다가, 사용자가 오프라인 화면을 열었을 때 비로소 디코딩 오류가 드러나는 경우가 흔합니다. 해결책은 모든 오류를 조용히 넘기는 것이 아닙니다. 실제 과거 데이터의 형태를 고정된 샘플로 남기고, 어떤 변경은 호환되어야 하며 어떤 입력은 반드시 오류가 나야 하는지 명확히 검증해야 합니다.
먼저 호환성의 경계를 정하기
계정 요약을 예로 들어 보겠습니다. id와 status는 비즈니스 로직에 필요한 필수 필드이고, nickname은 나중에 추가된 선택 필드입니다. 기대하는 동작은 세 가지입니다. nickname이 없는 오래된 데이터는 읽을 수 있어야 하고, 새 데이터에 클라이언트가 아직 모르는 키가 있어도 읽을 수 있어야 하며, id가 없으면 실패해야 합니다. 이 세 입력은 따로 테스트해야 합니다. ‘정상 응답’ 하나만으로 호환성을 입증할 수는 없습니다.
합성된 Decodable 구현에서 String?은 값이 없어도 받아들이며 결과는 nil이 됩니다. 반면 필수 타입인 String의 값이 없으면 오류가 발생합니다. 합성 구현은 일반적으로 알 수 없는 키를 무시합니다. 하지만 이는 디코딩 단계에만 해당합니다. 화면 코드에서 nickname을 강제 언래핑한다면 오래된 캐시는 여전히 문제를 일으킵니다. 모델 테스트를 통과한 뒤에는 이 필드를 사용하는 코드에 대체 표시가 마련되어 있는지도 확인해야 합니다.
‘오래된 데이터를 읽을 수 있다’와 ‘손상된 데이터는 거부한다’를 모두 테스트에 명시하세요. 전자만 검사하면 실제 데이터 오류를 호환성 처리로 감추기 쉽습니다.
과거 응답을 고정된 샘플로 만들기
테스트 타깃에 Fixtures 디렉터리를 만들고 account-v1.json, account-v2.json, account-missing-id.json을 저장합니다. 파일명은 특정 CI 실행 날짜가 아니라 데이터의 형태를 나타내야 합니다. 세 파일을 테스트 타깃의 Copy Bundle Resources에 추가하세요. 프로젝트 디렉터리에 파일을 만들기만 해서는 테스트 실행 중에 자동으로 찾을 수 없습니다.
account-v1.json에는 id와 status가 있지만 nickname은 없는 이전 응답을 보관합니다. account-v2.json에는 nickname과 현재 모델에 선언되지 않은 키 하나를 추가합니다. 세 번째 샘플에서는 id를 삭제해 실패해야 하는 반례로 사용합니다. 샘플은 민감 정보를 제거한 실제 데이터 형태를 바탕으로 정리해야 합니다. 개인 정보와 토큰은 바꾸되 필드 타입, 누락 여부, 중첩 구조는 유지하세요. API가 바뀔 때마다 오래된 파일을 덮어쓰면 회귀를 발견하는 테스트의 역할이 사라집니다.
서버가 필드를 문자열에서 숫자로 바꿨다면 새 버전을 별도로 저장하고, 앱이 두 형식을 모두 지원해야 하는지 먼저 결정하세요. 테스트를 통과시키려고 모든 필드에 느슨한 문자열 변환을 적용해서는 안 됩니다. 호환이 필요하다면 명시적인 커스텀 디코딩 분기와 그에 맞는 샘플을 작성합니다.
XCTest로 세 가지 결과 검증하기
다음 테스트는 샘플이 테스트 bundle의 루트 디렉터리로 복사되었다고 가정합니다. 예제에서는 AccountSnapshot을 별도로 선언했지만, 실제 프로젝트에서는 테스트 대상인 프로덕션 모델로 바꿔야 합니다. 임시로 만든 다른 구조체만 테스트에 통과하는 일을 피하기 위해서입니다.
import Foundation
import XCTest
private struct AccountSnapshot: Decodable {
let id: String
let status: String
let nickname: String?
}
final class CodableContractTests: XCTestCase {
private func decode(_ name: String) throws -> AccountSnapshot {
let url = try XCTUnwrap(
Bundle(for: Self.self).url(
forResource: name,
withExtension: "json"
)
)
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(AccountSnapshot.self, from: data)
}
func testOldResponseRemainsReadable() throws {
let account = try decode("account-v1")
XCTAssertEqual(account.id, "example-001")
XCTAssertNil(account.nickname)
}
func testUnknownFieldDoesNotBreakDecoding() throws {
let account = try decode("account-v2")
XCTAssertEqual(account.id, "example-001")
XCTAssertEqual(account.nickname, "Tester")
}
func testMissingRequiredFieldFails() {
XCTAssertThrowsError(try decode("account-missing-id"))
}
}
읽을 수 있어야 하는 두 샘플에 동일한 예시 id를 사용해야 단언의 의미가 분명해집니다. 현재 실패 테스트는 오류가 발생하는지만 확인합니다. 오류 유형 자체가 데이터 처리 계약에 포함된다면 DecodingError.keyNotFound와 해당 필드명까지 추가로 검증하세요. 그러면 ‘누락된 필드를 예상대로 거부한 경우’와 ‘테스트 리소스를 아예 찾지 못한 경우’를 구분할 수 있습니다.
날짜와 열거형은 기본 동작을 추측하지 않기
샘플에 날짜가 있다면 프로덕션 코드에서 실제로 사용하는 JSONDecoder.dateDecodingStrategy를 먼저 확인하고, 테스트에도 같은 설정을 적용해야 합니다. 상태 값에 새로운 열거형 케이스가 추가된다면 알 수 없는 상태를 보존하고 일반적인 문구를 표시할지, 처리를 거부할지도 결정해야 합니다. 이 결정을 모델과 샘플에 반영하세요. 테스트에서 임의의 디코딩 정책을 만들어서는 안 됩니다.
클라우드 Mac의 테스트 작업에 포함하기
먼저 xcodebuild -list로 프로젝트와 공유된 scheme을 확인한 뒤, 현재 사용 가능한 시뮬레이터 목록에서 기기 ID를 가져옵니다. 다음 명령의 환경 변수는 CI 작업에서 제공하며, TEST_TARGET에는 테스트 타깃 이름을 지정합니다. 기기 ID를 사용하면 이름이 같은 시뮬레이터 때문에 대상이 모호해지는 일을 피할 수 있습니다.
: "${PROJECT:?Set PROJECT}"
: "${SCHEME:?Set SCHEME}"
: "${SIM_ID:?Set SIM_ID}"
: "${TEST_TARGET:?Set TEST_TARGET}"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,id=$SIM_ID" \
"-only-testing:$TEST_TARGET/CodableContractTests" \
test
CDNMac 클라우드 Mac에서는 선택한 Xcode로 프로젝트를 열 수 있는지, scheme이 공유되어 있는지, 테스트 리소스가 bundle에 포함되어 있는지 먼저 확인한 다음 작업을 실행하세요. 여기서 실행하는 것은 데이터 계약 테스트이므로 서명 자료나 실제 기기 연결은 필요하지 않습니다. CI는 xcodebuild의 종료 상태를 그대로 반영해야 합니다. 샘플 누락, 디코딩 실패, 단언 불일치는 모두 작업 실패로 이어져야 하며, 로그의 특정 단어로 성공 여부를 판단해서는 안 됩니다.
샘플을 유지할 때 확인할 사항
모델 변경 사항을 코드 리뷰할 때는 항목별로 확인하세요. 오래된 캐시를 다시 읽을 가능성이 있는가? 새 필드가 과거 데이터에 없을 때 화면에는 무엇을 표시하는가? 알 수 없는 키를 무시해야 하는가? 필수 필드가 손상되면 오류는 어디로 전달되는가? 이어서 테스트가 겉보기에만 같은 예제 구현이 아니라 프로덕션 모델과 프로덕션 디코딩 설정을 사용하는지도 확인합니다.
테스트가 실패하면 먼저 오류의 출처를 살펴보세요. 파일을 찾지 못했다면 타깃 포함 여부와 bundle을 확인합니다. keyNotFound라면 해당 필드가 정말 필수 데이터인지, 새로 추가된 선택 필드인지 확인합니다. typeMismatch라면 실제 데이터 타입이 바뀌었는지 살펴봅니다. 단언이 실패했다면 디코딩 결과의 비즈니스 의미를 확인합니다. 이 모든 경우를 try?로 바꾸고 빈 객체를 반환해서는 안 됩니다. 보존한 과거 샘플은 ‘무엇을 여전히 읽을 수 있는가’에 대한 팀의 실행 가능한 기록입니다.
자주 묻는 질문
선택 필드를 추가했다면 이전 JSON 샘플을 계속 보관해야 하나요?
예. 오래된 캐시에는 새 필드가 없을 수 있으므로, 해당 샘플로 누락된 값이 nil로 해석되는지 계속 확인해야 합니다.
JSON에 알 수 없는 필드가 있으면 디코딩이 실패해야 하나요?
일반적으로 아닙니다. 합성된 Decodable 구현은 선언되지 않은 키를 무시합니다. 필수 필드 누락은 별도 샘플에서 실패하는지 검증하세요.
다음 iOS 빌드는 전용 클라우드 Mac에서 실행하세요
M4, 16GB RAM, 256GB SSD를 갖춘 고정 물리 환경을 제공합니다. 6개 노드와 4가지 대여 기간 중 현재 작업에 맞는 구성을 선택하세요.