[Swift4] JSON Codable - encode편

Danuel·2020년 5월 2일
6
post-thumbnail

본 포스트는 Swift4에서 JSON을 Swift스럽게 변환하는 방법을 소개한 후 다양한 경우를 알아 봅니다.
겪어 본 여러 경우를 최대한 모두 작성했습니다.

각 섹션에서 중복적으로 작성한 부분이 있으나, 이는 필요한 부분만 빠르게 찾아 읽을 수 있게 하기 위함입니다.
(입문자/초보자도 처음부터 끝까지 읽기 보다는 기본 섹션을 익힌 후 필요한 부분만 찾아 읽는 것을 권장합니다.)

본 포스트의 코드는 쉬운 이해를 위해 에러 핸들링을 과감히 생략했습니다.
이는 Best Practice가 아니므로 각자의 상황에 맞게 에러핸들링 하는 것을 권장합니다.

용어 정의

경우, 케이스

본 포스트에서는 switch/enum 문법에서만 케이스라고 표현하고 나머지는 경우라고 표현하고 있습니다.

Nullable

본 포스트에서는 null 값일 수 있는 경우를 nullable하다고 표현합니다.

Optional

본 포스트에서는 필드가 없을 수 있는 경우를 optional하다고 표현합니다.

Codable

JSON을 다룰 수 있는 방법을 Swift4부터 Codable이라는 Protocol로 공식적으로 지원합니다.

import Foundation

조금 더 정확히, Codable은 Foundation 프레임워크에서 제공하는 Protocol이므로 파일 최상단에서 Foundation을 꼭 import 해줘야 합니다.
(Xcode에서 파일을 생성하면 기본적으로 작성해주지만, 저는 VS Code에서 주로 작성하는 탓에 잠깐 헤맸습니다.)

typealias Codable = Encodable & Decodable

이 Codable은 Protocol 2개로 구성되어 있습니다.
Encodable 은 JSON 포맷의 String으로 변환하는 역할을,
Decodable 은 JSON 포맷의 String에서 Swift에서 다룰 수 있는 값으로 변환하는 역할을 합니다.

Encodable

기본

import Foundation

let data0 = try! JSONEncoder().encode(false)
let result0 = String(data: data0, encoding: .utf8)! // false

let data1 = try! JSONEncoder().encode(123)
let result1 = String(data: data1, encoding: .utf8)! // 123

let data2 = try! JSONEncoder().encode("Danuel")
let result2 = String(data: data2, encoding: .utf8)! // "Danuel"

JSONEncoder().encode 메서드는 Encodable을 상속한 타입의 값을 입력으로 받으며, Data 타입을 출력합니다.
이렇게 출력받은 값은 JSON 포맷의 문자열을 byte 형태로 가지고 있으며, 만약 String 값을 원한다면 추가적으로 직접 변환을 해주어야 합니다.

Encodable을 기본적으로 상속하고 있는 타입은 아래와 같습니다.

Struct, Class, Enum

import Foundation

struct Post: Codable {
  let id: String
  let title: String
}

let post = Post(id: "1", title: "first post")
let data0 = try! JSONEncoder().encode(post)
let result0 = String(data: data0, encoding: .utf8)! // {"id":"1","title":"first post"}

class Account: Codable {
  let id: String
  let displayName: String

  init (id: String, displayName: String) {
    self.id = id
    self.displayName = displayName
  }
}

let account = Account(id: "1", displayName: "Danuel")
let data1 = try! JSONEncoder().encode(account)
let result1 = String(data: data1, encoding: .utf8)! // {"id":"1","displayName":"Danuel"}

enum BoardType: String, Codable {
  case common
  case notice
}

let boardType: BoardType = .common
let data2 = try! JSONEncoder().encode(boardType)
let result2 = String(data: data2, encoding: .utf8)! // "common"

Encodable을 상속시키는 것만으로 간단히 JSON 포맷으로 변환할 준비가 끝났습니다.

다양한 경우

모든 것이 이전의 내용처럼 간결하면 마음이 편안하겠지만 현실은 그렇지 않은 경우가 더 많습니다.
이 섹션에서는 다양한 경우를 소개하고, 이에 대한 해결책은 알아 봅니다.
(입문자/초보자라면 끝까지 정독해 이해하고자 하기 보다는, "이런 경우도 있구나" 하는 정도로 이해하고 넘겨도 괜찮습니다.)

추가적으로, 이 섹션에서는 정의하는 방법만 소개하며 JSON 포맷으로 변환하는 코드는 작성하지 않았습니다. (JSONEncoder().encode(value) 는 대부분 동일하거든요.)

배열 값으로 오는 경우

{
  "id": "123",
  "tags": ["swift", "codable", "json"]
}

위 예시처럼 값이 배열로 오는 경우가 있을 수 있습니다.
이 경우는 아래처럼 Codable을 상속한 타입의 배열로 지정하면 알아서 변환합니다.

import Foundation

