• knowledge Base
  • SubClass Encode Issue
  • encode ?
  • container ?
  • Tip
  • 03.07 수정

Knowledge Base

이전 포스트와 동일하게 보편적으로 우리는 json 형식으로 데이터를 감싸는 방식을 자주 활용한다. 다시 한번 개념을 돌아보자면, 외부의 표현을 내부의 표현으로 변경하는 것이 decode, 내부의 표현을 외부의 표현으로 변경 하는 것이 encode 이다. 이때 외부의 표현은 여러가지로 해석될 수 있으며 해당 포스트에서는 JSON 형식을 갖춘 데이터이다.

사실 encode는 decode와 반대의 개념이기 때문에, 이전 포스트를 통해 decode는 어떤 방식으로 활용 했는지 알고 오는 것이 좋을 것이라고 생각한다. 또한 Codable(Encodable & Decodable)이 아닌 Encodable을 활용할 예정이고, 해당 프로토콜에 대한 설명과 기본 사용법은 이 포스트를 참고하도록 하자.

SubClass Encode Issue

이전 Decode 포스트의 상황과 굉장히 유사하다. 유저의 데이터를 계속해서 보내야 한다고 생각해보자. 어떤 부분은 유저의 데이터 그리고 핸드폰 정보를 보내야 하는 반면, 어느 부분에서는 유저의 데이터와 함께 주소 정보를 보내야 한다. 동일한 유저의 데이터를 활용하기 위해서 상속을 통해서 해결하려고 한다.

class UserData: Encodable {
    var name: String?
    var age: Int?
}

class UserAndPhone: UserData {
    var phone: String?
    var number: String?
}

class UserAndAddress: UserData {
    var address: String?
    var postCode: String?
}

그리고 이제 UserAndPhone의 정보를 모두 채우고 encode를 해보자. 우리가 원하는 방향은 name, age, phone, number의 정보를 모두 외부의 표현, 즉 JSON 형식으로 변경하는 것이다. 그런데 예측과는 다른 결과가 나온다.

let userAndPhone = UserAndPhone()
userAndPhone.name = "onemoon"
userAndPhone.age = 25
userAndPhone.phone = "iPhone X"
userAndPhone.number = "01012341234"

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(userAndPhone)
if let text = String(data: data, encoding: .utf8) {
    print(text)
}

//{
//  "name" : "onemoon",
//  "age" : 25
//}

분명히 phone 과 number 에 대한 데이터를 넣어 주었음에도 불구하고 데이터에는 생략 되었다. 왜 이런 일이 발생하는 걸까?

이유는 상속에 있다. 객체를 데이터로 바꾸는 Encode과정을 생각해보면 JSONEncoder에서 각 객체의 encode함수를 호출해서 data를 만드는 것이다. UserAndPhone 의 encode(_:) 의 호출은 사실상 UserData의 encode만 호출되기 때문에 name과 age 만 데이터로 변환이 되는 것이다. 즉 이를 해결하기 위해서는 UserAndPhone(SubClass)에서 encode(_:)를 override 해주면 된다.

class UserAndPhone: UserData {
    var phone: String?
    var number: String?

    enum CodingKeys: String, CodingKey {
        case phone
        case number
    }

    override func encode(to encoder: Encoder) throws {
        try super.encode(to: encoder)
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(phone, forKey: .phone)
        try container.encode(number, forKey: .number)
    }
}

//{
//  "age" : 25,
//  "phone" : "iPhone X",
//  "number" : "01012341234",
//  "name" : "onemoon"
//}

생각해보면 굉장히 간단 하지만 에러가 나지 않기 때문에 꼼꼼하게 보지 않는다면 놓치기 쉬운 부분이다. 꼭 유념해서 encode를 진행 하기를 바란다.

Encode ?

encode(_ value: T)에 대해서 조금 더 자세하게 알아보자. 어떤 프로퍼티를 옵셔널로 선언하고 encode를 한다면 해당 키에 대한 정보는 보내지지 않을 것이다. 문제는 실제로 데이터를 다루다 보면 항상 예외 상황이라는 것이 존재한다. 특정 프로퍼티는 없으면 보내지 말고, 또 다른 프로퍼티는 없어도 기본 값으로 보낸다던지 말이다. 사실 개발을 하다가 이런 예외 상황을 마주치면 처리하기가 힘들거나 깔끔하게 코드를 작성하기 어렵다.

