← Swift & Foundation 로드맵 19 / 33

19 · Codable ① 합성과 커스텀

구조체·클래스가 Codable을 언제 공짜로 합성하고 언제 컴파일 에러나 조용한 데이터 유실로 끊기는지부터, CodingKeys 제외 규칙, 컨테이너 3종, 클래스 상속의 superEncoder·superDecoder, SE-0295 연관값 enum 합성까지 실제 컴파일러 진단과 실행 결과로 확인한다.
진행률
0 / 0 완료

Codable은 프로퍼티가 전부 이미 Codable인 표준·Foundation 타입이면 별다른 코드 없이 init(from:)encode(to:)를 컴파일러가 대신 써준다. 이 챕터는 그 합성이 정확히 어디까지 공짜이고, 어디서부터 컴파일 에러로 끊기고, 어디서는 에러조차 없이 조용한 데이터 유실로 이어지는지를 실제 컴파일러 진단과 실행 결과로 확인한다. CodingKeys를 조작하는 법, 컨테이너 3종을 다루는 법, 클래스 상속과 enum 연관값처럼 struct 하나로는 안 풀리는 구조까지 다룬다. 전략·진단·성능 이야기는 다음 챕터(20장)로 넘긴다.

Q1. Codable 합성은 언제 되고 언제 안 되는가?

🔑 30초 답변

타입이 채택한 모든 저장 프로퍼티가 이미 Codable이면(String/Int/Double 같은 표준 타입, Date/URL/Data 같은 Foundation 타입, 이들을 담은 Array/Dictionary/Optional 포함) 컴파일러가 init(from:)encode(to:)를 그냥 만들어준다. Codable이 아닌 저장 프로퍼티, 제약 없는 제네릭 파라미터, 프로토콜(실존) 타입 프로퍼티가 하나라도 섞이면 합성이 실패해 컴파일 에러가 난다. 클래스 상속은 함정이 다르다 — 서브클래스가 새 저장 프로퍼티를 추가해도 에러 없이 컴파일되고, 그 프로퍼티는 인코딩·디코딩 양쪽에서 조용히 사라진다.

원리

Apple 공식 문서 "Encoding and Decoding Custom Types"는 이 규칙을 "Any type whose properties are codable automatically conforms to Codable just by declaring that conformance"로 요약한다. Codable은 Swift 4에서 도입됐고(관련 제안서 SE-0167: Swift Encoders), 이 합성은 런타임에 타입 정보를 훑는 리플렉션이 아니라 컴파일 타임 코드 생성이다 — 컴파일러가 각 저장 프로퍼티를 보고 CodingKeys를 자동으로 만들고, 그 키로 값을 읽고 쓰는 init(from:)/encode(to:) 본문을 직접 써 넣는다. 그러니 합성 성공 여부는 "런타임에 무엇이 들어오는가"가 아니라 "컴파일 시점에 이 타입의 모든 저장 프로퍼티 타입이 Codable이라고 증명할 수 있는가"에 달려 있다.

내부 동작

깨지는 네 가지 경우를 한 파일에 넣고 swiftc -typecheck로 돌려 실제 진단을 그대로 인용한다(Swift 6.2.1 / macOS 26 SDK).

깨지는 경우 ① — Codable이 아닌 저장 프로퍼티
struct NonCodableType {}
struct Case1: Codable {
    var value: NonCodableType
}
// error: type 'Case1' does not conform to protocol 'Decodable'
// note: cannot automatically synthesize 'Decodable' because
//       'NonCodableType' does not conform to 'Decodable'
// (Encodable도 같은 이유로 별도 에러가 난다)
깨지는 경우 ② — 제약 없는 제네릭 파라미터
struct Case2<T>: Codable {
    var value: T
}
// error: type 'Case2<T>' does not conform to protocol 'Decodable'
// note: cannot automatically synthesize 'Decodable' because
//       'T' does not conform to 'Decodable'
깨지는 경우 ③ — 프로토콜(실존 타입) 프로퍼티
protocol Shape {}
struct Case3: Codable {
    var shape: Shape
}
// error: type 'Case3' does not conform to protocol 'Decodable'
// note: cannot automatically synthesize 'Decodable' because
//       'Shape' does not conform to 'Decodable'

셋 다 메시지 구조가 같다 — "이 타입은 Decodable/Encodable을 합성할 수 없다, 왜냐하면 프로퍼티 타입 X가 Decodable/Encodable이 아니라서"다. TShape는 컴파일 타임에 "구체적으로 무엇인지"가 확정되지 않으므로(제네릭은 호출부마다 다른 타입, 프로토콜은 런타임에야 실제 타입이 정해짐) 컴파일러가 그 자리에서 Codable 여부를 증명할 수 없다. 고치려면 Case2<T: Codable>처럼 제약을 걸거나, ShapeShape: Codable로 만들거나, 실존 타입을 감싸는 별도의 Codable wrapper를 손으로 짜야 한다.

네 번째는 종류가 다르다. struct와 달리 클래스 상속에서는 컴파일 에러조차 나지 않고 데이터가 조용히 사라진다.

직접 실행 — Dog의 breed가 인코딩·디코딩 양쪽에서 사라진다
class Animal: Codable {
    var name: String = ""
}
class Dog: Animal {
    var breed: String = ""   // 새 저장 프로퍼티를 추가했을 뿐, Codable을 다시 선언하지 않음
}

let dog = Dog()
dog.name = "바둑이"; dog.breed = "진돗개"
let data = try! JSONEncoder().encode(dog)
print(String(data: data, encoding: .utf8)!)
// {"name":"바둑이"}                 ← breed가 통째로 빠졌다, 에러 없음

let incoming = #"{"name":"흰둥이","breed":"말티즈"}"#.data(using: .utf8)!
let decoded = try! JSONDecoder().decode(Dog.self, from: incoming)
print(decoded.name, decoded.breed)
// 흰둥이                            ← breed는 JSON에 있어도 무시되고 기본값 "" 그대로

이유는 합성 규칙이 "그 타입이 직접 Codable을 채택할 때만" 적용되기 때문이다. Dog: Codable을 다시 쓰지 않았으니 새 합성은 일어나지 않고, Animal이 이미 가진 init(from:)/encode(to:)를 그대로 물려받는다. 그 코드는 애초에 breed라는 프로퍼티가 존재한다는 사실 자체를 모른다.

Codable 합성 — 되는 경우와 안 되는 경우 struct User: Codable { var id: Int var name: String } 전부 Codable init(from:) { id = try c.decode(...) } encode(to:) { try c.encode(id, ...) } 컴파일러가 자동 생성 — 합성 합성이 깨지는 세 가지 — 전부 같은 이유(구체 타입을 컴파일 타임에 확정 불가) Codable 아닌 저장 프로퍼티 제약 없는 제네릭 <T> 프로토콜(실존) 프로퍼티 → 컴파일 에러: does not conform to 'Decodable'/'Encodable' 네 번째는 다르다 — 클래스 상속, 컴파일은 되지만 조용히 사라짐 class Dog: Animal { var breed = "" } Dog가 Codable을 다시 선언 안 함 {"name":"바둑이"} breed 없음 — 에러 없이 유실
모든 저장 프로퍼티가 Codable이면 컴파일러가 합성하지만, 제네릭·프로토콜·비Codable 프로퍼티는 컴파일 에러로, 상속의 새 프로퍼티는 에러조차 없이 조용히 유실된다.

