← Swift & Foundation 로드맵 20 / 33

20 · Codable ② 전략·진단·성능

convertFromSnakeCase가 왜 손실이 있는지부터, DecodingError codingPath로 깨진 필드 찾기, type 판별자로 다형 JSON 디코딩하기, Any가 Codable일 수 없는 이유, swift-foundation 재작성이 JSONDecoder를 얼마나 빠르게 만들었는지까지.
진행률
0 / 0 완료

지난 장에서는 Codable이 언제 합성되고 컨테이너 3종을 어떻게 손으로 다루는지를 봤다. 이번 장은 그다음 층이다 — 키 변환 전략이 실제로 무엇을 잃어버리는지, 디코딩이 실패했을 때 범인 필드를 정확히 짚어내는 법, 서버가 type 필드로 갈라 보내는 다형(polymorphic) JSON을 받아내는 법, Any가 왜 애초에 Codable이 될 수 없는지, 그리고 swift-foundation 재작성이 JSONDecoder를 실제로 얼마나 빠르게 만들었는지를 다룬다. 아래 스니펫은 전부 Swift 6.2.1 / macOS 26 SDK에서 직접 실행해 나온 결과다.

Q1. .convertFromSnakeCase가 "손실이 있다"는 말은 무슨 뜻인가?

🔑 30초 답변

convertToSnakeCaseconvertFromSnakeCase는 서로의 완전한 역함수가 아니다. 인코딩 쪽은 연속된 대문자(약어)를 하나의 소문자 토큰으로 뭉개고, 디코딩 쪽은 밑줄로 쪼갠 뒤 각 조각의 첫 글자만 대문자화한다. 그래서 imageURL을 인코딩했다가 그대로 디코딩하면 imageUrl이 만들어지는데, 이건 원래 프로퍼티 이름 imageURL과 글자 하나(대소문자)가 달라 실제로 keyNotFound 에러가 난다. Apple 문서 자신도 이 변환을 "본질적으로 손실이 있다(lossy)"고 명시한다.

원리

JSON 쪽 관례(snake_case)와 Swift 쪽 관례(camelCase)는 애초에 별개의 명명 규칙이고, 그 사이를 자동으로 잇는 keyDecodingStrategy/keyEncodingStrategy는 문자열 하나만 보고 규칙을 추측하는 알고리즘적 다리일 뿐이지 의미를 이해하는 번역기가 아니다. 밑줄이 "단어가 여기서 끊긴다"는 신호의 전부이기 때문에, 원래 이름에 연속된 대문자(약어·ID·URL·HTTP)가 있었다는 정보는 그 신호 하나로 표현할 수 없다 — 인코딩하는 순간 이미 사라진다.

내부 동작

실제로 무슨 문자열이 오가는지 확인해보자. Item(imageURL:).convertToSnakeCase로 인코딩하면 {"image_url":"..."}이 나온다 — 연속된 대문자 URL이 통째로 소문자 url 하나의 토큰으로 뭉개진 것이다. 이걸 다시 .convertFromSnakeCase로 디코딩하면, 알고리즘은 image_url을 밑줄 기준으로 ["image", "url"]로 쪼갠 뒤 첫 조각은 그대로 두고 이후 조각의 첫 글자만 대문자화한다 — 결과는 imageUrl이지 imageURL이 아니다. 컴파일러가 합성한 CodingKeys.imageURLstringValue는 정확히 "imageURL"이므로, 디코더가 실제로 찾을 수 있는 키(imageUrl)와 한 글자가 어긋나 keyNotFound가 난다. 아래는 직접 실행한 결과다.

실제 실행 결과 — "이상적으로 무손실"이어야 할 imageURL조차 왕복에 실패한다
[imageURL] encode -> {"image_url":"https:\/\/x"}
[imageURL] decode -> 실패! keyNotFound: imageURL / No value associated with key CodingKeys(stringValue: "imageURL", intValue: nil) ("imageURL").
[xmlHTTPRequest] encode -> {"xml_http_request":"v"}
[xmlHTTPRequest] decode -> 실패! keyNotFound: xmlHTTPRequest / No value associated with key CodingKeys(stringValue: "xmlHTTPRequest", intValue: nil) ("xmlHTTPRequest").

여기서 더 미묘한 함정이 하나 있다. "그럼 CodingKeys를 명시적으로 써서 전략을 우회하면 되지 않나?" 싶어서 CodingKeys에 JSON과 완전히 똑같은 문자열(case imageURL = "image_url")을 지정하고 .convertFromSnakeCase를 동시에 켠 채로 디코딩해봤다 — 여전히 keyNotFound가 난다. 전략은 "요청된 키를 못 찾으면 그때 변환을 시도"하는 게 아니라, 컨테이너를 만드는 시점에 JSON의 원본 키 자체를 먼저 변환해 사용 가능한 키 집합을 만들어버린다. 그래서 image_url이라는 원본 키는 imageUrl로 이미 바뀐 뒤이고, CodingKeys가 요청하는 리터럴 문자열 "image_url"과는 글자 그대로 어긋나 못 찾는다. 리서치 단계에서 참고한 2차 자료(블로그)는 "CodingKeys에 명시한 키가 keyDecodingStrategy보다 우선한다"고 적어뒀지만, 실측 결과는 이와 다르다 — 최소한 CodingKeys원본 JSON 키 그대로를 지정하는 방식으로는 전략을 이길 수 없다.

convertToSnakeCase → convertFromSnakeCase 왕복 imageURL 프로퍼티 원래 이름 convertToSnakeCase 연속 대문자 → 하나의 토큰 image_url JSON 키(전송됨) convertFromSnakeCase 조각별 첫 글자만 대문자화 imageUrl 디코더가 찾는 키 imageURL ≠ imageUrl → keyNotFound CodingKeys.imageURL.stringValue는 "imageURL"인데, 컨테이너에는 "imageUrl"만 있다
인코딩 시 대문자 약어가 하나의 토큰으로 뭉개지고, 디코딩 시 그 토큰의 첫 글자만 복원돼 원래 스펠링을 되찾지 못한다.

실험 · 도구

직접 재현하려면 swift 파일명.swift로 실행 가능한 스니펫을 하나 짜면 된다. try! 대신 do/catch로 감싸야 실패 지점에서 크래시하지 않고 결과를 계속 관찰할 수 있다.

라운드트립 실패를 직접 확인하는 코드
import Foundation

struct Item: Codable { var imageURL: String }

let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

let data = try! encoder.encode(Item(imageURL: "https://x"))
print(String(data: data, encoding: .utf8)!)   // {"image_url":"https:\/\/x"}