이때 encode를 직접 정의 해준다면 어느정도 해결 될지도 모른다. 게시물을 업데이트 하는 API를 호출하려고 한다.

struct UpdatePostBody: Encodable {
    var user: UserData?
    var postId: String?
    var title: String?
    var content: String?
}

이때 서버 팀에서 user와 postId 는 항상 존재 해야한다는 요청이 들어왔다. 만약 실수로 user 혹은 postId 에 nil이 들어가는 경우에는 키 값조차 전달되지 않기 때문에 디버깅에 문제가 생길 수 있다. 이 경우 다음과 같은 방식으로 데이터를 보낼 수 있을 것이다.

// Version 1
struct UpdatePostBody: Encodable {
    var user: UserData?
    var postId: String?
    var title: String?
    var content: String?

    enum CodingKeys: String, CodingKey {
        case user, postId, title, content
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(user ?? UserData(), forKey: .user)
        try container.encode(postId ?? "", forKey: .postId)
        try container.encodeIfPresent(title, forKey: .title)
        try container.encodeIfPresent(content, forKey: .content)
    }
}
// Version 2
struct UpdatePostBody: Encodable {
    var user: UserData = UserData()
    var postId: String = ""
    var title: String?
    var content: String?
}

Version 1 과 같이 encode를 작성한다면 user 와 postId 에 nil 이 들어가더라도 UserData() , 그리고 ""로 들어가게 될 것이다. container.encode의 경우에는 값이 무조건 존재해야 하며 해당 값을 통해서 매칭되는 키 값과 함께 데이터가 만들어진다. container.encodeIfPresent 의 경우 만약 값이 nil이라면 키 와 값 모두 보내지 않는다. 이렇게 데이터가 만들어진다. 물론 Version 2 처럼 작성을 해도 동일하게 encode는 동작을 할 것이다. 여기에서는 encode 가 저런 방식으로 작성이 된다는 것을 알아 두자. 다양한 활용 법은 아래에서 알아 볼 것이다.

Container ?

함수의 구현을 살펴보면 container라는 struct를 확인해볼 수 있을 것이다. decode와 동일하게 container 는 다음과 같이 세가지 container로 분류된다.

1. KeyedEncodingContainer

2. UnKeyedEncodingContainer

3. SingleValueContainer

KeyedEncodingContainer의 경우 위에서 사용한 것과 같이 CodingKey를 채택한 타입이 필요하다. CodingKey에 들어온 키 값에 따라서 값들을 encode하여 data로 바꿀 수 있다.

UnkeyedEncodingContainer 는 키가 없이 데이터 배열로 값을 변환하여 데이터로 만든다. 아래의 예시를 보는 것이 이해가 빠를 것이다. 타입도 다를 수 있으며 Key: Value 형태가 아니라는 것을 주목하자.

class UserData: Encodable {
    var name: String? = "onemoon"
    var age: Int? = 25

    func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()
        try container.encode(name)
        try container.encode(age)
    }
}

//[
//  "onemoon",
//  25
//]

SingleValueContainer 는 말 그대로 객체를 하나의 값인 데이터로 변경 해주는 것이다. 말 그대로 singleValue 이기 때문에 encode는 단 한번만 사용될 수 있으며, 여러번 encode를 하면 에러가 발생한다. 따라서 모든 값을 사용할 필요도 없으며 단 하나의 데이터 값만 표현하면 된다.

class UserData: Encodable {
    var name: String? = "onemoon"
    var age: Int? = 25

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(name)
    }
}

//"onemoon"

Tip

  • Enum 최대한 활용하기
  • 데이터 조합하기 ( Data = struct + struct )

Enum을 활용하는 것과 데이터의 조합은 decode편에서도 다룬 내용이다. 단 decode에서는 init을 활용 했다면 encode에서는 함수를 활용 한다는 점이다.

Enum 최대한 활용하기

