← iOS 개발자 CS 로드맵 31 / 35

31 · 직렬화와 스키마

Codable 합성이 성립하는 조건과 클래스 상속의 구멍, CodingKeys 제외의 양방향성, 세 컨테이너와 superEncoder, DecodingError의 codingPath, 다형 JSON 판별자 패턴, convertFromSnakeCase의 비가역성과 디코더 재사용 통념, JSON 스키마 진화.
진행률
0 / 0 완료

직렬화는 앱의 경계를 넘는 모든 데이터가 반드시 지나는 관문이다. 서버 응답, 로컬 저장, 앱 익스텐션 간 전달, 딥링크 페이로드가 전부 여기를 통과한다.

이 챕터의 버그들은 앞선 장들과 같은 성질을 갖는다 — 조용하다. 프로퍼티가 인코딩에서 빠져도 컴파일은 통과하고, 상속 계층의 필드가 사라져도 에러가 없으며, 키 변환 전략이 원본 키를 이기고 keyNotFound를 낸다.

P0 09장 Q4에서 데이터베이스 스키마 진화를 다뤘다 — "코드는 배포로 한 번에 교체되지만 데이터는 각 기기에 과거 버전 그대로 남아 있다"는 문제였다. 이 챕터는 같은 문제를 클라이언트-서버 JSON 계약 축에서 본다. 여기서는 양쪽 모두가 서로 다른 속도로 바뀐다.

Q1. Codable은 무엇을 자동 생성하며 어디서 무너지는가?

🔑 30초 답변

컴파일 타임 코드 합성이지 런타임 리플렉션이 아니다. 저장 프로퍼티가 전부 이미 Codable인 타입이면 컴파일러가 init(from:)·encode(to:)컴파일 시점에 생성한다. 그래서 Codable이 아닌 프로퍼티, 제약 없는 제네릭, 실존 타입 프로퍼티가 섞이면 그 자리에서 컴파일 에러로 끊긴다 — 정적으로 검증된다는 뜻이다. 구멍은 클래스 상속이다. 서브클래스가 새 저장 프로퍼티를 추가해도 에러 없이 컴파일되고, 그 프로퍼티는 인코딩·디코딩 양쪽에서 조용히 사라진다.

CS 원리

직렬화 구현 방식은 크게 셋이고, 각각 다른 시점에 실패한다.

방식실패 시점대가
런타임 리플렉션실행 중느리고, 타입 정보 유지 필요
수동 구현사람이 빠뜨리면 안 남보일러플레이트, 필드 추가 시 누락
컴파일 타임 합성빌드 시점합성 규칙을 벗어나면 직접 써야 함

Swift는 세 번째를 골랐다. 이 선택의 값어치는 "직렬화할 수 없는 타입을 담으면 빌드가 안 된다"는 보장이다. 런타임에 "이 필드는 인코딩 못 합니다"라는 예외를 만나는 대신, 코드를 쓰는 순간 알게 된다.

그런데 합성은 타입 단위로 일어난다. 클래스 상속에서는 서브클래스가 자기 프로퍼티만 보고 합성하는데, 상위 타입이 이미 Codable을 채택했다면 서브클래스는 그 구현을 상속받는다. 상속받은 init(from:)상위 타입의 프로퍼티만 안다.

여기서 정적 분석이 뚫린다. 컴파일러 입장에서는 프로토콜 요구사항이 이미 만족돼 있으므로 합성할 이유가 없고, 새 프로퍼티가 빠진다는 사실은 타입 시스템이 표현하지 않는 문제다.

합성은 컴파일 타임에 검증된다 — 단, 타입 하나 단위로 ✅ struct — 정적으로 안전 모든 저장 프로퍼티가 Codable 이어야 아니면 그 자리에서 컴파일 에러 필드를 추가해도 자동 반영된다 ⚠️ class 상속 — 구멍 상위가 Codable 이면 구현이 상속된다 서브클래스 프로퍼티는 조용히 사라진다 에러도 경고도 없다 무슨 일이 일어나는가 Base: id, name Sub: + level 인코딩 {"id":1,"name":"A"} level 이 없다 디코딩 시 level 은 기본값 또는 실패 "Codable 을 한 번 채택하면 상속 계층 전체가 처리된다"는 틀렸다
정적 검증의 이점이 상속 경계에서 끊긴다. 값 타입을 쓰면 이 문제가 아예 없다.

iOS에서는

합성이 거부되는 경우는 컴파일러가 알려준다.

struct Fine: Codable {          // ✅ 전부 Codable
    let id: Int
    let name: String
    let tags: [String]
}

struct Broken: Codable {        // ❌ 컴파일 에러
    let id: Int
    let handler: () -> Void     // 함수 타입은 Codable 이 아니다
    let any: any Shape          // 실존 타입도 합성 불가
}
//   "type 'Broken' does not conform to protocol 'Decodable'"
//   → 정적으로 막힌다. 이게 이 설계의 값어치다.

클래스 상속은 다르다. 에러 없이 넘어간다.

class Animal: Codable {
    var name: String = ""
}

class Dog: Animal {
    var breed: String = ""      // ⚠️ 에러도 경고도 없다
}

let dog = Dog(); dog.name = "바둑"; dog.breed = "진돗개"
let data = try JSONEncoder().encode(dog)
String(data: data, encoding: .utf8)      // {"name":"바둑"}  ← breed 가 없다

해결은 직접 구현하고 super를 명시적으로 다루는 것이다.

class Dog2: Animal {
    var breed: String = ""

    private enum CodingKeys: String, CodingKey { case breed }

    override func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(breed, forKey: .breed)
        try super.encode(to: c.superEncoder())      // 상위를 별도 컨테이너로 (Q3)
    }

    required init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        breed = try c.decode(String.self, forKey: .breed)
        try super.init(from: c.superDecoder())
    }
}

연관값 enum도 SE-0295(Swift 5.5+)부터 합성된다. 다만 모양을 알아야 한다.

enum Event: Codable {
    case login(userId: String)
    case logout
}
// {"login":{"userId":"u1"}}
// {"logout":{}}                ← 연관값이 없어도 빈 객체다. 문자열이 아니다.
//
// 서버가 흔히 쓰는 {"type":"login","userId":"u1"} 형태와는 스키마가 다르다 (Q5)

실험 · 도구

상속 누락을 직접 재현해 보자.

import Foundation

class Base: Codable { var id = 1 }
class Child: Base { var extra = "숨은 값" }

let enc = JSONEncoder(); enc.outputFormatting = .sortedKeys
print(String(data: try enc.encode(Child()), encoding: .utf8)!)
//   {"id":1}      ← extra 가 없다. 경고 하나 없이.

// 디코딩도 마찬가지다
let json = #"{"id":9,"extra":"서버가 보낸 값"}"#.data(using: .utf8)!
let c = try JSONDecoder().decode(Child.self, from: json)
print(c.id, c.extra)      // 9  "숨은 값"   ← 서버 값이 무시됐다

코드베이스에서 위험한 조합을 찾는다.

# Codable 을 채택한 클래스의 서브클래스 — 누락 후보
grep -rn "class .*: .*Codable" --include=*.swift . > /tmp/codable-classes.txt
while read -r line; do
  cls=$(echo "$line" | sed -E 's/.*class ([A-Za-z0-9_]+).*/\1/')
  grep -rn "class .*: *$cls\b" --include=*.swift . | grep -v "$cls *:"
done < /tmp/codable-classes.txt

# 인코딩 결과에 기대 필드가 다 있는지 테스트로 고정하는 편이 확실하다
grep -rn "encode(.*)" --include=*Tests*.swift . | head

프로젝트 적용

  1. 직렬화 모델은 struct로 만든다. 상속 구멍이 아예 생기지 않고, 값 의미론이라 동시성에도 유리하다(35장).
  2. 상속이 불가피하면 encode(to:)·init(from:)을 직접 쓴다. superEncoder()로 상위를 격리한다(Q3).
  3. 왕복 테스트를 만든다. 인코딩 → 디코딩 후 모든 필드가 살아 있는지 검사하면 누락이 잡힌다.
  4. 도메인 모델과 전송 모델을 분리한다. 서버 스키마가 바뀔 때 도메인이 흔들리지 않고, 상속이 필요한 도메인 계층과 Codable이 분리된다.
  5. 연관값 enum의 인코딩 모양을 확인하고 서버와 합의한다. 합성 결과가 서버 기대와 다를 수 있다(Q5).
⚠️ 흔한 오해

"Codable은 런타임 리플렉션이다"는 틀렸다컴파일 타임 합성이라 정적으로 검증된다. "Codable을 한 번 채택하면 상속 계층 전체가 알아서 처리된다"가 가장 비싼 오해다 — 서브클래스 프로퍼티는 조용히 사라진다. "합성이 안 되면 컴파일 에러가 나니까 안전하다"도 상속에서는 성립하지 않는다. "연관값 enum은 Codable이 안 된다"도 이제는 틀렸다 — SE-0295부터 되지만 JSON 모양이 서버 관례와 다를 수 있다.

🧒 쉽게 이해하기

짐을 상자에 싸는 기계가 있다. 이 기계는 물건을 보고 자동으로 포장 방법을 만들어 준다.

좋은 점은, 포장할 수 없는 물건이 섞여 있으면 미리 알려준다는 것이다. 살아 있는 고양이를 넣으려 하면 "이건 못 쌉니다" 하고 시작도 안 한다. 나중에 상자를 열어보고 고양이가 없어진 걸 아는 것보다 훨씬 낫다.

그런데 한 가지 빈틈이 있다. 기존 상자를 물려받아서 새 물건을 몇 개 더 넣는 경우다.

이때 기계는 "포장 방법은 이미 있네" 하고 물려받은 방법을 그대로 쓴다. 그 방법은 원래 물건들만 알고 있다. 그래서 새로 넣은 물건은 포장할 때 그냥 빠진다. 아무 말도 없이.

상자를 받은 쪽은 물건이 원래 그것뿐인 줄 안다. 보낸 쪽도 빠진 걸 모른다. 양쪽 다 모른 채 몇 달이 지나간다.

그래서 물려받는 구조를 피하는 게 제일 안전하다.

꼬리 질문

도메인 모델과 전송 모델을 분리하면 무엇이 좋아지는가?

서버 스키마 변경이 앱 내부로 번지지 않는다. 이건 P0 17장의 관심사 분리가 직렬화에 적용된 형태다.

구체적 이점이 넷이다.

  • 이름·형태를 독립적으로 정한다. 서버가 usr_nm을 보내도 도메인은 displayName을 쓴다.
  • 검증 지점이 명확해진다. DTO → 도메인 변환이 유효성 검사가 일어나는 자리가 된다. 잘못된 데이터가 도메인에 들어오지 못한다.
  • 옵셔널이 도메인으로 새지 않는다. 서버 응답은 대부분 옵셔널인데, 도메인 모델까지 옵셔널투성이면 호출부가 전부 언래핑해야 한다.
  • 상속·값 타입 선택이 자유로워진다. DTO는 struct로 두고 도메인은 필요한 구조를 쓴다.
// 전송 모델 — 서버 스키마를 그대로 반영, 전부 옵셔널
struct UserDTO: Decodable {
    let id: Int?
    let usr_nm: String?
    let created_at: String?
}

// 도메인 모델 — 앱이 원하는 형태, 불변식이 지켜진 상태
struct User {
    let id: Int
    let displayName: String
    let createdAt: Date
}

extension UserDTO {
    func toDomain() throws -> User {
        guard let id, let name = usr_nm, !name.isEmpty,
              let raw = created_at, let date = try? Date(raw, strategy: .iso8601)
        else { throw MappingError.invalid(self) }
        return User(id: id, displayName: name, createdAt: date)
    }
}

비용은 보일러플레이트다. 필드가 적고 스키마가 안정적이면 과할 수 있으므로, 외부 계약이 자주 바뀌는 경계에만 적용하는 것이 실용적이다.

쉽게 말하면 택배로 온 물건을 포장 그대로 집 안에 쌓아 두면, 택배 회사가 상자 규격을 바꿀 때마다 집 안 배치를 다 고쳐야 한다. 현관에서 풀어서 우리 집 규격 통에 옮겨 담으면, 밖에서 뭐가 바뀌든 집 안은 그대로다.
왕복 테스트는 구체적으로 어떻게 작성하는가?

인코딩 → 디코딩 후 원본과 같은지를 검사하는 것이 기본이고, 여기에 키 목록 검증을 더하면 누락이 확실히 잡힌다.

import Testing
import Foundation

@Test func 왕복이_보존된다() throws {
    let original = Dog2(name: "바둑", breed: "진돗개")

    let data = try JSONEncoder().encode(original)
    let back = try JSONDecoder().decode(Dog2.self, from: data)

    #expect(back.name == original.name)
    #expect(back.breed == original.breed)     // 상속 누락이 여기서 잡힌다
}

@Test func 인코딩_키가_기대와_일치한다() throws {
    let data = try JSONEncoder().encode(Dog2(name: "바둑", breed: "진돗개"))
    let obj = try JSONSerialization.jsonObject(with: data) as! [String: Any]

    // 키 집합을 명시적으로 고정한다 — 필드가 빠지거나 늘면 실패한다
    #expect(Set(obj.keys) == ["breed", "super"])
}