do {
    let back = try decoder.decode(Item.self, from: data)
    print("성공:", back)
} catch {
    print("실패:", error)   // keyNotFound: imageURL
}

프로젝트 적용

완전히 통제하지 않는 서버 API — 즉 필드 이름 짓는 규칙을 우리 쪽이 강제할 수 없는 API — 라면, 약어가 하나라도 들어간 프로퍼티는 전략에 맡기지 말고 CodingKeys에 명시적으로 매핑한다.

약어가 낀 필드만 명시적 매핑, 나머지는 전략에 맡기는 하이브리드
struct Article: Codable {
    let title: String        // user_id 류 평범한 단어는 전략으로 충분
    let viewCount: Int
    let imageURL: String     // 약어 포함 — 전략에 맡기면 깨지므로 명시

    enum CodingKeys: String, CodingKey {
        case title
        case viewCount
        case imageURL = "image_url"   // 전략을 끈 채로 이 필드만 직접 매핑한다는 전제
    }
}

다만 위 실험에서 보였듯, keyDecodingStrategy를 켜둔 채로 일부 필드만 CodingKeys로 덮어쓰는 건 안전하지 않다. 섞어 쓰려면 아예 전략을 끄고 모든 필드를 CodingKeys에 명시하는 쪽이 예측 가능하다.

전략을 끄고 전부 명시 — 가장 예측 가능한 선택
struct SafeArticle: Codable {
    let title: String
    let viewCount: Int
    let imageURL: String

    enum CodingKeys: String, CodingKey {
        case title
        case viewCount = "view_count"
        case imageURL = "image_url"
    }
}
// decoder.keyDecodingStrategy 는 .useDefaultKeys(기본값) 그대로 둔다 — 전략과 섞지 않는다
⚠️ 흔한 오해

"convertFromSnakeCaseconvertToSnakeCase의 완전한 역함수라 무손실 왕복이 보장된다"는 틀렸다 — Apple 스스로 "본질적으로 손실"이라고 밝힌다. 여기서 한 걸음 더: "그래도 CodingKeys를 명시하면 전략을 이긴다"도 정확하지 않다. 위에서 확인했듯 전략은 컨테이너 생성 시점에 원본 JSON 키 자체를 먼저 바꿔버리므로, CodingKeys에 원본 그대로의 문자열을 넣어도 그 변환된 키 집합과 어긋나 실패할 수 있다.

🧒 쉽게 이해하기

영어 단어를 한글로 소리 나는 대로 적었다가, 그 한글을 보고 다시 영어로 되돌리는 걸 상상해보자. "URL"을 한글로 "유알엘"이라고 적으면, 나중에 "유알엘"만 보고는 원래 대문자 세 글자였는지 그냥 소문자 단어였는지 알 방법이 없다. 비유가 깨지는 곳: 사람이 한글 표기를 볼 때는 앞뒤 문맥으로 "아 이거 약어였구나" 짐작이라도 하지만, 컴퓨터의 이 변환 규칙은 문맥을 전혀 안 보고 밑줄 위치라는 딱 하나의 신호만으로 기계적으로 되돌리기 때문에 짐작조차 하지 않는다.

꼬리 질문

CodingKeyskeyDecodingStrategy를 동시에 쓰면 어느 쪽이 이기는가?
"CodingKeys가 항상 이긴다"는 통념은 정확하지 않다. 실측으로 확인한 바로는 keyDecodingStrategy가 컨테이너를 만드는 시점에 JSON의 원본 키 자체를 먼저 변환해버리고, CodingKeys는 그 변환된 키 집합과 문자 그대로 비교될 뿐이다. 그래서 CodingKeys에 원본 JSON 키 그대로의 문자열을 지정해도 전략이 켜져 있으면 어긋날 수 있다. 안전한 규칙은 "섞어 쓰지 않는다" — 전략을 쓰려면 문제 되는 필드까지 포함해 전략에 전부 맡기거나, 전략을 끄고 CodingKeys에 전부 명시한다.
쉽게 말하면 자동 번역기를 켜둔 채로 특정 단어 하나만 내가 정한 뜻으로 쓰고 싶다고 될 일이 아니다. 번역기를 끄고 전부 내가 정하거나, 번역기에 전부 맡기거나 둘 중 하나를 골라야 한다.
순수하게 서버 → 클라이언트로 디코딩만 하고 다시 인코딩해 서버로 보내지 않는다면, 이 손실 문제와 무관한가?
완전히 무관하지는 않다. "왕복"이 문제의 전부가 아니라, 애초에 스네이크→카멜 변환 규칙(조각별 첫 글자만 대문자화)과 Swift의 실제 명명 관례(연속 대문자로 약어를 표시)가 서로 다른 규칙이라는 게 근본 원인이다. 서버가 html_url처럼 보내면 변환 결과는 htmlUrl인데, Swift 개발자가 관례대로 프로퍼티를 htmlURL이라고 지었다면 인코딩을 한 번도 안 해도 처음부터 keyNotFound가 난다.
쉽게 말하면 왕복 여행을 안 해도,애초에 목적지 주소를 다른 방식으로 줄여 적으면 택배가 못 찾아가는 것과 같다.
allowsJSON5assumesTopLevelDictionary는 각각 언제 쓰는가?
allowsJSON5(iOS 15+)는 홑따옴표, 여러 줄 문자열, 주석, 16진수 리터럴, Infinity/NaN 같은 JSON5 문법을 받아들이게 하는 옵션으로, 설정 파일처럼 사람이 직접 손으로 편집하는 JSON을 다룰 때 쓴다. assumesTopLevelDictionary는 최상위가 { }로 감싸여 있지 않아도(예: 단일 스칼라 값만 오는 응답) 딕셔너리로 간주하게 해, 서버 응답이 표준을 살짝 벗어난 경우를 흡수한다. 둘 다 "표준을 좀 봐준다"는 옵션이지, 기본 동작을 바꾸는 건 아니다.
쉽게 말하면 원래는 정장만 입고 들어오게 하는 곳인데, 손님이 넥타이만 안 맸어도 들여보내 주는 유연한 규칙 두 가지라고 보면 된다.

Q2. DecodingError가 났을 때 codingPath로 범인 필드를 어떻게 찾는가?

🔑 30초 답변

DecodingError의 네 케이스(typeMismatch/valueNotFound/keyNotFound/dataCorrupted) 모두 DecodingError.Context를 담고, 그 안의 codingPath: [CodingKey]가 루트부터 실패 지점까지 내려간 키·인덱스 경로를 그대로 갖고 있다. 최상위 에러 메시지만 로그로 남기면 "어딘가 실패했다"만 알 수 있지만, codingPath를 이어붙이면 address.zip처럼 정확히 어느 필드인지 즉시 특정된다. 이 챕터에서 가장 실무 가치가 큰 산출물은 이 네 케이스를 사람이 읽는 한 줄 문장으로 바꾸는 재사용 헬퍼다 — 아래에 전체를 싣는다.