사실 데이터를 보내기 전에 UI 에서 부터 Enum으로 데이터를 자주 관리하는 편이다. 이 경우에도 동일하게 enum의 rawValue 대신에 따로 데이터에 들어갈 값을 설정해 줄 수 있다. 또한 만약 enum에 관련된 값을 따로 추출해서 보내야 하는 경우 어떻게 처리할까? 다음 예시를 생각해보자

enum Phone: String, Encodable {
    case galaxy
    case iPhone
    case pixel

    var textForEncode: String {
        switch self {
        case .iPhone:
            return "IPHONE"
        case .galaxy:
            return "GALAXY"
        case .pixel:
            return "PIXEL"
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.textForEncode)
    }

    var os: String {
        switch self {
        case .iPhone:
            return "iOS"
        case .galaxy, .pixel:
            return "AOS"
        }
    }
}

rawValue 와 다르게 server에는 모두 대문자로 된 값을 전송해야 한다고 가정하며, phone의 기종과 더불어 OS 를 보내야 한다고 가정하자. 각각의 프로퍼티를 만들어서 직접 둘다 할당해줄 수도 있지만 아래와 같이 쉽게 처리할 수 있다.

struct UserData: Encodable {
    var name: String?
    var age: Int?
    var phone: Phone?

    enum CodingKeys: String, CodingKey {
        case name
        case age
        case phone
        case phoneOS
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(name, forKey: .name)
        try container.encodeIfPresent(age, forKey: .age)
        try container.encodeIfPresent(phone, forKey: .phone)
        try container.encodeIfPresent(phone?.os, forKey: .phoneOS)
    }
}
//{
//  "age" : 25,
//  "phone" : "IPHONE",
//  "name" : "onemoon",
//  "phoneOS" : "iOS"
//}

여기서 두가지 부분을 한번 주목 해보자.

첫번째 enum 에서 encode를 처리했기 때문에 encodeIfPresent(phone, forKey: .phone) 부분에는 rawValue가 아닌 대문자로 변환된 값이 들어간다.

두번째 phoneOS 라는 프로퍼티를 따로 만들어 줄 필요 없이 CodingKey만 따로 관리한다면 데이터를 보내는 키 값을 쉽게 처리할 수 있다는 점이다. CodingKeys에 phoneOS라는 케이스를 만들고 이를 encode 에서 phone?.os로 처리할 수 있다. 이렇게 처리한다면 UI와 데이터의 변환이 편하게 될 것이다. ( 단, encode를 직접 구현하지 않는다면 codingKeys와 프로퍼티가 대응이 안되기 때문에 에러가 발생할 것이다. )

사실 Enum 뿐만 아니라 struct 나 class 등에 encode를 직접 구현 해준다면 코드가 상당부분 줄어듦과 동시에 관리 하기에도 훨씬 더 편해질 것이라고 장담한다.

예전에 나는 네트워크에서 받은 Entity를 통해서 업데이트를 위한 Body를 만들었는데, 이때 Body를 통해서 UI에 표현될 데이터와 실제로 보내질 데이터의 프로퍼티를 따로 관리했던 기억이 난다. (예를 들면 전자는 name 이 될 것이고, 후자는 Id값이 될 것이다.) 이 때문에 작성을 해야하는 프로퍼티는 계속해서 늘어나게 되고 결국에는 관리가 힘들었던 기억이 난다. 지금 이 지식을 알고 나서 작업 했다면 적어도 30%의 시간이 줄어들 것이라고 생각한다.

데이터 조합하기 ( Data = struct + struct )

서비스가 커지면 기존의 데이터 구조를 활용하는 것이 훨씬 더 간편한 경우가 많을 것이다. 이런 경우를 위해서 여러 개의 데이터를 조합해서 body를 직접 만들어 보자. 서버에서 원하는 구조와 현재 사용 되는 데이터가 다음과 같다고 생각해보자.

// -----------------
// 서버에서 받아야 하는 body값
{
    "name": String
    "age": Int
    "kakaoProfileURL": String
    "kakaoId": String
    "howManyKakaoFriends": Int
}
// -----------------

struct UserData: Encodable {
    var name: String?
    var age: Int?
}

struct KakaoUserData: Encodable {
    var profileURL: String
    var id: String
    var friends: [KakaoUserData]
}