왕복 테스트만으로 부족한 경우가 있다 — 인코딩과 디코딩이 대칭적으로 틀린 경우다. 둘 다 같은 필드를 빠뜨리면 왕복은 통과한다. 그래서 키 집합 검증이나 고정된 JSON 픽스처와의 대조를 함께 쓴다.

@Test func 서버_스키마와_일치한다() throws {
    // 실제 서버 응답을 픽스처로 고정한다
    let fixture = try Data(contentsOf: Bundle.module.url(forResource: "user_v3", withExtension: "json")!)
    let user = try JSONDecoder().decode(UserDTO.self, from: fixture)
    #expect(user.id != nil)
}

픽스처는 서버 팀이 준 실제 응답이어야 의미가 있다. 우리가 만든 JSON으로 테스트하면 우리 오해를 검증할 뿐이다.

쉽게 말하면 짐을 쌌다가 다시 풀어서 처음과 같은지 확인하는 것이다. 그런데 쌀 때도 빼먹고 풀 때도 빼먹으면 결과가 같아 보인다. 그래서 상자에 뭐가 들었는지 목록도 함께 확인해야 한다.
Codable을 채택할 수 없는 타입은 어떻게 다루는가?

래퍼를 만들어 변환하거나, @propertyWrapper로 변환 규칙을 재사용한다.

대표 사례가 NSAttributedString이다 — Decodable을 채택하지 않아 Codable로 대체할 수 없고, NSSecureCoding으로 아카이빙해야 한다.

// 변환 래퍼 — Codable 이 아닌 타입을 Data 로 우회한다
struct AttributedTextBox: Codable {
    let value: NSAttributedString

    init(_ v: NSAttributedString) { value = v }

    init(from decoder: Decoder) throws {
        let data = try decoder.singleValueContainer().decode(Data.self)
        guard let s = try NSKeyedUnarchiver.unarchivedObject(
                ofClass: NSAttributedString.self, from: data) else {
            throw DecodingError.dataCorruptedError(
                in: try decoder.singleValueContainer(),
                debugDescription: "NSAttributedString 복원 실패")
        }
        value = s
    }

    func encode(to encoder: Encoder) throws {
        let data = try NSKeyedArchiver.archivedData(
            withRootObject: value, requiringSecureCoding: true)
        var c = encoder.singleValueContainer()
        try c.encode(data)
    }
}

여기서 requiringSecureCoding: true가 중요하다. NSSecureCoding은 암호화가 아니라 객체 치환 공격 방어다 — 아카이브에 적힌 클래스 이름을 그대로 믿지 않고, 호출부가 허용 클래스를 미리 선언하게 한다. 화이트리스트와 다르면 크래시가 아니라 nil이 정상적으로 반환된다.

다른 접근으로 @propertyWrapper가 있다 — 같은 변환이 여러 모델에 반복되면 래퍼로 뽑는다(32장에서 다룬다).

쉽게 말하면 기계가 못 싸는 물건이 있으면 먼저 우리가 손으로 봉투에 담고, 기계에는 "이건 봉투 하나"라고 알려준다. 그리고 봉투를 열 때 "이 봉투엔 이것만 들어 있어야 한다"고 미리 정해 두면, 누가 다른 걸 바꿔치기해도 안 열린다.

Q2. CodingKeys에서 빼면 어떻게 되는가?

🔑 30초 답변

인코딩·디코딩 양쪽 모두에서 빠진다. "제외하면 인코딩만 막힐 뿐 디코딩은 될 것"이라는 가정은 틀렸다 — 제외는 항상 양방향이다. 그리고 합성이 계속 성립하려면 제외된 프로퍼티에 기본값이 있거나 옵셔널이어야 하고, 둘 다 없으면 컴파일 에러가 난다. 더 미묘한 것은 진단 강도가 경우마다 다르다는 점이다 — let speed: Int = 100처럼 초기값 있는 letCodingKeys남아 있어도 디코딩되지 않고 경고만 나오는데, 기본값 있는 var를 목록에서 빼면 경고조차 없이 조용히 생략된다.

CS 원리

CodingKeys필드 이름과 외부 표현의 사상을 선언하는 장치다. 합성 규칙은 단순하다 — 이 열거형에 있는 케이스만 직렬화 대상이 된다.

여기서 진단의 비대칭이 생긴다. 컴파일러가 잡을 수 있는 것과 없는 것이 갈린다.

상황진단결과
기본값 없는 var를 제외컴파일 에러디코딩 시 값을 만들 수 없으므로
초기값 있는 let이 목록에 있음경고덮어쓸 수 없어 디코딩 불가
기본값 있는 var를 제외없음조용히 생략된다

세 번째가 위험하다. 합법적인 선택이기 때문이다 — 캐시된 파생값이나 로컬 전용 상태를 제외하는 것은 정당한 의도다. 컴파일러는 의도한 제외와 실수로 빠뜨린 것을 구별할 수 없다.

일반화하면 "기본값이 있으면 누락이 성공처럼 보인다"는 문제다. 기본값은 편의를 주지만, 값이 오지 않았다는 사실을 감춘다. 이는 P1 21장 Q6의 페일 사일런트 구조와 같다.

제외는 항상 양방향이다 — 한쪽만 막히지 않는다 Swift 프로퍼티 인코딩 디코딩 JSON CodingKeys 에서 빼면 양쪽이 함께 끊긴다 진단 강도가 경우마다 다르다 컴파일 에러 기본값 없는 var 를 제외 즉시 막힌다 디코딩 시 값을 만들 수 없으므로 경고만 let + 초기값이 목록에 있음 있어도 디코딩 안 된다 덮어쓸 수 없기 때문 진단 없음 기본값 있는 var 를 제외 조용히 생략된다 의도한 제외와 구별할 수 없다 "기본값이 있으면 누락이 성공처럼 보인다" P1 21장 Q6의 페일 사일런트와 같은 구조다
세 번째가 가장 위험하다 — 의도한 제외와 실수를 컴파일러가 구별할 수 없기 때문이다.

iOS에서는

세 경우를 실제 코드로 보면 차이가 분명하다.

struct Car: Codable {
    var name: String
    let speed: Int = 100          // ⚠️ 경고: 초기값이 있어 덮어쓸 수 없으므로 디코딩되지 않음
    var mileage: Int = 0          // CodingKeys 에서 빼면 경고조차 없다
    var owner: String             // 기본값 없음 — 빼면 컴파일 에러

    enum CodingKeys: String, CodingKey {
        case name, speed, owner   // mileage 제외 — 진단 없이 조용히 빠진다
    }
}

let json = #"{"name":"A","speed":300,"mileage":5000,"owner":"김"}"#.data(using: .utf8)!
let car = try JSONDecoder().decode(Car.self, from: json)
car.speed        // 100    ← 서버가 300 을 보냈지만 초기값이 이긴다
car.mileage      // 0      ← 제외했으므로 무시됐다

키 이름 매핑CodingKeys의 주 용도다.

struct User: Codable {
    let displayName: String
    let profileImageURL: URL?

    enum CodingKeys: String, CodingKey {
        case displayName = "display_name"
        case profileImageURL = "profile_image_url"
    }
}
//   자동 변환 전략(convertFromSnakeCase)과 섞어 쓰면 안 된다 — Q6 에서 다룬다

의도적 제외는 명시하는 것이 좋다. 주석이나 별도 타입으로 의도를 남긴다.

struct Article: Codable {
    let id: String
    let title: String

    /// 로컬 전용 — 서버로 보내지 않고 받지도 않는다 (의도적으로 CodingKeys 에서 제외)
    var isRead: Bool = false

    enum CodingKeys: String, CodingKey { case id, title }
}

실험 · 도구

누락을 자동으로 잡으려면 키 집합을 검사하는 것이 가장 확실하다.

import Foundation

/// 인코딩 결과의 최상위 키 집합을 뽑는다
func encodedKeys<T: Encodable>(_ v: T) throws -> Set<String> {
    let data = try JSONEncoder().encode(v)
    let obj = try JSONSerialization.jsonObject(with: data)
    guard let dict = obj as? [String: Any] else { return [] }
    return Set(dict.keys)
}

// Mirror 로 저장 프로퍼티 이름을 얻어 대조한다
func storedProperties<T>(_ v: T) -> Set<String> {
    Set(Mirror(reflecting: v).children.compactMap(\.label))
}

let car = Car(name: "A", owner: "김")
let missing = storedProperties(car).subtracting(
    try encodedKeys(car).union(["speed"]))   // 매핑된 키는 별도 처리
print("인코딩에서 빠진 프로퍼티: \(missing)")   // ["mileage"]

테스트로 고정하면 필드가 추가될 때 자동으로 잡힌다.

@Test func 모든_필드가_인코딩된다() throws {
    let sample = Car(name: "A", owner: "김")
    let intentionallyExcluded: Set<String> = ["mileage", "isRead"]

    let props = storedProperties(sample).subtracting(intentionallyExcluded)
    let keys = try encodedKeys(sample)

    // 매핑된 이름을 고려해야 하므로 개수로 1차 확인
    #expect(props.count == keys.count,
            "인코딩 누락 의심 — 프로퍼티 \(props) vs 키 \(keys)")
}

코드베이스 훑기도 유용하다.

# CodingKeys 가 있는 타입의 프로퍼티 수와 케이스 수를 비교한다
grep -rn -A20 "enum CodingKeys" --include=*.swift . | head -40

# let + 초기값 경고를 놓치지 않도록 경고를 에러로 승격한다
# Build Settings › Other Swift Flags 에
#   -warnings-as-errors
# 를 넣으면 이 경고가 빌드를 막는다 (P1 21장 Q6 의 "조용한 실패를 빌드 실패로")

프로젝트 적용

  1. 제외는 의도를 주석으로 남긴다. 컴파일러가 구별하지 못하므로 사람이 남겨야 다음 사람이 실수를 알아본다.
  2. 경고를 에러로 승격한다. let + 초기값 경고는 실제 버그인 경우가 대부분이다.
  3. 서버가 보내는 값을 let + 초기값으로 선언하지 않는다. 초기값이 이겨서 서버 값이 무시된다.
  4. 키 집합 테스트를 둔다. 필드를 추가하고 CodingKeys를 안 고치는 실수가 가장 흔하다.
  5. 로컬 전용 상태는 아예 다른 타입에 둔다. 직렬화 모델에 섞으면 매번 제외를 관리해야 한다.
⚠️ 흔한 오해

"CodingKeys에서 빼면 인코딩만 막히고 디코딩은 된다"는 틀렸다 — 제외는 항상 양방향이다. "CodingKeys에 남아 있으면 반드시 디코딩된다"도 틀렸다 — 초기값 있는 let은 남아 있어도 덮어쓸 수 없어 디코딩되지 않는다. "빠뜨리면 컴파일 에러가 나니까 안전하다"도 기본값이 있으면 성립하지 않는다. "CodingKeys를 안 쓰면 자동으로 모든 프로퍼티가 포함된다"는 맞지만, 한 번 정의하는 순간 목록이 곧 계약이 되므로 이후 필드 추가마다 갱신해야 한다.

🧒 쉽게 이해하기

이삿짐을 쌀 때 물건 목록표를 만든다고 하자. 목록에 적힌 것만 상자에 들어간다.

여기서 헷갈리는 게 있다. 목록에서 뺀 물건은 보낼 때만 빠지는 게 아니라 받을 때도 빠진다. 상대가 그 물건을 보냈어도 목록에 없으니 안 꺼낸다.

그리고 알려주는 정도가 경우마다 다르다.

목록에서 뺐는데 그 물건 없이는 짐을 못 쌀 때 — 이삿짐센터가 바로 거부한다. 좋다, 알 수 있다.

목록에 있는데 이미 상자에 붙어서 안 떨어지는 물건 — "이건 못 바꿔요" 하고 메모를 남긴다. 못 보고 지나칠 수도 있다.

목록에서 뺐는데 없어도 짐이 성립하는 물건아무 말도 안 한다. 왜냐하면 일부러 뺀 건지 실수로 뺀 건지 구별할 방법이 없기 때문이다.

세 번째가 제일 무섭다. 몇 달 뒤에 "그 물건 어디 갔지?" 하고 찾게 된다.

꼬리 질문

필드를 추가했는데 서버로 안 나가는 버그를 어떻게 예방하는가?

세 겹으로 막는다 — 관행, 테스트, 도구.

(1) 관행 — CodingKeys를 꼭 필요할 때만 쓴다. 키 이름을 바꿔야 하거나 제외가 필요한 경우가 아니면 아예 정의하지 않는다. 그러면 필드 추가가 자동 반영된다.

// ✅ CodingKeys 가 없으면 필드 추가가 자동 반영된다
struct Item: Codable {
    let id: String
    let title: String
    let newField: String        // 추가만 하면 끝
}

// ⚠️ 키 이름 매핑이 필요하다면 — 전부 나열해야 하고 갱신 의무가 생긴다
struct Item2: Codable {
    let id: String
    let createdAt: Date
    enum CodingKeys: String, CodingKey { case id, createdAt = "created_at" }
}