원리

Apple 문서가 정의하는 네 케이스는 서로 다른 실패 원인을 명확히 구분한다. typeMismatch(Any.Type, Context)는 "이 타입을 기대했는데 페이로드에 있던 값의 타입과 안 맞았다", valueNotFound(Any.Type, Context)는 "옵셔널이 아닌 값을 기대했는데 null이 왔다", keyNotFound(CodingKey, Context)는 "이 키 자체가 컨테이너에 없었다", dataCorrupted(Context)는 "형식 자체가 깨졌거나(JSON 문법 오류) 값이 의미상 유효하지 않다"는 뜻이다. 네 케이스 모두 공통으로 갖는 ContextcodingPathdebugDescription을 담는다.

내부 동작

codingPath[CodingKey] 배열이고, 각 원소는 중첩 컨테이너를 타고 내려갈 때마다 하나씩 쌓인다 — decoder.container(keyedBy:)로 최상위를 열고, nestedContainer(keyedBy:forKey:)decode(_:forKey:)로 한 단계 더 들어갈 때마다 그 키가 경로에 추가된다. 실패가 일어난 정확한 깊이에서 예외가 던져지므로, codingPath를 펼치면 그 순간까지 지나온 경로 전체가 남아 있다. 배열 인덱스도 CodingKey로 취급되므로(intValue가 채워짐), users[3].address.zip처럼 배열을 낀 깊은 경로도 그대로 재구성할 수 있다.

중첩 JSON에서 codingPath가 정확히 어디를 가리키는지 확인
struct Address: Codable { let city: String; let zip: Int }
struct User: Codable { let name: String; let address: Address }

let bad = #"{"name":"kim","address":{"city":"Seoul","zip":"12345"}}"#.data(using: .utf8)!
do {
    _ = try JSONDecoder().decode(User.self, from: bad)
} catch let DecodingError.typeMismatch(type, context) {
    print(context.codingPath.map(\.stringValue))   // ["address", "zip"]
    print(context.debugDescription)                 // Expected to decode Int but found a string instead.
}
codingPath가 중첩을 타고 내려가며 쌓이는 과정 최상위 컨테이너 codingPath = [] .address address 중첩 컨테이너 codingPath = ["address"] .zip zip 디코드 실패 typeMismatch context.codingPath == ["address", "zip"] 두 단계를 지나온 순서 그대로 배열에 남아 범인 필드를 특정한다
실패 시점의 codingPath는 루트부터 실패 지점까지 지나온 키를 순서대로 담고 있다.

실험 · 도구

DecodingError 네 케이스를 전부 강제로 발생시켜 codingPath가 실제로 어떻게 채워지는지 눈으로 확인할 수 있다 — swift 파일명.swift로 바로 실행 가능하다.

프로젝트 적용

아래 헬퍼는 어떤 DecodingError든 "어느 필드에서, 무엇을 기대했고, 무엇이 왔는지"를 한 줄 문장으로 바꾼다. 네트워크 레이어의 catch 블록에 한 번만 넣어두면 이후 모든 디코딩 실패 로그가 바로 읽힌다.

DecodingError → 사람이 읽는 메시지 (재사용 헬퍼, 전체)
import Foundation

/// 어떤 DecodingError든 "어느 필드에서, 무엇을 기대했고, 무엇이 왔는지"로 바꾼다.
func humanReadable(_ error: Error) -> String {
    guard let decodingError = error as? DecodingError else {
        return "디코딩과 무관한 에러: \(error)"
    }

    func pathString(_ path: [CodingKey]) -> String {
        path.isEmpty ? "(최상위)" : path.map { key -> String in
            if let intValue = key.intValue { return "[\(intValue)]" }
            return key.stringValue
        }.joined(separator: ".")
    }

    switch decodingError {
    case let .typeMismatch(type, context):
        return "'\(pathString(context.codingPath))' 필드: \(type) 타입을 기대했지만 다른 타입의 값이 왔다. (\(context.debugDescription))"
    case let .valueNotFound(type, context):
        return "'\(pathString(context.codingPath))' 필드: \(type) 값이 필요한데 null이 왔다. (\(context.debugDescription))"
    case let .keyNotFound(key, context):
        let parent = pathString(context.codingPath)
        return "'\(parent)' 아래에 '\(key.stringValue)' 키가 없다. (\(context.debugDescription))"
    case let .dataCorrupted(context):
        return "'\(pathString(context.codingPath))' 지점: 데이터 자체가 손상되었거나 형식이 맞지 않는다. (\(context.debugDescription))"
    @unknown default:
        return "알 수 없는 DecodingError: \(decodingError)"
    }
}

실제로 네 케이스 모두를 흘려보내면 각각 다른 문장이 나온다 — 직접 실행해 확인한 결과다.

네 케이스 전부 테스트한 실제 출력
[typeMismatch] 'address.zip' 필드: Int 타입을 기대했지만 다른 타입의 값이 왔다. (Expected to decode Int but found a string instead.)
[valueNotFound] 'name' 필드: String 값이 필요한데 null이 왔다. (Cannot get value of type String -- found null value instead)
[keyNotFound] '(최상위)' 아래에 'address' 키가 없다. (No value associated with key CodingKeys(stringValue: "address", intValue: nil) ("address").)
[dataCorrupted] '(최상위)' 지점: 데이터 자체가 손상되었거나 형식이 맞지 않는다. (The given data was not valid JSON.)
⚠️ 흔한 오해

"에러 메시지만 봐도 어느 필드인지 바로 안다"는 실전에서 틀리기 쉽다. localizedDescription이나 최상위 error 값만 로그로 남기면 "디코딩 실패"라는 사실만 알 수 있을 뿐, 열 몇 개짜리 중첩 JSON에서 정확히 어느 필드가 문제였는지는 codingPath를 직접 꺼내 보지 않으면 알 수 없다. 반드시 DecodingError로 캐스팅해 context.codingPath를 로깅해야 한다.

🧒 쉽게 이해하기

택배 상자 안에 상자, 그 안에 또 상자가 든 러시아 인형 같은 구조를 상상해보자. 맨 안쪽 상자에 문제가 생겼을 때 "뭔가 잘못됐어요"라고만 하면 어느 상자인지 알 수 없지만, "제일 큰 상자 열고 → 두 번째 상자 열고 → 그 안의 세 번째 물건"이라고 지나온 길을 그대로 적어두면 바로 찾을 수 있다. codingPath가 정확히 그 "지나온 길 목록"이다. 비유가 깨지는 곳: 러시아 인형은 사람이 손으로 하나씩 열어봐야 하지만, codingPath는 디코더가 실패하는 그 순간 이미 자동으로 기록해둔 길이라 다시 열어볼 필요가 없다.