struct Post: Codable {
  let id: String
  let tags: [String]
}

null 값일 수 있는 경우

{
  "id": "123",
  "displayName": "Danuel",
  "email": null
}

때로는 위처럼 특정 키의 값이 null로 오는 경우가 있으며, 이를 nullable하다고 표현합니다.

이 경우는 간단히 아래처럼 작성하면 됩니다.

import Foundation

struct Account: Codable {
  let id: String
  let displayName: String
  let email: String?
}

이렇게 타입 끝에 물음표(?)를 추가하면 알아서 null을 nil로 변환합니다.

키가 없을 수 있는 경우

{
  "id": "123",
  "displayName": "Danuel"
}

현실에서는 어떠한 이유로 특정 키가 없을 수도 있는 경우가 있으며, 이를 optional하다고 표현합니다.

위 예시가 대표적이며, 계정 정보에 email을 등록하지 않은 경우 해당 필드를 포함하지 않는다고 가정하겠습니다.

import Foundation

struct Account: Codable {
  let id: String
  let displayName: String
  let email: String?
}

이렇게 타입 끝에 물음표(?)를 추가하면, email 필드가 없는 경우 알아서 null로 변환합니다.

특정 필드가 없거나 특정 필드의 값이 null이면 기본값을 넣고 싶은 경우

// 특정 필드가 없는 경우
{
  "id": "123",
  "displayName": "Danuel"
}

// 특정 필드의 값이 null인 경우
{
  "id": "123",
  "displayName": "Danuel",
  "email": null
}

변환작업을 하다 보면 특정 필드가 없거나 특정 필드의 값이 null인 경우 기본값을 할당하고 싶을 수 있습니다.
그런 경우에는 아래처럼 작성하면 알아서 기본값으로 변환합니다.

import Foundation

struct Account: Codable {
  let id: String
  let displayName: String
  let email: String = "unknown@unknown.unknown"
}

키가 서로 다른 경우

{
  "id": "123",
  "title": "first post",
  "like_count": 17
}
import Foundation

struct Post: Codable {
  let id: String
  let title: String
  let likeCount: Int
}

포스트의 좋아요 수를 JSON에서는 like_count 로 표시하는데, 코드에서는 likeCount 로 사용하고 있습니다.
이 경우 서로 키가 다르다는 것을 아래와 같이 명시하면 알아서 변환합니다.

import Foundation

struct Post: Codable {
  enum CodingKeys: String, CodingKey {
    case id
    case title
    case likeCount = "like_count"
  }

  let id: String
  let title: String
  let likeCount: Int
}

안심하세요! 갑자기 코드가 확 늘어났다고 겁 먹을 필요 없어요.

키가 서로 다를 뿐이라면 CodingKeys라는 enum을 추가적으로 정의하기만 하면 됩니다.
이 CodingKeys에는 해당 타입의 Property를 모두 명시해 주세요.
(다른 이름으로는 작동하지 않으니 꼭 CodingKeys로 이름을 지어야 합니다.)

어느 버전부터는 이런 코드를 작성하지 않아도 camelCase, snake_case, kebab-case도 알아서 변환한다고 하는데, 일단 저는 확인하지 못 했습니다.

단순 enum이 아닌 경우는 섹션을 따로 준비했습니다.

직접 정의한 타입으로 지정하고 싶은 경우

{
  "id": "123",
  "title": "first post",
  "author": {
    "id": "456",
    "displayName": "Danuel"
  }
}
import Foundation

struct Post: Codable {
  let id: String
  let title: String
  let author: Account
}

struct Account: Codable {
  let id: String
  let displayName: String
}

대표적으로 위 예시처럼, 직접 정의한 타입으로 지정해야 하는 경우가 있을 수 있습니다.
이 경우는 Codable을 상속하도록 정의한 후 타입을 지정해주면 알아서 변환합니다.

{
  "id": "123",
  "title": "first post",
  "kind": "notice"
}
import Foundation

struct Post: Codable {
  let id: String
  let title: String
  let kind: PostKind
}

enum PostKind: String, Codable {
  case notice
  case common
}

enum도 비슷한 원리로 작성할 수 있습니다.

이 때, enum에서 정의한 케이스 외의 값이 오는 경우는 Enum에서 정의하지 않은 케이스가 있는 경우 에서 알아보겠습니다.

Enum의 각 케이스와 JSON 필드의 값이 다른 경우

{
  "id": "123",
  "role": "a", // admin
  "displayName": "Danuel"
}
import Foundation

struct Account: Codable {
  let id: String
  let role: AccountRole
  let displayName: String
}

enum AccountRole: String, Codable {
  case admin
  case common
}

enum의 각 케이스와 JSON 필드의 값이 다른 경우가 있을 수 있습니다.
위 예시가 대표적인 경우이며, JSON의 role 필드에 있는 a는 AccountRole enum의 admin 케이스를 의미합니다.
이 경우는 아래처럼 작성할 수 있습니다.

import Foundation