실험 · 도구

위 네 스니펫을 각각 파일로 저장해 swiftc -typecheck 파일명.swift로 돌리면 정확히 같은 진단이 나온다. 상속 케이스는 컴파일이 통과하므로 swiftc -typecheck로는 아무 것도 안 잡히고, 반드시 swift 파일명.swift로 실행해 실제 JSON 출력을 눈으로 확인해야 한다 — 정적 분석이 못 잡는 버그라는 게 이게 위험한 진짜 이유다.

프로젝트 적용

Codable을 채택하기 전에 저장 프로퍼티 타입을 감사한다. UIColor류의 UIKit 타입은 Codable이 아니므로, DTO에 직접 넣지 말고 인코딩 가능한 형태(hex 문자열)로 저장한 뒤 필요한 곳에서 변환하는 계산 프로퍼티를 둔다.

Before — UIColor를 직접 저장해 합성이 깨짐 / After — hex 문자열로 우회
// ❌ UIColor는 Codable이 아니라 컴파일 에러
struct BadgeStyleBroken: Codable {
    var tint: UIColor
}

// ✅ 실제로 저장하는 값은 hex 문자열, UIColor는 계산 프로퍼티로만 노출
struct BadgeStyle: Codable {
    var tintHex: String     // 예: "#FF6B00"
    var tint: UIColor {
        UIColor(hex: tintHex) ?? .systemOrange
    }
}

클래스 상속에서 서브클래스가 새 프로퍼티를 추가했다면 반드시 스스로 init(from:)/encode(to:)를 다시 작성한다. 키가 겹치지 않는다면 같은 컨테이너 레벨에 이어 쓰는 것도 방법이다.

Fixed — Dog가 스스로 encode/decode를 책임진다
final class Dog: Animal {
    var breed: String = ""

    private enum CodingKeys: String, CodingKey { case breed }

    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: decoder)   // 같은 decoder를 넘겨 name도 함께 복원
    }
    override func encode(to encoder: Encoder) throws {
        try super.encode(to: encoder)   // name을 먼저 쓰고
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(breed, forKey: .breed)   // 이어서 breed
    }
}

이 방식은 상위·하위 타입의 키가 절대 겹치지 않을 때만 안전하다. 키 충돌 위험을 원천적으로 차단하는 표준 패턴(superEncoder()/superDecoder()로 상위 타입을 "super" 키 아래 격리하는 법)은 Q4에서 다룬다.

⚠️ 흔한 오해

"Codable을 한 번 채택하면 상속 계층 전체가 알아서 처리된다"는 틀렸다 — 위 Dog 예처럼 서브클래스가 새 프로퍼티를 추가하면 컴파일러는 아무 경고도 없이 그 프로퍼티를 인코딩·디코딩에서 빼먹는다. "Codable 합성은 런타임에 Mirror 같은 리플렉션으로 일어난다"도 틀렸다 — 컴파일 타임에 소스 코드 형태로 생성되는 코드다(이 차이가 성능에 미치는 영향은 20장에서 다룬다).

🧒 쉽게 이해하기

Codable 합성은 우체국의 "규격 상자 포장 대행"과 비슷하다. 넣을 물건(프로퍼티)이 전부 이미 정해진 규격 상자(Codable 타입)에 맞으면 우체국 직원이 알아서 큰 상자에 담아 부쳐준다. 그런데 규격에 안 맞는 물건(Codable 아닌 타입, 제네릭, 프로토콜)이 하나라도 섞이면 직원이 그 자리에서 "이건 저희가 못 싸드려요"라고 바로 거절한다(컴파일 에러). 비유가 깨지는 곳: 상속의 경우엔 새로 추가한 물건이 있어도 직원이 거절하지 않고 그냥 원래 상자 크기만큼만 담아 보내버린다 — 사람 직원이면 있을 수 없는 실수지만, 컴파일러는 실제로 이렇게 조용히 넘어간다.

꼬리 질문

연산 프로퍼티(computed property)도 Codable 합성 대상에 포함되는가?
아니다. 합성은 저장 프로퍼티만 본다. 연산 프로퍼티는 값을 갖고 있는 게 아니라 호출될 때마다 계산해 돌려주는 것이라 인코딩할 "값" 자체가 없고, 디코딩한 값을 대입해 넣을 저장 공간도 없다. CodingKeys에 연산 프로퍼티 이름을 적어도 의미가 없다.
쉽게 말하면 매번 새로 계산해서 보여주는 안내판은 택배로 부칠 실물이 없는 것과 같다.
기본값이 아예 없는 옵셔널 프로퍼티도 CodingKeys에서 빼도 되는가?
된다. 실제로 var nickname: String?처럼 기본값 없이 옵셔널로만 선언한 프로퍼티를 CodingKeys에서 빼도 컴파일이 통과하고, 디코딩 시 nil로 채워진다(직접 컴파일해 확인한 동작). Optional 자체가 "값이 없을 수도 있다"는 상태를 이미 표현하므로, 컴파일러가 그 프로퍼티의 초기화를 nil로 보장할 수 있는 것이다. 기본값 있는 let/var와 이 옵셔널 케이스는 "합성이 계속되려면 무언가로 초기화를 보장해야 한다"는 같은 규칙의 두 얼굴이다.
쉽게 말하면 "몰라도 괜찮음"이라고 미리 적어둔 칸(옵셔널)은 택배 상자에 안 담겨 와도 그냥 빈 칸으로 두면 되는 것과 같다.
Codable 합성이 실패했을 때 컴파일러가 대신 만들어주지 못한 부분만 손으로 채울 수 있는가?
아니다. 합성은 "전부 되거나 전부 안 되거나"다. 저장 프로퍼티 하나라도 합성 조건을 못 채우면 init(from:)encode(to:) 둘 다 컴파일러가 아예 생성하지 않으므로, 나머지 프로퍼티까지 포함해 전체를 손으로 다시 작성해야 한다. 부분 합성이라는 중간 지점은 없다.
쉽게 말하면 우체국이 상자 하나를 통째로 포장해주거나 안 해주거나 둘 중 하나지, "이 물건만 우체국이 싸고 나머지는 네가 싸"는 절충은 없는 것과 같다.

Q2. CodingKeys에서 프로퍼티를 빼면 무슨 일이 일어나는가?

🔑 30초 답변

CodingKeys에 없는 프로퍼티는 인코딩과 디코딩 양쪽 모두에서 조용히 빠진다 — JSON에 쓰이지도 않고, JSON에 값이 와 있어도 무시된다. 합성이 계속 성립하려면 그 프로퍼티에 기본값이 있거나(var x = 5) 옵셔널(String?)이어야 한다. 기본값도 옵셔널도 없이 뺐다면 컴파일 에러가 난다. 그리고 별개로, CodingKeys남아 있어도 let x = 5처럼 초기값이 있는 let이면 디코딩되지 않는다 — 이번엔 에러 대신 경고로 알려준다.

원리