꼬리 질문

typeMismatchvalueNotFound는 실제로 어떻게 구분되는가?
typeMismatch는 그 자리에 값이 있긴 한데 기대한 타입과 다를 때(문자열 "12345"가 왔는데 Int를 기대) 발생하고, valueNotFound는 옵셔널이 아닌 프로퍼티 자리에 JSON의 null이 명시적으로 왔을 때 발생한다. 둘 다 "키는 있는데 값이 이상하다"는 공통점이 있지만, 전자는 타입 불일치, 후자는 값의 부재(null)라는 점이 다르다.
쉽게 말하면 택배 상자를 열었더니 주문한 것과 다른 물건이 들어있으면 typeMismatch, 상자를 열었는데 텅 비어 있으면 valueNotFound다.
배열 안의 특정 원소에서 실패하면 codingPath에 그 인덱스도 나오는가?
그렇다. 배열을 순회하며 디코딩할 때 UnkeyedDecodingContainer는 현재까지 읽은 개수를 CodingKeyintValue로 노출하므로, [User] 배열의 세 번째 원소에서 실패하면 codingPathintValue == 2CodingKey가 섞여 나온다. 위 헬퍼의 pathStringintValue가 있는 키를 [2] 형태로 바꿔주는 이유가 이것이다.
쉽게 말하면 여러 상자가 나란히 줄 서 있을 때 "몇 번째 상자"인지도 길 안내에 같이 적어주는 것과 같다.
헬퍼가 반환하는 문장을 그대로 사용자에게 보여줘도 되는가?
권장하지 않는다. codingPathdebugDescription은 내부 구조(프로퍼티 이름, 서버 필드명)를 그대로 노출하는 디버그용 정보라 로그·크래시 리포트·개발자 대상 도구에는 적합하지만, 최종 사용자에게는 "일시적인 문제가 발생했어요" 같은 일반화된 메시지를 따로 보여주고 이 헬퍼의 결과는 내부 로깅 채널로만 보내는 것이 맞다.
쉽게 말하면 정비사에게 보여줄 상세 고장 코드와, 운전자에게 보여줄 "엔진 점검 필요" 경고등은 다른 목적을 위한 다른 메시지다.

Q3. type 필드로 갈라지는 다형 JSON 배열은 어떻게 디코딩하는가?

🔑 30초 답변

래퍼 타입의 init(from:) 안에서 먼저 singleValueContainer()type 필드만 얕게 읽어 어떤 케이스인지 판별한 뒤, 같은 컨테이너에 대고 switch로 알맞은 구체 타입을 다시 디코딩한다. 같은 컨테이너를 두 번 디코딩할 수 있다는 게 핵심 — Decoder/KeyedDecodingContainer는 값을 소비하는 게 아니라 매번 처음부터 다시 읽는 뷰라서 가능하다. 서버가 모르는 type을 보내는 경우, 배열 전체를 실패시킬지 그 원소만 건너뛸지는 실제 설계 선택이다.

원리

Codable 자체에는 "이 JSON이 어떤 구체 타입인지 필드 값을 보고 스스로 판단"하는 기능이 없다. 지난 장에서 다룬 컨테이너 API는 정적 타입을 미리 알고 있을 때 그 타입으로 디코딩하는 도구일 뿐이라, 판별은 항상 개발자가 직접 짜야 한다. 서버가 {"type": "circle", ...}/{"type": "rectangle", ...}처럼 판별자 필드로 케이스를 구분해 내려주는 패턴은 실무에서 매우 흔하고, 이걸 받는 표준 레시피가 "판별자만 먼저 읽고 나머지를 다시 읽기"다.

내부 동작

decoder.singleValueContainer()가 반환하는 컨테이너는 "값 하나"에 대한 뷰이지, 그 값을 한 번 읽으면 사라지는 소모품이 아니다. 같은 컨테이너에 대고 container.decode(TypeProbe.self)type 필드만 뽑아 케이스를 판별한 다음, 같은 컨테이너에 container.decode(Circle.self)를 다시 호출하면 같은 JSON 조각을 처음부터 다시 Circle로 파싱한다. 두 번 디코딩하는 게 비용처럼 보이지만, 판별자 하나만 읽는 첫 번째 패스는 JSON을 다시 통째로 파싱하는 게 아니라 이미 파싱된 내부 표현(트리) 위에서 필드 하나만 조회하는 정도라 실무에서 문제 되는 비용이 아니다.

실험 · 도구

세 종류가 섞인 배열(그중 하나는 알려지지 않은 type)을 만들어 실제로 디코딩해보면 관용 처리가 어떻게 동작하는지 바로 보인다.

type 판별자로 케이스를 갈라 디코딩하는 완전한 구현
import Foundation

protocol Shape {}

struct Circle: Shape, Decodable { let type: String; let radius: Double }
struct Rectangle: Shape, Decodable { let type: String; let width: Double; let height: Double }

// type 키만 얕게 읽기 위한 프로브
private struct TypeProbe: Decodable { let type: String }

// 래퍼: type을 먼저 보고 switch로 알맞은 구체 타입을 다시 디코딩한다
struct ShapeWrapper: Decodable {
    let shape: Shape?     // 관용 모드: 모르는 타입은 nil로 건너뛴다 (배열 전체를 실패시키지 않는다)
    let rawType: String

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let probe = try container.decode(TypeProbe.self)
        rawType = probe.type
        switch probe.type {
        case "circle":
            shape = try container.decode(Circle.self)
        case "rectangle":
            shape = try container.decode(Rectangle.self)
        default:
            shape = nil   // 알 수 없는 type -> 이 원소만 건너뛰고 배열 전체는 살린다
        }
    }
}

let json = """
[
  {"type":"circle","radius":2.5},
  {"type":"rectangle","width":3,"height":4},
  {"type":"triangle","base":1,"height":1}
]
"""