서버에서 원하는 body 값을 보내기 위해서는 어떻게 해야할까? user 와 kakaoUser 프로퍼티로 받아달라고 서버에 요청할 수도 있겠지만, 직접 한번 해결해보자 위와 같은 경우 UserData와 KakaoUserData를 조합한다면 쉽게 처리가 가능할 것이다. 다섯개의 프로퍼티를 갖고 있는 새로운 struct를 만들 필요가 없다. 아래를 살펴보자

struct UserAndKakaoUser: Encodable {
    var originUserData: UserData
    var kakaoUserData: KakaoUserData

    enum CodingKeys: String, CodingKey {
        case name, age, kakaoProfileURL, kakaoId, howManyKakaoFriends
    }

    func encode(to encoder: Encoder) throws {
        try self.originUserData.encode(to: encoder)
        try self.kakaoUserData.encode(to: encoder)
    }
}

이렇게 두개의 객체 조합을 통해서 하나의 바디를 간단하게 만들 수 있다. Key 값을 직접 처리하여 마치 하나의 객체에 다섯 개의 프로퍼티가 있는 것 처럼 표현할 수 있으며, kakaoUserData.friends.count와 같이 필요한 경우 데이터를 변경해서 처리할 수도 있다.

03.07 수정

  • enum Keys -> CodingKeys로 변경하였다.

실행 여부는 확인했으니 상관없으나 혼돈을 방지하고자 CodingKeys 로 이름을 변경하였다. Swift 에서는 키를 해석하는 default CodingKey로 CodingKeys를 사용하기 때문에 굳이 다른 이름을 붙일 필요가 없다고 판단했다.

 

  • Data 조합 파트에서 encode 로직 변경
    // 기존에 사용된 코드
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encodeIfPresent(originUserData.name, forKey: .name)
        try container.encodeIfPresent(originUserData.age, forKey: .age)
        try container.encodeIfPresent(kakaoUserData.profileURL, forKey: .kakaoProfileURL)
        try container.encodeIfPresent(kakaoUserData.id, forKey: .kakaoId)
        try container.encodeIfPresent(kakaoUserData.friends.count, forKey: .howManyKakaoFriends)
    }

 

기존에는 직접 키값을 뽑아서 값을 넣어주는 방식으로 진행했었다.(물론 이 방식도 제대로 동작한다.) 하지만, 직접 사용을 하면서 생각해보니 각 프로퍼티에서 encode를 실행시키면 훨씬 쉽고 보기에도 편하다고 생각했다. 이를 변경하였으니 참고하도록 하자

'iOS & Swift' 카테고리의 다른 글

Responder Chain ( 앱은 유저의 인터렉션을 어떻게 처리하는가 ? )  (0) 2021.03.18
Reducing Dynamic Dispatch ( 성능향상 )  (0) 2021.01.24
Decoding 정복하기  (2) 2020.11.14
Codable  (0) 2020.10.25
Text Size 구하기  (0) 2020.04.26

Decoding 정복하기

  • Knowledge Base
  • SubClass Decode 이슈
  • Key가 없는 경우에 대한 이슈
  • container ?
  • Tip
  • 03.07 수정

들어가며

네트워크를 통해서 데이터를 주고 받을 때 우리가 편한 객체로 변환하거나 변환 되어서 데이터를 관리 하고는 한다. 해당 포스트는 데이터를 처리하는 방식에 대해서 말해 보고자 한다. 기본적인 개념도 있지만 그 보다는 실제로 데이터를 주고 받으면서 발생했던 이슈 들을 조금 더 집중해서 다루고 공유하기 위해서 해당 포스트를 작성하였다.

Knowledge Base

API를 통해 데이터를 주고 받기 위해서는 json 형식이 보편적으로 사용된다. json으로 데이터를 쉽게 처리하기 위해서 우리는 Codable 혹은 Decodable, Encodable 프로토콜을 사용한다. ( 개인적으로 목적에 따라서 Decodable, Encodable 하나만 채택해서 사용하는 편이다. ) 해당 프로토콜에 대한 설명과 기본 사용법은 포스트를 참고하도록 하자.

Subclass Decode Issue