(2) 테스트 — 키 집합 검증. 앞의 실험 절 코드가 그대로 쓰인다. 프로퍼티가 늘었는데 키가 안 늘면 실패시킨다.

(3) 도구 — 계약 검증. 서버 스키마(OpenAPI 등)가 있다면 생성된 모델을 쓰는 것이 근본 해법이다. 사람이 두 곳을 손으로 맞추는 구조 자체가 어긋남의 원인이다.

실무에서 가장 효과가 큰 것은 (1)이다. CodingKeys를 안 쓰면 이 버그 자체가 존재하지 않는다.

쉽게 말하면 목록표를 안 만들면 물건을 빠뜨릴 일이 없다. 목록표는 이름을 바꿔야 하거나 일부러 빼야 할 때만 만든다. 만들었으면 물건 늘 때마다 목록도 고쳐야 하는 숙제가 생긴다.
서버가 보낸 필드를 무시하고 싶을 때는 어떻게 하는가?

그냥 모델에 안 넣으면 된다. Codable모르는 키를 무시하는 것이 기본 동작이다.

// 서버가 20개 필드를 보내도 3개만 선언하면 나머지는 무시된다
struct Minimal: Decodable {
    let id: String
    let title: String
    let price: Decimal
}
//   서버가 필드를 추가해도 앱은 깨지지 않는다 — 하위 호환의 기본이다

이 성질이 스키마 진화의 토대다(Q7). 서버가 필드를 추가하는 것은 기존 클라이언트를 깨뜨리지 않는 변경이다.

다만 알 수 없는 필드를 감지하고 싶은 경우도 있다 — 계약 위반을 조기에 발견하려는 목적이다. 이때는 직접 확인해야 한다.

struct StrictItem: Decodable {
    let id: String
    let title: String

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        title = try c.decode(String.self, forKey: .title)

        // 원본 키 집합과 대조해 예상 밖 키를 로깅한다 (실패시키지는 않는다)
        let known = Set(CodingKeys.allCases.map(\.stringValue))
        let actual = Set(c.allKeys.map(\.stringValue))
        let unknown = actual.subtracting(known)
        if !unknown.isEmpty { logger.notice("알 수 없는 필드: \(unknown)") }
    }

    enum CodingKeys: String, CodingKey, CaseIterable { case id, title }
}

여기서도 실패시키지 않는 것이 핵심이다. 서버가 필드를 추가할 때마다 앱이 죽으면 배포가 묶인다 — 기록만 하고 지표로 추적한다(P1 18장).

쉽게 말하면 택배에 주문 안 한 물건이 같이 왔다고 반품할 필요는 없다. 그냥 안 꺼내면 된다. 다만 "요즘 이상한 게 자꾸 온다"는 것은 적어 두면 나중에 도움이 된다.
옵셔널과 기본값 중 무엇을 써야 하는가?

"값이 없다"가 의미를 갖는지로 갈린다.

옵셔널기본값
키 없음nil기본값
nullnil디코딩 실패 (기본값이 적용되지 않는다)
의미"없음"을 구별할 수 있다"없음"이 사라진다

중요한 함정 하나 — 기본값은 키가 아예 없을 때만 적용된다. 서버가 명시적으로 null을 보내면 타입 불일치로 실패한다.

struct A: Decodable { var count: Int = 0 }

try JSONDecoder().decode(A.self, from: #"{}"#.data(using: .utf8)!)          // ✅ count = 0
try JSONDecoder().decode(A.self, from: #"{"count":null}"#.data(using: .utf8)!)
//   ❌ valueNotFound — 기본값이 구제해 주지 않는다

서버가 null을 보낼 가능성이 있으면 옵셔널로 받고 나중에 기본값을 적용하는 편이 안전하다.

struct B: Decodable {
    private let rawCount: Int?
    var count: Int { rawCount ?? 0 }
    enum CodingKeys: String, CodingKey { case rawCount = "count" }
}

설계 관점에서는 도메인 모델에 옵셔널을 남기지 않는 것이 좋다(Q1 꼬리질문). DTO에서 옵셔널로 받고, 도메인 변환 시점에 기본값을 적용하거나 실패시킨다.

쉽게 말하면 "안 적혀 있음"과 "없다고 적혀 있음"은 다른 말이다. 기본값은 칸이 아예 비어 있을 때만 채워 준다. 상대가 "없음"이라고 또박또박 적어 보내면 오히려 실패한다. 그래서 상대가 그럴 수 있으면 일단 "있을 수도 없을 수도"로 받고 우리가 처리하는 게 안전하다.

Q3. 세 컨테이너는 무엇이 다르며 언제 직접 쓰는가?

🔑 30초 답변

JSON의 세 가지 모양에 대응한다. KeyedDecodingContainer(키로 접근 — JSON 객체), UnkeyedDecodingContainer(순서로 접근 — JSON 배열), SingleValueDecodingContainer(그 자리 자체가 값 하나). 중첩 JSON을 평평한 Swift 프로퍼티로 펼 때 nestedContainer를 쓴다. 결정적으로 중요한 성질이 하나 있다 — 컨테이너는 값을 소비하는 게 아니라 이미 파싱된 트리의 한 지점을 가리키는 뷰다. 그래서 같은 지점을 여러 번 디코딩할 수 있고, 이것이 다형 JSON 패턴의 토대다(Q5). 단 UnkeyedDecodingContainer의 커서는 한 번 지나가면 되돌릴 수 없다.

CS 원리

Codable의 설계는 인코딩 형식과 데이터 모델의 분리다. 모델은 "무엇을 저장할지"만 선언하고, 형식(JSON·plist·바이너리)은 인코더가 정한다. 이 사이를 잇는 것이 컨테이너 추상화다.

세 컨테이너는 대부분의 계층적 데이터 형식이 공유하는 세 가지 모양에 대응한다.

이 셋으로 JSON뿐 아니라 plist·YAML·CBOR도 표현되므로, 같은 모델을 여러 형식으로 직렬화할 수 있다.

뷰 성질이 중요한 설계 결정이다. 컨테이너가 값을 소비한다면 한 번 읽은 자리를 다시 읽을 수 없지만, 라면 가능하다. 후자를 택했기 때문에 "판별자만 먼저 읽고 나머지를 다시 읽기"가 성립한다. 비용도 작다 — 재파싱이 아니라 이미 만들어진 트리에서 한 번 더 조회하는 정도다.

다만 배열 컨테이너는 예외다. 순서 접근이므로 currentIndex 커서가 전진만 하고, 한 번 지나간 원소로 되돌아갈 수 없다. 이 비대칭을 모르면 배열에서 판별자 패턴을 쓰려다 막힌다.

세 컨테이너 = 계층 데이터의 세 가지 모양 Keyed {"a":1,"b":2} — 키로 접근 같은 키를 여러 번 읽을 수 있다 Unkeyed [1,2,3] — 순서로 접근 커서는 전진만 — 되돌릴 수 없다 SingleValue 42 · "text" — 값 그 자체 같은 자리를 다시 읽을 수 있다 컨테이너는 소비자가 아니라 뷰 파싱된 트리 1차 조회 2차 조회 같은 지점을 두 번 읽어도 된다 이 성질이 다형 JSON 판별자 패턴의 토대다 (Q5) 그리고 상속에서는 superEncoder 가 "super" 키 아래로 상위를 격리한다 "지금 이름이 안 겹친다"가 아니라 "앞으로도 겹칠 수 없다"를 보장한다
뷰이기 때문에 재읽기가 가능하다. 다만 배열 커서만은 전진 전용이다.

iOS에서는

중첩 JSON을 평평하게 펴는 것이 가장 흔한 용도다.

// 서버: {"id":1,"meta":{"author":{"name":"김"},"tags":["a","b"]}}
// 앱:   Article(id:1, authorName:"김", tags:["a","b"])

struct Article: Decodable {
    let id: Int
    let authorName: String
    let tags: [String]

    enum CodingKeys: String, CodingKey { case id, meta }
    enum MetaKeys: String, CodingKey { case author, tags }
    enum AuthorKeys: String, CodingKey { case name }

    init(from decoder: Decoder) throws {
        let root = try decoder.container(keyedBy: CodingKeys.self)
        id = try root.decode(Int.self, forKey: .id)

        let meta = try root.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta)
        tags = try meta.decode([String].self, forKey: .tags)

        let author = try meta.nestedContainer(keyedBy: AuthorKeys.self, forKey: .author)
        authorName = try author.decode(String.self, forKey: .name)
    }
}

상속에서는 superEncoder()가 키 충돌을 구조적으로 차단한다.

// ❌ 같은 컨테이너에 상위·하위가 같은 키를 쓰면 조용히 덮어써진다
//    에러가 나지 않으므로 발견이 늦다

// ✅ superEncoder() 는 예약된 "super" 키 아래 별도 컨테이너를 만든다
override func encode(to encoder: Encoder) throws {
    var c = encoder.container(keyedBy: CodingKeys.self)
    try c.encode(breed, forKey: .breed)
    try super.encode(to: c.superEncoder())     // {"breed":"...","super":{"name":"..."}}
}
//   이름을 조심해서 짓는 것과 다르다 — "겹칠 수 없는 구조"를 만드는 것이다

배열 커서의 전진 전용 성질도 실제로 부딪힌다.

init(from decoder: Decoder) throws {
    var arr = try decoder.unkeyedContainer()
    let first = try arr.decode(String.self)     // currentIndex: 0 → 1
    // arr.currentIndex 를 0 으로 되돌릴 방법이 없다

    // 배열 원소별로 판별이 필요하면 원소 단위로 nested 컨테이너를 연다
    while !arr.isAtEnd {
        let element = try arr.nestedContainer(keyedBy: ItemKeys.self)
        let type = try element.decode(String.self, forKey: .type)
        // element 안에서는 재읽기가 가능하다
    }
}

실험 · 도구

재읽기가 실제로 되는지 확인해 보자.

import Foundation

struct Probe: Decodable {
    let a: Int
    let aAgain: Int

    enum K: String, CodingKey { case a }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: K.self)
        a = try c.decode(Int.self, forKey: .a)
        aAgain = try c.decode(Int.self, forKey: .a)     // 같은 키를 두 번 — 문제없다
    }
}

