Swift/개념 & 응용

[Swift] NSCoding과 Codable 차이점 (상속 관계 유지하기)

유정주 2023. 9. 24. 18:38
반응형

NSCoding과 Codable

Codable이 나오면서 NSCoding은 옛날의 그것으로 생각되었습니다.

그런데 NSCoding만이 할 수 있는 일이 있었습니다.

바로 상속 관계를 유지해서 저장하는 것입니다.

 

Shape 부모 클래스를 상속하는 두 개의 자식 클래스가 있습니다.

class Plane {
    var shapes: [Shape]
    
    init(shapes: [Shape]) {
        self.shapes = shapes
    }
    
    func display() {
        for shape in shapes {
            print("\(type(of: shape)): \(shape.description)")
        }
    }
}

let shapes: [Shape] = [
    Rect(point: .init(x: 10, y: 10), size: .init(width: 20, height: 20), text: "RECT1"),
    Circle(point: .init(x: 30, y: 30), size: .init(width: 40, height: 40), color: "CIRCLE1"),
    Rect(point: .init(x: 50, y: 50), size: .init(width: 60, height: 60), text: "RECT2"),
    Circle(point: .init(x: 70, y: 70), size: .init(width: 80, height: 80), color: "CIRCLE2")
]

Plane을 아카이빙 했을 때 Shape 배열의 상속 관계가 유지되는지 확인해 봅시다.

 

 

Codable은 상속 관계를 잃어서 Shape 타입으로 출력되지만,

NSCoding은 자식 클래스 타입으로 출력되는 걸 볼 수 있습니다.

 

결론은 빠르게 알아봤고, 이제부터는 하나씩 자세히 알아봅시다.

 

 

NSCoding

NSCoding은 객체(object)를 인코딩, 디코딩할 수 있게 해주는 프로토콜입니다.

앞에 NS가 붙은 것으로도 알 수 있듯이 Objective-C에서 사용되는 프로토콜이고,

In Objective-C, NSCoding defines a protocol for encoding and decoding objects. When adding serialization to your own types, you should also adopt NSSecureCoding. This protocol adds protection against security vulnerabilities introduced by instantiating arbitrary objects as part of the decoding process.

Archives and Serialization 문서에서도 NSCoding은 Objective-C와 함께 설명되고 있습니다.

 

In keeping with object-oriented design principles, an object being encoded or decoded is responsible for encoding and decoding its instance variables

결정적으로, 객체 지향 설계 원칙에 따라 인코딩, 디코딩해 준다고 하네요.

(사실 여기서 말하는 객체 지향 설계 원칙이 상속 관계를 유지해준다는 말은 아닐 수 있습니다.

Objective-C가 객체 지향 언어이기 때문에 위와 같은 표현을 한 걸 수도 있어요.

 혹시 정확한 의도를 아신다면 댓글로 가르쳐 주시면 감사하겠습니다.)

 

NSObject

NSCoding을 준수하기 위해서는 NSObject를 상속해야 합니다.

Swift 클래스를 Objective-C 클래스의 라이프 사이클로 동작시키는 역할입니다.

Swift의 클래스 init은 2-phase init으로 동작하는데 이게 NSCoding이 지원하는 init과 맞지 않다고 합니다.

그래서 NSObject를 상속해서 Swift 클래스를 Objective-C 클래스로 변경시키는 것입니다.

 

 

NSCoding 구현 코드

NSCoding으로 Plane을 아카이빙, 언아카이빙하는 코드를 살펴봅시다.

 

NSCoding을 준수하려면 encode(with:)와 init(coder:)를 구현해야 합니다.

class Plane: NSObject, NSCoding {
    
    var shapes: [Shape]
    
    init(shapes: [Shape]) {
        self.shapes = shapes
    }
    
    // NSCoding
    required init?(coder: NSCoder) {
        self.shapes = coder.decodeObject(forKey: "shapes") as! [Shape]
    }
    
