본문 바로가기

스위프트

[Swift] NSCoding, NSSecureCoding 고촬

NSCoding

스위프트의 Codable처럼 데이터를 인코딩/디코딩하여 직렬화를 한다음 저장하거나 불러오는 방법을 사용하기 위해 구현해야하는 프로토콜이다. Codable이 스위프트가 생긴뒤에 만들어진 프로토콜이라면, NSCoding은 iOS 2.0버전부터 사용되던 아주 오래된 방식이라고 보면된다. NSCoding을 구현하기 위해서는 객체가 반드시 NSObject를 상속받아야한다. NSObject는 클래스만 상속받을 수 있으므로, NSCoding을 통한 데이터 직렬화는 구조체는 불가능하다고 보면된다.

 

그래서 NSCoding 프로토콜을 준수하면 아래와 같은 두 메서드를 구현해야하는데, 두 메서드를 통해 데이터를 인코딩/디코딩 시킨다. 그리고 NSCoding을 준수한 객체를 실제 인코딩/디코딩을 시키는 객체는 NSKeyedArchiver와 NSKeyedUnarchiver이다. 객체 이름을 그대로 해석하면 나오듯이 Key: Value기반의 저장장치 및 로드장치라고 보면된다. 실제로 두 객체를 통해 인스턴스를 인코딩/디코딩시키려면 전달되는 파라미터가 NSCoding을 준수해야한다고 한다. 그러므로 컴파일 단계에서 에러가 발생하기 때문에 NSCoding을 깜박하고 못넣어서 런타임시 에러가 발생할 일은 없어보인다.

 

돌아와서 NSCoding을 준수하면 구현해야하는 메서드는 아래와 같이 두가지다.

func encode(with coder: NSCoder)

파라미터로 전달되는 NSCoder는 데이터를 인코딩하기 위한 인터페이스 객체이다. 이 메서드안에서 NSCoding을 준수하는 객체가 인코딩(인스턴스가 데이터 직렬화 될 때) 될 때 저장할 로직을 태우면된다. 

init?(coder: NSCoder)

직렬화 된 데이터를 불러와서 디코딩하여 인스턴스를 생성할 때 호출되는 메서드이다. 해당 로직안에서 인터페이스 객체를 통해 인스턴스의 값을 셋팅해주면 된다.

 

임의로 예제를 만들어보자.

 

import Foundation

// 데이터 직렬화를 하여 로컬저장소에 저장하고 로드할 필요가 있는 자료구조
final class UserInfo: NSObject, NSCoding {
	// Key값을 편하게 셋팅하기 위한 enum, Codable에서 쓰는 형태이다.
    enum Keys: String, CodingKey {
        case text, age, address, gender
    }

    enum Gender: Int, CustomStringConvertible {
        case male, female

        // MARK: - CustomStringConvertible

        var description: String {
            switch self {
            case .male:
                return "Male"
            case .female:
                return "Female"
            }
        }
    }

    private let text: String
    private let age: Int
    private let address: String
    private let gender: Gender

    override var description: String {
        "text: \(text), age: \(age), address: \(address), gender: \(gender)"
    }

    // MARK: - Initializers

    init(text: String, age: Int, address: String, gender: Gender) {
        self.text = text
        self.age = age
        self.address = address
        self.gender = gender
    }

    // MARK: - NSCoding

    func encode(with coder: NSCoder) {
        coder.encode(text, forKey: Keys.text.rawValue)
        coder.encode(age, forKey: Keys.age.rawValue)
        coder.encode(address, forKey: Keys.address.rawValue)
        coder.encode(gender.rawValue, forKey: Keys.gender.rawValue)
    }

    convenience init?(coder: NSCoder) {
        let text = coder.decodeObject(forKey: Keys.text.rawValue) as? String ?? ""
        let age = coder.decodeObject(forKey: Keys.age.rawValue) as? Int ?? 0
        let address = coder.decodeObject(forKey: Keys.address.rawValue) as? String ?? ""
        let genderValue = coder.decodeObject(forKey: Keys.gender.rawValue) as? Int ?? 0
        let gender = Gender(rawValue: genderValue) ?? .male

        self.init(text: text, age: age, address: address, gender: gender)
    }
}


// 로컬 저장소에 저장할 인스터스를 생성한다.
let object = UserInfo(text: "시진핑", age: 50, address: "중국", gender: .male)

// 저장위치는 임시 저장소로 지정했다.
let url = FileManager.default.temporaryDirectory.appendingPathComponent("Test.plist")

// 인코딩 성공 여부
var isEncodingSucceed = false

do {
	// NSKeyedArchiver를 통해 Key:Value쌍으로 아카이버를 통해 객체를 저장한다.
    // 문제는, func archivedData(withRootObject:)는 iOS 12.0 부터 deprecated되었다.
    let encodedData = NSKeyedArchiver.archivedData(withRootObject: object)
    try encodedData.write(to: url)
    isEncodingSucceed = true
}
catch {
    print(error)
}