회사의 도메인 특성상 필드 그리고 엔티티가 굉장히 많고 세부적으로 나뉘어져 있다. 단순히 Struct로 모든 엔티티를 관리하기에는 많은 어려움이 있어 생각한 것이 Class inheritance 를 이용하는 것이었다.

하지만 처음에 상속받은 클래스를 Decode 하는 과정에서 이슈가 하나 있었는데 이 경험을 공유하고자 한다. 아래의 데이터를 보자

 

let jsonData = """
{
    "hello": "world",
    "wow": "wonderful",
    "onemoon": null
}
""".data(using: .utf8)!

let jsonData2 = """
{
    "hello": "world",
    "wow": "wonderful",
    "changed": "field"
}
""".data(using: .utf8)!

 

위와 같이 데이터가 들어온 경우 <hello, wow> 필드를 공통으로 가지는 클래스를 만들고 , 필드를 각각 가지는 Subclass 를 아래와 같이 만들었다.

 

class A: Decodable {
    var hello: String
    var wow: String
}
class Aa: A {
    var onemoon: String?
}
class Ab: A {
    var chagned: String?
}

let jsonDecoder = JSONDecoder()
do {
    let subClassData = try jsonDecoder.decode(Aa.self, from: jsonData)
    let subClassData2 = try jsonDecoder.decode(Ab.self, from: jsonData2)
    print("SUCCESS \\n")
    dump(subClassData)
    dump(subClassData2)
} catch let err {
    print("FAIL")
    print(err.localizedDescription)
}

// 결과 값 
// SUCCESS
//
//▿ __lldb_expr_7.Aa #0
//  ▿ super: __lldb_expr_7.A
//    - hello: "world"
//    - wow: "wonderful"
//  - onemoon: nil
//
//▿ __lldb_expr_7.Ab #0
//  ▿ super: __lldb_expr_7.A
//    - hello: "world"
//    - wow: "wonderful"
//  - chagned: nil

 

결과적으로 SUCCESS 와 두 객체가 로그에 남지만, 실제로는 제대로 decode가 되지 않았다. 문제는 catch 구문을 거치지 않기 때문에 놓치기 쉬울 수 있다. 실제로 배포가 된 이후, 나중에서야 파악이 될 수도 있다는 것이다. 그렇다면 아래의 객체와 데이터를 살펴보자. <hello, wow> 필드는 제대로 값을 받아 왔지만 < changed > 필드는 값을 받아오지 못한다. 왜 제대로 decode 가 되지 않았을까 ?

 

알고 보면 굉장히 단순하다. Ab 클래스가 Decode 될 때 required init(from decoder: Decoder) 가 제대로 호출되지 않았기 때문이다. 상속의 특성을 생각해보자 subclass 에서 별도로 initializer 를 설정하지 않는 이상, subclass의 initializer 대신 super class 의 initializer가 호출이 된다. 따라서 Ab 클래스 대신 A의 init만 호출 되었기 때문에 A의 필드인 <hello, wow>만 제대로 값을 가져온 것이다. 이를 해결하기 위해서는 Ab 클래스에서 init을 다시 작성하면 된다.

 

class Ab: A {
    var chagned: String?
    enum Keys: String, CodingKey {
        case changed
    }
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        self.chagned = try container.decode(String?.self, forKey: .changed)
        try super.init(from: decoder)
    }
}

//SUCCESS
//▿ __lldb_expr_15.Ab #0
//  ▿ super: __lldb_expr_15.A
//    - hello: "world"
//    - wow: "wonderful"
//  ▿ chagned: Optional("field")
//    - some: "field"

 

Ab Class 에서 decoder를 통한 init이 호출이 되고 < chagned > 필드를 decode 한 다음, super.init을 통해서 부모클래스인 A 에서도 init이 호출 됨을 파악할 수 있다. 이로써 온전하게 상속받은 클래스를 decode 할 수 있다.

Key가 없는 경우에 대한 Issue

실제로 데이터를 주고 받는 과정에서 키 값이 일정하지 않고 유동적으로 변경되는 상황이 종종 일어나고는 한다. 이 상황에서의 문제점은 Decoding 된 객체를 아예 만들지 못한다는 점인데, 자칫하면 UI를 그리지 못하기 때문에 사용성이 굉장히 떨어질 수 있다. 아래 예시를 살펴보자.

 