    func encode(with coder: NSCoder) {
        coder.encode(shapes, forKey: "shapes")
    }
    
    func display() {
        for shape in shapes {
            print("\(type(of: shape)): \(shape.description)")
        }
    }
}

init에서는 key를 이용해 저장한 데이터를 불러오는 로직을,

encode에서는 key를 이용해 객체를 저장하는 로직을 작성하면 됩니다.

 

 

자식 클래스 프로퍼티

자식 클래스 프로퍼티를 살리고 싶다면 각 클래스에서 따로 아카이빙/언아카이빙을 해주면 됩니다.

final class Rect: Shape {
    private let text: String
    
    override var description: String {
        "Rect, Text: \(text)"
    }
    
    init(point: Point, size: Size, text: String) {
        self.text = text
        super.init(point: point, size: size)
    }
    
    required init?(coder: NSCoder) {
        self.text = coder.decodeObject(forKey: "text") as! String
        super.init(coder: coder)
    }
    
    override func encode(with coder: NSCoder) {
        coder.encode(text, forKey: "text")
        super.encode(with: coder)
    }
}

final class Circle: Shape {
    private let color: String
    
    override var description: String {
        "Circle, Color: \(color)"
    }
    
    init(point: Point, size: Size, color: String) {
        self.color = color
        super.init(point: point, size: size)
    }
    
    required init?(coder: NSCoder) {
        self.color = coder.decodeObject(forKey: "color") as! String
        super.init(coder: coder)
    }
    
    override func encode(with coder: NSCoder) {
        coder.encode(color, forKey: "color")
        super.encode(with: coder)
    }
}

Rect는 text를 가지고 있고, Circle은 color를 가지고 있어요.

각자의 init?(coder:)와 encode(with:)에서 각자의 프로퍼티를 인코딩, 디코딩하고 부모 클래스의 메서드를 호출합니다.

이러면 상속 관계를 유지하면서 각자의 프로퍼티도 가질 수 있어요.

 

 

키 작성 시 주의점

키를 작성할 때 주의할 점이 있습니다.

The keys given to encoded values must be unique only within the scope of the currently-encoding object. A keyed archive is hierarchical, so the keys used by object A to encode its instance variables don’t conflict with the keys used by object B. This is true even if A and B are instances of the same class.
Within a single object, however, the keys used by a subclass can conflict with keys used in its superclasses.

NSKeyedArchiver의 문서에 따르면  부모-자식 관계에서는 키 중복이 발생하면 안 된다고 합니다.

상속 관계를 유지해서 저장하기 때문에 이런 주의점이 필요하네요.

 

 

NSArchiver

참고로 키 아카이버를 사용하기 전에는 NSArchiver를 사용했습니다.

이 아카이버는 키 값을 사용하지 않고, 객체를 그대로 저장하는 방식이었습니다.

그래서 객체의 프로퍼티 순서와 저장하는 순서가 같아야 했다고 합니다.

정확히 순서를 맞추는게 번거로웠는지 deprecated 시키고 NSKeyedArchiver를 도입했네요.

NSArchiver에 대한 더 자세한 내용은 https://developer.apple.com/documentation/foundation/nsarchiver를 참고하면 됩니다.

 

 

아카이빙, 언아카이빙

이렇게 저장한 데이터는 NSKeyedArchiver로 아카이빙하고 NSKeyedUnarchiver로 언아키이빙할 수 있습니다.

func archive(with things: Plane) -> Data {
    do {
        let archived = try NSKeyedArchiver.archivedData(withRootObject: things, requiringSecureCoding: false)
        return archived
    } catch {
        print(error)
    }
    return Data()
}

func unarchive(with text: Data) -> Plane? {
    do {
        return try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(text) as? Plane
    } catch {
        print(error)
    }
    return nil
}

위처럼 메서드를 정의해 주고

let plane = Plane(shapes: shapes)

let archiveData = archive(with: plane)

if let plane = unarchive(with: archiveData) {
    plane.display()
}