Apple 공식 문서는 이 규칙을 명확히 못 박는다: "A property omitted from CodingKeys needs a default value in order for its containing type to receive automatic conformance to Decodable or Codable." 합성된 init(from:)CodingKeys 열거형에 있는 케이스만 훑어서 값을 채운다 — 열거형에 없는 케이스는 애초에 존재를 모른다. 그러니 그 프로퍼티가 뭔가로 초기화될 방법이 없다면(기본값도, 옵셔널의 암묵적 nil도 없다면) 타입 전체가 초기화를 보장할 수 없어 합성이 거부된다.

내부 동작

세 가지 경우를 직접 컴파일·실행해 실제 결과로 비교한다(Swift 6.2.1 / macOS 26 SDK). 첫째, 기본값이 있는 varCodingKeys에서 뺀 경우.

경우 ① — 기본값 있는 var를 CodingKeys에서 제외 (합성 유지, 조용히 생략)
struct Car: Codable {
    var name: String
    var speed: Int = 100   // CodingKeys에서 뺐지만 기본값이 있어 합성은 통과

    enum CodingKeys: String, CodingKey {
        case name
    }
}

let car = Car(name: "소나타", speed: 220)
let data = try! JSONEncoder().encode(car)
print(String(data: data, encoding: .utf8)!)
// {"name":"소나타"}                       ← speed 자체가 안 나온다

let incoming = #"{"name":"아반떼","speed":180}"#.data(using: .utf8)!
let decoded = try! JSONDecoder().decode(Car.self, from: incoming)
print(decoded.name, decoded.speed)
// 아반떼 100                              ← JSON에 180이 와도 무시, 기본값 100 그대로

둘째, 기본값도 옵셔널도 없이 뺀 경우는 컴파일 자체가 막힌다.

경우 ② — 기본값·옵셔널 없이 제외 (컴파일 에러)
struct Car: Codable {
    var name: String
    var speed: Int   // CodingKeys에서 뺐는데 기본값도 옵셔널도 아님

    enum CodingKeys: String, CodingKey {
        case name
    }
}
// error: type 'Car' does not conform to protocol 'Decodable'
// note: cannot automatically synthesize 'Decodable' because 'speed'
//       does not have a matching CodingKey and does not have a default value

셋째, CodingKeys에 프로퍼티가 남아 있어도 let + 초기값 조합이면 같은 증상(디코딩 안 됨)이 나지만 이번엔 컴파일러가 의심스러워하며 경고를 낸다 — let은 한 번 초기화되면 재대입이 불가능한데, 선언 시점에 이미 초기화됐으니 생성된 init(from:)이 디코딩한 값을 대입할 방법이 없어서다.

경우 ③ — let + 초기값 (CodingKeys에는 있지만 디코딩 안 됨, 경고 발생)
struct Config: Codable {
    let version: Int = 1   // let + 초기값 -> 디코딩되지 않음
    var name: String
}
// warning: immutable property will not be decoded because it is declared
//          with an initial value which cannot be overwritten
// note: set the initial value via the initializer or explicitly define a
//       CodingKeys enum including a 'version' case to silence this warning
// note: make the property mutable instead

let incoming = #"{"version":99,"name":"설정A"}"#.data(using: .utf8)!
let decoded = try! JSONDecoder().decode(Config.self, from: incoming)
print(decoded.version, decoded.name)   // 1 설정A          ← 99가 아니라 고정값 1
print(String(data: try! JSONEncoder().encode(decoded), encoding: .utf8)!)
// {"name":"설정A","version":1}                            ← encode는 정상적으로 1을 내보낸다

흥미로운 점은 컴파일러 노트가 알려주는 침묵 방법이다 — version을 포함하는 CodingKeys를 명시적으로 다시 선언하면(내용은 똑같이 케이스를 나열할 뿐인데도) 경고가 사라진다. 실제로 enum CodingKeys { case version, name }을 추가해 재실행하면 경고 없이 조용히 넘어가고, decoded.version은 여전히 1이다. 즉 컴파일러는 "네가 CodingKeys를 직접 썼다 = 이 동작을 알고도 의도한 것"이라 해석해 경고를 접는다.

프로퍼티가 CodingKeys에 있는가? enum CodingKeys { case ... } 포함됨 제외됨 var speed: Int 정상 — encode/decode 둘 다 반영 let version: Int = 1 ⚠️ 경고 — decode 안 됨, encode는 고정값 var speed: Int = 100 (기본값 有) 합성 유지 — 조용히 생략(에러·경고 없음) var speed: Int (기본값 無) 🛑 컴파일 에러 — does not conform to 'Decodable' 공통 원칙: 무언가로 "초기화가 보장"돼야 합성이 성립한다 기본값·옵셔널의 nil·let의 선언 시 초기값 — 셋 다 "초기화 보장" 수단이지만 let 초기값만은 이후 재대입이 막혀 있어 디코딩된 값이 끼어들 자리가 없다
CodingKeys 포함 여부와 var/let·기본값 유무의 조합에 따라 정상·경고·컴파일 에러 세 갈래로 갈린다.

실험 · 도구

경우 ①·③은 swiftc 파일명.swift -o out && ./out으로 직접 실행해 눈으로 봐야 한다 — 둘 다 컴파일은 통과하므로 -typecheck만으로는 아무 것도 안 잡힌다. 경우 ②만 swiftc -typecheck 파일명.swift 한 줄로 즉시 에러가 뜬다. 경고 문구가 실제로 뜨는지는 swiftc 파일명.swift 2>&1 | grep warning으로 빠르게 확인할 수 있다.

프로젝트 적용

서버로 절대 나가면 안 되는 로컬 전용 UI 상태는 기본값을 주고 CodingKeys에서 뺀다 — 서버 응답을 디코딩할 때도 이 필드가 실수로 덮이지 않는다는 보너스가 있다.

로컬 전용 상태를 CodingKeys에서 제외 — 서버 왕복에 전혀 관여하지 않는다
struct ArticleDTO: Codable {
    var title: String
    var body: String
    var isBookmarkedLocally: Bool = false   // 로컬 북마크 UI 상태, 서버 개념 아님

    enum CodingKeys: String, CodingKey {
        case title, body   // isBookmarkedLocally는 의도적으로 제외
    }
}
// 서버가 이 필드를 보내도 무시되고, 우리가 저장/전송할 때도 안 나간다

로컬 캐시나 마이그레이션 마커처럼 "값이 항상 이 상수여야 한다"를 강제하고 싶을 땐 let + 초기값을 의도적으로 쓰고, 컴파일러 경고는 위에서 확인한 대로 CodingKeys를 명시해 침묵시킨다. 단, 그 의도를 주석으로 반드시 남긴다 — 나중에 읽는 사람은 경고 없는 let만 보고 "당연히 디코딩되겠지"라고 오해하기 쉽다.

스키마 버전 마커를 항상 최신값으로 고정 — 의도를 주석으로 명시
struct CachedProfile: Codable {
    // schemaVersion은 항상 2로 고정한다: 디스크의 옛 캐시(v1)를 읽어도
    // 메모리 위에서는 최신 스키마 취급하기 위한 의도적 let. 디코딩 안 됨.
    let schemaVersion: Int = 2
    var nickname: String