let p = try JSONDecoder().decode(Probe.self, from: #"{"a":7}"#.data(using: .utf8)!)
print(p.a, p.aAgain)      // 7 7

배열 커서는 되돌릴 수 없다는 것도 확인된다.

struct ArrProbe: Decodable {
    init(from decoder: Decoder) throws {
        var arr = try decoder.unkeyedContainer()
        print("시작 index:", arr.currentIndex)      // 0
        _ = try arr.decode(Int.self)
        print("한 번 읽은 뒤:", arr.currentIndex)     // 1
        // 되돌리는 API 가 없다 — 필요하면 원소를 통째로 먼저 디코딩해 보관한다
    }
}
_ = try JSONDecoder().decode(ArrProbe.self, from: "[1,2,3]".data(using: .utf8)!)

인코딩 결과 구조를 확인하는 것도 유용하다.

# 인코딩 결과를 보기 좋게 출력해 구조를 확인한다
swift run MyTool | python3 -m json.tool

# super 키가 실제로 생기는지 확인
echo '{"breed":"진돗개","super":{"name":"바둑"}}' | python3 -m json.tool

프로젝트 적용

  1. 합성으로 되는 것은 직접 쓰지 않는다. 컨테이너 코드는 필드 추가 시 갱신 의무가 생기는 수동 코드다.
  2. 중첩 평탄화는 DTO 계층에 가둔다. 도메인 모델까지 init(from:)을 직접 쓰면 유지 비용이 커진다.
  3. 상속 인코딩에는 반드시 superEncoder()를 쓴다. 키 충돌은 에러 없이 값이 덮어써지는 형태로 나타난다.
  4. 배열에서 판별이 필요하면 원소 단위로 컨테이너를 연다. 배열 커서를 되돌리려 하지 않는다.
  5. 직접 쓴 init(from:)에는 왕복 테스트를 반드시 붙인다. 합성이 주던 안전망이 사라진 상태다.
⚠️ 흔한 오해

"컨테이너에서 값을 읽으면 소비된다"는 키 컨테이너에서는 틀리다라서 여러 번 읽을 수 있다. "배열도 마찬가지로 다시 읽을 수 있다"는 틀렸다currentIndex전진 전용이다. "판별자를 읽으려면 데이터를 두 번 파싱해야 한다"도 틀렸다 — 이미 파싱된 트리에서 한 번 더 조회할 뿐이라 비용이 작다. "상속에서 키 이름만 안 겹치게 하면 된다"도 부족하다 — 나중에 누군가 같은 이름을 추가하면 조용히 덮어써진다. superEncoder()는 그것을 구조적으로 막는다.

🧒 쉽게 이해하기

짐 상자를 열어 물건을 꺼내는 방법이 세 가지다.

이름표를 보고 꺼내기 — "칫솔 어디 있지?" 하고 이름으로 찾는다. 이건 몇 번이고 다시 볼 수 있다. 칫솔을 확인하고 나서 또 확인해도 된다.

순서대로 꺼내기 — 줄 서 있는 물건을 앞에서부터 하나씩 꺼낸다. 여기서는 한 번 지나가면 돌아갈 수 없다. 세 번째 물건을 꺼낸 뒤에 "아 두 번째를 다시 볼래" 하면 안 된다.

상자에 물건이 하나뿐 — 그냥 그걸 꺼낸다.

첫 번째가 여러 번 볼 수 있다는 게 아주 유용하다. 나중에 "이 상자가 무슨 종류인지 먼저 확인하고, 그다음 종류에 맞게 꺼내기"를 할 수 있기 때문이다.

그리고 물려받은 상자를 다룰 때 요령이 하나 있다. 물려받은 물건들을 같은 칸에 섞어 넣으면 이름이 같은 물건끼리 덮어써진다. 그래서 "물려받은 것"이라는 칸을 따로 만들어 거기 몰아넣는다. 그러면 나중에 누가 같은 이름의 물건을 넣어도 섞일 수가 없다.

꼬리 질문

같은 필드가 문자열로도 숫자로도 올 수 있으면 어떻게 하는가?

재읽기 성질을 이용해 순서대로 시도한다. 컨테이너가 뷰이므로 실패해도 자리가 소비되지 않는다.

struct FlexibleID: Decodable {
    let value: String

    init(from decoder: Decoder) throws {
        let c = try decoder.singleValueContainer()

        // 문자열로 먼저 시도
        if let s = try? c.decode(String.self) { value = s; return }
        // 숫자로 시도
        if let i = try? c.decode(Int.self)    { value = String(i); return }
        if let d = try? c.decode(Double.self) { value = String(d); return }

        throw DecodingError.typeMismatch(String.self, .init(
            codingPath: decoder.codingPath,
            debugDescription: "String·Int·Double 중 어느 것도 아님"))
    }
}

주의할 점이 둘이다.

  • try? 남용은 진단을 잃는다. 마지막에 의미 있는 에러를 던져 codingPath가 남게 해야 한다(Q4).
  • 숫자를 문자열로 받는 것은 정밀도 문제가 될 수 있다. 큰 정수 ID를 Double로 시도하면 2⁵³을 넘을 때 뭉개진다(30장 Q1). 시도 순서를 IntDouble로 두는 이유다.

근본적으로는 서버와 타입을 하나로 합의하는 것이 맞다. 유연한 디코딩은 이미 배포된 구버전을 지탱하는 임시 방편으로 보는 편이 건강하다.

쉽게 말하면 상자 안 물건이 어떤 때는 글자, 어떤 때는 숫자로 온다면, 글자로 꺼내 보고 안 되면 숫자로 꺼내 본다. 몇 번이고 다시 볼 수 있으니 가능한 방법이다. 다만 왜 물건 종류가 왔다 갔다 하는지를 보내는 쪽과 정리하는 게 먼저다.
인코딩 형식을 JSON에서 다른 것으로 바꾸려면 무엇을 고쳐야 하는가?

모델은 그대로 두고 인코더만 바꾸면 된다. 이것이 컨테이너 추상화의 값어치다.

let model = Article(...)

// 같은 모델을 세 형식으로
let json = try JSONEncoder().encode(model)
let plist = try PropertyListEncoder().encode(model)
// 서드파티 인코더(CBOR·MessagePack 등)도 같은 프로토콜을 채택한다

다만 형식마다 표현 가능한 것이 다르다는 점을 알아야 한다.

  • JSONnan·infinity 표현이 없다. Double.nan을 인코딩하면 실패하거나 전략에 따라 문자열로 치환된다(30장 Q1 꼬리질문).
  • JSON — 정수와 실수의 구분이 형식상 없어, 파서에 따라 큰 정수가 Double로 해석될 수 있다.
  • plistDate·Data를 네이티브로 표현한다. JSON은 문자열로 인코딩해야 한다.
// JSON 에서 nan 을 다루려면 전략을 지정한다
let enc = JSONEncoder()
enc.nonConformingFloatEncodingStrategy = .convertToString(
    positiveInfinity: "+inf", negativeInfinity: "-inf", nan: "nan")

그래서 형식 독립적인 모델을 유지하려면 가장 제약이 많은 형식(대개 JSON)에 맞춰 설계하는 것이 안전하다.

쉽게 말하면 짐 목록만 있으면 택배로도 우편으로도 보낼 수 있다. 다만 운송 수단마다 못 보내는 물건이 다르다. 어떤 곳은 액체를 못 받고 어떤 곳은 크기 제한이 있다. 그래서 제일 까다로운 곳 기준으로 싸 두면 어디로든 보낼 수 있다.
직접 쓴 init(from:)이 필드 추가를 놓치는 문제는 어떻게 막는가?

수동 구현은 합성이 주던 안전망을 포기하는 것이므로, 그 안전망을 테스트로 되살려야 한다.

세 가지를 함께 쓴다.

(1) 왕복 테스트. 모든 필드가 살아 돌아오는지 확인한다(Q1 꼬리질문).

(2) 프로퍼티 수 대조. Mirror로 저장 프로퍼티 개수를 세어 인코딩 키 수와 비교한다(Q2 실험 절).

(3) 수동 구현 범위 최소화. 가장 효과적이다 — 정말 필요한 필드만 직접 쓰고 나머지는 합성에 맡긴다.

// ❌ 전체를 직접 구현 — 필드 추가 시마다 갱신해야 한다
struct Full: Decodable {
    let a: String, b: Int, c: Date, d: [String]
    init(from decoder: Decoder) throws { /* 네 줄 모두 수동 */ }
}

// ✅ 중첩 부분만 별도 타입으로 뽑아 합성을 살린다
struct Meta: Decodable { let author: Author; let tags: [String] }
struct Article2: Decodable {
    let id: Int
    let meta: Meta            // 중첩은 타입으로 표현하고 합성에 맡긴다
    var authorName: String { meta.author.name }
}

마지막 방식이 실무에서 가장 유지보수가 쉽다 — JSON 구조를 그대로 타입으로 반영하고, 평탄화는 계산 프로퍼티로 제공한다. 수동 디코딩 코드가 사라지므로 필드 추가가 자동 반영된다.

쉽게 말하면 짐 싸는 방법을 손으로 적어 두면 물건이 늘 때마다 그 종이도 고쳐야 한다. 그런데 기계에게 맡기면 알아서 반영된다. 그러니 기계가 못 하는 부분만 손으로 하고, 나머지는 최대한 기계에 맡기는 게 낫다.

Q4. 디코딩 실패는 어떤 정보를 주는가?

🔑 30초 답변

네 가지 실패 종류와 정확한 경로를 준다 — 꺼내 쓰지 않으면 잃는다. DecodingErrortypeMismatch(타입이 다름)·valueNotFound(옵셔널 아닌 자리에 nullkeyNotFound(키 자체가 없음)·dataCorrupted(형식이 깨짐) 네 케이스이고, 모두 Context를 담는다. 그 안의 codingPath가 루트부터 실패 지점까지의 경로를 그대로 갖고 있어 ["address","zip"]처럼 범인 필드가 특정된다. 배열 인덱스도 CodingKey로 취급돼 users[2].address.zip 같은 깊은 경로도 재구성된다. localizedDescription만 로그에 남기면 "실패했다"만 알게 된다.

CS 원리

파싱 실패를 다루는 설계의 핵심은 "어디서, 무엇이, 왜"를 잃지 않는 것이다.

일반적인 Error실패했다는 사실만 전달한다. 그런데 계층적 데이터의 디코딩에서 정작 필요한 정보는 위치다 — 응답에 필드가 200개인데 "타입이 안 맞음"만 알면 탐색 공간이 전혀 줄지 않는다(P1 20장 Q7의 좁히기 원리).

그래서 DecodingError경로를 누적한다. 디코더가 중첩 컨테이너로 내려갈 때마다 codingPath에 키를 쌓고, 실패 시점의 경로가 그대로 에러에 담긴다. 이는 스택 트레이스와 같은 아이디어다 — 실패 지점만이 아니라 거기까지 온 경로를 남긴다.

네 케이스의 구분도 원인별로 대응이 다르기 때문에 존재한다.

케이스의미대개의 원인
keyNotFound키가 아예 없다서버 스키마 변경 · 키 이름 오타 · 변환 전략 충돌(Q6)
valueNotFoundnull이 왔다옵셔널로 선언하지 않음
typeMismatch타입이 다르다숫자↔문자열 혼용 · 계약 불일치
dataCorrupted형식 자체가 깨짐JSON이 아님 · 날짜 형식 불일치 · 인코딩 오류

keyNotFoundvalueNotFound의 구분이 특히 유용하다 — 전자는 서버가 필드를 안 보냈다는 뜻이고 후자는 보냈는데 null이라는 뜻이다. 원인이 완전히 다르다.

에러는 실패 지점까지의 경로를 들고 온다 루트 객체 users (배열) [2] (인덱스) address zip ← 실패 ❌ localizedDescription 만 로깅하면 "The data couldn't be read because it isn't in the correct format." 200개 필드 중 어디인지 전혀 알 수 없다 ✅ DecodingError 로 캐스팅해 context 를 펼치면 codingPath → users[2].address.zip debugDescription → Expected Int but found a string 범인이 한 줄로 특정된다 스택 트레이스와 같은 아이디어 — 지점이 아니라 경로를 남긴다 단, 이 문자열은 내부 로깅 채널로만 보낸다
배열 인덱스도 CodingKey(intValue)로 취급돼 깊은 경로가 그대로 재구성된다.

iOS에서는

네 케이스를 사람이 읽는 한 줄로 바꾸는 헬퍼가 네트워크 계층의 표준 관용구다.

import Foundation

extension DecodingError {
    /// 내부 로깅용 한 줄 요약 — 사용자에게 보여주지 않는다
    var diagnostic: String {
        func path(_ ctx: Context) -> String {
            ctx.codingPath.map { key in
                key.intValue.map { "[\($0)]" } ?? ".\(key.stringValue)"
            }.joined().trimmingCharacters(in: CharacterSet(charactersIn: "."))
        }
        switch self {
        case .typeMismatch(let type, let ctx):
            return "타입 불일치 at \(path(ctx)) — \(type) 기대, \(ctx.debugDescription)"
        case .valueNotFound(let type, let ctx):
            return "null 수신 at \(path(ctx)) — \(type) 은 옵셔널이 아님"
        case .keyNotFound(let key, let ctx):
            return "키 없음 at \(path(ctx)).\(key.stringValue)"
        case .dataCorrupted(let ctx):
            return "형식 손상 at \(path(ctx)) — \(ctx.debugDescription)"
        @unknown default:
            return "알 수 없는 디코딩 실패"
        }
    }
}

호출부에서는 이렇게 쓴다.

do {
    let users = try JSONDecoder().decode([User].self, from: data)
} catch let error as DecodingError {
    logger.error("디코딩 실패: \(error.diagnostic, privacy: .public)")
    // 예: 디코딩 실패: 타입 불일치 at users[2].address.zip — Int 기대, Expected to decode Int but found a string
    metrics.increment("decode.failure", tags: ["kind": error.kindLabel])
} catch {
    logger.error("알 수 없는 오류: \(error.localizedDescription, privacy: .public)")
}

지표로 종류별 빈도를 추적하면 서버 스키마 변경을 조기에 감지할 수 있다 — keyNotFound가 특정 릴리스부터 급증하면 계약이 깨진 것이다(P1 18장).

프라이버시 주의가 필요하다. 이 문자열은 프로퍼티명과 서버 필드명을 그대로 노출하고, debugDescription값의 일부가 섞일 수 있다. P1 23장 Q1에서 본 대로 사용자에게 보여주는 메시지가 아니라 내부 로깅 채널로만 보낸다.

실험 · 도구

네 케이스를 각각 재현해 보면 어떤 정보가 오는지 분명해진다.

import Foundation

struct Address: Decodable { let zip: Int }
struct User: Decodable { let name: String; let address: Address }

let cases: [(String, String)] = [
    ("typeMismatch",  #"[{"name":"A","address":{"zip":"12345"}}]"#),
    ("valueNotFound", #"[{"name":"A","address":{"zip":null}}]"#),
    ("keyNotFound",   #"[{"name":"A","address":{}}]"#),
    ("dataCorrupted", #"[{"name":"A","address":{"zip":1}},"#),        // JSON 이 깨짐
]

for (label, json) in cases {
    do {
        _ = try JSONDecoder().decode([User].self, from: json.data(using: .utf8)!)
        print("\(label): 성공")
    } catch let e as DecodingError {
        print("\(label): \(e.diagnostic)")
    } catch {
        print("\(label): \(error)")
    }
}
// typeMismatch:  타입 불일치 at [0].address.zip — Int 기대, …
// valueNotFound: null 수신 at [0].address.zip — Int 은 옵셔널이 아님
// keyNotFound:   키 없음 at [0].address.zip
// dataCorrupted: 형식 손상 at  — …

실패한 원본을 남겨 두면 재현이 쉬워진다.

# 디코딩 실패 시 원본 응답을 임시 파일로 남기고 검사한다 (개발 빌드에서만)
python3 -m json.tool < failed-response.json          # 형식 자체가 유효한지
jq '.users[2].address' failed-response.json           # 문제 지점만 뽑아 본다
jq -r '.. | objects | keys[]' failed-response.json | sort -u | head -40   # 전체 키 목록

프로젝트 적용

  1. localizedDescription만 로깅하지 않는다. DecodingError로 캐스팅해 codingPath를 반드시 펼친다. 이것 하나가 조사 시간을 몇 시간 단축한다.
  2. 진단 문자열은 내부 채널로만 보낸다. 필드명·값이 섞이므로 사용자 메시지로 쓰면 정보 유출이다.
  3. 케이스별 빈도를 지표로 추적한다. keyNotFound 급증은 서버 계약 변경의 신호다.
  4. 실패한 원본 응답을 개발 빌드에서 저장한다. 프로덕션에서는 PII 때문에 저장하지 않고 경로와 종류만 남긴다.
  5. 사용자에게는 복구 가능한 메시지를 보여준다. "데이터를 불러오지 못했습니다 · 다시 시도" 정도로 충분하다.
⚠️ 흔한 오해

"에러 메시지만 봐도 어느 필드인지 안다"는 실전에서 자주 틀린다localizedDescription은 "형식이 올바르지 않다"만 말한다. "keyNotFoundvalueNotFound는 비슷하다"도 틀렸다 — 전자는 안 보냈다, 후자는 null을 보냈다로 원인이 다르다. "배열 안의 실패는 위치를 알 수 없다"도 틀렸다 — 인덱스가 CodingKey로 경로에 남는다. "진단 문자열을 사용자에게 보여주면 친절하다"는 위험하다 — 내부 필드명과 값이 노출된다.

🧒 쉽게 이해하기

택배 상자를 열었는데 뭔가 잘못됐다. 이때 두 가지 방식으로 알려줄 수 있다.

하나는 "상자에 문제가 있습니다"라고만 말하는 것이다. 상자 안에 물건이 200개면, 이 말로는 아무것도 알 수 없다. 하나하나 다 뒤져야 한다.

다른 하나는 "세 번째 사람의 주소 칸의 우편번호가 이상합니다"라고 말하는 것이다. 이러면 바로 그 자리로 갈 수 있다.

다행히 이 시스템은 두 번째 정보를 갖고 있다. 상자를 열어 들어갈 때마다 "지금 어디까지 왔는지"를 계속 적어 두기 때문이다. 문제가 생기면 그 기록이 그대로 딸려 온다.

문제는 많은 사람이 그 기록을 안 꺼내 본다는 것이다. 짧은 요약만 일지에 적고 넘어간다. 그러면 나중에 "왜 실패했지?" 하고 봐도 실패했다는 사실만 남아 있다.

그리고 조심할 게 하나 있다. 이 자세한 기록에는 서버가 쓰는 이름과 값이 그대로 들어 있어서, 손님에게 보여주면 안 된다. 우리끼리 보는 일지에만 적는다.

꼬리 질문

일부 항목만 디코딩에 실패했을 때 전체를 버려야 하는가?

도메인이 정할 문제이고, 두 전략이 있다.

(1) 엄격 모드 — 하나라도 실패하면 전부 실패시킨다. 결제·계약·정산처럼 부분 데이터가 위험한 도메인에 맞다.

(2) 복원력 모드 — 실패한 원소만 건너뛰고 나머지를 살린다. 피드·목록처럼 일부가 없어도 화면이 성립하는 경우에 맞다.

/// 실패한 원소를 건너뛰는 배열 래퍼
struct Lossy<T: Decodable>: Decodable {
    let elements: [T]
    let failures: [String]          // 진단을 버리지 않는다

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var ok: [T] = [], bad: [String] = []
        while !container.isAtEnd {
            do {
                ok.append(try container.decode(T.self))
            } catch let e as DecodingError {
                bad.append(e.diagnostic)
                _ = try? container.decode(AnyCodable.self)   // 커서를 전진시킨다
            }
        }
        elements = ok; failures = bad
    }
}

중요한 것은 건너뛴 사실을 잃지 않는 것이다. 조용히 무시하면 목록이 짧아진 이유를 아무도 모른다. 실패를 지표로 올리고, 비율이 임계를 넘으면 알림을 낸다.

커서 전진에 주의해야 한다 — 배열 컨테이너는 되돌릴 수 없으므로(Q3), 실패한 원소를 어떤 형태로든 소비해야 무한 루프에 빠지지 않는다.

쉽게 말하면 택배 열 상자 중 한 상자가 깨졌을 때 어떻게 할까? 이삿짐이면 깨진 것만 빼고 나머지를 쓰면 된다. 그런데 계약서라면 한 장이라도 없으면 전부 무효로 하는 게 맞다. 어느 쪽이든 몇 개가 깨졌는지는 반드시 적어 둬야 한다.
날짜 디코딩 실패는 왜 dataCorrupted로 오는가?

날짜는 형식 자체가 계약이기 때문이다. JSON에는 날짜 타입이 없으므로 문자열이나 숫자로 표현되고, 그 문자열이 약속한 형식과 다르면 "데이터가 손상됐다"고 판정된다.

struct Event: Decodable { let at: Date }

let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601

// 소수 초가 섞여 오면 실패한다 (29장 Q5 꼬리질문)
let json = #"{"at":"2026-08-15T14:30:00.123Z"}"#.data(using: .utf8)!
do { _ = try dec.decode(Event.self, from: json) }
catch let e as DecodingError { print(e.diagnostic) }
//   형식 손상 at .at — Expected date string to be ISO8601-formatted.

대응은 커스텀 전략으로 여러 형식을 시도하는 것이다.

dec.dateDecodingStrategy = .custom { decoder in
    let s = try decoder.singleValueContainer().decode(String.self)
    let withFrac = ISO8601DateFormatter()
    withFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
    if let d = withFrac.date(from: s) { return d }

    let plain = ISO8601DateFormatter()
    plain.formatOptions = [.withInternetDateTime]
    if let d = plain.date(from: s) { return d }

    throw DecodingError.dataCorruptedError(
        in: try decoder.singleValueContainer(),
        debugDescription: "지원하지 않는 날짜 형식: \(s)")
}

다만 이건 임시 방편이다. 29장에서 본 대로 서버와 형식을 하나로 합의하는 것이 근본 해법이고, 형식이 흔들리는 것 자체가 계약이 없다는 신호다.

쉽게 말하면 날짜는 정해진 모양으로 적기로 약속한 글자다. 약속과 다른 모양이 오면 시스템은 "이건 날짜가 아니라 이상한 글자"라고 판단한다. 그래서 "형식이 깨졌다"고 말하는 것이다. 여러 모양을 다 받아 주게 만들 수도 있지만, 모양을 하나로 정하는 게 먼저다.
디코딩 실패를 사용자에게 어떻게 전달해야 하는가?

사용자가 할 수 있는 행동으로 번역한다. 내부 진단은 사용자에게 아무 도움이 안 된다.

실패를 사용자 관점으로 분류하면 이렇다.

내부 원인사용자에게제공할 행동
스키마 불일치 (전체 실패)"앱 업데이트가 필요합니다"스토어로 이동
일부 항목 실패아무 말도 안 함나머지를 보여준다
형식 손상 (응답이 깨짐)"불러오지 못했습니다"다시 시도
인증 만료 등"다시 로그인해 주세요"로그인 화면

여기서 "앱 업데이트가 필요합니다"를 정확히 판정하려면 버전 정보가 필요하다. 서버가 최소 지원 버전을 내려주면 디코딩 실패를 추측하지 않고 명시적으로 안내할 수 있다.

struct APIResponse<T: Decodable>: Decodable {
    let minimumClientVersion: String?     // 서버가 명시적으로 알려준다
    let data: T?
}
//   디코딩 실패를 원인 추측으로 다루지 않고, 계약 안에 버전 협상을 둔다

그리고 사용자에게 재시도를 권할 때는 실제로 재시도가 의미 있어야 한다. 스키마 불일치는 재시도해도 계속 실패하므로 "다시 시도"를 보여주면 안 된다 — P0 04장 Q9의 재시도 가능성 구분과 같은 판단이다.

쉽게 말하면 손님에게 "3번 손님 주소 칸 우편번호 형식 오류"라고 말하면 아무 도움이 안 된다. 손님이 알고 싶은 건 "내가 뭘 하면 되나"다. 다시 눌러보면 되는 일인지, 앱을 업데이트해야 하는 일인지, 그냥 기다려야 하는 일인지를 알려줘야 한다. 그리고 다시 눌러도 소용없는 일에 "다시 시도" 버튼을 주면 안 된다.

Q5. 다형 JSON은 왜 어려운가?

🔑 30초 답변

어느 타입으로 디코딩할지가 데이터 안에 들어 있기 때문이다. 서버가 {"type":"circle","radius":5}처럼 판별자 필드로 케이스를 나눠 보내면, Codable은 이를 스스로 알아채지 못한다. 표준 레시피는 판별자만 먼저 읽고, 같은 컨테이너에 대고 알맞은 구체 타입을 다시 디코딩하는 것이다 — Q3에서 본 컨테이너가 뷰라는 성질 덕에 가능하다. 중요한 함정 하나 — SE-0295의 연관값 enum 합성은 이 문제를 풀어주지 않는다. 그 합성이 만드는 JSON은 {"circle":{"radius":5}} 형태라 판별자와 페이로드가 형제로 나란한 서버 관례와 스키마가 다르다.

CS 원리

이것은 태그된 합집합(tagged union)의 직렬화 문제다. 정적 타입 언어에서 "여러 타입 중 하나"를 표현하려면 어느 것인지 알려주는 태그가 필요한데, 그 태그를 어디에 두느냐가 스키마를 가른다.

배치모양쓰는 곳
내부 태그{"type":"circle","radius":5}대부분의 REST API
외부 태그{"circle":{"radius":5}}Swift 합성이 만드는 형태
인접 태그{"type":"circle","value":{...}}일부 RPC 스타일

세 배치는 표현력이 같지만 호환되지 않는다. 그래서 "Codable이 enum을 지원한다"는 사실만으로는 서버와 통하지 않는다 — 어느 배치인지가 실제 계약이다.

내부 태그 방식이 어려운 이유는 타입 결정과 데이터 읽기가 같은 레벨에서 섞여 있기 때문이다. radius를 읽으려면 먼저 circle임을 알아야 하는데, 그 정보가 같은 객체 안에 있다. 그래서 같은 자리를 두 번 봐야 한다 — 한 번은 태그를 위해, 한 번은 페이로드를 위해.

여기서 설계 선택이 하나 남는다. 모르는 태그를 만났을 때 그 원소만 건너뛸지, 전체를 실패시킬지다. 이는 복원력 대 정확성의 트레이드오프이고, 도메인이 정한다 — 결제·계약 도메인은 엄격 모드가 맞다.

태그를 어디에 두느냐가 스키마를 가른다 내부 태그 — 서버 관례 {"type":"circle","radius":5} 태그와 페이로드가 형제로 나란하다 외부 태그 — Swift 합성 결과 {"circle":{"radius":5}} 스키마가 다르다 — 서로 못 읽는다 내부 태그를 읽는 표준 레시피 ① 같은 컨테이너에서 type 만 먼저 읽는다 ② switch 로 구체 타입 을 고른다 같은 자리를 다시 디코딩한다 컨테이너가 뷰라서 가능하다 — 재파싱이 아니라 한 번 더 조회다 (Q3) 모르는 태그를 만나면 — 건너뛸지 실패시킬지는 도메인이 정한다 피드는 복원력, 결제·계약은 정확성 쪽이 맞다
"Codable이 enum을 지원한다"는 사실만으로는 부족하다 — 태그 배치가 실제 계약이다.

iOS에서는

내부 태그 방식의 표준 구현이다.

import Foundation

struct Circle: Decodable { let radius: Double }
struct Rect: Decodable { let width: Double; let height: Double }

enum Shape: Decodable {
    case circle(Circle)
    case rect(Rect)
    case unknown(String)

    private enum Tag: String, CodingKey { case type }

    init(from decoder: Decoder) throws {
        // ① 태그만 먼저 읽는다
        let tagContainer = try decoder.container(keyedBy: Tag.self)
        let type = try tagContainer.decode(String.self, forKey: .type)

        // ② ③ 같은 decoder 로 구체 타입을 다시 디코딩한다
        switch type {
        case "circle": self = .circle(try Circle(from: decoder))
        case "rect":   self = .rect(try Rect(from: decoder))
        default:       self = .unknown(type)      // 복원력 모드
        }
    }
}

let json = #"[{"type":"circle","radius":5},{"type":"rect","width":2,"height":3}]"#
let shapes = try JSONDecoder().decode([Shape].self, from: json.data(using: .utf8)!)

엄격 모드가 필요하면 default에서 던진다.

default:
    throw DecodingError.dataCorruptedError(
        forKey: .type, in: tagContainer,
        debugDescription: "지원하지 않는 shape 타입: \(type)")
//   codingPath 가 남으므로 어느 원소인지 특정된다 (Q4)

SE-0295 합성이 왜 답이 아닌지도 코드로 보면 분명하다.

enum AutoShape: Codable {
    case circle(radius: Double)
    case rect(width: Double, height: Double)
}

let s = AutoShape.circle(radius: 5)
String(data: try JSONEncoder().encode(s), encoding: .utf8)!
//   {"circle":{"radius":5}}
//   서버가 기대하는 {"type":"circle","radius":5} 와 다르다.
//   합성은 편리하지만 스키마를 우리가 고를 수 없다.

인코딩도 대칭으로 직접 써야 한다.

extension Shape: Encodable {
    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: Tag.self)
        switch self {
        case .circle(let v):
            try c.encode("circle", forKey: .type)
            try v.encode(to: encoder)          // 같은 레벨에 페이로드를 편다
        case .rect(let v):
            try c.encode("rect", forKey: .type)
            try v.encode(to: encoder)
        case .unknown(let t):
            try c.encode(t, forKey: .type)
        }
    }
}

실험 · 도구

두 스키마가 서로 못 읽는다는 것을 직접 확인해 보자.

import Foundation

// 합성이 만드는 외부 태그 JSON
let auto = try JSONEncoder().encode(AutoShape.circle(radius: 5))
print(String(data: auto, encoding: .utf8)!)      // {"circle":{"radius":5}}

// 내부 태그를 기대하는 디코더에 넣으면 실패한다
do { _ = try JSONDecoder().decode(Shape.self, from: auto) }
catch let e as DecodingError { print(e.diagnostic) }
//   키 없음 at .type      ← 태그 배치가 다르다

// 반대도 마찬가지다
let manual = #"{"type":"circle","radius":5}"#.data(using: .utf8)!
do { _ = try JSONDecoder().decode(AutoShape.self, from: manual) }
catch let e as DecodingError { print(e.diagnostic) }

서버가 어떤 배치를 쓰는지 빠르게 확인하는 방법이다.

# 응답에서 판별자 후보 필드를 찾는다
jq -r '.. | objects | keys[]' response.json | sort | uniq -c | sort -rn | head -20
#   type · kind · __typename 같은 이름이 상위에 보이면 내부 태그 방식이다

# 태그 값의 종류를 모아 본다 — unknown 처리 대상 파악
jq -r '.. | objects | select(has("type")) | .type' response.json | sort -u

프로젝트 적용

  1. 서버의 태그 배치를 먼저 확인하고 계약에 명시한다. "enum을 쓰면 된다"가 아니라 어느 배치인지가 계약이다.
  2. 모르는 태그 처리 방침을 도메인별로 정한다. 피드는 unknown 케이스로 살리고, 결제는 실패시킨다.
  3. unknown을 만나면 지표로 올린다. 서버가 새 타입을 추가했다는 신호이고, 얼마나 많은 사용자가 못 보고 있는지 알 수 있다.
  4. 인코딩도 대칭으로 구현하고 왕복 테스트를 붙인다. 디코딩만 구현하면 보내는 쪽에서 어긋난다.
  5. 합성을 쓸 거면 서버가 그 형태에 맞춰야 한다. 앱이 데이터 형식을 정하는 관계(로컬 저장 등)에서는 합성이 편하다.
⚠️ 흔한 오해

"Swift가 연관값 enum Codable을 지원하니 다형 JSON이 해결됐다"는 틀렸다 — 합성이 만드는 것은 외부 태그라 서버의 내부 태그 관례와 스키마가 다르다. "판별자를 읽으려면 데이터를 두 번 파싱해야 한다"도 틀렸다 — 이미 파싱된 트리에서 한 번 더 조회할 뿐이다. "모르는 타입은 무조건 건너뛰는 게 안전하다"도 도메인에 따라 틀리다 — 결제 항목이 조용히 사라지는 것이 더 위험하다. "연관값 없는 케이스는 문자열로 인코딩된다"도 틀렸다 — 빈 객체 {"logout":{}}가 된다.

🧒 쉽게 이해하기

상자에 물건이 들어 있는데, 상자마다 종류가 다르다. 어떤 건 동그라미 물건, 어떤 건 네모 물건이다. 꺼내기 전에 종류를 알아야 어떻게 다룰지 정할 수 있다.

그래서 상자에 "종류: 동그라미"라고 적어 둔다. 이걸 먼저 읽고, 그다음에 안에 든 걸 꺼낸다. 같은 상자를 두 번 보는 셈인데, 상자를 열어 두고 보는 거라 문제없다.

여기서 헷갈리는 게 하나 있다. Swift가 알아서 상자를 싸 주는 기능을 지원한다. 그런데 그 기능이 만드는 상자는 모양이 다르다.

Swift는 "동그라미"라고 적힌 큰 상자 안에 물건을 넣는 방식으로 싼다. 서버는 물건 옆에 "종류: 동그라미" 쪽지를 붙이는 방식을 쓴다.

둘 다 종류를 알려주지만 모양이 달라서 서로 못 읽는다. 그래서 "Swift가 지원하니까 되겠지" 하면 안 되고, 서버가 어느 방식을 쓰는지부터 확인해야 한다.

꼬리 질문

서버가 새 타입을 추가했는데 구버전 앱이 깨지지 않게 하려면?

unknown 케이스를 처음부터 두는 것이 핵심이다. 이건 스키마 진화의 기본 대비다(Q7).

enum Shape: Decodable {
    case circle(Circle)
    case rect(Rect)
    case unknown(type: String)      // ✅ 처음부터 있어야 나중에 안 깨진다
}

그런데 unknown을 UI에서 어떻게 다룰지도 정해야 한다. 그냥 건너뛰면 목록에 구멍이 생기고, 사용자는 왜 없는지 모른다.

  • 목록에서 제외 — 가장 단순하지만 개수가 안 맞을 수 있다.
  • 플레이스홀더 표시 — "이 항목을 보려면 앱을 업데이트하세요". 사용자가 상황을 이해한다.
  • 서버가 폴백 표현을 함께 제공 — 새 타입에 fallbackText 같은 공통 필드를 두면 구버전도 대략은 보여줄 수 있다. 가장 우아하지만 서버 설계가 필요하다.

그리고 비율을 지표로 추적한다. unknown 비율이 높아지면 구버전 사용자가 반쪽 화면을 보고 있다는 뜻이고, 업데이트 유도를 고려할 시점이다.

P1 23장 Q5에서 본 "구버전 앱은 영원히 옛 규칙"이 여기서도 그대로 적용된다 — 새 타입을 추가하는 것은 서버의 자유지만, 이미 배포된 앱은 고칠 수 없다.

쉽게 말하면 나중에 새로운 종류의 상자가 올 수 있다고 미리 생각해 두고, "모르는 종류" 칸을 만들어 둔다. 그러면 새 상자가 와도 앱이 놀라지 않는다. 다만 모르는 상자를 그냥 버리면 손님은 물건이 없어진 줄 안다. "이건 새 버전에서 볼 수 있어요"라고 알려주는 편이 낫다.
제네릭 컨테이너 안에 다형 타입이 들어가면 어떻게 다루는가?

제네릭 파라미터가 Decodable을 요구하므로 자연스럽게 합성된다. 다형 타입 자체가 Decodable이면 그대로 들어간다.

struct Page<T: Decodable>: Decodable {
    let items: [T]
    let nextCursor: String?
}

// Shape 이 Decodable 이므로 그대로 쓸 수 있다
let page = try JSONDecoder().decode(Page<Shape>.self, from: data)

주의할 점은 제네릭 타입 자체는 Codable 합성이 되지만, 제약이 없으면 안 된다는 것이다(Q1).

struct Bad<T>: Decodable { let value: T }        // ❌ T 에 제약이 없어 합성 불가
struct Good<T: Decodable>: Decodable { let value: T }   // ✅

더 까다로운 경우는 응답 형태 자체가 타입에 따라 다른 경우다 — 성공이면 data, 실패면 error가 오는 식이다. 이것도 같은 판별자 패턴으로 푼다.

enum APIResult<T: Decodable>: Decodable {
    case success(T)
    case failure(APIError)

    private enum K: String, CodingKey { case status, data, error }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: K.self)
        switch try c.decode(String.self, forKey: .status) {
        case "ok":  self = .success(try c.decode(T.self, forKey: .data))
        default:    self = .failure(try c.decode(APIError.self, forKey: .error))
        }
    }
}
쉽게 말하면 "무엇이든 담을 수 있는 통"을 만들 때, "담기는 것은 반드시 포장 가능해야 한다"는 조건을 붙이면 된다. 그러면 통 안에 종류가 여러 가지인 물건이 들어가도 각자 알아서 풀린다.
GraphQL의 __typename도 같은 방식으로 다루는가?

구조는 같지만 몇 가지 차이가 있다. GraphQL의 __typename내부 태그이므로 앞의 레시피가 그대로 적용된다.

다만 세 가지가 다르다.

  • 태그가 항상 있지는 않다. 쿼리에서 __typename을 명시적으로 요청해야 응답에 포함된다. 요청을 빠뜨리면 keyNotFound가 난다.
  • 인터페이스·유니온 타입에서 주로 필요하다. 구체 타입 하나만 반환하는 필드에는 판별자가 필요 없다.
  • 코드 생성 도구가 흔하다. Apollo 같은 도구가 스키마에서 Swift 타입과 디코딩 코드를 생성하므로 손으로 쓸 일이 줄어든다 — 이게 가장 큰 차이다.

코드 생성을 쓰면 Q2에서 본 필드 누락 문제도 함께 사라진다. 스키마가 진실의 원천이 되어 사람이 두 곳을 맞출 필요가 없다.

다만 생성된 코드도 모르는 타입 처리는 정해야 한다 — 서버 스키마에 새 타입이 추가되고 앱이 재생성 전이면 여전히 unknown이 필요하다.

쉽게 말하면 GraphQL도 상자에 종류 쪽지를 붙이는 방식이다. 다른 점은 "쪽지를 붙여 주세요"라고 요청해야 붙여 준다는 것과, 기계가 상자 푸는 코드를 자동으로 만들어 준다는 것이다. 자동으로 만들어 주면 사람이 빠뜨릴 일이 없어져서 훨씬 안전하다.

Q6. 키 변환 전략과 디코더 재사용에 대한 통념은 맞는가?

🔑 30초 답변

둘 다 실측과 어긋난다.keyDecodingStrategy = .convertFromSnakeCase손실이 있다imageURL을 인코딩하면 image_url로 뭉개지고, 다시 디코딩하면 조각별 첫 글자만 대문자화해 imageUrl이 나와 원래 이름과 어긋나 keyNotFound가 난다. 게다가 이 전략이 켜져 있으면 CodingKeys에 원본 키를 그대로 적어도 못 이긴다 — 컨테이너 생성 시점에 키가 먼저 변환되기 때문이다. ② "JSONDecoderDateFormatter처럼 재사용하라"는 통념은 실측과 다르다 — 5만 건(4.6MB) 기준 재사용과 매번 생성의 디코드 시간이 사실상 같았다(둘 다 약 86ms).

CS 원리

키 변환 전략의 문제는 비가역 사상(non-invertible mapping)이다.

camelCase → snake_case 변환은 정보를 잃는다. imageURLimageUrl이 둘 다 image_url로 가므로, 역방향에서 어느 것이 원본인지 알 수 없다. 역변환 규칙은 하나만 고를 수 있고, 그것이 imageUrl이다.

여기서 연쇄 실패가 생긴다.

그리고 적용 순서가 중요하다. 전략은 컨테이너를 만들 때 키 전체를 변환하므로, CodingKeys에 무엇을 적든 이미 변환된 키와 비교된다. 그래서 전략과 명시적 키를 섞으면 안 된다 — 전부 전략에 맡기거나, 전부 명시하거나 둘 중 하나다.

두 번째 통념 — 인스턴스 생성 비용은 타입마다 다르다. 29장 Q5에서 DateFormatter는 로케일·달력 데이터를 로드하고 심볼 테이블을 구성하느라 18배 차이가 났다. JSONDecoder는 그런 무거운 초기화가 없어 생성 자체가 가볍고, 진짜 비용은 파싱에 있다. "객체 재사용은 항상 이득"이라는 일반화가 여기서 깨진다.

convertFromSnakeCase 는 되돌릴 수 없는 변환이다 imageURL imageUrl image_url 둘이 하나로 뭉개진다 역변환 imageUrl 원래 imageURL 과 어긋난다 → keyNotFound 그리고 전략이 켜져 있으면 CodingKeys 에 원본 키를 적어도 못 이긴다 컨테이너 생성 시점에 키가 먼저 변환되기 때문이다 "디코더도 재사용하라"는 통념 — 실측 결과 DateFormatter (29장 Q5) 로케일·심볼 테이블 구성이 무겁다 재사용이 약 18배 빠르다 JSONDecoder 생성이 가볍고 비용은 파싱에 있다 5만 건 기준 둘 다 약 86ms "객체 재사용은 항상 이득"이라는 일반화가 여기서 깨진다
같은 "재사용" 조언이 타입에 따라 맞기도 하고 무의미하기도 하다. 측정이 답한다.

iOS에서는

전략과 명시적 키를 섞었을 때 무슨 일이 생기는지 보자.

struct Photo: Decodable {
    let imageURL: URL
    enum CodingKeys: String, CodingKey {
        case imageURL = "image_url"       // 원본 키를 그대로 적었다
    }
}

let dec = JSONDecoder()
dec.keyDecodingStrategy = .convertFromSnakeCase     // 전략도 켰다

let json = #"{"image_url":"https://x.com/a.jpg"}"#.data(using: .utf8)!
try dec.decode(Photo.self, from: json)
//   ❌ keyNotFound — 전략이 "image_url" 을 "imageUrl" 로 먼저 바꿨고,
//      CodingKeys 의 "image_url" 과 비교되지 않는다.

해결은 한쪽만 쓰는 것이다.

// ✅ 방법 A — 전략만 쓴다. CodingKeys 를 정의하지 않는다.
struct PhotoA: Decodable { let imageUrl: URL }      // 프로퍼티명을 변환 결과에 맞춘다
// dec.keyDecodingStrategy = .convertFromSnakeCase

// ✅ 방법 B — 전략을 끄고 전부 명시한다. 이름을 우리가 통제한다.
struct PhotoB: Decodable {
    let imageURL: URL
    enum CodingKeys: String, CodingKey { case imageURL = "image_url" }
}
// dec.keyDecodingStrategy = .useDefaultKeys       (기본값)

방법 B를 권한다 — 약어(URL·ID·HTML)가 섞이면 전략의 왕복이 깨지고, 서버가 snake_case가 아닌 키를 하나라도 쓰면 전략만으로는 감당이 안 된다.

디코더 재사용은 성능이 아니라 설정 일관성 때문에 한다.

// 재사용의 진짜 이유 — 날짜 전략·키 전략을 한 곳에서 관리한다
enum API {
    static let decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.dateDecodingStrategy = .iso8601
        d.keyDecodingStrategy = .useDefaultKeys
        return d
    }()
}
//   성능 때문이 아니라 "설정이 어긋나지 않게" 하려는 것이다.
//   JSONDecoder 는 값 타입이 아니지만 설정 후 변경하지 않으면 동시 사용에 문제가 없다.

