데이터 보관은 어떻게이루어질까(직렬화: NSCoding부터 Codable까지)

2025. 9. 9. 20:04·iOS

앱을 종료했다가 다시 켰을 때도, 내가 설정해둔 값들이 그대로 남아있어야 하잖아요?

예를 들어 다크모드 설정이라든지, 게임 속 설정 같은 것들이요.

 

그런데 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. 클래스만 지원

사실 이부분이 가장 큰데 NSCoding은 NSObject를 상속받은 클래스만 지원했기에 구조체에서는 사용을 못했어요
 

이런 불편함을 해결하기 위해 등장한 게 바로 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)를 호출하면 이런 일이 벌어져요:🕵🏻‍♀️

  1. JSONDecoder가 내부적으로 _JSONDecoder 인스턴스를 생성
  2. JSON 데이터를 파싱해서 Foundation 타입(NSArray, NSDictionary, NSString)으로 변환
  3. User.init(from: decoder) 호출 (컴파일러가 자동 생성)
  4. 컴파일러 생성 코드가 decoder.container(keyedBy: CodingKeys.self) 호출
  5. 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() 호출이 뭘 하는지 보면:

  1. 현재 인덱스(0번째)의 데이터를 가리키는 새로운 Decoder 생성
  2. 컨테이너의 인덱스를 1로 증가 (다음 루프를 위해)
  3. 새 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
'iOS' 카테고리의 다른 글
  • URLSession에 대한 에브리띵
  • 스크롤뷰는 어떻게 스크롤될까? 🤔 UIView 렌더링부터 시작하는 완벽 분석
  • 객체 간 통신(delegate, Closure, NotificationCenter, KVO)
  • PHPickerController의 UTI 활용법
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (133) N
      • SWIFT개발일지 (28)
        • ARkit (1)
        • Vapor-Server with swift (3)
        • UIkit (2)
      • 알고리즘 (25)
      • Design (6)
      • iOS (42) N
        • 반응형프로그래밍 (12)
      • 디자인패턴 (6)
      • CS (3)
      • 도서관 (2)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
2료일
데이터 보관은 어떻게이루어질까(직렬화: NSCoding부터 Codable까지)
상단으로

티스토리툴바