지난 장에서는 Codable이 언제 합성되고 컨테이너 3종을 어떻게 손으로 다루는지를 봤다. 이번 장은 그다음 층이다 — 키 변환 전략이 실제로 무엇을 잃어버리는지, 디코딩이 실패했을 때 범인 필드를 정확히 짚어내는 법, 서버가 type 필드로 갈라 보내는 다형(polymorphic) JSON을 받아내는 법, Any가 왜 애초에 Codable이 될 수 없는지, 그리고 swift-foundation 재작성이 JSONDecoder를 실제로 얼마나 빠르게 만들었는지를 다룬다. 아래 스니펫은 전부 Swift 6.2.1 / macOS 26 SDK에서 직접 실행해 나온 결과다.
Q1. .convertFromSnakeCase가 "손실이 있다"는 말은 무슨 뜻인가?
convertToSnakeCase와 convertFromSnakeCase는 서로의 완전한 역함수가 아니다. 인코딩 쪽은 연속된 대문자(약어)를 하나의 소문자 토큰으로 뭉개고, 디코딩 쪽은 밑줄로 쪼갠 뒤 각 조각의 첫 글자만 대문자화한다. 그래서 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.imageURL의 stringValue는 정확히 "imageURL"이므로, 디코더가 실제로 찾을 수 있는 키(imageUrl)와 한 글자가 어긋나 keyNotFound가 난다. 아래는 직접 실행한 결과다.
[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 키 그대로를 지정하는 방식으로는 전략을 이길 수 없다.
실험 · 도구
직접 재현하려면 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(기본값) 그대로 둔다 — 전략과 섞지 않는다"convertFromSnakeCase는 convertToSnakeCase의 완전한 역함수라 무손실 왕복이 보장된다"는 틀렸다 — Apple 스스로 "본질적으로 손실"이라고 밝힌다. 여기서 한 걸음 더: "그래도 CodingKeys를 명시하면 전략을 이긴다"도 정확하지 않다. 위에서 확인했듯 전략은 컨테이너 생성 시점에 원본 JSON 키 자체를 먼저 바꿔버리므로, CodingKeys에 원본 그대로의 문자열을 넣어도 그 변환된 키 집합과 어긋나 실패할 수 있다.
영어 단어를 한글로 소리 나는 대로 적었다가, 그 한글을 보고 다시 영어로 되돌리는 걸 상상해보자. "URL"을 한글로 "유알엘"이라고 적으면, 나중에 "유알엘"만 보고는 원래 대문자 세 글자였는지 그냥 소문자 단어였는지 알 방법이 없다. 비유가 깨지는 곳: 사람이 한글 표기를 볼 때는 앞뒤 문맥으로 "아 이거 약어였구나" 짐작이라도 하지만, 컴퓨터의 이 변환 규칙은 문맥을 전혀 안 보고 밑줄 위치라는 딱 하나의 신호만으로 기계적으로 되돌리기 때문에 짐작조차 하지 않는다.
꼬리 질문
CodingKeys와 keyDecodingStrategy를 동시에 쓰면 어느 쪽이 이기는가?
keyDecodingStrategy가 컨테이너를 만드는 시점에 JSON의 원본 키 자체를 먼저 변환해버리고, CodingKeys는 그 변환된 키 집합과 문자 그대로 비교될 뿐이다. 그래서 CodingKeys에 원본 JSON 키 그대로의 문자열을 지정해도 전략이 켜져 있으면 어긋날 수 있다. 안전한 규칙은 "섞어 쓰지 않는다" — 전략을 쓰려면 문제 되는 필드까지 포함해 전략에 전부 맡기거나, 전략을 끄고 CodingKeys에 전부 명시한다.순수하게 서버 → 클라이언트로 디코딩만 하고 다시 인코딩해 서버로 보내지 않는다면, 이 손실 문제와 무관한가?
html_url처럼 보내면 변환 결과는 htmlUrl인데, Swift 개발자가 관례대로 프로퍼티를 htmlURL이라고 지었다면 인코딩을 한 번도 안 해도 처음부터 keyNotFound가 난다.allowsJSON5와 assumesTopLevelDictionary는 각각 언제 쓰는가?
allowsJSON5(iOS 15+)는 홑따옴표, 여러 줄 문자열, 주석, 16진수 리터럴, Infinity/NaN 같은 JSON5 문법을 받아들이게 하는 옵션으로, 설정 파일처럼 사람이 직접 손으로 편집하는 JSON을 다룰 때 쓴다. assumesTopLevelDictionary는 최상위가 { }로 감싸여 있지 않아도(예: 단일 스칼라 값만 오는 응답) 딕셔너리로 간주하게 해, 서버 응답이 표준을 살짝 벗어난 경우를 흡수한다. 둘 다 "표준을 좀 봐준다"는 옵션이지, 기본 동작을 바꾸는 건 아니다.Q2. DecodingError가 났을 때 codingPath로 범인 필드를 어떻게 찾는가?
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 문법 오류) 값이 의미상 유효하지 않다"는 뜻이다. 네 케이스 모두 공통으로 갖는 Context가 codingPath와 debugDescription을 담는다.
내부 동작
codingPath는 [CodingKey] 배열이고, 각 원소는 중첩 컨테이너를 타고 내려갈 때마다 하나씩 쌓인다 — decoder.container(keyedBy:)로 최상위를 열고, nestedContainer(keyedBy:forKey:)나 decode(_:forKey:)로 한 단계 더 들어갈 때마다 그 키가 경로에 추가된다. 실패가 일어난 정확한 깊이에서 예외가 던져지므로, codingPath를 펼치면 그 순간까지 지나온 경로 전체가 남아 있다. 배열 인덱스도 CodingKey로 취급되므로(intValue가 채워짐), users[3].address.zip처럼 배열을 낀 깊은 경로도 그대로 재구성할 수 있다.
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.
}실험 · 도구
DecodingError 네 케이스를 전부 강제로 발생시켜 codingPath가 실제로 어떻게 채워지는지 눈으로 확인할 수 있다 — swift 파일명.swift로 바로 실행 가능하다.
프로젝트 적용
① 아래 헬퍼는 어떤 DecodingError든 "어느 필드에서, 무엇을 기대했고, 무엇이 왔는지"를 한 줄 문장으로 바꾼다. 네트워크 레이어의 catch 블록에 한 번만 넣어두면 이후 모든 디코딩 실패 로그가 바로 읽힌다.
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는 디코더가 실패하는 그 순간 이미 자동으로 기록해둔 길이라 다시 열어볼 필요가 없다.
꼬리 질문
typeMismatch와 valueNotFound는 실제로 어떻게 구분되는가?
typeMismatch는 그 자리에 값이 있긴 한데 기대한 타입과 다를 때(문자열 "12345"가 왔는데 Int를 기대) 발생하고, valueNotFound는 옵셔널이 아닌 프로퍼티 자리에 JSON의 null이 명시적으로 왔을 때 발생한다. 둘 다 "키는 있는데 값이 이상하다"는 공통점이 있지만, 전자는 타입 불일치, 후자는 값의 부재(null)라는 점이 다르다.배열 안의 특정 원소에서 실패하면 codingPath에 그 인덱스도 나오는가?
UnkeyedDecodingContainer는 현재까지 읽은 개수를 CodingKey의 intValue로 노출하므로, [User] 배열의 세 번째 원소에서 실패하면 codingPath에 intValue == 2인 CodingKey가 섞여 나온다. 위 헬퍼의 pathString이 intValue가 있는 키를 [2] 형태로 바꿔주는 이유가 이것이다.헬퍼가 반환하는 문장을 그대로 사용자에게 보여줘도 되는가?
codingPath와 debugDescription은 내부 구조(프로퍼티 이름, 서버 필드명)를 그대로 노출하는 디버그용 정보라 로그·크래시 리포트·개발자 대상 도구에는 적합하지만, 최종 사용자에게는 "일시적인 문제가 발생했어요" 같은 일반화된 메시지를 따로 보여주고 이 헬퍼의 결과는 내부 로깅 채널로만 보내는 것이 맞다.Q3. type 필드로 갈라지는 다형 JSON 배열은 어떻게 디코딩하는가?
래퍼 타입의 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)을 만들어 실제로 디코딩해보면 관용 처리가 어떻게 동작하는지 바로 보인다.
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개프로젝트 적용
① 대부분의 실무 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의 컨테이너는 같은 조각을 몇 번이고 다시 "열어" 다른 타입으로 디코딩해볼 수 있다.
꼬리 질문
같은 컨테이너를 두 번 디코딩하는 게 성능에 문제가 되지 않는가?
판별자 필드가 최상위가 아니라 중첩된 객체 안에 있으면 어떻게 하는가?
singleValueContainer() 대신 decoder.container(keyedBy:)로 키드 컨테이너를 열고, nestedContainer(keyedBy:forKey:)로 판별자가 있는 위치까지 내려가 값을 읽으면 된다. 판별 이후 구체 타입을 디코딩할 때도 같은 최상위 decoder를 다시 넘겨야 전체 구조가 올바르게 읽힌다는 점만 주의하면 원리는 동일하다.판별자 값 자체가 여러 언어로 오거나 대소문자가 섞여 오면 어떻게 방어하는가?
switch probe.type에 .lowercased() 같은 정규화를 먼저 거치거나, Kind: String, Codable 같은 enum으로 판별자를 받아 알려진 값 집합만 통과시키고 나머지는 자동으로 default 분기(또는 디코딩 실패)로 떨어지게 설계하는 게 안전하다. 문자열을 날것 그대로 switch에 물리면 서버가 대소문자를 바꾸는 순간 조용히 전부 "알 수 없는 타입"으로 빠질 수 있다.Q4. Any는 왜 Codable이 될 수 없는가?
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) // 컴파일 에러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일 때만 Dictionary도 Encodable)가 성립해야 하는데, 그 조건의 전제인 "Any가 Encodable"이 애초에 불가능하다.
실험 · 도구
swiftc -typecheck 파일명.swift로 실행까지 가지 않고 타입 체크만 시켜보면 위 진단을 그대로 재현할 수 있다. 실행 파일을 만들 필요가 없어 빠르다.
프로젝트 적용
① 정말로 모양을 미리 알 수 없는 동적인 JSON(설정값, 원격 구성, 플러그인 페이로드 등)을 다뤄야 한다면, Any를 흉내 내는 대신 알려진 케이스만 열거하는 손으로 쓴 타입을 만든다. 아래 JSONValue는 JSON이 표현할 수 있는 모든 모양(문자열/숫자/불/객체/배열/null)을 유한한 케이스로 감싸, 각 케이스는 전부 구체 타입이라 Codable이 성립한다.
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]를 바로 받는 편이 코드가 짧다 — 다만 이 경로는 타입 안전성을 완전히 포기하는 대가를 치른다는 걸 명시적으로 인지해야 한다.
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로 넘기려면 그 시점에 컴파일러가 다시 구체 타입을 알아야 한다 — 결국 JSONValue의 init(from:)이 try? 체인으로 하는 일과 똑같은 "알려진 타입을 순서대로 시도"하는 로직을 직접 짜야 한다. Any라는 이름 자체가 Codable을 우회시켜주는 마법은 없다.JSONValue의 init(from:)에서 try?를 순서대로 시도하는 방식에 함정은 없는가?
Bool을 Double보다 먼저 시도하는 순서가 중요하다 — JSON에서 true/false는 0/1로도 잘못 디코딩될 수 있는 여지가 있는 디코더 구현이 있다면 순서를 바꾸는 순간 결과가 달라질 수 있다. 또한 정수만 담긴 값도 전부 Double로 흡수되어 Int였다는 정보가 사라지므로, 정수/실수를 구분해야 하는 도메인이라면 case int(Int)를 number(Double)보다 먼저 두는 케이스를 추가해야 한다.Swift 5.5의 연관값 enum Codable 합성(SE-0295)은 Any 문제를 대신 풀어주지 않는가?
case load(String))에 대한 자동 합성일 뿐이라, 연관값 자체가 Any이거나 타입이 런타임에만 정해지는 경우는 여전히 대상이 아니다. JSONValue가 하는 일은 그 반대 방향 — 연관값의 타입을 JSONValue 자신으로 재귀시켜, "무엇이든"을 "이 다섯 가지 중 하나"로 유한하게 좁히는 것이다.Q5. 구형 JSONDecoder는 왜 느렸고 swift-foundation 재작성은 무엇을 바꿨는가?
구형(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% 개선"이다.
실험 · 도구
지금 SDK의 JSONDecoder가 5만 건짜리 현실적인 레코드 배열을 얼마나 빨리 디코딩하는지 ContinuousClock으로 직접 재봤다.
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)생성된 JSON 크기: 4644 KB, 레코드 50000 건
재사용 디코더로 decode 1회: 0.086121375 seconds
새 디코더로 decode 1회: 0.085992958 seconds
평균(5회): 0.0853305668 seconds4.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 이상) 배열 전체를 한 번에 메모리에 올리는 대신 스트리밍 파싱을 고려한다.
// ❌ 나쁜 예: 일단 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%"는 정확히 무엇을 기준으로 한 수치인가?
디코더를 재사용하지 않아도 되는 게 JSONDecoder만의 특징인가?
JSONDecoder가 DateFormatter와 근본적으로 다른 종류의 객체이기 때문이다. DateFormatter는 생성 시점에 로케일·캘린더 데이터를 로드하고 포맷 문자열을 파싱하는 무거운 준비 작업을 하지만, JSONDecoder는 생성 자체가 가벼운 설정 객체를 만드는 것뿐이고 실제 무거운 작업(파싱)은 decode(_:from:) 호출 시점에 일어난다. 그래서 "포매터는 재사용하라"는 조언을 그대로 디코더에 적용하면 안 된다 — 실측이 이걸 보여준다.아주 큰(수백 MB) JSON 파일을 다뤄야 한다면 이 챕터의 접근으로 충분한가?
JSONDecoder.decode(_:from:)는 Data 전체가 메모리에 이미 올라와 있다는 전제로 동작하는 "한 번에 전부" 방식이라, 파일 자체가 수백 MB 이상이면 파일 전체를 한 번에 읽는 문제가 먼저 발목을 잡는다. 이런 규모에서는 JSON을 부분부분 잘라 읽는 스트리밍 파서(서드파티)나, 애초에 서버 쪽에서 페이지네이션·NDJSON(줄 단위 JSON)으로 잘라 보내도록 설계를 바꾸는 게 더 근본적인 해법이다.