struct Account: Codable {
  let id: String
  let role: AccountRole
  let displayName: String
}

enum AccountRole: String, Codable {
  case admin = "a"
  case common = "c"
}

Enum에서 정의하지 않은 케이스가 있는 경우

{
  "id": "123",
  "role": "unknown role",
  "displayName": "Danuel"
}
import Foundation

struct Account: Codable {
  let id: String
  let role: AccountRole
  let displayName: String
}

enum AccountRole: String, Codable {
  case admin
  case common
}

이 경우는 Swift4의 Codable에서 조금 특별합니다. 제가 아는 것이 맞다면, 현실적으로 종종 발생하지만 딱히 이렇다 할만하게 널리 쓰이는 방법이 없거든요.

여기서는 제가 사용하는 방법을 소개하겠습니다.
(읽는 분에 따라 이해하기 어려울 수 있으니 마음의 준비를 해주세요.)

import Foundation

protocol UnknownCaseRepresentable: RawRepresentable, CaseIterable where RawValue: Equatable {
  static var unknownCase: Self { get }
}

extension UnknownCaseRepresentable {
  init (rawValue: RawValue) {
    let value = Self.allCases.first(where: { $0.rawValue == rawValue })
    self = value ?? Self.unknownCase
  }
}

struct Account: Codable {
  let id: String
  let role: AccountRole
  let displayName: String
}

enum AccountRole: String, Codable, UnknownCaseRepresentable {
  static var unknownCase = .unknown

  case admin
  case common
  case unknown
}

UnknownCaseRepresentable이라는 Protocol을 정의한 후 원하는 enum에 상속시키는 구조입니다.
일치하는 값이 없는 경우 unknownCase 값으로 초기화하며, 이를 위해 unknown이라는 케이스를 추가합니다.
(보다 자세한 원리는 Codable의 동작을 이해해야 하므로 생략하겠습니다.)

이렇게 작성하면 해당 enum에서 정의하지 않는 케이스는 unknownCase에 할당한 케이스로 변환합니다.

단순 Enum이 아닌 경우

{
  "id": "123",
  "title": "first post",
  "attachments": [
    {
      "kind": "image",
      "url": "/images/123.jpg",
      "size": "large"
    },
    {
      "kind": "audio",
      "url": "/audios/123.wav"
    }
  ]
}
import Foundation

struct Post: Codable {
  let id: String
  let title: String
  let attachments: [PostAttachment]
}

enum PostAttachment: Codable {
  case image(url: String, size: String)
  case audio(url: String)
}

코드를 작성하다 보면 위와 같은 경우도 종종 있을 수 있습니다.
enum은 enum인데 각 케이스 마다 적절한 값을 들고 있는 경우이죠.

이 경우는 이전과 많이 다르니 마음의 준비를 해주세요.

import Foundation

struct Post: Codable {
  let id: String
  let title: String
  let attachments: [PostAttachment]
}

enum PostAttachment {
  case image(url: String, size: String)
  case audio(url: String)
  case unknown
}

extension PostAttachment: Codable {
  enum CodingKeys: String, CodingKey {
    case kind: String
    case url: String
    case size: String
  }

  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    let kind = try container.decode(String.self, forKey: .kind)
    switch kind {
      case "image":
        let url = try container.decode(String.self, forKey: .url)
        let size = try container.decode(String.self, forKey: .size)
        self = .image(url: url, size: size)
      case "audio":
        let url = try container.decode(String.self, forKey: .url)
        self = .audio(url: url, size: size)
      default:
        self = .unknown
    }
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    switch self {
      case let .image(url, size):
        try encoder.encode("image", forKey: .kind)
        try encoder.encode(url, forKey: .url)
        try encoder.encode(size, forKey: .size)
      case let .audio(url):
        try encoder.encode("audio", forKey: .kind)
        try encoder.encode(url, forKey: .url)
      case .unknown:
        try encoder.encode("unknown", forKey: .kind)
    }
  }
}

놀라지 마세요! 원리 자체는 간단해요!

찬찬히 살펴 보면 extension 문법을 통해 Codable을 상속하도록 하고 init과 decode 메서드를 구현하는 구조입니다.
init은 decode 로직이고, encode는 encode 로직입니다.

init을 살펴 보면 container를 생성한 후 kind 필드를 꺼내 적절한 타입으로 직접 변환을 합니다.
encode 메서드도 마찬가지로, container를 생성한 후 타입 마다 직접 JSON 포맷으로 변환을 하고 있습니다.

보면 알 수 있지만, 이 경우는 사람이 직접 변환 로직을 작성해줘야 하기 때문에 실수가 발생할 여지가 많으므로 각별한 주의가 필요합니다.
또한, 잘 이해한다면 보다 복잡한 경우도 대응할 수 있습니다.

profile
다뉴하는 코딩

1개의 댓글

comment-user-thumbnail
2020년 5월 2일

좋아요와 댓글 감사합니다!

답글 달기