// Rect: Rect, Text: Hello
// Circle: Circle, Color: Red
// Rect: Rect, Text: Jeong
// Circle: Circle, Color: Blue

이렇게 사용하면 됩니다.

display를 호출했을 때 Shape 배열 아이템의 상속 관계를 그대로 가지고 있는 것을 볼 수 있습니다.

 

unarchiveTopLevelObjectWithData

unarchiveTopLevelObjectWithData은 iOS 12.0에서 deprecated 된 메서드입니다.

이거 대신 unarchivedObject(ofClass:from:)을 써야 하는데 이걸 사용하려면 NSCoding 대신 NSSecureCoding을 사용합니다.

아닛 그런데 아무리 해도 런타임 에러가 발생하고 정보도 없는 거예요? 왜 다들 deprecated 된 메서드만 사용하는 거죠? ㅠㅠ

그래서 너무 아쉽지만 다음을 기약하고 저도 unarchiveTopLevelObjectWithData 코드를 작성했습니다... 죄송하네요.

 

 

Codable

다음은 Codable입니다.

Codable은 많은 분들이 이미 익숙하실 거 같아서 뒷부분에 배치했어요.

저도 블로그 초창기에 한 번 포스팅했었고요? 

 

Codable은 Swift에 어울리는 인코딩과 디코딩을 위한 프로토콜입니다.

Archives and Serialization 문서에서도 

In Swift, the standard library defines the Encodable, Decodable, and Codable types, along with Encoder and Decoder APIs to perform encoding and decoding

라고 언급하고 있습니다.

 

Codable이 하나의 프로토콜은 아니고 Decodable & Encodable 형태입니다.

따라서 디코딩만 필요할 때는 Decodable을, 인코딩만 필요할 때는 Encodable만 채택해도 무관합니다.

 

바로 구현 코드를 볼게요.

 

 

Codable 구현 코드

class Plane: Codable {
    
    var shapes: [Shape]
    
    init(shapes: [Shape]) {
        self.shapes = shapes
    }
    
    func display() {
        for shape in shapes {
            print("\(type(of: shape)): \(shape.description)")
        }
    }
}

정말 간단하죠?

Codable은 프로퍼티가 모두 Codable 하다면 별도의 구현 없이 준수가 됩니다.

(직접 구현하는 건 조금 더 아래에서 다룹니다.)

 

func encode(with things: Plane) -> String {
    let jsonEncoder = JSONEncoder()
    do {
        let json = try jsonEncoder.encode(things)
        return String(data: json, encoding: .utf8) ?? ""
    }
    catch {
        print(error)
    }
    return ""
}

func decode(with text: String) -> Plane {
    let jsonDecoder = JSONDecoder()
    do {
        let jsonObject = try jsonDecoder.decode(Plane.self, from: text.data(using: .utf8) ?? Data())
        return jsonObject
    }
    catch {
        print(error)
    }
    return .init(shapes: [])
}

JSONEncdoer로 인코딩하고, String을 Data로 디코딩할 수 있습니다.

 

let plane = Plane(shapes: shapes)

let encodeData = encode(with: plane)

let decodingData = decode(with: encodeData)
decodingData.display()

// Shape: Shape
// Shape: Shape
// Shape: Shape
// Shape: Shape

위 메서드들은 이렇게 쓰시면 돼요.

처음에 다뤘던 대로 NSCoding과 달리 상속 관계를 잃고 Shape로 출력되는 모습을 볼 수 있습니다.

 

 

Codable이 상속 관계를 저장하지 않는 이유

아쉽게도 정확한 이유에 대해 설명한 공식 문서는 없습니다.

https://github.com/apple/swift/issues/47905에 올라온 버그 리포트를 보면

This is by design — if you need the dynamism required to do this, we recommend that you adopt NSSecureCoding and use NSKeyedArchiver/NSKeyedUnarchiver, which will allow you to decode based on the class found in the archive rather than what is requested at runtime. See https://developer.apple.com/documentation/foundation/nscoding and https://developer.apple.com/documentation/foundation/nssecurecoding for more info.