let wrappers = try! JSONDecoder().decode([ShapeWrapper].self, from: Data(json.utf8))
for w in wrappers {
    if let shape = w.shape {
        print("디코딩 성공:", type(of: shape), shape)
    } else {
        print("알 수 없는 타입 건너뜀: type=\(w.rawType)")
    }
}
실제 실행 결과
디코딩 성공: Circle Circle(type: "circle", radius: 2.5)
디코딩 성공: Rectangle Rectangle(type: "rectangle", width: 3.0, height: 4.0)
알 수 없는 타입 건너뜀: type=triangle
전체 3개 중 성공 2개
판별자 디스패치 흐름 (배열 원소 하나당) JSON 원소 {"type": "...", ...} type 프로브 같은 컨테이너, 1차 디코딩 switch probe.type circle/rectangle 같은 컨테이너, 2차 디코딩 알 수 없는 type shape = nil, 건너뜀 Circle/Rectangle 인스턴스 배열의 다음 원소로 계속
type 필드를 먼저 읽어 분기하고, 같은 컨테이너를 다시 디코딩해 구체 타입을 얻거나 관용적으로 건너뛴다.

프로젝트 적용

대부분의 실무 API는 배열 안에 알 수 없는 타입이 섞여도 전체 응답을 버리기보다는 알려진 항목만이라도 화면에 보여주는 쪽이 사용자 경험상 유리하다. 위 코드처럼 shape: Shape?로 감싸고 compactMap으로 걸러내는 패턴을 쓴다.

알려진 타입만 골라 화면에 쓸 배열로 압축
let knownShapes: [Shape] = wrappers.compactMap { $0.shape }
print("표시 가능한 도형", knownShapes.count, "개")

반대로 "알 수 없는 타입이 하나라도 있으면 전체를 신뢰할 수 없다"고 판단하는 도메인(결제, 계약서 등)이라면 관용 대신 명시적으로 실패시켜야 한다. 이건 복원력(resilience) 대 정확성(correctness)의 트레이드오프이고, 어느 쪽을 택할지는 API 계약과 도메인 성격에 달려 있다.

엄격 모드 — 모르는 타입을 만나면 즉시 에러로 실패시킨다
struct StrictShapeWrapper: Decodable {
    let shape: Shape

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let probe = try container.decode(TypeProbe.self)
        switch probe.type {
        case "circle":
            shape = try container.decode(Circle.self)
        case "rectangle":
            shape = try container.decode(Rectangle.self)
        default:
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: "알 수 없는 도형 타입: \(probe.type)"
            )
        }
    }
}
⚠️ 흔한 오해

"이종 배열(서로 다른 타입이 섞인 배열)도 Codable이 알아서 타입을 유추해 디코딩해준다"는 틀렸다. Decodable은 정적 타입 하나를 기준으로만 동작하므로, 배열 원소마다 다른 구체 타입이 나올 수 있다는 걸 스스로 알아채지 못한다. 판별 로직은 항상 개발자가 init(from:)에 직접 써야 한다.

🧒 쉽게 이해하기

택배 상자 겉면에 붙은 "내용물: 책/장난감/식품" 스티커를 먼저 확인하고, 그 스티커에 맞는 처리 방법(책장에 꽂기/장난감 상자에 넣기/냉장고에 넣기)으로 상자를 여는 것과 같다. 스티커를 안 보고 무작정 열면 뭐가 들었는지 미리 알 수 없다. 비유가 깨지는 곳: 택배는 스티커를 한 번 보고 나면 상자를 다시 처음부터 열 수 없지만, Codable의 컨테이너는 같은 조각을 몇 번이고 다시 "열어" 다른 타입으로 디코딩해볼 수 있다.

꼬리 질문

같은 컨테이너를 두 번 디코딩하는 게 성능에 문제가 되지 않는가?
JSON 텍스트 자체를 두 번 파싱하는 게 아니라, 디코더가 이미 만들어둔 내부 표현(파싱된 트리) 위에서 값을 두 번 읽는 것이라 원소 하나당 판별자 필드 조회 1회가 추가되는 정도다. 배열이 수십만 건 규모로 커지면 체감될 수 있지만, 일반적인 API 응답 크기에서는 무시할 수준이다. 정말 극한의 성능이 필요하면 판별자를 배열 최상위로 끌어올려 서버 스펙 자체를 바꾸는 게 근본 해법이다.
쉽게 말하면 이미 펼쳐놓은 책에서 같은 페이지를 두 번 읽는 것이지, 책을 다시 처음부터 인쇄하는 게 아니다.
판별자 필드가 최상위가 아니라 중첩된 객체 안에 있으면 어떻게 하는가?
singleValueContainer() 대신 decoder.container(keyedBy:)로 키드 컨테이너를 열고, nestedContainer(keyedBy:forKey:)로 판별자가 있는 위치까지 내려가 값을 읽으면 된다. 판별 이후 구체 타입을 디코딩할 때도 같은 최상위 decoder를 다시 넘겨야 전체 구조가 올바르게 읽힌다는 점만 주의하면 원리는 동일하다.
쉽게 말하면 스티커가 상자 겉면이 아니라 상자를 한 번 연 안쪽 뚜껑에 붙어 있으면, 뚜껑까지만 살짝 열어 스티커를 확인하고 다시 전체를 여는 것과 같다.
판별자 값 자체가 여러 언어로 오거나 대소문자가 섞여 오면 어떻게 방어하는가?
switch probe.type.lowercased() 같은 정규화를 먼저 거치거나, Kind: String, Codable 같은 enum으로 판별자를 받아 알려진 값 집합만 통과시키고 나머지는 자동으로 default 분기(또는 디코딩 실패)로 떨어지게 설계하는 게 안전하다. 문자열을 날것 그대로 switch에 물리면 서버가 대소문자를 바꾸는 순간 조용히 전부 "알 수 없는 타입"으로 빠질 수 있다.
쉽게 말하면 스티커에 "책"이라고 쓰든 "Book"이라고 쓰든 같은 걸로 알아보게, 확인하기 전에 표기를 한 가지 방식으로 맞춰두는 것과 같다.

Q4. Any는 왜 Codable이 될 수 없는가?

🔑 30초 답변

Codable의 요구사항인 encode(to:)는 컴파일 타임에 구체 타입을 알아야 그 타입에 맞는 인코딩 로직(어떤 컨테이너를 열지, 어떤 키를 쓸지)을 결정할 수 있다. Any는 정확히 그 정적 타입 정보를 지워버린 상자라서, 컴파일러가 "이 값을 어떻게 인코딩할지"를 증명할 방법이 없다. 그래서 [String: Any]Decodable이 아니고, 이를 JSONEncoder에 넘기면 컴파일이 아예 되지 않는다 — 실제 진단 메시지를 아래에 그대로 인용한다.

원리

Codable제네릭 프로토콜 요구사항이다. 어떤 타입이 이 요구사항을 만족한다고 컴파일러가 인정하려면, 그 타입에 대해 정적으로(타입 체크 시점에) 요구되는 메서드 구현이 존재함을 증명할 수 있어야 한다. Any는 "무엇이든 담을 수 있는 상자"일 뿐 그 자체로 구체적인 저장 형태나 인코딩 규칙을 갖지 않으므로, 이 증명 자체가 성립하지 않는다.