    enum CodingKeys: String, CodingKey { case schemaVersion, nickname }
}
⚠️ 흔한 오해

"CodingKeys에서 빼면 인코딩만 막고, JSON에 값이 오면 디코딩은 여전히 될 것"이라 생각하기 쉽다 — 틀렸다. 제외는 인코딩·디코딩 양쪽에 똑같이 적용된다. 그리고 "let으로 선언했으니 최소한 안전하게 컴파일 에러라도 나겠지"도 틀렸다 — let + 초기값은 에러가 아니라 경고이고, 빌드는 그대로 통과하며 디코딩만 조용히 실패한다(정확히는 시도조차 안 한다).

🧒 쉽게 이해하기

CodingKeys는 택배 상자에 붙이는 "보낼 물건 체크리스트"다. 체크리스트에 없는 물건(프로퍼티)은 상자를 쌀 때도(encode) 안 넣고, 상자를 풀 때도(decode) 안 꺼낸다 — 대신 그 물건이 원래 자기 자리에 예비로 놓여 있어야 한다(기본값이나 "없어도 됨" 표시인 옵셔널). 예비 물건도 없이 체크리스트에서만 빼면 포장 자체가 실패한다(컴파일 에러). 비유가 깨지는 곳: 체크리스트에 이름을 적어놨는데도(let + 초기값) 그 물건이 이미 접착제로 상자에 붙어 있어(재대입 불가) 새 물건으로 못 바꿔 끼우는 경우가 있다 — 이땐 담당 직원이 "이거 맞아요?"라고 한 번 되물어보는 것(경고)까지만 하고 그냥 넘어간다.

꼬리 질문

클래스에서도 CodingKeys 제외 규칙이 구조체와 똑같이 적용되는가?
기본 규칙(기본값 또는 옵셔널 필요)은 똑같다. 다만 클래스는 Q1에서 본 대로 상속이 끼면 문제가 하나 더 늘어난다 — 서브클래스가 CodingKeys를 아예 새로 정의하지 않으면 그 서브클래스의 저장 프로퍼티는 "제외됐다"가 아니라 애초에 상위 init(from:)/encode(to:)가 존재를 모르는 상태가 된다. 즉 구조체의 제외는 명시적 선택이지만, 클래스 상속의 누락은 선언조차 안 한 결과다.
쉽게 말하면 구조체는 체크리스트에서 일부러 지운 것이고, 서브클래스 누락은 애초에 새 체크리스트를 안 만든 것과 같다.
CodingKeys에서 뺀 프로퍼티에 didSet 옵저버가 있다면 디코딩 시 호출되는가?
호출되지 않는다. 그 프로퍼티는 디코딩 경로에서 아예 대입 자체가 일어나지 않으므로(선언 시점의 기본값을 그대로 유지) didSet이 반응할 "변경"이 존재하지 않는다. 초기화 중 didSet이 호출되지 않는 일반적인 규칙(05장 참고)과 겹치는 맥락이지만, 여기서는 애초에 대입 자체가 없다는 점이 더 근본적인 이유다.
쉽게 말하면 아무도 안 건드린 물건에는 "누가 만졌다"는 알림이 울릴 일이 없다.
기본값이 있는데도 그 프로퍼티를 CodingKeys에 남겨두면 어떻게 되는가?
정상적으로 인코딩·디코딩된다 — 기본값은 "이 프로퍼티가 없어도 초기화는 가능하다"는 합성 조건만 만족시킬 뿐, CodingKeys에 남아 있다면 여전히 그 값을 JSON에 쓰고 JSON에서 읽어온다. 기본값의 역할이 발동하는 건 오직 CodingKeys에서 뺐을 때(경우 ①)뿐이다.
쉽게 말하면 예비 물건이 있다고 해서 체크리스트에 올려도 손해 볼 건 없다 — 예비 물건은 "혹시 안 왔을 때"를 위한 보험일 뿐이다.

Q3. init(from:)/encode(to:)와 컨테이너 3종은 어떻게 쓰는가?

🔑 30초 답변

Swift Codable은 KeyedDecodingContainer(딕셔너리처럼 키로 값을 꺼내는), UnkeyedDecodingContainer(배열처럼 순서대로 값을 꺼내는), SingleValueDecodingContainer(그 자리 자체가 값 하나인) 세 종류의 컨테이너로 JSON 트리를 손으로 탐색한다. 인코딩도 대응하는 세 컨테이너 타입으로 거울처럼 대칭이다. 가장 실무적인 무기는 nestedContainer(keyedBy:forKey:)/nestedUnkeyedContainer(forKey:)인데, 서버가 중첩시켜 보낸 JSON 객체·배열을 Swift에서는 평평한 프로퍼티로 펴서 다룰 수 있게 해준다.

원리

Apple 공식 문서 예제("Encoding and Decoding Custom Types")가 표준으로 제시하는 패턴은, JSON의 중첩 구조와 Swift 타입의 평평한 구조가 다를 때 CodingKeys 하나로는 표현이 안 되니 컨테이너를 중첩해서 그 간극을 직접 이어주는 것이다. 세 컨테이너 타입은 JSON의 세 가지 값 형태(객체·배열·원시값)에 정확히 대응하고, Decoder/Encoder 자체는 "지금 이 트리의 어느 지점에 있는가"만 알 뿐 그 지점을 어떤 모양으로 읽고 쓸지는 컨테이너가 결정한다.

내부 동작

다음 JSON을 평평한 WeatherReport 구조체로 펴는 예로 세 컨테이너를 한 번에 보여준다. 서버 응답은 main이 중첩 객체이고 coord가 배열인데, Swift 쪽에서는 temp·humidity·latitude·longitude를 최상위 프로퍼티로 다루고 싶은 실무에서 흔한 상황이다.

keyed → nestedContainer(keyed) → nestedUnkeyedContainer — 중첩 JSON을 평평하게 편다
// 입력: {"city":"Seoul","main":{"temp":23.5,"humidity":60},"coord":[37.5665,126.978]}
struct WeatherReport {
    var city: String
    var temp: Double
    var humidity: Int
    var latitude: Double
    var longitude: Double
}

extension WeatherReport: Codable {
    enum CodingKeys: String, CodingKey { case city, main, coord }
    enum MainKeys: String, CodingKey { case temp, humidity }

    init(from decoder: Decoder) throws {
        // 1) keyed container — 최상위 { } 를 키로 접근
        let top = try decoder.container(keyedBy: CodingKeys.self)
        city = try top.decode(String.self, forKey: .city)

        // 2) nestedContainer — "main" 중첩 객체를 별도 keyed container로 펼침
        let main = try top.nestedContainer(keyedBy: MainKeys.self, forKey: .main)
        temp = try main.decode(Double.self, forKey: .temp)
        humidity = try main.decode(Int.self, forKey: .humidity)

        // 3) unkeyed container — "coord" 배열은 순서로 접근
        var coord = try top.nestedUnkeyedContainer(forKey: .coord)
        latitude = try coord.decode(Double.self)
        longitude = try coord.decode(Double.self)
    }