참고로 swift-foundation 재작성으로 디코딩 성능이 크게 개선됐다. swift.org의 검증 가능한 개선치는 구형(corelibs-foundation) 대비 200~500%이며, 원인은 리플렉션이 아니라 JSONSerialization이 만드는 Objective-C 객체 그래프와의 브리징(swift_dynamicCast) 제거다.

실험 · 도구

왕복이 깨지는 것을 직접 확인해 보자.

import Foundation

struct Round: Codable { let imageURL: String }

let enc = JSONEncoder(); enc.keyEncodingStrategy = .convertToSnakeCase
let dec = JSONDecoder(); dec.keyDecodingStrategy = .convertFromSnakeCase

let data = try enc.encode(Round(imageURL: "a.jpg"))
print(String(data: data, encoding: .utf8)!)      // {"image_url":"a.jpg"}

do { _ = try dec.decode(Round.self, from: data) }
catch let e as DecodingError { print(e.diagnostic) }
//   키 없음 at .imageUrl    ← 역변환이 imageUrl 을 기대하는데 프로퍼티는 imageURL 이다

디코더 생성 비용도 직접 재 볼 수 있다.

let big = try Data(contentsOf: url)      // 약 4.6MB, 5만 건
let clock = ContinuousClock()

let shared = JSONDecoder()
let reused = clock.measure { _ = try? shared.decode([Record].self, from: big) }
let fresh  = clock.measure { _ = try? JSONDecoder().decode([Record].self, from: big) }