// origin Data -> Good
{ 
"name": "onemoon",
"thumbnailURL": "<https://ooo.com>"
}

// changed Data -> Bad ( Swift.DecodingError.keyNotFound )
{ 
"name": "onemoon"
}

struct UserData: Decodable {
    let name: String
    let thumbnailURL: String
}

 

위와 같이 thumbnailURL 을 받아 오다가 어떠한 이유로 인해서 더 이상 해당 키에 대한 값이 사라지는 경우 DecodingError.KeyNotFound 에러가 발생하고 UserData는 생성되지 않는다. 이에 대한 해결책을 생각해보자.

 

첫번째, 키 값이 없는 프로퍼티의 타입이 옵셔널인 경우에는 ( thumnailURL: String? 인 경우 ) 해당 에러가 발생하지 않는다.

 

두번째 ,만약 옵셔널을 처리하기 번거롭거나 기본 값이 필요한 경우에는 다음과 같이 Decode에 실패한 경우 할당할 수 있다.

 

struct UserData: Decodable {
    let name: String
    let thumbnailURL: String

    enum Keys: String, CodingKey {
        case name, thumnailURL
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        self.name = try container.decode(String.self, forKey: .name)
        self.thumbnailURL = (try? container.decode(String.self, forKey: .thumnailURL)) ?? "default Thumbnail URL"
    }
}

 

세번째, container.decodeIfPresent를 활용하는 것이다. 값 자체에 옵셔널을 처리하는 방식과 동일한데 이런 방식도 있다는 것을 참고하도록 하자.

 

struct UserData: Decodable {
    var name: String
    var thumbnailURL: String?

    enum Keys: String, CodingKey {
        case name, thumnailURL
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        self.name = try container.decode(String.self, forKey: .name)
        self.thumbnailURL = try container.decodeIfPresent(String.self, forKey: .thumnailURL)
                // 키가 있는 경우 decode를 진행하고 없는 경우 nil을 할당한다.
    }
}

 

참고로 init(decoder:)을 직접 구현하는 경우 모든 프로퍼티를 정확하게 써야 하는데, 놓칠 수 있으니 주의를 하도록 하자. 현재는 프로퍼티가 let 으로 선언이 되어서 init에 하나의 프로퍼티라도 빠지는 순간 에러가 발생하지만 var로 선언한 경우 경우는 실제로 데이터가 있어도 값을 가져오지 못하는 경우가 생길 수 있기 때문이다. 아래를 간단하게 참고하도록 하자

 

// Data
{
"name": "onemoon"
}

struct UserData: Decodable {
    var name: String?
    var thumbnailURL: String

    enum Keys: String, CodingKey {
        case name, thumnailURL
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
                // name 에 대한 decode를 실수로 작성하지 못한 경우
        self.thumbnailURL = (try? container.decode(String.self, forKey: .thumnailURL)) ?? "default Thumbnail URL"
    }
}

// Log ::: UserData(name: nil, thumbnailURL: "default Thumbnail URL")
// 값을 제대로 가져오지 못하는 것을 확인할 수 있다.

Container ?

포스트를 작성하다가 container 에 대해서 조금 더 알아봤을 때 container(keyedBy:) 말고도 singleValueContainer(), unkeyedContainer()의 존재를 알게 되었다. 간단하게 짚고 넘어 가보자

SingleValueContainer

Key : Value 가 아닌 단순히 값 하나만 들어온 경우 이를 decode할 수 있다.

 

// jsonData2 > "hello singleValueContainer"

struct SimpleData: Decodable {
    let text: String

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        self.text = try container.decode(String.self)
    }
}

print(try! JSONDecoder().decode(SimpleData.self, from: jsonData2))
// Log ::: SimpleData(text: "hello singleValueContainer")

unkeyedContainer

배열에서 타입이 일정하지 않은 경우를 생각해보면 해당 container로 쉽게 해결할 수 있다.

 

// jsonData3 > ["a", 1, true, 20.0, 50.0]