    func encode(to encoder: Encoder) throws {
        var top = encoder.container(keyedBy: CodingKeys.self)
        try top.encode(city, forKey: .city)

        var main = top.nestedContainer(keyedBy: MainKeys.self, forKey: .main)
        try main.encode(temp, forKey: .temp)
        try main.encode(humidity, forKey: .humidity)

        var coord = top.nestedUnkeyedContainer(forKey: .coord)
        try coord.encode(latitude)
        try coord.encode(longitude)
    }
}

let decoded = try! JSONDecoder().decode(WeatherReport.self, from: json.data(using: .utf8)!)
print(decoded)
// WeatherReport(city: "Seoul", temp: 23.5, humidity: 60, latitude: 37.5665, longitude: 126.978)
print(String(data: try! JSONEncoder().encode(decoded), encoding: .utf8)!)
// {"city":"Seoul","coord":[37.5665,126.978],"main":{"temp":23.5,"humidity":60}}

세 번째 컨테이너인 singleValueContainer()는 그 지점 자체가 키도 인덱스도 없는 원시값 하나일 때 쓴다 — JSON 최상위가 배열도 객체도 아니라 숫자·문자열 하나뿐이거나, 커스텀 타입 하나를 다른 원시값으로 완전히 대체해 표현하고 싶을 때다.

singleValueContainer — 그 자리 자체가 값 하나
struct Celsius: Codable {
    var value: Double
    init(from decoder: Decoder) throws {
        let single = try decoder.singleValueContainer()
        value = try single.decode(Double.self)
    }
    func encode(to encoder: Encoder) throws {
        var single = encoder.singleValueContainer()
        try single.encode(value)
    }
}
let temp = try! JSONDecoder().decode(Celsius.self, from: "23.5".data(using: .utf8)!)
print(temp.value)   // 23.5 — JSON 최상위가 그냥 숫자 하나였다
JSON 트리 ↔ 컨테이너 API { "city": "Seoul", "main": { "temp": 23.5, "humidity": 60 }, "coord": [37.5665, 126.978] } container(keyedBy:) — 최상위 { } nestedContainer(keyedBy:forKey:) — "main" nestedUnkeyedContainer(forKey:) — "coord" singleValueContainer() — 원시값 하나 키/인덱스가 없는 최상위 JSON — 예: 파일 전체가 그냥 23.5
JSON 값의 세 형태(객체·배열·원시값)와 중첩 여부가 그대로 컨테이너 API 선택으로 이어진다.

실험 · 도구

위 스니펫을 그대로 파일로 저장해 swift 파일명.swift로 실행하면 print(decoded)가 Swift 기본 리플렉션 출력으로 프로퍼티 값을 그대로 보여주고, 두 번째 print가 왕복 인코딩 결과를 보여준다. codingPathnestedContainer 호출 직후 출력해보면(print(main.codingPath.map(\.stringValue))) 경로에 ["main"]이 쌓여 있는 걸 확인할 수 있다 — 20장에서 다룰 DecodingError.codingPath 추적이 바로 이 스택 위에서 동작한다.

프로젝트 적용

서버 응답이 페이지네이션 메타데이터로 한 겹 감싸져 있는({"data": {...}, "meta": {...}}) 흔한 API 응답 모양을 nestedContainer로 곧장 평평하게 받아, 호출부는 감싸는 봉투를 전혀 신경 쓰지 않게 만든다.

봉투에 감싸인 응답을 nestedContainer로 곧장 평평하게
// 서버: {"data":{"id":1,"name":"item"},"meta":{"page":1}}
struct Item: Codable {
    var id: Int
    var name: String
    var page: Int   // meta.page를 최상위로 끌어올림

    enum CodingKeys: String, CodingKey { case data, meta }
    enum DataKeys: String, CodingKey { case id, name }
    enum MetaKeys: String, CodingKey { case page }

    init(from decoder: Decoder) throws {
        let top = try decoder.container(keyedBy: CodingKeys.self)
        let data = try top.nestedContainer(keyedBy: DataKeys.self, forKey: .data)
        id = try data.decode(Int.self, forKey: .id)
        name = try data.decode(String.self, forKey: .name)
        let meta = try top.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta)
        page = try meta.decode(Int.self, forKey: .page)
    }
    func encode(to encoder: Encoder) throws { /* 대칭 구현 생략 */ }
}

배열 안에 서로 다른 개수의 원소가 섞여 있어 매번 다른 타입으로 디코딩해야 하는 상황에선 unkeyedContainer()를 직접 순회하며 isAtEnd를 확인한다 — 자동 합성으로는 절대 표현할 수 없는 구조다.

unkeyedContainer를 수동으로 순회 — isAtEnd로 배열 끝을 확인
init(from decoder: Decoder) throws {
    var arr = try decoder.unkeyedContainer()
    var results: [Int] = []
    while !arr.isAtEnd {
        results.append(try arr.decode(Int.self))
    }
    self.values = results
}
⚠️ 흔한 오해

"nestedContainer는 새로운 JSON 조각을 만들어내는 것"이라 생각하기 쉽지만, 실제로는 이미 있는 트리의 한 지점을 가리키는 뷰일 뿐이다. 데이터를 복사하지도, 새 메모리 구조를 할당하지도 않는다. 또한 "컨테이너는 한 번만 꺼낼 수 있다"도 부정확하다 — 같은 키에 대해 여러 번 nestedContainer를 호출하는 것 자체는 되지만(값을 다시 읽을 뿐), unkeyedContainer의 커서(currentIndex)는 한 번 지나가면 되돌릴 수 없다는 점은 정확하다.

🧒 쉽게 이해하기

JSON은 상자 속에 또 상자, 그 속에 물건이 들어있는 러시아 마트료시카 인형이다. keyedContainer는 "이름표가 붙은 서랍장"이고, nestedContainer는 그 서랍 안에서 또 서랍장 하나를 더 여는 것이다. unkeyedContainer는 이름표 없이 순서대로 줄 세워진 물건들(배열)을 하나씩 꺼내는 것이고, singleValueContainer는 서랍이고 뭐고 없이 그 자리에 물건 딱 하나만 놓여 있는 경우다. 비유가 깨지는 곳: 진짜 마트료시카는 열어보기 전까진 안이 뭔지 모르지만, Codable에서는 어떤 컨테이너를 열지 코드에 미리 정확히 적어놔야 한다 — 틀리게 적으면 그 자리에서 에러가 난다.

꼬리 질문