print("재사용: \(reused)")     // 약 86ms
print("매번 생성: \(fresh)")   // 약 86ms — 차이가 거의 없다
//   DateFormatter 와 달리 생성 비용이 파싱 비용에 묻힌다

기존 코드에서 위험한 조합을 찾는다.

# 전략과 CodingKeys 를 함께 쓰는 파일 — 충돌 후보
grep -rl "convertFromSnakeCase" --include=*.swift . | while read f; do
  grep -l "enum CodingKeys" "$f"
done

# 약어가 섞인 프로퍼티명 — 전략 왕복이 깨지는 후보
grep -rnE "let [a-z]+[A-Z]{2,}" --include=*.swift . | head -20

프로젝트 적용

  1. 키 변환 전략과 CodingKeys를 섞지 않는다. 프로젝트 차원에서 한쪽으로 통일한다.
  2. 약어가 있는 이름이 하나라도 있으면 전략을 쓰지 않는다. URL·ID·HTML이 흔하다.
  3. 디코더는 성능이 아니라 설정 일관성 때문에 공유한다. "재사용하면 빨라진다"는 근거로 쓰지 않는다.
  4. 재사용 조언을 타입별로 검증한다. DateFormatter는 맞고 JSONDecoder는 무의미하다 — 측정이 답한다.
  5. 왕복 테스트에 약어 필드를 포함한다. 전략의 비가역성은 왕복 테스트로만 드러난다.