struct SpecialArrayData: Decodable {
    var a: String?
    var b: Int?
    var c: Bool?
    var d: Double?
//    var e: Double?

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        a = try container.decode(String?.self)
        b = try container.decode(Int?.self)
        c = try container.decode(Bool?.self)
        d = try container.decode(Double?.self)
//        e = try container.decode(Double?.self)
    }
}

print(try! JSONDecoder().decode(SpecialArrayData.self, from: jsonData3))

// Log ::: SpecialArrayData(a: Optional("a"), b: Optional(1), c: Optional(true), d: Optional(20.0))

 

참고로 해당 container는 데이터와 객체 프로퍼티의 순서를 맞춰야 한다는 것에 유의하도록 하자. 특히 unkeyedcontainer가 재밌다고 느껴졌는데, 복잡한 데이터에서 원하는 값만 추출할 때 유용하게 사용할 수 있을 것 같다. 아마 내부적으로 3가지의 container를 JSON data에 맞춰 적절히 사용하여 Decodable이 구현되었을 것이라고 생각한다.

Tip

  • Enum 최대한 활용하기
  • 데이터 조합하기 ( Data = struct + struct )

포스팅을 작성하면서 여러 가지를 느꼈다. 이전에 내가 겪었던 이슈들도 생각해보고 이렇게 저렇게 활용하면 좋겠다는 생각이 들었다. 내가 느끼고 활용할 수 있는 방식들을 직접 테스트 해보고 공유하고자 한다.

Enum 최대한 활용하기

먼저 Enum을 생각해보자 나는 Decoding을 할때 만약 데이터가 몇 가지의 케이스로 나뉘어서 들어온다면, String으로 그냥 값을 decoding 하는 것 보다는 Enum 을 최대한 활용하는 편이다. 보통 다음과 같이 사용한다.

 

enum Phone1: String, Decodable {
    case iPhone
    case galaxy
    case pixel = "Pixel"
}

 

이렇게 되면 Enum의 RawValue 가 Key처럼 되어 "iPhone"이라는 텍스트가 들어온다면 Phone1.iPhone으로 활용할 수 있는 것이다. 하지만 다음과 같은 상황에서는 Enum을 통해 Decoding 하기가 힘들어진다.

  1. RawValue를 이미 다른 값 ( Int, Double ) 로 사용하는 경우, 이를 다시 String으로 변경하거나 처리하는 과정이 복잡하다. ( 혹은 귀찮다. )
  2. associated Value 를 사용하는 경우 decode 단계에서 RawValue를 사용할 수 없다. 따라서 decodable을 채택하는 순간 에러가 발생한다.
  3. 데이터 자체가 결함(?)이 있는 경우 ( 앞 글자가 대문자, 변환이 필요한 경우 등등... )

해당 포스트를 작성하기 전에는 나도 위와 같은 경우 대부분 String으로 처리를 하거나 다른 방식을 찾았던 것 같다. 그렇다면 이 과정을 어떻게 하면 쉽게 처리할 수 있을까? 방법은 init(from:)를 직접 활용하면 된다. 아래 예시를 보자

 

// User1Phone > "blackberry"
// User2Phone > "IPHONE"

enum Phone: Decodable {
    case iPhone
    case galaxy
    case pixel
    case uncommon(whatKind: String)

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        var value = try container.decode(String.self)
        value = value.lowercased() // 받아온 데이터를 직접 처리할 수 있다.

        if value == "iphone" {
            self = .iPhone
        } else if value == "galaxy" {
            self = .galaxy
        } else if value == "pixel" {
            self = .pixel
        } else {
            self = .uncommon(whatKind: value)
        }
    }
}

print(try! JSONDecoder().decode(Phone.self, from: User1Phone)) // uncommon(whatKind: "blackberry")
print(try! JSONDecoder().decode(Phone.self, from: User2Phone)) // iPhone

 

이렇게 직접 decoder를 작성하는 순간 위에서 언급한 세가지 이슈가 모두 해결 된다. 1. RawValue는 신경 쓸 필요가 없어진다. 2. 따라서 RawValue를 신경 안쓰고 decoder에서 직접 값을 만들어 주면 된다 ( uncommon 확인 ). 3. 데이터가 중간에 대문자가 포함되어 있거나 값을 처리해야 하는 경우도 직접 처리하면 된다.