nestedContainer를 호출했는데 실제 JSON에 그 키가 없으면 어떻게 되는가?
DecodingError.keyNotFound가 던져진다 — nestedContainer(keyedBy:forKey:)도 결국 내부적으로 그 키의 값을 먼저 찾아야 하므로, decode(_:forKey:)가 없는 키에 실패하는 것과 같은 방식으로 실패한다. 옵셔널로 처리하고 싶다면 decodeIfPresent에 대응하는 nestedContainerIfPresent 같은 API는 없으므로, contains(_:)로 먼저 존재를 확인하거나 try?로 감싸야 한다.
쉽게 말하면 열려는 서랍 자체가 가구에 없으면 손잡이를 당겨도 아무것도 안 열린다.
encode(to:)에서 nestedContainer를 두 번 호출하면 값이 겹쳐 쓰이는가?
같은 키로 nestedContainer를 두 번 열어 각각 다른 값을 쓰면, 최종적으로 나중에 쓴 값이 남는다(Q4의 super 키 충돌과 같은 원리). 그래서 실무에서는 한 키당 한 번만 컨테이너를 열고 그 안에서 필요한 모든 값을 다 쓰는 게 안전하다.
쉽게 말하면 같은 서랍에 두 번 다른 물건을 넣으면 나중에 넣은 것만 남고 먼저 넣은 건 밀려나 사라진다.
컨테이너 API를 쓰는 대신 그냥 JSONSerialization으로 직접 파싱하면 안 되는가?
가능은 하지만 타입 안전성을 포기하는 것이다. 컨테이너 API는 decode(Int.self, forKey:)처럼 기대 타입을 명시해 컴파일 타임에 검사받고, 실패 시 DecodingError로 구조화된 실패 정보(codingPath 포함)를 받는다. JSONSerialization[String: Any]를 돌려주므로 캐스팅 실패가 런타임에야 드러나고 실패 지점을 추적하기 어렵다(Any가 왜 Codable이 될 수 없는지는 20장에서 다룬다).
쉽게 말하면 컨테이너는 정해진 모양의 그릇에 맞춰 담으라고 시키는 것이고, JSONSerialization은 아무 그릇에나 담고 나중에 뭐가 들었는지 열어봐야 아는 것과 같다.

Q4. 클래스 상속 구조에서 superEncoder()/superDecoder()가 필요한 이유는?

🔑 30초 답변

superEncoder()/superDecoder()는 예약된 "super" 키 아래에 별도의 중첩 컨테이너를 만들어, 상위 클래스가 자기 프로퍼티를 인코딩·디코딩할 때 하위 클래스와 같은 최상위 컨테이너를 공유하지 않게 격리한다. 이게 없으면 상위·하위 클래스가 우연히 같은 키 이름을 쓸 때 나중에 쓴 쪽이 먼저 쓴 값을 덮어써버리는, 컴파일도 안 잡고 런타임 에러도 안 나는 조용한 데이터 유실이 생긴다.

원리

Q1에서 본 대로 클래스 상속에서는 Codable이 자동 합성되지 않으므로(Apple 문서: "the compiler is able to automatically generate code to conform to the Codable protocols for structures, it doesn't seem to do this with classes that inherit from a class that is Codable") 서브클래스는 init(from:)/encode(to:)를 손으로 쓰고 그 안에서 super.init(from:)/super.encode(to:)를 호출해야 한다. 문제는 이때 넘기는 decoder/encoder를 무엇으로 하느냐다 — 그냥 최상위 것을 그대로 넘기면 상위 클래스가 최상위 컨테이너에 자기 키를 직접 쓰게 되어 하위 클래스의 키와 이름 공간을 공유해버린다. KeyedDecodingContainer/KeyedEncodingContainer가 이 문제를 풀도록 표준으로 제공하는 게 superDecoder(forKey:)/superEncoder(forKey:)(기본 키가 "super")다.

내부 동작

먼저 superEncoder() 없이, 상위·하위가 우연히 겹치는 키를 쓰면 실제로 무슨 일이 나는지 재현한다.

super 없이 같은 컨테이너를 공유 — 값이 조용히 덮어써진다
class Animal: Codable {
    var name: String
    init(name: String) { self.name = name }
    enum CodingKeys: String, CodingKey { case name }
    required init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        name = try c.decode(String.self, forKey: .name)
    }
    func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(name, forKey: .name)
    }
}

class Cat: Animal {
    var name2: String
    enum CodingKeys: String, CodingKey { case name2 = "name" }  // 의도치 않게 겹침

    init(name: String, name2: String) { self.name2 = name2; super.init(name: name) }
    override func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(name2, forKey: .name2)     // "name" 키에 먼저 씀
        try super.encode(to: encoder)            // Animal도 같은 "name" 키에 다시 씀
    }
    required init(from decoder: Decoder) throws { fatalError() }
}

let cat = Cat(name: "고양이-원본", name2: "야옹이-대체값")
print(String(data: try! JSONEncoder().encode(cat), encoding: .utf8)!)
// {"name":"고양이-원본"}   ← name2("야옹이-대체값")는 통째로 사라졌다, 에러 없음

이제 superEncoder()/superDecoder()로 상위 클래스를 "super" 키 아래에 격리하면 이 충돌이 원천적으로 불가능해진다.

superEncoder()/superDecoder()로 격리 — "super" 예약 키 아래 중첩
class Dog: Animal {
    var breed: String
    init(name: String, breed: String) { self.breed = breed; super.init(name: name) }
    enum CodingKeys: String, CodingKey { case breed }

    required init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        breed = try c.decode(String.self, forKey: .breed)
        let superDecoder = try c.superDecoder()   // "super" 키 아래 별도 디코더
        try super.init(from: superDecoder)
    }
    override func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(breed, forKey: .breed)
        let superEncoder = c.superEncoder()       // "super" 키 아래 별도 인코더
        try super.encode(to: superEncoder)
    }
}

let dog = Dog(name: "바둑이", breed: "진돗개")
let data = try! JSONEncoder().encode(dog)
print(String(data: data, encoding: .utf8)!)
// {"breed":"진돗개","super":{"name":"바둑이"}}   ← name이 "super" 아래로 격리됨

let decoded = try! JSONDecoder().decode(Dog.self, from: data)
print(decoded.name, decoded.breed)   // 바둑이 진돗개 — 왕복 성공

결과 JSON에서 보듯, Animal의 프로퍼티는 하위 클래스가 무슨 키를 쓰든 절대 부딪힐 수 없는 "super" 이름 공간 안에 갇힌다. 앞선 충돌 예제와 정확히 대칭되는 해법이다.

super 없이 — 키 충돌 {"name": "야옹이-대체값"} → super.encode가 같은 "name"에 {"name": "고양이-원본"} 만 남음 superEncoder() — 격리 {"breed": "진돗개", "super": { "name": "바둑이" } } breed 값이 통째로 유실 두 값 모두 안전하게 왕복 "super"는 사용자 CodingKeys와 절대 겹치지 않는 예약 키다
super 키 아래로 격리하지 않으면 상위·하위 클래스의 키 이름 공간이 충돌해 값이 덮어써질 수 있다.

실험 · 도구

두 스니펫을 각각 실행해 JSON 문자열을 직접 눈으로 비교하는 것이 가장 빠르다. 충돌 여부가 궁금하면 encoder.outputFormatting = [.prettyPrinted]를 켜서 최종 딕셔너리에 키가 몇 개 남았는지 세어보면 된다 — 충돌이 나면 기대한 키 개수보다 적게 나온다.

프로젝트 적용

3단 이상 상속(예: Animal → Pet → Dog)에서는 각 단계가 자기 레벨의 superEncoder()/superDecoder()를 한 번씩만 호출해 체인으로 연결한다 — DogPetsuperEncoder()를 부르면 Pet이 다시 자기 super.encode 안에서 AnimalsuperEncoder()를 부르는 식으로, 매 단계가 한 겹씩 "super"를 중첩시킨다.