라고 합니다.

즉, 일부러 이렇게 만들었다는 거죠.

 

그래서 정확한 이유는 아니지만 뇌피셜로 생각을 해봤는데 Swift는 POP를 중점으로 구현되기 때문이지 않을까 싶습니다.

NSCoding 설명의 "In keeping with object-oriented design principles"를 보면 객체 지향을 말하고 있는데

Swift는 Protocol Oriented Programming이기 때문에 굳이 상속 관계를 저장하지 않는 게 아닐까 싶어요.

애초에 상속보다는 프로토콜을 이용한 수평 확장을 권장하고 있으니까요..!

 

제 개인적인 의견이니 혹시 다른 의견, 보충 의견이 있다면 댓글로 부탁드립니다.

 

 

NSCoding과 Codable을 함께 쓰기

하지만 NSCoding만 쓰기에는 문제가 많습니다.

NSCoding을 쓰기 위해서는 NSObject를 상속해야 하고, 상속을 하기 위해서는 구조체를 포기해야 하기 때문입니다.

 

이를 해결하기 위해 NSCoding과 Codable을 함께 쓸 수 있습니다.

struct Point: Codable {
    let x: Double
    let y: Double
}

struct Size: Codable {
    let width: Int
    let height: Int
}

class Shape: NSObject, NSCoding {
    enum CodingKeys: String, CodingKey {
        case point, size
    }
    
    let point: Point
    let size: Size
    
    override var description: String {
        return "Shape"
    }
    
    init(point: Point, size: Size) {
        self.point = point
        self.size = size
    }
    
    // NSCoding
    required init?(coder: NSCoder) {
        do {
            let pointData = coder.decodeObject(forKey: "point") as! Data
            let sizeData = coder.decodeObject(forKey: "size") as! Data
            
            self.point = try JSONDecoder().decode(Point.self, from: pointData)
            self.size = try JSONDecoder().decode(Size.self, from: sizeData)
        } catch {
            return nil
        }
    }
    
    func encode(with coder: NSCoder) {
        do {
            let pointData = try JSONEncoder().encode(point)
            let sizeData = try JSONEncoder().encode(size)
        
            coder.encode(pointData, forKey: "point")
            coder.encode(sizeData, forKey: "size")
        } catch {
            print(error)
        }
    }
}

Shape 클래스는 상속 관계를 지켜야 하는데 내부 Point, Size 타입은 구조체입니다.

NSCoding만 사용한다면 Point, Size의 구조체 타입을 사용할 수 없죠.

 

그래서 Point와 Size는 Codable을 채택하고 Shape는 NSCoding을 채택했어요.

NSCoding을 위한 init?(coder:)와 encode(with:)에서 Point와 Size를 Data로 변환하고 이 Data를 아카이빙 합니다.

언아카이빙을 할 때도 Data를 꺼내서 JSONDecoder을 이용해 구조체 타입으로 변환하면 구조체 타입을 지키면서 NSCoding을 사용할 수 있습니다.

 

 

마무리

이번 포스팅에서는 NSCoding과 Codable에 대해 알아봤습니다.

지금까지 NSCoding은 Objective-C에서만 쓰이는 과거의 기술이라고만 생각했는데요.

필요한 상황이 있다는 걸 배운 경험이었습니다.

 

감사합니다.

 

 

참고

https://developer.apple.com/documentation/foundation/archives_and_serialization#2877987

https://developer.apple.com/documentation/foundation/nscoding

https://developer.apple.com/documentation/swift/codable

https://developer.apple.com/documentation/foundation/nscoder

https://developer.apple.com/documentation/foundation/nskeyedarchiver

https://developer.apple.com/documentation/foundation/nsarchiver

https://codesquad-yoda.medium.com/codable-vs-nscoding-차이점-4b47e240c0b8

https://github.com/apple/swift/issues/47905


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

반응형