if isEncodingSucceed {
    do {
    	// 로컬저장소 경로의 데이터를 가져와서 NSKeyedUnarchiver를 통해 로드한다.
	    // 문제는, func unarchiveObject(with:)는 iOS 12.0 부터 deprecated되었다.
        let data = try Data(contentsOf: url)
        let userInfo = NSKeyedUnarchiver.unarchiveObject(with: data) as? UserInfo

        if let userInfo = userInfo {
            print(userInfo)
        }
    }
    catch {
        print(error)
    }
}

UserInfo 클래스는 데이터 직렬화를 통해 로컬 저장소에 저장되고, 불러올 필요가 있다. 그러므로 NSCoding을 준수해야하고, NSCoding을 준수하기 위해서는 NSObject를 상속받아야 하므로, 해당 클래스는 NSObject와 NSCoding을 구현하였다.

 

그리고 해당 클래스에 Keys라는 enum타입이 있는데, 사실 func encoded(with coder: NSCoder)메서드로 전달되는 NSCoder를 이용해 encode할 때 필요한 key값의 자료형이 String인데, 스위프트 Codable형태에서 사용하는 걸 가져와서 편하게 할 뿐, 반드시 필요하진 않다.

 

위에서 알게된 NSCoding의 두 가지 메서드에서 해당 클래스의 인스턴스를 각각 NSCoder를 통해 인코딩/디코딩하는 로직을 작성했다.

 

이후에는 인스턴스를 인코딩해서 데이터로 만들고, 데이터를 로컬저장소에 저장한 다음 다시 로컬저장소로 부터 데이터를 불러와 디코딩하여 인스턴스를 생성한다.

 

하지만 위같이 하면 인스턴스를 인코딩/디코딩하는데 성공하여도 컴파일시 Warning이 뜬다.

func archivedData(withRootObject rootObject: Any) -> Data
func unarchiveObject(with data: Data) -> Any?

위 두개는 iOS 12.0부터 deprecated되었다. Warning이 뜨면서 대신 사용할 메서드를 보여준다.

func archivedData(withRootObject object: Any, requiringSecureCoding requiresSecureCoding: Bool) throws -> Data
static func unarchivedObject<DecodedObjectType>(ofClass cls: DecodedObjectType.Type, from data: Data) throws -> DecodedObjectType? where DecodedObjectType : NSObject, DecodedObjectType : NSCoding

근본적인 메서드의 기능은 다르지 않다. NSCoding을 준수한 객체 인스턴스를 인코딩하고 디코딩하는 것. 하지만, 데이터를 인코딩하는 메서드에 새로운 파라미터가 생겼다. requiresSecureCoding인데 Bool값으로 해당 값은 첫 번째 파라미터로 전달되는 object가 NSSecureCoding을 준수하도록 요구하느냐 마느냐에 대한 속성이다. 자 그럼 이젠 NSSecureCoding이 무엇인지 알아볼 때 이다.

NSSecureCoding

객체를 대체할 수 있는 공격에 대한 대응책을 설정하는 프로토콜이다. 객체들 대체하는 공격이 무엇인지 모른다. 좀 더 설명을 읽어보자.

 

전통적으로 많은 클래스들은 자신 클래스의 인스턴스를 디코딩하는 데 다음과 같이 한다.

if let object = decoder.decodeObjectForKey("myKey") as MyClass {
    // ...succeeds...
} else {
    // ...fail...
}

이 기술은 클래스 유형을 확인할 수 있을 때까지 개체가 이미 구성되어 있고 이것이 컬렉션 클래스의 일부인 경우 잠재적으로 개체 그래프에 삽입되기 때문에 잠재적으로 안전하지 않다. NSSecureCoding을 준수하기 위해서는 init(coder:)를 오버라이딩하지 않는 객체는 어떠한 변경없이도 NSSecureCoding을 준수할 수 있다. init(coder:)를 오버라이딩하는 객체는 반드시 decodeObjectOfClass:forKey:를 통해 디코딩해야한다. 그리고 NSSecureCoding을 준수하면 아래 getter를 구현해야하는데, NSSecureCoding을 사용한다면 반드시 True를 반환해줘야한다.

 

supportsSecureCoding

secure coding을 지원하는 클래스인지 아닌지 여부를 판단하는 속성값

 

솔직히 말하자면 내부적으로 정확히 어떻게 돌아가는 지는 친절하게 설명되어있지 않으나, deprecated가 된 메서드를 대체해서 돌리면 컴파일시는 아니지만, 런타임시 아래와 같은 에러를 만날 수 있다.

Error Domain=NSCocoaErrorDomain Code=4864 
"This decoder will only decode classes that adopt NSSecureCoding. 
Class '__lldb_expr_28.UserInfo' does not adopt it." 
UserInfo =
{
	NSDebugDescription = This decoder will only decode classes that adopt NSSecureCoding.
    Class '__lldb_expr_28.UserInfo' does not adopt it.
}

 