3단 상속 — 각 단계가 한 번씩만 superEncoder를 호출해 체인 연결
class Pet: Animal {
    var ownerName: String
    enum CodingKeys: String, CodingKey { case ownerName }
    override func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: CodingKeys.self)
        try c.encode(ownerName, forKey: .ownerName)
        try super.encode(to: c.superEncoder())   // Animal 몫은 한 단계 더 깊이 격리
    }
    // init(from:)도 대칭으로 구현
}
// 결과 JSON: {"breed":"진돗개","ownerName":"김철수","super":{"super":{"name":"바둑이"}}}

상속 depth가 3단 이상으로 늘어나거나 각 단계마다 superEncoder 배치를 손으로 맞추는 게 부담스럽다면, 애초에 클래스 상속 대신 컴포지션으로 설계를 바꾸는 게 실무에서 더 안전하다 — 상위 타입을 별도 구조체 프로퍼티로 갖고 있으면 그 프로퍼티 자체가 이미 자연스러운 중첩 키가 된다.

상속 대신 컴포지션 — superEncoder 없이도 이름 공간이 자연히 분리된다
struct AnimalInfo: Codable { var name: String }
struct Dog: Codable {
    var animal: AnimalInfo   // 상속 대신 포함 — CodingKeys 충돌 걱정 자체가 없음
    var breed: String
}
⚠️ 흔한 오해

"클래스 상속에서 CodingKeys 이름만 서로 다르게 지으면 superEncoder 없이도 안전하다"는 반은 맞고 반은 위험하다 — 지금 겹치지 않아도, 나중에 상위 클래스에 새 프로퍼티가 추가되면서 우연히 하위 클래스 키와 겹칠 위험은 그대로 남는다. superEncoder()는 "지금 안 겹친다"가 아니라 "앞으로도 구조적으로 겹칠 수 없다"를 보장하는 것이라 이름 관리보다 근본적인 해법이다.

🧒 쉽게 이해하기

상위 클래스와 하위 클래스가 같은 서랍(최상위 컨테이너)을 나눠 쓰면, 둘이 우연히 같은 이름표를 붙인 칸에 각자 물건을 넣다가 나중에 넣은 사람 것만 남는 사고가 난다. superEncoder()는 상위 클래스에게 "super"라고 적힌 자기 전용 작은 서랍을 따로 내주는 것이다 — 그 서랍 안에서 무슨 이름표를 쓰든 하위 클래스의 서랍과는 절대 안 겹친다. 비유가 깨지는 곳: 진짜 서랍은 안 쓰면 그냥 비어 있지만, superEncoder()를 안 부르면 상위 클래스의 값이 사라지는 게 아니라 하위 클래스와 뒤섞여 조용히 덮어써진다는 점이 더 위험하다.

꼬리 질문

superEncoder()에 커스텀 키를 지정할 수 있는가?
있다. superEncoder(forKey:)/superDecoder(forKey:)"super" 대신 원하는 키를 쓸 수 있다. 2단 상속에서는 기본 "super"로 충분하지만, 다중 상속처럼 보이는 구조(믹스인을 여러 프로토콜 확장으로 흉내내는 경우)에서 컨테이너를 여러 개 격리해야 할 때 유용하다.
쉽게 말하면 서랍에 붙는 이름표를 "super" 대신 원하는 걸로 바꿔 달 수 있다는 뜻이다.
super.encode(to:)에 최상위 encoder를 그대로 넘기면 컴파일이 안 되는가?
컴파일은 된다 — Encoder 타입이면 뭐든 받아들이므로 최상위 encoder를 그대로 넘겨도 타입 체크는 통과한다. 문제는 런타임 동작이다: 그러면 Animal이 최상위 컨테이너에 직접 자기 키를 쓰게 되어, 위에서 본 Cat 예제처럼 조용한 충돌 가능성이 열린다. 컴파일러가 이 실수를 잡아주지 못하므로 컨벤션으로 항상 superEncoder()를 거치는 습관이 중요하다.
쉽게 말하면 문법적으로는 아무 서랍에나 물건을 넣을 수 있지만, 그러면 안 되는 서랍이 있다는 걸 컴파일러는 안 알려준다.
구조체(struct)도 상속이 없는데 superEncoder가 필요한 경우가 있는가?
거의 없다. superEncoder는 명시적으로 클래스 상속 체인에서 상위 타입 인코딩을 위임하기 위한 API라, 상속이 없는 구조체에서는 애초에 위임할 "super"가 없다. 구조체가 다른 타입을 값으로 포함하는 컴포지션 상황에서는 그냥 nestedContainer(Q3)로 충분하다.
쉽게 말하면 부모가 없는 사람에게 "부모님 몫 서랍"이 필요 없는 것과 같다.

Q5. 연관값 enum의 Codable 합성(SE-0295)은 어떤 모양의 JSON을 만드는가?

🔑 30초 답변

SE-0295(Swift 5.5+)부터는 연관값을 가진 enum도 다른 타입처럼 그냥 : Codable만 붙이면 합성된다. 생성되는 JSON은 케이스 이름을 유일한 키로 갖는 단일 키 객체다 — 그 안에 연관값이, 레이블이 있으면 그 이름으로, 없으면 _0/_1 같은 위치 기반 이름으로 들어간다. 연관값이 없는 케이스도 문자열이 아니라 빈 객체 {}가 된다는 게 실무에서 자주 놓치는 디테일이다.

원리

Swift Evolution 제안서 원문 예시가 이 모양을 정확히 규정한다 — case load(key: String){"load": {"key": "..."}}로, 레이블 없는 case store(String, Int){"store": {"_0": "...", "_1": ...}}로 인코딩된다. 이전(Swift 5.5 이전)에는 RawRepresentable enum(원시값이 있는 단순 enum)만 자동 합성 대상이었고, 연관값이 있는 enum은 항상 수동으로 init(from:)/encode(to:)를 작성해야 했다. SE-0295는 그 수작업을 없앴지만, 그 대신 나온 JSON 모양이 고정된 컨벤션이라는 점은 트레이드오프다 — 서버가 이미 다른 모양(예: {"type": "load", "key": "..."}처럼 판별자 필드가 형제로 나란히 있는 모양)으로 내려주는 흔한 API 스타일과는 대개 맞지 않는다.

내부 동작

레이블 있는 연관값, 레이블 없는 연관값, 연관값 없는 케이스를 한 enum에 모아 실제로 어떤 JSON이 나오는지 그대로 확인한다(Swift 6.2.1).

SE-0295 합성 결과 — 케이스별로 실제로 찍히는 JSON
enum AppEvent: Codable {
    case login(userId: String)   // 레이블 있는 연관값 1개
    case purchase(String, Int)   // 레이블 없는 연관값 2개 -> _0, _1
    case logout                  // 연관값 없음
}

let events: [AppEvent] = [.login(userId: "u_42"), .purchase("sku_100", 3), .logout]
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
for e in events {
    print(String(data: try! encoder.encode(e), encoding: .utf8)!)
}
// {"login":{"userId":"u_42"}}
// {"purchase":{"_0":"sku_100","_1":3}}
// {"logout":{}}                          ← 문자열 "logout"이 아니라 빈 객체다