내부 동작

실제로 [String: Any]JSONEncoder().encode(_:)에 넘기면 무슨 일이 일어나는지 직접 확인해보자.

컴파일이 되지 않는 코드
import Foundation

let dict: [String: Any] = ["name": "kim", "age": 30]
let data = try JSONEncoder().encode(dict)   // 컴파일 에러
swiftc가 실제로 내는 진단 메시지
error: type 'Any' cannot conform to 'Encodable' [#ProtocolTypeNonConformance]
    let data = try JSONEncoder().encode(dict)
                                  |- error: type 'Any' cannot conform to 'Encodable'
                                  |- note: only concrete types such as structs, enums and classes can conform to protocols
                                  `- note: requirement from conditional conformance of '[String : Any]' to 'Encodable'

메시지가 정확히 원리를 그대로 말해준다 — "concrete types만 프로토콜을 준수할 수 있다"는 것이 이 챕터 전체의 핵심이다. [String: Any]Encodable이 되려면 Dictionary조건부 준수(Value: Encodable일 때만 DictionaryEncodable)가 성립해야 하는데, 그 조건의 전제인 "AnyEncodable"이 애초에 불가능하다.

실험 · 도구

swiftc -typecheck 파일명.swift로 실행까지 가지 않고 타입 체크만 시켜보면 위 진단을 그대로 재현할 수 있다. 실행 파일을 만들 필요가 없어 빠르다.

프로젝트 적용

정말로 모양을 미리 알 수 없는 동적인 JSON(설정값, 원격 구성, 플러그인 페이로드 등)을 다뤄야 한다면, Any를 흉내 내는 대신 알려진 케이스만 열거하는 손으로 쓴 타입을 만든다. 아래 JSONValue는 JSON이 표현할 수 있는 모든 모양(문자열/숫자/불/객체/배열/null)을 유한한 케이스로 감싸, 각 케이스는 전부 구체 타입이라 Codable이 성립한다.

JSONValue — Any 대신 쓰는 손으로 쓴 동적 JSON 표현 (전체)
import Foundation

enum JSONValue: Codable {
    case string(String)
    case number(Double)
    case bool(Bool)
    case object([String: JSONValue])
    case array([JSONValue])
    case null

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if container.decodeNil() {
            self = .null
        } else if let v = try? container.decode(Bool.self) {
            self = .bool(v)
        } else if let v = try? container.decode(Double.self) {
            self = .number(v)
        } else if let v = try? container.decode(String.self) {
            self = .string(v)
        } else if let v = try? container.decode([String: JSONValue].self) {
            self = .object(v)
        } else if let v = try? container.decode([JSONValue].self) {
            self = .array(v)
        } else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "알 수 없는 JSON 값")
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .string(let v): try container.encode(v)
        case .number(let v): try container.encode(v)
        case .bool(let v): try container.encode(v)
        case .object(let v): try container.encode(v)
        case .array(let v): try container.encode(v)
        case .null: try container.encodeNil()
        }
    }
}

실제로 실행하면 이렇게 동작한다.

실행 결과
let json = #"{"name":"kim","age":30,"tags":["swift","ios"],"active":true,"nickname":null}"#
let value = try! JSONDecoder().decode(JSONValue.self, from: Data(json.utf8))
if case let .object(dict) = value {
    print("name:", dict["name"] ?? "nil")       // string("kim")
    print("age:", dict["age"] ?? "nil")         // number(30.0)
    print("tags:", dict["tags"] ?? "nil")       // array([string("swift"), string("ios")])
    print("nickname:", dict["nickname"] ?? "nil") // null
}

정적 타입을 미리 정의할 수 있는 상황(즉 "동적"이 아니라 "그냥 아직 안 정했을 뿐")이라면 JSONValue보다 훨씬 저렴한 JSONSerialization으로 [String: Any]를 바로 받는 편이 코드가 짧다 — 다만 이 경로는 타입 안전성을 완전히 포기하는 대가를 치른다는 걸 명시적으로 인지해야 한다.

JSONSerialization으로 우회 — 타입 안전성을 포기하는 대신 코드가 짧다
let raw = try! JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
let name = raw["name"] as? String   // 매번 as? 캐스팅이 필요, 실수하면 nil로 조용히 새어나간다
⚠️ 흔한 오해

"제네릭이나 프로토콜을 잘 쓰면 Any도 결국 Codable로 만들 수 있다"는 틀렸다. Any 자체를 준수시키는 방법은 없다 — 반드시 JSONValue처럼 알려진 타입 집합에 대한 수동 디코딩 로직이거나, 정적 타입 검사를 아예 포기하는 JSONSerialization 우회 둘 중 하나를 골라야 한다.

🧒 쉽게 이해하기

택배 상자에 "내용물: 무엇이든 가능"이라고만 적혀 있으면, 택배 기사는 그 상자를 어떻게 다뤄야 할지(냉장 보관? 깨지기 쉬움?) 전혀 알 수 없다. 반면 "내용물: 책 또는 장난감 또는 식품, 셋 중 하나"라고 명확히 몇 가지로 정해두면 각 경우에 맞는 처리 방법을 미리 정해둘 수 있다. 비유가 깨지는 곳: 택배 상자는 열어보면 결국 뭐가 들었는지 알 수 있지만, Any는 프로그램이 실행되는 그 순간까지도 컴파일러 입장에서는 "안을 열어볼 방법 자체가 없는" 완전히 다른 종류의 불확실성이다.

꼬리 질문

런타임에 Any 값의 실제 타입을 확인해서 그때그때 인코딩하면 안 되는가?
is/as?로 런타임에 타입을 확인하는 것 자체는 가능하지만, 그렇게 확인한 타입을 Encodable로 넘기려면 그 시점에 컴파일러가 다시 구체 타입을 알아야 한다 — 결국 JSONValueinit(from:)try? 체인으로 하는 일과 똑같은 "알려진 타입을 순서대로 시도"하는 로직을 직접 짜야 한다. Any라는 이름 자체가 Codable을 우회시켜주는 마법은 없다.
쉽게 말하면 상자를 열어서 확인한 다음에야 어떻게 포장할지 정할 수 있는 것이지, 상자에 "무엇이든"이라고 적어놓는다고 포장법이 저절로 정해지지 않는다.
JSONValueinit(from:)에서 try?를 순서대로 시도하는 방식에 함정은 없는가?
있다. BoolDouble보다 먼저 시도하는 순서가 중요하다 — JSON에서 true/false0/1로도 잘못 디코딩될 수 있는 여지가 있는 디코더 구현이 있다면 순서를 바꾸는 순간 결과가 달라질 수 있다. 또한 정수만 담긴 값도 전부 Double로 흡수되어 Int였다는 정보가 사라지므로, 정수/실수를 구분해야 하는 도메인이라면 case int(Int)number(Double)보다 먼저 두는 케이스를 추가해야 한다.
쉽게 말하면 여러 열쇠를 순서대로 자물쇠에 넣어보는 것과 같은데, 어떤 열쇠를 먼저 넣어보느냐에 따라 다른 자물쇠가 먼저 열려버릴 수 있다.
Swift 5.5의 연관값 enum Codable 합성(SE-0295)은 Any 문제를 대신 풀어주지 않는가?
풀어주지 않는다. SE-0295는 연관값의 타입이 이미 정해진 enum(예: case load(String))에 대한 자동 합성일 뿐이라, 연관값 자체가 Any이거나 타입이 런타임에만 정해지는 경우는 여전히 대상이 아니다. JSONValue가 하는 일은 그 반대 방향 — 연관값의 타입을 JSONValue 자신으로 재귀시켜, "무엇이든"을 "이 다섯 가지 중 하나"로 유한하게 좁히는 것이다.
쉽게 말하면 SE-0295는 "상자 안에 정확히 무엇이 들어있는지 이미 알고 있을 때" 포장을 자동으로 해주는 기능이지, "상자 안에 뭐가 들었는지 아직 모를 때"를 풀어주는 기능이 아니다.

Q5. 구형 JSONDecoder는 왜 느렸고 swift-foundation 재작성은 무엇을 바꿨는가?

🔑 30초 답변

구형(swift-corelibs-foundation 세대) JSONDecoder는 리플렉션 때문이 아니라, JSONSerialization을 거쳐 Objective-C 컬렉션 타입(NSDictionary/NSNumber/NSString) 그래프를 먼저 만든 뒤 그걸 다시 Swift 타입으로 옮기는 swift_dynamicCast 브리징 비용이 병목이었다. swift-foundation 재작성은 이 중간 단계를 통째로 없애고 JSON 바이트를 곧장 Swift 타입으로 파싱한다. swift.org가 공개한 벤치마크는 "JSON 디코딩 200%~500% 개선"이고, 직접 5만 건 레코드로 측정한 값도 아래에 그대로 싣는다.

원리

Codable 자체는 컴파일 타임에 코드가 생성되는(synthesis) 정적 메커니즘이라 런타임 리플렉션(Mirror)을 쓰지 않는다 — 그래서 "JSONDecoder는 원래 리플렉션 때문에 느리다"는 설명은 원인을 잘못 짚은 것이다. 실제 병목은 어떤 경로로 JSON 바이트를 Swift 값으로 만드는가에 있었다. 두 세대의 차이는 그 경로 자체가 다르다는 데서 온다.

내부 동작

구형 경로는 JSON 바이트 → JSONSerialization이 만든 NSDictionary/NSArray(값마다 NSNumber/NSString으로 박싱) → Codable이 그 ObjC 객체 그래프를 다시 훑으며 Swift 타입으로 변환, 총 세 단계를 거쳤다. swift-corelibs-foundation의 실제 이슈 분석(SR-6252)은 이 변환 과정에서 대부분의 시간이 swift_dynamicCast()에 쓰인다는 걸 확인했고, 7MB JSON 파일 기준 NSDictionary를 그대로 다루도록 우회하면 약 1초에서 약 0.25초로 줄었다고 보고했다 — 브리징 자체가 비용의 대부분이었다는 뜻이다. swift-foundation 재작성은 순수 Swift로 JSONDecoder/JSONEncoder를 다시 구현해 이 ObjC 왕복을 아예 걷어냈다 — JSON 바이트를 곧장 Swift 타입으로 파싱하는 두 단계 경로가 된다. 같이 재작성된 Locale/Calendar/TimeZone도 더 이상 ObjC 브리징이 필요 없어졌고, swift.org가 공개한 수치는 고정 Locale 조회가 "an order of magnitude faster", Calendar 계산이 "20%대 개선", FormatStyle이 "150% 개선", 그리고 JSON 디코딩이 "200%~500% 개선"이다.

구형 파이프라인 vs swift-foundation 재작성 구형 (corelibs-foundation) JSON 바이트 JSONSerialization NSDictionary/NSNumber 박싱 swift_dynamicCast ObjC → Swift 변환 Swift 값 swift-foundation (재작성 이후) JSON 바이트 순수 Swift 파서 Swift 값 — 중간 ObjC 단계 없음
구형 경로의 ObjC 왕복 두 단계가 재작성 이후 사라지고, JSON 바이트가 곧장 Swift 타입으로 파싱된다.

실험 · 도구

지금 SDK의 JSONDecoder가 5만 건짜리 현실적인 레코드 배열을 얼마나 빨리 디코딩하는지 ContinuousClock으로 직접 재봤다.

5만 건 JSON 디코딩 벤치마크 코드
import Foundation

struct Order: Codable {
    let id: Int
    let customerName: String
    let items: [String]
    let total: Double
    let isPaid: Bool
}

let count = 50_000
var orders: [Order] = []
orders.reserveCapacity(count)
for i in 0..<count {
    orders.append(Order(id: i, customerName: "고객\(i % 1000)",
                         items: ["item\(i % 7)", "item\(i % 11)"],
                         total: Double(i) * 1.5, isPaid: i % 2 == 0))
}
let data = try! JSONEncoder().encode(orders)

let decoder = JSONDecoder()
let clock = ContinuousClock()
let elapsed = clock.measure { _ = try! decoder.decode([Order].self, from: data) }
print(elapsed)
실제 측정 결과 (Swift 6.2.1, macOS 26, Apple Silicon, swift 파일명.swift로 실행 — 최적화 없이)
생성된 JSON 크기: 4644 KB, 레코드 50000 건
재사용 디코더로 decode 1회: 0.086121375 seconds
새 디코더로 decode 1회: 0.085992958 seconds
평균(5회): 0.0853305668 seconds

4.6MB, 5만 건짜리 배열을 약 86ms에 디코딩했다 — 레코드 한 건당 약 1.7마이크로초다. 눈에 띄는 점은 "재사용 디코더"와 "매번 새로 만든 디코더"의 시간이 사실상 같다는 것이다. DateFormatter는 인스턴스 생성 자체가 로케일·캘린더 데이터를 로드하는 무거운 작업이라 재사용이 확실히 이득이지만, JSONDecoder는 인스턴스 생성 비용이 거의 없고 진짜 비용은 파싱·타입 변환 작업 자체에 있다는 뜻이다. 이건 리서치 단계의 막연한 "디코더도 재사용하면 좋다"는 통념과 실측이 다르게 나온 지점이라 정직하게 적는다 — 큰 단일 페이로드 하나를 디코딩하는 시나리오에서는 디코더 재사용 자체가 성능에 유의미한 차이를 만들지 않았다.

프로젝트 적용

디코딩은 여전히 메인 스레드를 막을 수 있는 크기의 작업이니, 네트워크 응답 파싱은 백그라운드 큐(또는 Task.detached)에서 하고 결과만 메인 액터로 넘긴다.

디코딩을 백그라운드로, 결과만 메인으로
func loadOrders(from data: Data) async throws -> [Order] {
    try await Task.detached(priority: .userInitiated) {
        try JSONDecoder().decode([Order].self, from: data)
    }.value
}
// 호출부(@MainActor)는 await로 받기만 하면 되고, 파싱 자체는 메인 스레드를 막지 않는다

[String: Any]로 받아 나중에 캐스팅하는 패턴은 Q4에서 본 대로 타입 안전성도 잃고, 브리징 왕복이 필요해 성능상으로도 손해다. 구체 타입으로 바로 decode하고, 정말 페이로드가 매우 크면(수십 MB 이상) 배열 전체를 한 번에 메모리에 올리는 대신 스트리밍 파싱을 고려한다.

구체 타입으로 바로 디코딩 — [String: Any] 왕복을 거치지 않는다
// ❌ 나쁜 예: 일단 Any로 받고 나중에 캐스팅
let raw = try JSONSerialization.jsonObject(with: data) as! [[String: Any]]
let firstId = raw[0]["id"] as? Int   // 매번 캐스팅, 타입 오류가 런타임까지 숨는다

// ✅ 좋은 예: 처음부터 구체 타입으로
let orders = try JSONDecoder().decode([Order].self, from: data)
let firstId = orders[0].id   // 컴파일 타임에 Int로 보장됨
⚠️ 흔한 오해

"JSONDecoder가 느린 건 리플렉션 때문이다"는 원인을 잘못 짚었다. Codable은 컴파일 타임 코드 생성이지 런타임 리플렉션이 아니다. 구형 구현이 느렸던 진짜 원인은 JSONSerialization이 만드는 ObjC 객체 그래프와의 브리징 비용이었고, swift-foundation 재작성이 정확히 그 브리징을 없앤 것이다. 또한 "최신 OS라면 무조건 이 수치만큼 빨라져 있다"도 정확하지 않다 — 이 개선은 FoundationEssentials/재작성된 Foundation을 실제로 쓰는 툴체인·플랫폼 조건에서 측정된 것이라, 프로젝트의 최소 배포 타깃에 따라 체감폭이 다를 수 있다.

🧒 쉽게 이해하기

외국어로 된 편지를 읽는다고 하자. 예전 방식은 편지를 일단 다른 사람(ObjC)에게 통째로 번역시킨 다음, 그 번역본을 다시 내가 이해하는 말로 한 번 더 옮기는 것과 같았다 — 사람을 두 번 거치니 느릴 수밖에 없다. 새 방식은 내가 직접 원문을 읽고 바로 이해하는 것이다. 비유가 깨지는 곳: 사람이 번역을 두 번 거치면 뜻이 조금씩 달라질 위험이라도 있지만, 컴퓨터의 이중 변환은 뜻이 달라지지는 않고 순전히 시간만 더 잡아먹었다는 점이 다르다.

꼬리 질문

swift.org가 공개한 "200%~500%"는 정확히 무엇을 기준으로 한 수치인가?
swift.org 공식 블로그의 "Foundation Package Preview Now Available" 글이 밝힌 벤치마크로, "테스트 데이터를 파싱하는 벤치마크에서 디코딩 시간이 200%에서 거의 500%까지 개선됐다"는 문구다. 정확한 배율은 테스트 데이터의 종류(중첩 깊이, 필드 수, 문자열/숫자 비율)에 따라 달라진다고 원문이 밝히고 있어, 어떤 JSON이든 항상 그 배율이 나온다고 일반화하면 안 된다 — 재현하려면 swift-foundation 저장소의 벤치마크를 직접 돌려보는 게 정확하다.
쉽게 말하면 "이 신발을 신으면 평균 20% 더 빨리 달린다"는 광고가 모든 사람, 모든 트랙에서 정확히 20%를 보장하지는 않는 것과 비슷하다. 어떤 상황을 기준으로 쟀는지가 중요하다.
디코더를 재사용하지 않아도 되는 게 JSONDecoder만의 특징인가?
아니다. 이건 JSONDecoderDateFormatter와 근본적으로 다른 종류의 객체이기 때문이다. DateFormatter는 생성 시점에 로케일·캘린더 데이터를 로드하고 포맷 문자열을 파싱하는 무거운 준비 작업을 하지만, JSONDecoder는 생성 자체가 가벼운 설정 객체를 만드는 것뿐이고 실제 무거운 작업(파싱)은 decode(_:from:) 호출 시점에 일어난다. 그래서 "포매터는 재사용하라"는 조언을 그대로 디코더에 적용하면 안 된다 — 실측이 이걸 보여준다.
쉽게 말하면 어떤 도구는 꺼내는 것 자체가 오래 걸려서 한 번 꺼내면 계속 쓰는 게 이득이고(DateFormatter), 어떤 도구는 꺼내는 건 순식간이라 매번 새로 꺼내도 상관없다(JSONDecoder). 도구마다 사정이 다르다.
아주 큰(수백 MB) JSON 파일을 다뤄야 한다면 이 챕터의 접근으로 충분한가?
충분하지 않을 수 있다. JSONDecoder.decode(_:from:)Data 전체가 메모리에 이미 올라와 있다는 전제로 동작하는 "한 번에 전부" 방식이라, 파일 자체가 수백 MB 이상이면 파일 전체를 한 번에 읽는 문제가 먼저 발목을 잡는다. 이런 규모에서는 JSON을 부분부분 잘라 읽는 스트리밍 파서(서드파티)나, 애초에 서버 쪽에서 페이지네이션·NDJSON(줄 단위 JSON)으로 잘라 보내도록 설계를 바꾸는 게 더 근본적인 해법이다.
쉽게 말하면 얇은 책 한 권은 통째로 들고 읽어도 되지만, 백과사전 전체를 한 번에 통째로 들고 읽으려 하면 팔이 먼저 아프다 — 그럴 땐 한 권씩 나눠서 읽는 방법을 찾아야 한다.

출처 · 참고자료