Swift의 커스텀 타입을 직렬화하기

이 글은 https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types 의 내용을 일부 발췌, 의역한 것입니다.

프로그래밍 분야에서의 많은 작업은 데이터를 디스크에 저장하거나, 네트워크를 통해 전송하거나 API를 호출하기 위해 데이터를 제출하는 등의 일과 관련된다. 이 과정에서 데이터는 전송되는 동안에 적절한 형식으로 인코딩되고 다시 디코딩될 필요가 있다.

Swift의 표준 라이브러리는 이러한 목적으로 임의의 값을 인코딩/디코딩하는 방법을 정의한다. 실제로는 Encodable/Decodable이라는 두 개의 프로토콜을 구현하여 커스텀 타입의 직렬화를 지원할 수 있다. 이 방법들을 사용하여 커스텀 데이터들은 JSON이나 프로퍼티 타입등의 외부에서 인식할 수 있는 데이터로 변환되게 된다. 이러한 프로토콜을 적용하기 위해서 여러분은 자신의 타입이 Condable 프로토콜을 따른다고 명시하는것부터 시작할 수 있다. 이 프로토콜은 단순히 Encodable, Decodable 프로토콜을 합친 것이며, 이렇게 직렬화/역직렬화가 가능한 타입을 ‘codable’하다고 부른다.

Swift의 멋진 기능 중 하나로 Automatic Conformance라는 것이 있다. 이는 A라는 프로토콜이 있고 어떤 타입들이 해당 프로토콜을 준수할 때, 해당 타입들로만 구성된 (즉 모든 프로퍼티가 A를 준수하는) 새로운 타입B는 별다른 구현 코드 없이도 프로토콜 A를 적용할 수 있게 하는 것이다. 이것은 Swift 4.1의 새로운 기능으로 이미 표준 Swift 타입들이 이를 따르고 있다. 따라서 어떤 커스텀 타입이 원시 타입의 프로퍼티들로만 구성되었다면, Equatable이라는 프로토콜을 준수한다는 것을 정의에 써주는 것으로 == 연산의 별다른 구현 없이 적용이 가능하다.

아래 Landmark 라는 타입은 문자열과 정수 타입의 프로퍼티를 가지고 있고, 문자열과 정수는 각각 codable한 타입이므로 Landmark 타입을 codable하게 만들기 위해서는 단순히 Codable 프로토콜을 따른다고 정의 부분을 명시한다. 그러면 Automatic Conformance에 의해서 Landmark 타입은 Codable이 된다.

struct Landmark: Codable {
  var name: String
  var foundingYear: Int
}

Codable 프로토콜을 따르는 타입은 해당 타입이 임의의 빌트인 데이터 포맷으로 인코딩/디코딩 될 수 있음을 의미한다. 위의 Landmark 구조체는 PropertyListEncoderJSONEncoder를 통해서 인코딩이 가능한데, 그 내부에는 자체적으로 프로퍼티 리스트나 JSON을 다루는 코드를 전혀 포함하지 않는다. 이와 똑같은 규칙이 codable한 다른 임의의 타입에도 적용될 수 있다. 어떤 타입의 모든 프로퍼티가 Codable하다면, 그 타입은 자체로 Codable이 된다. 또 흥미로운 지점은 인코딩된 결과는 인코더에만 의존할 뿐, 데이터 타입 자체는 Codable이기만 할 뿐이라는 점이다. 즉 커스텀 바이너리 데이터로 인코딩할 필요가 있는 경우에는 Codable 인터페이스를 근간으로 인코더를 만들면 인코딩 가능한 모든 Swift 타입을 인코딩할 수 있게 된다.

Automatic Conformance에 의해서 만약 위의 Landmark 타입을 아래와 같이 확장하더라도, 여전히 Codable 프로토콜은 별도의 비용없이 적용이 가능한데, 예를 들면 아래와 같을 것이다.

struct Coordinate: Codable {
  var latitude: Double
  var longitude: Double
}

struct Landmark: Codable {
  // 아래 두 프로퍼티는 기본적으로 Codable인 빌트인 타입이다.
  var name: String
  var foundingYear: Int

  // 위에서 보았듯이 Coordinate 역시 Codable이다.
  var location: Coordinate
}

한쪽으로만 변환되는 타입

몇몇 경우에 양방향 인코딩/디코딩이 모두 지원될 필요가 없기에 Codable을 따를 필요가 없는 경우도 있을 수 있다. 예를 들어 앱에서 사용되던 특정한 타입의 값을 네트워크를 통해 서버로 전송하기만 하고, 그 반대로 네트워크로부터 데이터를 받아와서 해당 타입 객체를 재구성할 일이 없다면 인코딩만 가능한 타입을 생각할 수 있다.

명시적으로 한쪽으로만 변환되는 타입을 만들려고 한다면 Codable 대신에 Encodable 이나 Decodable 만 명시한다. 이 때 각 프로퍼티가 가져야 하는 요건들은 변함이 없다. 명시적으로 한쪽의 변환만 선언된 타입들은 그 반대쪽의 변환이 실질적으로는 가능하겠지만, 컴파일러에 의해 역방향 변환은 불가능한 것으로 취급된다.

코딩 키를 사용하여 인코드/디코드될 프로퍼티를 선택하기

Codable한 타입은 CodingKeys라는 특별한 이름의 열거 타입을 내장할 수 있는데, 이 열거형은 CodingKey 프로토콜을 따르게 된다. 만약 이 이름의 열거체가 존재한다면 이 각각의 케이스들은 인코딩/디코딩 시에 필요한 키 이름을 제공하게 된다.

이때의 각 케이스의 이름은 해당 타입의 프로퍼티 이름과 완전하게 동일해야 한다.