NSCoder는 오직 NSSecureCoding을 준수한 클래스에 대해서만 인코딩/디코딩을 할 수 있다. 그러므로 바뀐 메서드 + 클래스에 NSSecureCoding을 적용하면 아래처럼 수정된다.

import Foundation

// 위 예제에서 NSSecureCoding을 추가했다.
// 이유는 Deprecated된 메서드의 대체 메서드를 사용하면 Decoder가 NSSecureCoding을 요구하기 때문이다.
final class UserInfo: NSObject, NSCoding, NSSecureCoding {
	// NSSecureCoding을 준수하면 구현해야할 getter 변수, True로 반환한다.
    static var supportsSecureCoding: Bool = true

    enum Keys: String, CodingKey {
        case text, age, address, gender
    }

    enum Gender: Int, CustomStringConvertible {
        case male, female

        // MARK: - CustomStringConvertible

        var description: String {
            switch self {
            case .male:
                return "Male"
            case .female:
                return "Female"
            }
        }
    }

    private let text: String
    private let age: Int
    private let address: String
    private let gender: Gender

    override var description: String {
        "text: \(text), age: \(age), address: \(address), gender: \(gender)"
    }

    // MARK: - Initializers

    init(text: String, age: Int, address: String, gender: Gender) {
        self.text = text
        self.age = age
        self.address = address
        self.gender = gender
    }

    // MARK: - NSCoding

	// 디코딩할 때 파라미터가 NSCoding을 준수해야하므로, 인코딩할 때 또한 NSCoding을 준수하는 객체인 NSString, NSNumber를 인코딩한다.
    func encode(with coder: NSCoder) {
        coder.encode(text as NSString, forKey: Keys.text.rawValue)
        coder.encode(age as NSNumber, forKey: Keys.age.rawValue)
        coder.encode(address as NSString, forKey: Keys.address.rawValue)
        coder.encode(gender.rawValue as NSNumber, forKey: Keys.gender.rawValue)
    }

	// 디코딩 할 때, 위 인코딩 과정에서 NSCoding을 준수하는 클래스로 인코딩하였으므로, 불러올 때 또한 NSString, NSNumber로 불러온다.
    convenience init?(coder: NSCoder) {
        let text = coder.decodeObject(of: NSString.self, forKey: Keys.text.rawValue) as String?
        let age = coder.decodeObject(of: NSNumber.self, forKey: Keys.age.rawValue)
        let address = coder.decodeObject(of: NSString.self, forKey: Keys.address.rawValue) as String?
        let genderValue = coder.decodeObject(of: NSNumber.self, forKey: Keys.gender.rawValue)
        var gender: Gender

        if let value = genderValue?.intValue, let unwrappedGender = Gender(rawValue: value) {
            gender = unwrappedGender
        }
        else {
            gender = .male
        }

        self.init(
            text: text ?? "값이 없음",
            age: age?.intValue ?? 0,
            address: address ?? "값이 없음",
            gender: gender
        )
    }
}

let object = UserInfo(text: "시진핑", age: 50, address: "중국", gender: .male)
let url = FileManager.default.temporaryDirectory.appendingPathComponent("Test.plist")
var isEncodingSucceed = false

do {
	// Deprecated된 메서드를 대체한다. requiringSecureCoding이 false라면 NSKeyedUnarchiver메서드중, NSSecureCoding이 아닌 객체도 로드할 수 있는 메서드를 써야한다.
    let encodedData = try NSKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: false)
    try encodedData.write(to: url)
    isEncodingSucceed = true
}
catch {
    print(error)
}

if isEncodingSucceed {
    do {
        let data = try Data(contentsOf: url)
        // Deprecated된 메서드를 대체한다. 해당 메서드는 NSCoding 및 NSSecureCoding을 준수해야만 로드할 수 있다.
        let userInfo = try NSKeyedUnarchiver.unarchivedObject(ofClass: UserInfo.self, from: data)

        if let userInfo = userInfo {
            print(userInfo)
        }
    }
    catch {
        print(error)
    }
}

 

정리

NSCoding은 데이터를 직렬화해서 인코딩/디코딩할 수 있는 방법을 사용하기위해 준수해야만 하는 프로토콜이다.

NSCoding은 NSObject를 상속받은 클래스에만 사용할 수 있다.

NSKeyedArchiver, NSKeyedUnarchiver는 NSCoding을 준수한 인스턴스를 인코딩, 디코딩하는 인터페이스 객체이다.

iOS 12.0부턴 기존에 deprecated된 메서드가 있으며, 대체되는 메서드는 NSCoding을 준수하는 객체가 NSSecureCoding까지 준수하게 끔 요청한다.

바뀐 메서드의 애플 설명 자체가 가능하면 NSSecureCoding을 준수하라고 했으니 준수하는 편이 좋겠다.

NSSecureCoding을 준수한 객체에 대해서만 사용가능한 메서드를 이용할 땐 클래스도 NSSecureCoding을 준수해야하고 아카이빙하는 시점에 옵션인 requiresSecureCoding또한 True로 셋팅해줘야 한다.