앱을 종료했다가 다시 켰을 때도, 내가 설정해둔 값들이 그대로 남아있어야 하잖아요?
예를 들어 다크모드 설정이라든지, 게임 속 설정 같은 것들이요.
그런데 Swift에서 메모리에 올라간 객체들은 휘발성이라 앱이 종료되면 다 사라져 버립니다.
그래서 iOS에서는 데이터를 영구적으로 저장하기 위한 여러 방법이 있어요.
UserDefaults (가장 간단한 방법)
가장 먼저 떠올릴 수 있는 게 바로 UserDefaults입니다. 작고 단순한 데이터를 저장할 때 쓰기 딱 좋아요
하지만! 지원하는 타입은 한정적입니다:
String, Int, Double, Float, Bool, URL, Data 그리고 이 타입들로 구성된 Array, Dictionary
결국 기본 타입들이네요?
그러면 우리가 개발하면서 만든 객체들은 어떻게 저장할까요? 뭐 예를 들어, User라는 객체가 있을 수 있잖아요
그래서 나온 게 바로 Archive와 Serialization 개념이에요.
직렬화(Serialization)와 아카이빙(Archiving)
👉 직렬화(Serialization): 메모리의 객체를 Data 같은 저장 가능한 형태로 변환하는 과정
👉 역직렬화(Deserialization): Data를 다시 원래 객체로 복원하는 과정
👉 아카이빙(Archiving): 직렬화의 한 방식으로, 객체 그래프 전체를 파일처럼 저장하는 것이라고 이해하면 돼요
NSCoding과 NSKeyedArchiver
아주 머어어언 옛날부터 Objective-C 시절에 NSCoding 프로토콜이 존재했어요.
class User: NSObject, NSCoding {
var name: String
var age: Int
var email: String?
init(name: String, age: Int, email: String? = nil) {
self.name = name
self.age = age
self.email = email
}
// 인코딩 - 매번 모든 프로퍼티를 수동으로 인코딩
func encode(with coder: NSCoder) {
coder.encode(name, forKey: "name")
coder.encode(age, forKey: "age")
coder.encode(email, forKey: "email")
}
// 디코딩 - 실수하기 쉬운 타입 캐스팅과 키 관리
required init?(coder: NSCoder) {
guard let name = coder.decodeObject(forKey: "name") as? String else {
return nil
}
self.name = name
self.age = coder.decodeInteger(forKey: "age")
self.email = coder.decodeObject(forKey: "email") as? String
}
}
저장/복원할때는 요렇게 👇🏿
let user = User(name: "빈지노", age: 37, email: "zino@naver.com")
// 아카이빙 → Data로 변환
let encoded = try NSKeyedArchiver.archivedData(withRootObject: user, requiringSecureCoding: false)
UserDefaults.standard.set(encoded, forKey: "currentUser")
// 언아카이빙 → 다시 User로 복원
if let savedData = UserDefaults.standard.data(forKey: "currentUser"),
let restored = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(savedData) as? User {
print(restored.name) // 빈지노
}
여기서 encode(with:) → 인코딩할 때 자동 호출
init(coder:) → 디코딩할 때 자동 호출
NSKeyedArchiver의 문제점들
1. 엄청난 보일러플레이트 코드
프로퍼티 하나 추가할 때마다 세 곳을 수정해야 했어요:
- 프로퍼티 선언
- encode(with:) 메서드에 인코딩 로직 추가
- init(coder:) 메서드에 디코딩 로직 추가
2. 런타임 에러의 위험성
키를 문자열로 관리하다 보니 휴먼에러가 나도 컴파일 타임에 잡히지 않았어요:
3. 타입 안전성 부족
4. 클래스만 지원
이런 불편함을 해결하기 위해 등장한 게 바로 Codable!
Codable 등장 🏮
struct User: Codable { // 이게 끝!
let name: String
let age: Int
let email: String?
}
단 한 줄로 인코딩/디코딩이 모두 해결됩니다. 컴파일러가 자동으로 모든 구현을 생성해주거든요
Codable은 사실 두 프로토콜의 조합입니다:
typealias Codable = Encodable & Decodable
조건은 간단해요:
- 모든 프로퍼티가 Codable을 따를 것
- 클래스 상속 구조에서는 부모도 Codable을 따를 것
- 제네릭 타입일 경우, 타입 매개변수도 Codable일 것
만약 조건이 안 되면? 컴파일 에러로 바로 알려줍니다.
Container 시스템의 내부 동작 원리
여기서부터가 진짜 중요한 부분이에요.
많은 개발자들이 그냥 JSONDecoder().decode() 한 줄만 쓰고 넘어가는데, 어떻게 생긴지를 알아야 개발자죠 🧑🏻💻
실제로 JSONDecoder().decode(User.self, from: data)를 호출하면 이런 일이 벌어져요:🕵🏻♀️
- JSONDecoder가 내부적으로 _JSONDecoder 인스턴스를 생성
- JSON 데이터를 파싱해서 Foundation 타입(NSArray, NSDictionary, NSString)으로 변환
- User.init(from: decoder) 호출 (컴파일러가 자동 생성)
- 컴파일러 생성 코드가 decoder.container(keyedBy: CodingKeys.self) 호출
- KeyedDecodingContainer가 생성되어 각 프로퍼티를 디코딩
더 자세한 이야기는 밑에서 할거에요 이해 못하는 키워드가 있더라도 쪼끔만 더 읽어주세요
Decoder 프로토콜의 구조
Swift의 디코딩 과정은 컨테이너를 통해 이루어집니다. Decoder 프로토콜을 보면:
그 중에 자세히 보면 Container를 리턴하는 메서드가 총 3개죠. 왜 3개까지나 필요할까요?
이유는 JSON의 구조적 특성 때문이에요. JSON에는 객체({}), 배열([]), 그리고 단일 값("hello", 42 등) 이렇게 세 가지 형태가 있어요.
각각에 최적화된 컨테이너가 필요한 거죠!
세 가지 컨테이너의 역할
1. KeyedDecodingContainer vs KeyedDecodingContainerProtocol:
- 역할: 객체의 Key-Value 쌍을 처리
- JSON에서 { "id": 1, "name": "Sunho" } 같은 구조를 다룰 때 사용
그런데 궁금한게 왜 이건 구조체일까요? 이후에 보는 얘들은 프로토콜인데;??
물론 저 뒤를 보면 KeyedDecodingContainerProtcol이 있어요
에?? 잘 보면 associatedtype이 있네요? 그러면 우리는 이제
var containers: [KeyedDecodingContainerProtocol] = []
이런식으로 사용을 할 수가 없죠 이 대신
var containers: [KeyedDecodingContainer<SomeCodingKey>] = []
이렇게 static Polymorphism을 해주어야 합니다.
실제 내부 구현을 보면:
// JSONDecoder가 사용하는 구현체
class _JSONKeyedDecodingContainer<K: CodingKey>: KeyedDecodingContainerProtocol {
// JSON 특화 로직
}
// PropertyListDecoder가 사용하는 구현체
class _PropertyListKeyedDecodingContainer<K: CodingKey>: KeyedDecodingContainerProtocol {
// PropertyList 특화 로직
}
// 사용자에게는 이렇게 노출
let container: KeyedDecodingContainer<User.CodingKeys> =
decoder.container(keyedBy: User.CodingKeys.self)
- 왜 이렇게 복잡하게 만들었을까? -> Type Erasure!!!!!!!
사용자는 이런 내부 구현체들을 알 필요가 없어야 하죠. 그냥 "키로 값을 디코딩한다"는 공통 인터페이스만 알면 되는거고요.
// 이건 실제 Swift 소스코드와 비슷한 구조예요
public struct KeyedDecodingContainer<K: CodingKey> {
// 실제 구현체를 숨김 (Type Erasure!)
private var _box: any KeyedDecodingContainerProtocol
// 사용자에게는 깔끔한 인터페이스만 제공
public func decode<T>(_ type: T.Type, forKey key: K) throws -> T where T : Decodable {
// 내부 구현체에게 일을 위임
return try _box.decode(type, forKey: key)
}
public func contains(_ key: K) -> Bool {
return _box.contains(key)
}
// 생성자에서 실제 구현체를 받아서 숨김
internal init<Container>(_ container: Container) where Container: KeyedDecodingContainerProtocol, Container.Key == K {
self._box = container
}
}
이 방식의 장점은:
타입 안전성 확보
// ✅ 이제 이렇게 할 수 있어요!
var containers: [KeyedDecodingContainer<MyCodingKeys>] = []
// 모든 컨테이너가 같은 CodingKey 타입을 사용한다고 보장됨
구현체 숨김 (Encapsulation)
// 사용자는 JSONDecoder인지 PropertyListDecoder인지 몰라도 돼요
let container = decoder.container(keyedBy: MyCodingKeys.self)
// container의 타입: KeyedDecodingContainer<MyCodingKeys>
// 내부에 뭐가 들어있는지는 몰라도 됨!
일관된 API 제공
// 어떤 디코더를 쓰든 같은 방식으로 사용
let name = try container.decode(String.self, forKey: .name)
let age = try container.decode(Int.self, forKey: .age)
기니까 한번 더 정리할게요
// 1. JSONDecoder 내부에서
class _JSONDecoder: Decoder {
func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> {
// JSON 전용 구현체 생성
let jsonContainer = _JSONKeyedDecodingContainer<Key>(/* JSON 데이터 */)
// Type Erasure! 구체적인 타입을 숨기고 래퍼로 감싸서 리턴
return KeyedDecodingContainer(jsonContainer)
}
}
// 2. User.init(from:)에서 (컴파일러가 자동 생성)
init(from decoder: Decoder) throws {
// 사용자는 KeyedDecodingContainer<CodingKeys>만 받음
// 내부에 _JSONKeyedDecodingContainer가 들어있는지 모름
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
self.age = try container.decode(Int.self, forKey: .age)
}
2. UnkeyedDecodingContainer:
- 역할: 키 없이 순차적으로 값을 읽어오는 컨테이너
- 배열(Array) 디코딩에 사용
- 내부 인덱스를 하나씩 이동하면서 디코딩
- 끝에 도달하면 isAtEnd가 true
var container = try decoder.unkeyedContainer()
while !container.isAtEnd {
let value = try container.decode(Int.self)
print(value)
}
3. SingleValueDecodingContainer:
- 역할: 단일 값(예: Int, String, Bool)을 처리
- JSON 예시: "Hello" 또는 42 같은 경우
let container = try decoder.singleValueContainer()
let message = try container.decode(String.self)
왜 다른 컨테이너들은 프로토콜로 했을까? 🤷♂️
1. Associated Type이 없어요
protocol UnkeyedDecodingContainer {
// associatedtype이 없음!
var currentIndex: Int { get }
func decode<T>(_ type: T.Type) throws -> T where T : Decodable
}
2. 다형성이 필요 없어요
- UnkeyedContainer는 그냥 순서대로 읽기만 하면 됨
- SingleValueContainer는 그냥 하나의 값만 읽으면 됨
- CodingKey 같은 복잡한 제네릭이 필요하지 않아요
따라서 any UnkeyedDecodingContainer로 충분한 거죠!
그러면 이렇게 컨테이너를 사용하는 것 자체가 비용이 꽤 들지않을까 생각할 수 있습니다 하지만!!
컨테이너 생성은 실제로는 꽤 가벼운 연산이에요.
왜냐하면 컨테이너는 실제 데이터를 복사하지 않고, 원본 데이터에 대한 뷰(View) 역할만 하거든요.
여기서 뷰를:
원본 데이터를 복사하지 않고 접근하는 인터페이스
데이터베이스의 뷰와 비슷한 개념이에ㅛ
언제 커스텀 디코딩이 필요한가?
대부분의 경우는 JSONDecoder().decode(Model.self, from: data) 한 줄이면 끝이지만, 특정 상황에서는 직접 컨테이너를 사용해 값을 꺼내와야 합니다.
중첩 구조를 직접 풀어야 할 때
더 정확히는 서버의 JSON이 깊게 중첩되어 있고, 개별 DTO 타입 없이 한 번에 읽고 싶을 때
{
"title": "My Movie",
"director": {
"name": "Nolan",
"surname": "Christopher"
}
}
이럴때는
struct Movie: Decodable {
let title: String
let directorFullName: String
enum CodingKeys: String, CodingKey {
case title
case director
}
enum DirectorKeys: String, CodingKey {
case name
case surname
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = try container.decode(String.self, forKey: .title)
let directorContainer = try container.nestedContainer(keyedBy: DirectorKeys.self, forKey: .director)
let name = try directorContainer.decode(String.self, forKey: .name)
let surname = try directorContainer.decode(String.self, forKey: .surname)
directorFullName = "\(name) \(surname)"
}
}
이처럼 nestedContainer(keyedBy:forKey:)를 사용하면, 중첩된 값을 쉽게 꺼내고 구조를 단순화할 수 있어요 .
배열 안에 타입이 섞여있는 경우(다형성)
예를 들어 서버에서 이런 JSON을 내려준다고 해볼께요
[
{ "type": "circle", "radius": 5 },
{ "type": "rectangle", "width": 10, "height": 20 }
]
여기서 Shapable이라는 프로토콜을 정의하고, Circle, Rectangle이 각각 구현하도록 합니다.
protocol Shapable: Decodable {}
struct Circle: Shapable {
let radius: Double
}
struct Rectangle: Shapable {
let width: Double
let height: Double
}
그 다음, 배열을 담는 래퍼 타입에서 UnkeyedDecodingContainer + superDecoder()를 활용합니다.
struct ShapeList: Decodable {
let shapes: [Shapable]
enum TypeKey: String, CodingKey {
case type
}
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
var items: [Shapable] = []
while !container.isAtEnd {
let elementDecoder = try container.superDecoder()
let typeContainer = try elementDecoder.container(keyedBy: TypeKey.self)
let type = try typeContainer.decode(String.self, forKey: .type)
switch type {
case "circle":
items.append(try Circle(from: elementDecoder))
case "rectangle":
items.append(try Rectangle(from: elementDecoder))
default:
break // 알 수 없는 타입이면 그냥 무시
}
}
self.shapes = items
}
}
superDecoder란?
superDecoder()는 현재 컨테이너의 특정 위치에 있는 데이터를 온전히 디코딩할 수 있는 새로운 Decoder 인스턴스를 반환하는 메서드에요.
"같은 데이터를 다른 관점으로 바라볼 수 있게 해주는 창구"라고 보면 돼요
자 위의 코드를 한줄씩 볼께요
1단계: UnkeyedContainer로 배열 순회
var container = try decoder.unkeyedContainer()
// 이때 container는 배열 전체를 담고 있고, 내부 인덱스는 0
2단계: superDecoder() 호출
while !container.isAtEnd {
let elementDecoder = try container.superDecoder() // 🔥 여기가 핵심!
// ...
}
이 container.superDecoder() 호출이 뭘 하는지 보면:
- 현재 인덱스(0번째)의 데이터를 가리키는 새로운 Decoder 생성
- 컨테이너의 인덱스를 1로 증가 (다음 루프를 위해)
- 새 Decoder는 { "type": "circle", "radius": 5 } 이 하나의 객체만 볼 수 있음
3단계: 새로운 Decoder로 타입 확인
let elementDecoder = try container.superDecoder()
// elementDecoder는 이제 { "type": "circle", "radius": 5 } 만 담고 있음
let typeContainer = try elementDecoder.container(keyedBy: TypeKey.self)
let type = try typeContainer.decode(String.self, forKey: .type)
// type = "circle"
4단계: 같은 Decoder로 실제 객체 생성
switch type {
case "circle":
items.append(try Circle(from: elementDecoder)) // 🎯 같은 elementDecoder 재사용!
// ...
}
만약 superDecoder() 없이 한다면 어떻게 될까요?
// ❌ 이렇게는 안 돼요
while !container.isAtEnd {
let type = try container.decode(String.self, forKey: ???) // 키가 없어!
// UnkeyedContainer에는 키 개념이 없거든요
}
단일 값 래핑
서버에서 숫자나 문자열이 바로 오는 경우 굳이 DTO를 만들지 않고도 하나의 모델로 감쌀 수 있습니다
struct SingleValueWrapper: Decodable {
let value: Int
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
value = try container.decode(Int.self)
}
}
참고자료
https://developer.apple.com/documentation/foundation/archives-and-serialization
Archives and Serialization | Apple Developer Documentation
Convert objects and values to and from property list, JSON, and other flat binary representations.
developer.apple.com
https://developer.apple.com/documentation/foundation/encoding-and-decoding-custom-types
Encoding and Decoding Custom Types | Apple Developer Documentation
Make your data types encodable and decodable for compatibility with external representations such as JSON.
developer.apple.com
https://developer.apple.com/documentation/swift/keyeddecodingcontainer
KeyedDecodingContainer | Apple Developer Documentation
A concrete container that provides a view into a decoder’s storage, making the encoded properties of a decodable type accessible by keys.
developer.apple.com
KeyedDecodingContainer | Apple Developer Documentation
A concrete container that provides a view into a decoder’s storage, making the encoded properties of a decodable type accessible by keys.
developer.apple.com
'iOS' 카테고리의 다른 글
URLSession에 대한 에브리띵 (0) | 2025.09.27 |
---|---|
스크롤뷰는 어떻게 스크롤될까? 🤔 UIView 렌더링부터 시작하는 완벽 분석 (0) | 2025.09.26 |
객체 간 통신(delegate, Closure, NotificationCenter, KVO) (0) | 2025.09.04 |
PHPickerController의 UTI 활용법 (4) | 2025.08.29 |
포켓몬빵으로 이해하는 ObserverPattern & NotificationCenter (5) | 2025.08.27 |