만약 CodingKeys에서 이름이 누락된 프로퍼티가 있다면, 해당 프로퍼티는 (직렬화된 데이터로부터 안전하게 생성되기 위해) 디폴트값을 가져야 한다. 물론 누락된 프로퍼티는 인코딩되는 정보에는 포함되지 않는다.

아래 예시는 Landmark 타입에서  name, foundingYear에 대해 다른 키 이름을 적용하여 인코딩되도록 CodingKeys를 정의하는 예이다.

struct Landmark: Codable {
  var name: String
  var foundingYear: Int
  var location: Coordinate
  var vantagePoints: [Coordinate]

  enum CodingKeys: String, CodingKey {
    case name = "title"
    case foundingYear = "founding_date"
    case location
    case vatagePoints
  }
}

수동으로 인코드/디코드

만약 커스텀 타입의 구조가 인코딩된 형태의 구조와 다르다면 별도의 인코드/디코드 로직을 적용한 Encodable, Decodable 구현을 제공할 수도 있다. 아래의 예에서 Coordinate 구조체는 elevation 프로퍼티를 지원하기 위해 확장되었는데, 이 값은 인코딩된 구조 내에서는 존재하지 않고 대신에 additionalInifo 컨테이너 내에 포함된다. 이 상황에서 Coordinate 타입의 코딩 키는 다음과 같이 정의될 수 있다.

struct Coordinate {
  var latitude: Double
  var longitude: Double
  var elevation: Double

  enum CodingKeys: String, CodingKey {
    case latitude
    case longitude
    case additionalInfo
  }

  enum AdditionalInfoKeys: String, CodingKey {
    case elevation
  }
}

인코딩된 형태가 additionalInfo라는 이름의 중첩된 구조를 가지고 있으며, 따라서 각각의 레벨에 대응하도록 두 개의 열거 타입을 정의하였다. 이에 따라 먼저 Decodable을 정의해보자. init(from:)의 구현을 살펴보자.

Decodableinit(from:)NSCodinginit(coder:)과 거의 비슷하다. 모두 각 프로퍼티의 이름과 값을 키-값 쌍의 조합으로보고 인코더로부터 그 값을 얻어내는 방식이다. 다만, Decoder 프로토콜은 다음의 차이를 갖는다.

  • NSCoding의 모든 키 이름은 문자열 값이다. 하지만 Decoder에서 키는 미리 정의된 코딩키만 사용이 가능하다. (즉 컴파일타임에 키 이름이 잘못 들어가는 사고를 체크할 수 있다.)
  • 디코더로부터 데이터를 바로 추출하지 못하고 코딩키에 의존하는 컨테이너를 생성해야 한다. 컨테이너는 연결된 디코더의 내부 메모리 뷰의 각 영역을 키를 통해서 액세스할 수 있게해주는 장치이다.
  • 중첩된 구조를 갖는 경우, 컨테이너는 내부 구조에 대한 컨테이너를 추가로 생성할 수 있다. (nestedContainer(keyedBy:))

컨테이너로부터 특정한 키의 값을 얻는 메소드는 decode(_:forKey:) 인데, 얻어야 할 값의 타입을 먼저 전달해야 하고, 키에는 CodingKey 프로토콜의 enum 타입값을 전달한다.  이와 같은 구조를 염두에 두면 init(from:)의 구현이 쉽게 이해될 것이다.

extension Coordinate: Decodable {
  init(from decoder: Decoder) throws {
    let values = try decoder.container(keyedBy: CodingKeys.self)
    latitude = try values.decode(Double.self, forKey: .latitude)
    longitude = try values.decode(Double.self, forKey: .longitude)
    let addtionalInfo = try values.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo)
    elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
  }
}

위 코드에서는 additionalInfo 라는 컨테이너를 중첩된 데이터 구조로부터 생성해내고 그 속의 값을 꺼내어 elevation 값으로 사용했다.  이제 인코드하는 부분을 살펴보자. 인코드도 똑같은 형태로 컨테이너를 먼저 생성하고, 키와 값을 전달한다.

extension Coodinate: Encodable {
  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(latitude, forKey: .latitude)
    try container.encode(longitude, forkey: .longitude)
   
    var additionalInfoContainer = container.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo)
    additionalInfoConatiner.encode(elevation, forKey:.elevation)
  }
}

JSON 인코더/디코더 사용하기

인코딩/디코딩이 가능한 타입을 만들었다면 이들을 쓸 때에는 어떻게 쓸 것인가? JSONEncoder / JSONDecoder를 사용해서 Swift 타입을 JSON 데이터로 상호간 변환할 수 있다. 여기서 JSON 데이터라 함은, JSON은 텍스트로 표현되지만 파일에 저장되거나 네트워크를 통해서 전송할 때에는 보통 UTF8로 인코딩하여 실질적으로는 바이너리 데이터로 보는 것이다.

인코더/디코더를 사용하는 방법은 간단하다. 인코더를 인자없이 생성하고 포맷방식 등 필요한 설정을 변경해준 다음, encode/decode 메소드를 호출하면 된다.

struct GroceryProduct: Codable {
  var name: String
  var points: Int
  var description: String?
}

let pear = GroceryProduct(name: "Pear", points: 250, description: "A ripe pear.")


// 인코더 생성/설정
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted

// 인코더를 통한 인코딩
let data =  try! encoder.encode(pear)
print(String(data: data, encoding: .utf8)!)

// 데이터 디코딩
let jsonData = """{
  "name" : "Apple",
  "points" : 130,
  "description" : "I have an apple..."
}""".data(using:.utf8)!

// 디코더를 통해서 객체로 변환!
let decoder = JSONDecoder()
if let product = try? decoder(GroceryProduct.self, from: jsonData)
{
  print(product.name)
}