데이터 조합하기 ( Data = struct + struct )

서비스를 하다 보면 분명히 기존에 사용한 데이터가 잘 정리 되어 있을 것이다. 이는 서버도 마찬가지이다. 만약 User와 Device라는 데이터를 분리해서 ( 혹은 다른 키 값으로 ) 가져오다가, 어느날 두 가지를 합쳐서 준다면 어떻게 처리할까? 아래 예시를 한번 보자

 

// 기존에는 User 혹은 Device만 따로 데이터가 오는 경우 활용한 struct 들
struct UserEntity: Decodable {
    var name: String
    var age: Int

        enum CodingKeys: String, CodingKey {
        case name, age
    }
}

struct DeviceEntity: Decodable {
    var name: String
    var capacity: String

    enum CodingKeys: String, CodingKey {
        case name = "phoneName"
        case capacity = "phoneCapacity"
    }
}
// 새로운 데이터
var newUserAndPhoneData = """
{
"name": "onemoon",
"age": 25,
"phoneName": "iphone 12",
"phoneCapacity": "256G"
}

""".data(using: .utf8)!

 

데이터를 보자 마자 드는 생각은 두 개를 합치면 되겠다는 생각이 들 것이다. 아래의 형식처럼 되면 얼마나 좋겠는가?

 

struct UserAndDevice: Decodable {
	var user: UserEntity
	var device: DeviceEntity
}

 

물론 현실은 생각만큼 호락호락 하지 않다. newUserAndPhoneData를 보면 UserEntity와 DeviceEntity가 할당되는 키가 없기 때문에 이렇게 작성하면 decodeError 가 발생한다. UserAndDevice는 Key값이 user 와 device인 경우에만 동작하기 때문이다.

그렇다면 이를 어떻게 바꾸면 좋을까? decoder 를 직접 작성한다면 놀랍게도 위의 객체를 바로 활용할 수 있다. 아래와 같이 바꿔 보자

 

struct UserAndDevice: Decodable {
    var user: UserEntity
    var device: DeviceEntity

    init(from decoder: Decoder) throws {
	self.user = try UserEntity.init(from: decoder)
	self.device = try DeviceEntity.init(from: decoder)
    }
}

 

간단하게 생각해서 받아온 데이터로 UserEntity 그리고 DeviceEntity 를 생성 하면 되는 것이다. UserEntity가 만들어 질때 name과 age를 통해서 객체를 만들고 나머지 phoneName, phoneCapacity 는 무시하게 된다. 또한 DeviceEntity를 만들때는 그 반대가 될것이다. 이렇게 하면 간단하게 기존에 사용하던 객체를 활용하여 조합으로 데이터를 표현할 수 있을 것이다.

 

내가 든 예시는 극히 일부분이고 데이터가 점점 더 복잡해지고 처리 하면서 init(from decoder:)를 직접 활용 해보면 생각보다 많은 수고가 들지 않을 것이라고 생각한다.

 

03.07 수정 내용

 

수정 전 내용은 UserEntity, DeviveEntity에 enum Keys: String, CodingKey {} 형식으로 진행했고 UserAndDevice에 decode를 진행할 때 Key 를 직접 집어넣어서 decode 하는 형식으로 글을 작성했다. 물론 테스트를 직접 하고 진행했기 때문에 동작에는 문제가 없다. 하지만 조금 더 편한 방식이 있지 않을까 생각하던 도중 위와 같이 decoder 자체를 넘기면 조금 더 편하겠다는 생각이 들었다.

 

문제는 CodingKey의 이름이었는데 기본적으로 CodingKeys라는 이름을 가져야 decoder 에서 자동으로 키를 변환하고 값을 할당한다. 따라서 이를 변경하였고 decoder를 넘기는 방식으로 수정하였다. 

 

 

'iOS & Swift' 카테고리의 다른 글

Responder Chain ( 앱은 유저의 인터렉션을 어떻게 처리하는가 ? )  (0) 2021.03.18
Reducing Dynamic Dispatch ( 성능향상 )  (0) 2021.01.24
Encoding 정복하기  (0) 2020.11.23
Codable  (0) 2020.10.25
Text Size 구하기  (0) 2020.04.26

+ Recent posts