let decoded = try! JSONDecoder().decode([AppEvent].self, from: try! encoder.encode(events))
print(decoded)
// [AppEvent.login(userId: "u_42"), AppEvent.purchase("sku_100", 3), AppEvent.logout]  ← 왕복 성공

logout 케이스가 {"logout": {}}으로 나온다는 사실이 특히 자주 놓치는 부분이다 — enum을 단순 문자열 태그처럼 다룰 거라 기대하고 서버 계약을 짜면 이 빈 객체 형태와 충돌한다. 그리고 이 모양은 단일 키 객체이지 판별자 필드가 아니다: 실제 REST API에서 흔한 {"type": "login", "userId": "u_42"} 형태(판별자와 페이로드가 형제로 나란한 구조)와는 근본적으로 다른 스키마다. 이런 판별자 기반 다형 JSON을 다루는 법은 20장 Q3에서 다룬다.

SE-0295 — case 모양 → JSON 모양 case login(userId: String) 레이블 있는 연관값 {"login":{"userId":"u_42"}} case purchase(String, Int) 레이블 없는 연관값 → _0, _1 {"purchase":{"_0":"sku_100", "_1":3}} case logout 연관값 없음 {"logout":{}} ← 문자열 아니라 빈 객체 셋 다 "케이스 이름을 유일한 키로 갖는 단일 키 객체"라는 같은 규칙이다
연관값의 레이블 유무와 개수에 따라 안쪽 객체 모양만 달라지고, 바깥은 항상 케이스 이름 하나짜리 단일 키 객체다.

실험 · 도구

위 스니펫을 그대로 swift 파일명.swift로 실행하면 세 줄의 JSON을 즉시 눈으로 볼 수 있다. .sortedKeys를 끄면 _0/_1 순서가 선언 순서와 일치하는지도 함께 확인할 수 있다. 서버 스펙과 이 모양이 맞는지 궁금하다면 실제로 이렇게 인코딩해 API 문서의 예시 payload와 나란히 놓고 비교해보는 게 가장 빠른 검증이다.

프로젝트 적용

앱 내부 이벤트 로그나 로컬 디스크 캐시처럼 "우리가 쓰고 우리가 읽는" 상황에서는 SE-0295 합성을 그대로 쓰는 게 손 코드보다 압도적으로 적다 — 서버와의 계약이 없으니 이 고정된 모양이 오히려 안전한 기본값이다.

내부 전용 이벤트 로그 — 합성된 모양 그대로 사용
enum AnalyticsEvent: Codable {
    case screenView(name: String)
    case buttonTap(id: String, screen: String)
    case appLaunched
}
// 디스크에 줄 단위 JSON Lines로 append — 서버 계약이 없으니 합성 모양 그대로 OK
let line = try! JSONEncoder().encode(AnalyticsEvent.screenView(name: "Home"))

반대로 서버가 판별자 필드(type)와 페이로드를 형제로 나란히 두는 API와 통신할 때는 SE-0295의 합성을 쓰지 말고 init(from:)을 손으로 짜서 원하는 모양을 강제한다 — 이 패턴의 완전한 구현은 20장 Q3에서 다룬다.

서버 판별자 모양({"type":"login",...})은 합성이 아니라 수동 구현이 필요하다는 신호만 남겨둔다
// ❌ SE-0295 합성을 그대로 쓰면 {"login":{"userId":"u_42"}} 모양만 나온다
// enum ServerEvent: Codable { case login(userId: String) }

// ✅ {"type":"login","userId":"u_42"} 모양이 필요하면 init(from:)을 직접 짠다 — 20장 Q3 참고
struct ServerEvent: Codable {
    let type: String
    let userId: String?
    // ... type으로 분기하는 커스텀 init(from:)
}
⚠️ 흔한 오해

"연관값이 없는 case logout은 그냥 문자열 "logout"으로 인코딩될 것"이라 생각하기 쉽지만 틀렸다 — 실제로는 {"logout": {}}, 즉 값이 빈 객체인 단일 키 객체다. 그리고 "이 합성이 만드는 JSON이 표준적인 다형 JSON 스키마"라는 생각도 틀렸다 — 케이스 이름이 키가 되는 이 모양은 Swift만의 컨벤션이고, {"type": "...", ...} 형태의 판별자 스키마와는 호환되지 않는다.

🧒 쉽게 이해하기

SE-0295가 만드는 JSON은 이름표가 붙은 상자 하나다. 상자 겉면에 케이스 이름(예: "login")이 이름표로 붙어 있고, 상자를 열면 그 케이스가 들고 있던 물건들(연관값)이 들어있다 — 물건에 이름이 있으면 그 이름으로, 없으면 순서 번호(1번, 2번)로 적혀 있다. 물건이 하나도 없는 케이스(logout)도 상자 자체는 있고 그냥 비어 있을 뿐이다. 비유가 깨지는 곳: 서버가 기대하는 상자는 이름표가 상자 이 아니라 상자를 열었을 때 에 "이게 무슨 상자인지" 적힌 쪽지(type 필드)가 따로 들어있는 방식이라, 두 상자는 겉모습부터 다르다.

꼬리 질문

연관값이 여러 개인데 일부만 레이블이 있으면 어떻게 되는가?
레이블이 있는 연관값은 그 이름을, 없는 연관값은 위치 순서대로 _0, _1처럼 번호를 매긴다 — 레이블 유무가 케이스별이 아니라 연관값 하나하나 단위로 결정된다. 예를 들어 case mixed(String, tag: Int){"mixed": {"_0": "...", "tag": ...}}처럼 두 방식이 한 객체 안에 섞인다.
쉽게 말하면 이름표가 있는 물건은 이름으로, 없는 물건은 순서 번호로 적어두는 것뿐이다.
같은 이름의 case가 오버로드돼 있으면(연관값 타입만 다른 경우) 자동 합성이 여전히 되는가?
안 된다. SE-0295 제안서가 명시적으로 이 제약을 두고 있다 — 케이스 식별자가 같으면(연관값 타입이 달라 오버로드된 것이라도) 케이스 이름만으로는 인코딩된 JSON에서 어느 케이스인지 구분할 정보가 부족해 자동 합성 지원 대상에서 빠진다. 이런 오버로드가 있다면 수동 구현이 필요하다.
쉽게 말하면 같은 이름표를 두 개 만들어 놓으면 나중에 열어볼 때 어느 상자였는지 헷갈리는 것과 같다.
enum이 RawRepresentable(원시값 있는 단순 enum)이면 어떤 모양이 나오는가?
완전히 다른 모양이다 — 연관값이 없고 String/Int 같은 원시값만 있는 단순 enum(enum Status: String, Codable { case active, inactive })은 SE-0295 이전부터 지원됐고, 그 원시값 자체가 통째로 인코딩된다("active"처럼 객체가 아니라 그냥 문자열 하나). SE-0295는 그 위에 연관값이 있는 enum까지 지원 범위를 넓힌 것이지, 기존 RawRepresentable enum의 동작을 바꾼 게 아니다.
쉽게 말하면 물건을 안 담고 이름표 딱지 하나만 붙여 보내는 방식(원시값 enum)과, 이름표 붙은 상자 안에 물건까지 담아 보내는 방식(연관값 enum)은 처음부터 다른 이야기다.

출처 · 참고자료