class나 struct로 작성한 모델에 대한 객체 인스턴스를 Serialization 하거나 Archive 하기위해서 Codable 프로토콜을 채택하는 경우가 많습니다. 오브젝티브-C 시절부터 사용해 왔던 NSCoding 프로토콜은 구시대 유물이고, Codable로 완전히 대체된 것으로 이해하기도 합니다.
애플은 왜 동일한 것 같은데, 새로운 Codable 프로토콜을 만든 것일까요? 이 글은 Codable로 표현하지 못하는 제약사항에 대해 이야기 하고자 합니다. 객체 그래프를 따라서 데이터 구조로 만드는 작업을 해야 한다면 어떤 기준에서 선택해야 할까도 함께 알아보겠습니다.
struct 에서 Codable 채택하는 경우는 문제가 되는 부분이 없으니까 제외하고, class에서 Codable을 채택하는 경우를 예로 들어보겠습니다.
Codable 방식
간단하게 음료수를 표현하는 Beverage 클래스를 최상위 클래스로 상속해서 Coffee와 Soda 클래스를 만들고, 다시 Soda를 상속받아 Coke 클래스가 있다고 생각해보겠습니다.
이런 계층 구조를 갖는다면 음료수를 상속받은 모든 클래스는 리스코프 치환 법칙을 적용할 수 있으니까 [Beverage] 배열 형태로 한꺼번에 다룰 수 있습니다. 그리고CustomStringConvertible 프로토콜도 채택해서, description을 오버라이드하면 각각 음료에 대한 문자열 설명을 다형성으로 동작하도록 구현할 수 있습니다.
이런 배열을 Codable 프로토콜 방식으로 JSON형식으로 인코딩했다가, 다시 디코딩하는 예제 코드를 살펴보면 다음과 같습니다.
실행결과 : Beverage(Coca), Beverage(TOP), Beverage(Pepsi)
배열에 들어간 Coke, Coffee, Coke 인스턴스는 클래스에 description 부분을 오버라이드했기 때문에 각자 클래스 정보를 포함하는 형식으로 출력되어야 합니다. 배열 내부에 선언한 타입은 Beverage 상위 클래스지만, 하위 클래스 정보를 포함하고 있으니까요.
그런데 이렇게 JSON으로 인코딩했다가 디코딩하고 만들어진 jsonObject는 [Beverage] 배열이고, 하위 클래스 정보가 사라진 형태로 마치 Beverage 클래스로 인스턴스를 생성한 것처럼 동작합니다. Codable이 처음 나왔을 때 실제로 이런 문제를 “Swift 4 Decodable Loses Subclass Type Information” 제목으로 버그 리포팅했던 적도 있었습니다.
https://bugs.swift.org/browse/SR-5331
그렇지만 애플은 이건 Codable 프로토콜과 인코딩, 디코딩 방식에 대한 의도된 설계라는 답변을 합니다. 그리고 이렇게 다이나믹하게 다형성으로 동작하는 방식이 필요한 경우에는 NSCoding 또는 NSSecureCoding 프로토콜을 사용하라고 답변합니다.
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.
NSCoding + NSKeyedArchiver 방식
그러면 앞에서 살펴본 음료수 클래스를 NSCoding을 채택하도록 개선해보겠습니다. 여기서는 앞의 Codable만 채택한 경우와 구분하기 위해서 NBeverage로 이름에 Prefix를 붙였습니다. (사실 한 프로젝트에서 한꺼번에 확인하려고 붙였지만…)
단, 주의할 점이 있습니다. NSCoding을 채택하려면 음료수 Beverage 클래스도 반드시 NSObject를 상속해야 한다는 점입니다. 인코딩/디코딩할 때 내부에서 사용하는 NSObject 계열 메소드가 더 있어서 그렇습니다.
그리고 NSObject 는 Description을 포함하고 있어서 CustomStringConvertible을 채택할 수 없습니다. Beverage 클래스 경우 NSObject를 채택하면 description 부분을 override를 붙여야 합니다.
Codable과 동시에 채택하는 것은 가능합니다. 구현해야 하는 디코딩 init() 생성자와 encode() 인코딩 메소드가 충돌하지는 않습니다. 구현 방식이 조금 다르죠.
선언 부분이 달라졌으니 사용하는 방법도 다릅니다. NSCoding 프로토콜을 채택하면, NSKeyedArchiver 로 인코딩하고 NSKeyedUnarchiver 로 디코딩해야 합니다. 아카이브한 결과는 바이너리 Data 형식이 됩니다. 아쉽게도 아카이브를 하면 JSON 형식은 안됩니다. JSON 형식으로 서버에 전송하거나 저장하려면 이 방식을 못 쓰게 됩니다. 바이너리 PList 파일이나 UserDefault를 이용해서 저장하면 가능합니다.
자, 이렇게 구현하면 위에서 Codable로 실행한 것과 다르게 다형성이 올바르게 동작하는 것을 확인할 수 있습니다.
실행결과: Coke(true)-(Soda(50.0)-(Beverage(Coca))), Coffee(256.0)-(Beverage(TOP)), Coke(false)-(Soda(51.0)-(Beverage(Pepsi)))
Codable 방식으로 위 문제를 피해가는 방법은 디코딩할 때 세부 타입 정보를 찾아서 인스턴스를 직접 생성해주는 방법도 있습니다. 이런 방법은 코드가 복잡할 뿐만 아니라 타입이 추가될 때마다 같이 수정해야 하는 번거로운 방식이 됩니다.
결론
- Codable 프로토콜은 NSCoding 프로토콜에 대한 대체제가 아니다.
- 상속 관계를 갖고 계층화된 클래스가 다형성으로 동작할 때는 NSCoding 프로토콜을 사용해야 한다.
- Codable 프로토콜은 struct 나 class도 채택이 가능하다.
- NSCoding은 NSObject에서 상속받은 클래스만 채택할 수 있다.
- Codable과 NSCoding을 동시에 채택할 수는 있다.
- 어떤 타입이고, 어떤 관계를 갖는지 뿐만 아니라 다형성을 지원해야 하는지도 프로토콜을 채택할 때 기준이 되어야 한다.