⚠️ 흔한 오해

"convertFromSnakeCase를 켜고 CodingKeys로 예외를 두면 된다"는 틀렸다 — 전략이 먼저 적용돼 CodingKeys가 못 이긴다. "snake_case 변환은 가역적이다"도 틀렸다imageURLimageUrl같은 키로 뭉개진다. "포매터든 디코더든 재사용이 항상 이득"도 실측과 다르다JSONDecoder는 5만 건 기준 차이가 사실상 없다. "Codable이 느린 건 리플렉션 때문"도 틀렸다 — 컴파일 타임 합성이며, 구형의 병목은 ObjC 브리징이었다.

🧒 쉽게 이해하기

이름을 자동으로 바꿔 주는 기계가 있다. imageURLimage_url로 바꿔 준다. 편해 보인다.

그런데 문제가 있다. imageURLimageUrl둘 다 같은 이름으로 바뀐다. 두 개가 하나로 뭉개지는 것이다.

다시 되돌릴 때 기계는 둘 중 하나만 고를 수 있다. 그래서 imageUrl로 되돌린다. 원래 imageURL이었던 것은 영영 못 찾는다.

더 곤란한 건, "이 이름은 내가 직접 정할게"라고 적어 둬도 소용없다는 점이다. 기계가 먼저 이름을 바꿔 버리기 때문이다. 그래서 기계에게 전부 맡기든가, 전부 직접 하든가 둘 중 하나여야 한다.

또 하나 재미있는 이야기가 있다. "도구는 한 번 만들어서 계속 쓰는 게 빠르다"는 조언이 있다. 날짜 도구는 정말 그렇다 — 18배 차이가 난다. 그런데 JSON 도구는 재 보니 차이가 없었다. 도구마다 만드는 데 드는 품이 다르기 때문이다. 그래서 조언을 그대로 옮겨 붙이면 안 되고, 재 봐야 한다.

꼬리 질문

서버 키가 snake_case와 camelCase가 섞여 있으면 어떻게 하는가?

전략을 끄고 전부 명시하는 것이 유일하게 안전하다. 전략은 전부 아니면 전무다.

// 서버가 섞어 보내는 경우 — 전략으로는 감당이 안 된다
// {"user_id":1,"displayName":"김","image_url":"..."}

struct Mixed: Decodable {
    let userId: Int
    let displayName: String
    let imageURL: URL

    enum CodingKeys: String, CodingKey {
        case userId = "user_id"
        case displayName                     // 그대로 일치
        case imageURL = "image_url"
    }
}
// decoder.keyDecodingStrategy = .useDefaultKeys   ← 기본값 유지

명시가 번거로워 보이지만 이득이 있다.

  • 서버 키가 바뀌어도 한 줄만 고친다. 프로퍼티명을 바꿀 필요가 없다.
  • 약어를 자유롭게 쓴다. imageURL이 Swift 관례에 맞는 이름이다.
  • 어떤 키를 기대하는지 코드에 드러난다. 전략은 암묵적이라 읽는 사람이 추측해야 한다.

키가 많아 손이 많이 간다면 코드 생성을 고려한다 — OpenAPI 스키마에서 모델을 생성하면 이 작업이 사라진다(Q5 꼬리질문).

쉽게 말하면 자동 번역기가 어떤 문장은 잘 바꾸고 어떤 문장은 못 바꾸면, 반쯤 쓰는 건 오히려 더 헷갈린다. 그럴 바엔 전부 직접 적는 게 낫다. 적어 두면 나중에 상대가 말을 바꿔도 그 줄만 고치면 된다.
대용량 JSON 디코딩이 느리면 무엇을 봐야 하는가?

디코더 재사용이 아니라 다른 것들을 본다. 앞서 봤듯 생성 비용은 병목이 아니다.

확인 순서는 이렇다.

(1) 안 쓰는 필드를 디코딩하고 있는가. 가장 효과가 크다. 응답에 100개 필드가 있는데 화면에 5개만 쓴다면, 모델에 5개만 선언한다 — 나머지는 자동으로 무시된다(Q2 꼬리질문).

(2) 메인 스레드에서 하고 있는가. 4.6MB 파싱에 86ms면 프레임 예산을 5배 넘긴다(P0 08장 Q2). 백그라운드로 옮긴다.

let items = try await Task.detached(priority: .userInitiated) {
    try JSONDecoder().decode([Record].self, from: data)
}.value

(3) 전체를 한 번에 디코딩해야 하는가. 목록 화면이라면 첫 화면 분량만 먼저 디코딩하고 나머지는 지연할 수 있다. 서버가 페이지네이션을 지원하면 그쪽이 근본 해법이다.

(4) 날짜 전략이 무거운가. .iso8601이나 커스텀 전략은 항목마다 문자열 파싱을 한다. 5만 건이면 5만 번이다. 타임스탬프 정수로 받으면 이 비용이 사라진다(29장 Q6).

(5) 형식을 바꿀 수 있는가. 정말 큰 데이터라면 JSON 자체가 병목이다 — 바이너리 형식이 훨씬 빠르다.

쉽게 말하면 짐 푸는 게 느릴 때 가위를 새로 사는 것은 도움이 안 된다. 진짜 이유는 안 쓸 물건까지 다 꺼내고 있거나, 손님 앞에서 풀고 있거나(메인 스레드), 한 번에 다 풀려고 하기 때문이다.
"재사용하라"는 조언을 어떻게 검증하는가?

그 객체의 초기화가 실제로 무거운지를 재 보면 된다. 조언은 특정 타입에 대한 관찰이지 일반 법칙이 아니다.

판별 기준은 이렇다.

초기화에서 하는 일재사용 이득
외부 데이터 로드·테이블 구성크다DateFormatter·NumberFormatter
정규식 컴파일크다NSRegularExpression
프로퍼티 몇 개 초기화거의 없다JSONDecoder·JSONEncoder
값 타입 복사없다 (오히려 공유가 손해)FormatStyle

측정 방법은 단순하다 — 생성만 반복하는 벤치와 사용까지 반복하는 벤치를 비교한다.

let clock = ContinuousClock()

// 생성만
let createOnly = clock.measure { for _ in 0..<10_000 { _ = JSONDecoder() } }
// 생성 + 사용
let createAndUse = clock.measure {
    for _ in 0..<10_000 { _ = try? JSONDecoder().decode(Small.self, from: tiny) }
}
print("생성만: \(createOnly)")          // 생성 비용이 여기 드러난다
print("생성+사용: \(createAndUse)")     // 사용 비용에 묻히는지 본다

일반 원리는 "조언을 상속하지 말고 측정을 상속하라"이다. DateFormatter의 18배는 그 타입의 사실이지 Foundation 전체의 사실이 아니다.

쉽게 말하면 "도구는 아껴 쓰라"는 말이 어떤 도구에는 맞고 어떤 도구에는 무의미하다. 만드는 데 오래 걸리는 도구는 아껴 쓰는 게 맞고, 집어 들면 바로 쓰는 도구는 아낄 이유가 없다. 어느 쪽인지는 재 보면 안다.

Q7. JSON 스키마를 어떻게 진화시키는가?

🔑 30초 답변

양쪽이 서로 다른 속도로 바뀐다는 전제에서 설계한다. P0 09장 Q4의 DB 마이그레이션은 "코드는 배포로 교체되지만 데이터는 기기에 남는다"는 비대칭이었다. 여기서는 클라이언트와 서버가 각자 배포되고, P1 23장 Q5에서 본 대로 구버전 앱은 영원히 사라지지 않는다. 그래서 규칙은 "추가는 자유, 제거·변경은 금지"다. 필드 추가는 안전하고(Codable은 모르는 키를 무시한다), 필드 제거·타입 변경·의미 변경은 구버전을 깨뜨린다. 바꿔야 하면 새 필드를 추가하고 둘 다 유지하다가, 구버전 사용률이 충분히 낮아진 뒤 제거한다.

CS 원리

스키마 진화는 호환성의 두 방향으로 정리된다.

방향의미깨뜨리는 변경
하위 호환(backward)새 코드가 옛 데이터를 읽는다필수 필드 추가
상위 호환(forward)옛 코드가 새 데이터를 읽는다필드 제거·타입 변경·의미 변경

모바일에서 상위 호환이 특히 중요하다. 서버는 계속 배포되지만 앱은 사용자가 업데이트해야 하고, 일부는 영원히 하지 않는다. 그래서 서버의 변경이 구버전 앱을 깨뜨리지 않아야 한다.

여기서 안전한 변경과 위험한 변경이 갈린다.

가장 위험한 것이 의미 변경이다 — 타입도 이름도 그대로라 어떤 도구도 잡지 못하고, 구버전 앱은 조용히 잘못된 동작을 한다. 예컨대 amount가 원 단위에서 전 단위로 바뀌면 구버전은 100배 틀린 금액을 보여준다.

대응 원리는 "바꾸지 말고 더한다"이다. 새 의미가 필요하면 새 이름을 쓰고, 두 필드를 당분간 함께 채운다. 이는 데이터베이스의 확장-축소(expand-contract) 마이그레이션과 같은 패턴이다.

구버전 앱은 사라지지 않는다 — 그래서 더하기만 한다 ① 확장 — 새 필드 추가 amount(구) + amountWon(신) 서버가 둘 다 채운다 ② 이행 — 신규만 읽기 새 앱은 amountWon 을 쓴다 구버전은 amount 로 계속 동작 ③ 축소 구버전 사용률이 충분히 낮아진 뒤 제거 위험도 순 — 아래로 갈수록 잡기 어렵다 필드 제거 — keyNotFound 로 즉시 드러난다 타입 변경 — typeMismatch 로 드러난다 의미 변경 — 아무 에러도 안 난다 amount 가 원 → 전으로 바뀌면 구버전은 100배 틀린 금액을 보여준다
확장-축소는 데이터베이스 마이그레이션의 표준 패턴이고, API 계약에도 그대로 적용된다.

iOS에서는

클라이언트 쪽 대비는 모르는 것에 관대하고, 아는 것에 엄격한 구조를 만드는 것이다.

struct Product: Decodable {
    let id: String
    let name: String

    // 신규 필드 — 구버전 서버는 안 보낼 수 있으므로 옵셔널
    let amountWon: Int?
    // 구 필드 — 언젠가 사라질 수 있으므로 옵셔널
    private let amount: Int?

    /// 신규를 우선하고 없으면 구 필드로 폴백한다
    var priceWon: Int { amountWon ?? amount ?? 0 }

    enum CodingKeys: String, CodingKey { case id, name, amountWon, amount }
}

열거형unknown 케이스를 처음부터 둔다(Q5).

enum Status: String, Decodable {
    case pending, shipped, delivered
    case unknown

    init(from decoder: Decoder) throws {
        let raw = try decoder.singleValueContainer().decode(String.self)
        self = Status(rawValue: raw) ?? .unknown
        if self == .unknown { logger.notice("알 수 없는 status: \(raw, privacy: .public)") }
    }
}

버전 협상을 계약 안에 두면 추측이 필요 없어진다.

struct Envelope<T: Decodable>: Decodable {
    let schemaVersion: Int
    let minimumClientVersion: String?    // 서버가 명시적으로 알려준다
    let payload: T
}
//   디코딩 실패를 "앱이 낡아서"라고 추측하는 대신 서버가 말해 준다 (Q4 꼬리질문)

실험 · 도구

구버전 모델로 신버전 응답을 디코딩해 보는 것이 상위 호환 테스트다.

import Testing

@Test func 구버전_모델이_신버전_응답을_읽는다() throws {
    // 실제 서버가 앞으로 보낼 응답 (새 필드 포함)
    let future = #"""
    {"id":"1","name":"A","amount":1000,"amountWon":1000,"newField":"x"}
    """#.data(using: .utf8)!

    // 구버전 모델로 디코딩 — 깨지면 안 된다
    let old = try JSONDecoder().decode(ProductV1.self, from: future)
    #expect(old.amount == 1000)
}

@Test func 신버전_모델이_구버전_응답을_읽는다() throws {
    let past = #"{"id":"1","name":"A","amount":1000}"#.data(using: .utf8)!
    let new = try JSONDecoder().decode(Product.self, from: past)
    #expect(new.priceWon == 1000)      // 폴백이 동작한다
}

CI에 계약 픽스처를 두면 서버 변경이 앱을 깨뜨리는지 미리 알 수 있다.

# 서버 팀이 관리하는 응답 예시를 앱 저장소에 동기화한다
Fixtures/
  product_v1.json      # 구버전 — 여전히 읽혀야 한다
  product_v2.json      # 현재
  product_v3_draft.json # 예정된 변경 — 미리 검증한다

# 모든 픽스처를 현재 모델로 디코딩해 본다
swift test --filter ContractTests

프로젝트 적용

  1. "추가는 자유, 제거·변경은 금지"를 계약 규칙으로 문서화한다. 서버 팀과 공유해야 의미가 있다.
  2. 의미를 바꿔야 하면 새 이름을 쓴다. 같은 필드의 뜻을 바꾸는 것은 어떤 도구도 잡지 못한다.
  3. 확장-축소 3단계를 지킨다. 축소 시점은 구버전 사용률 지표로 판단한다(P1 21장 Q5의 릴리스 태깅이 여기서 쓰인다).
  4. 열거형에 unknown을 처음부터 둔다. 나중에 추가하면 이미 배포된 버전은 못 고친다.
  5. 구버전 모델로 신버전 응답을 읽는 테스트를 CI에 둔다. 상위 호환은 테스트하지 않으면 반드시 깨진다.
⚠️ 흔한 오해

"필드를 추가하면 앱이 깨진다"는 틀렸다Codable모르는 키를 무시한다. "타입만 안 바꾸면 안전하다"도 틀렸다의미 변경이 가장 위험하고 아무 에러도 안 난다. "서버 배포와 앱 배포를 맞추면 된다"도 모바일에서는 불가능하다 — 구버전 앱은 영원히 남는다. "필수 필드를 옵셔널로 바꾸는 것은 안전하다"도 방향에 따라 다르다 — 서버가 안 보내기 시작하면 구버전 앱이 깨진다. "스키마 버전 번호를 붙이면 해결된다"도 부분적이다 — 번호를 보고 무엇을 할지가 진짜 설계다.

🧒 쉽게 이해하기

편지를 주고받는데, 한쪽은 매주 새 규칙을 쓰고 다른 쪽은 몇 년째 옛날 규칙을 쓰고 있다고 하자. 앱을 업데이트 안 하는 사람이 늘 있기 때문이다.

그래서 규칙이 하나 필요하다 — 새로운 걸 더하는 건 괜찮고, 있던 걸 없애거나 바꾸는 건 안 된다.

새 칸을 추가하는 건 안전하다. 옛날 사람은 모르는 칸을 그냥 무시하니까.

칸을 없애면? 옛날 사람이 "그 칸이 없다"고 놀란다. 그래도 바로 알 수 있어서 그나마 낫다.

제일 무서운 건 칸 이름은 그대로인데 뜻만 바꾸는 것이다. "금액"이라는 칸이 있는데, 원래는 원 단위였다가 어느 날 전 단위로 바뀌었다고 하자. 옛날 사람은 아무것도 눈치채지 못하고 100배 틀린 금액을 보여준다. 어떤 검사로도 안 잡힌다.

그래서 뜻을 바꿔야 하면 새 칸을 만들고, 한동안 두 칸을 다 채워 두고, 옛날 사람이 거의 없어진 뒤에 옛 칸을 치운다.

꼬리 질문

구버전 사용률이 언제 충분히 낮아진 것인가?

절대 비율이 아니라 영향받는 사용자 수와 사업적 손실로 판단한다.

고려할 것들이다.

  • 절대 수. 1%가 남았어도 사용자가 1000만이면 10만 명이다.
  • 그 사용자들의 가치. 구버전에 남은 사용자가 결제 비중이 높은 집단일 수 있다.
  • 실패의 성격. 화면이 안 보이는 정도인지, 잘못된 금액이 표시되는지에 따라 허용치가 다르다.
  • 강제 업데이트 수단이 있는가. 최소 지원 버전 안내를 띄울 수 있으면 통제 가능하다.

실무 절차는 이렇다.

  1. 릴리스별 사용률을 지표로 본다(P1 21장 Q5의 릴리스 태깅).
  2. 제거 예정을 미리 공지하고 구버전에 업데이트 안내를 띄운다.
  3. 제거 후 에러율을 감시한다. 급증하면 롤백한다.

그리고 제거하지 않는 선택도 정당하다. 필드 하나 남겨 두는 비용이 구버전을 깨뜨리는 위험보다 작은 경우가 많다.

쉽게 말하면 "옛날 방식 쓰는 사람이 1%밖에 안 남았으니 없애자"고 할 때, 그 1%가 몇 명인지 세어 봐야 한다. 1000만 명 중 1%면 10만 명이다. 그리고 그 사람들이 겪을 일이 "화면이 좀 이상하다"인지 "돈이 잘못 나간다"인지에 따라 판단이 달라진다.
서버가 실수로 깨는 변경을 배포하는 것을 어떻게 막는가?

계약을 코드로 만들고 CI에서 검증하는 것이 근본 해법이다. 사람의 주의력에 기대면 반드시 새어 나간다.

층위별 방어가 이렇다.

(1) 스키마 정의를 단일 원천으로. OpenAPI·Protobuf 같은 스키마 파일을 두고 서버와 클라이언트가 거기서 생성한다. 손으로 맞추는 구조가 사라진다.

(2) 호환성 검사 도구. 스키마 변경 시 깨는 변경인지 자동 판정하는 도구가 있다. Protobuf는 이 검사가 성숙해 있고, OpenAPI도 diff 도구가 있다.

(3) 계약 테스트. 앱 저장소의 픽스처를 서버 CI에서도 돌린다. 서버가 응답 형태를 바꾸면 서버 빌드가 실패한다. 이것이 가장 직접적이다.

# 서버 CI 에서 클라이언트 픽스처를 검증한다
curl -s localhost:8080/api/products/1 > /tmp/actual.json
python3 - <<'PY'
import json
expected_keys = set(json.load(open("client-fixtures/product_v2.json")).keys())
actual_keys   = set(json.load(open("/tmp/actual.json")).keys())
missing = expected_keys - actual_keys
if missing:
    raise SystemExit(f"❌ 클라이언트가 기대하는 키가 사라졌다: {missing}")
print("✅ 계약 유지")
PY

(4) 카나리 배포. 새 응답을 일부 트래픽에만 적용하고 클라이언트 에러율을 본다(P1 18장).

기술보다 조직이 중요한 경우가 많다 — 서버 팀이 모바일 구버전의 존재를 인지하고 있는지가 실제 차이를 만든다.

쉽게 말하면 "편지 규칙을 바꾸지 마세요"라고 부탁만 해서는 안 지켜진다. 규칙을 종이에 적어 두고, 새 편지를 보내기 전에 기계가 옛 규칙에 맞는지 검사하게 만들어야 한다. 검사를 통과 못 하면 편지를 아예 못 보내게 하는 것이다.
로컬 저장 데이터의 스키마 진화는 무엇이 다른가?

양쪽이 모두 우리 코드라는 점이 다르고, 그래서 더 어렵다.

차이를 정리하면 이렇다.

API 계약로컬 저장
옛 데이터서버가 통제사용자 기기에 그대로
일괄 변환서버에서 가능불가능 — 기기마다 따로
실패 시재시도·폴백데이터 손실 가능

그래서 P0 09장 Q4의 마이그레이션 원칙이 그대로 적용된다 — 원자성, 단조 전진, 실제 사용자 데이터에 가까운 케이스로 검증.

추가로 신경 쓸 것이 버전 표시다. 저장 데이터에는 스키마 버전을 함께 저장해야 한다. 없으면 어느 버전인지 추측해야 하고, 추측은 틀린다.

struct StoredEnvelope<T: Codable>: Codable {
    let schemaVersion: Int
    let payload: T
}

func load(_ data: Data) throws -> Settings {
    let head = try JSONDecoder().decode(VersionHead.self, from: data)
    switch head.schemaVersion {
    case 1:  return try migrateV1toV2(JSONDecoder().decode(SettingsV1.self, from: data))
    case 2:  return try JSONDecoder().decode(Settings.self, from: data)
    default: throw StoreError.unsupportedVersion(head.schemaVersion)
    }
}

그리고 다운그레이드를 고려해야 한다. 사용자가 앱을 되돌리면 신버전이 쓴 데이터를 구버전이 읽게 된다. 대개는 설계로 막는다 — 마이그레이션은 앞으로만 가고, 되돌릴 수 없음을 전제한다.

쉽게 말하면 서버 데이터는 우리 창고에 있어서 한꺼번에 정리할 수 있다. 그런데 사용자 기기에 있는 데이터는 손이 닿지 않는다. 각자 알아서 정리되게 만들어야 하고, 정리하다 실패하면 그 사람 데이터가 날아간다. 그래서 훨씬 조심해야 하고, "이건 몇 번째 방식으로 적은 것"이라고 꼬리표를 꼭 붙여 둬야 한다.

출처 · 참고자료