[Codable] Response의 key 값을 미리 알 수 없을 때

정유진·2022년 10월 5일
0

swift

목록 보기
12/24
post-thumbnail

들어가며 👏

최근 MVP 아키텍처를 연습하는 토이 프로젝트를 진행하며 아래와 같이 Codable을 따르는 struct를 만들었다.

struct Articles: Codable {
    let item: [Article]
    
    struct Article: Codable {
    let title: String
    let originallink: String
    let description: String
    let pubDate: Date
}
}

Codable을 따르기만 하면 객체 type이 기본형이 아니더라도 item:[Article] 을 문제없이 decode할 수 있다. 그리고 Naver 검색 API를 사용하기 때문에 어떤 json이 넘어올지 이미 알고 있어서 property의 이름을 미리 response key 값과 동일하게 맞춰두었다. 그래서 CodingKey 프로토콜을 따르는 enum을 따로 선언하지 않았다.

지금 이 상태만으로도 (뭔가 허전해보이지만) Response를 decode하여 내 마음대로 휘뚜루마뚜루 사용하는 데에 문제가 전혀 없다. 하지만 문득 그런 생각이 든 것이다. '내가 만약에 Response key값을 미리 모른다면 그 값을 받아서 확인해볼 수가 없겠다. 하지만 그 값을 알고 싶으면 어떡해?'

문제 상황 🤔

//response로 넘어온 json string의 상태
let json = """
{ 
a: "aaa",
b: "bbb",
c: "ccc"
}
""".data(using: .utf8)!

//1번 Codable struct
struct Test1: Codable {
let a: String
let b: String
}

// 2번 Codable struct
struct Test2: Codable {
let a: String
let b: String
let c: String
let d: String?
}

let decoder = JSONDecoder()
let result1 = try decoder.decode(Test1.self, from:json)
let result2 = try decoder.decode(Test2.self, from:json)

/*
Test1(a:"aaa",b:"bbb")
Test2(a:"aaa",b:"bbb",c:"ccc",d:nil)
*/
  • 1번의 경우 a,b 는 decode가 되고 c는 json에 내용이 존재하지만 Test1의 property가 아니므로 무시된다.
  • 2번의 경우 d는 실제 json에 존재하지만 type을 optional로 해두면 초기화 값으로 nil이 들어갈 뿐 오류가 생기지 않는다.
1) 그런데 내가 c도 놓치지 싶지 않다면 어떻게 해야하는거지? 🙃 갖고싶다 c..
2) nil이 아니라 default값을 넣어줄 수는 없을까? nil 꼴 보기 싫어..🤬

해결하기 1) dynamic key

공식 문서에 소개된 기본 코드를 약간 변형하여 예시를 만들었다. 먼저 전체 코드를 공유한다.

  • decodable을 따른다는 것을 결국 public init(from decoder:Decoder) throws 함수가 핵심이다.
  • 나는 내가 이미 알고있는 키와 모르는 키의 컨테이너를 분리하여 decode하는 방법을 썼다.
import Foundation

let json = """
[
    {
        "name": "Banana",
        "point": 200,
        "description": "A banana grown in Ecuador.",
        "key1": "111",
        "key2": "222"
    },
    {
        "name": "Orange",
        "point": 100
    }
]
""".data(using: .utf8)!

struct Product: Decodable {
    var name: String
    var point: Int
    var description: String?
    var etc: [String:Any] = [:]
    
    enum CodingKeys: String, CaseIterable, CodingKey {
        case name
        case point
        case description
    }
    
    struct CustomCodingKeys: CodingKey {
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }
        
        var intValue: Int? { return nil }
        init?(intValue: Int) { return nil }
    }
    
    public init(from decoder: Decoder) throws {
        let defaultContainer = try decoder.container(keyedBy: CodingKeys.self)
        let extraContainer = try decoder.container(keyedBy: CustomCodingKeys.self)
        
        // 내가 아는 키
        self.name = try defaultContainer.decode(String.self, forKey: .name)
        self.point = try defaultContainer.decode(Int.self, forKey: .point)
        self.description = try defaultContainer.decodeIfPresent(String.self, forKey: .description)
        
        for key in extraContainer.allKeys {
            if CodingKeys.allCases.filter({ $0.rawValue == key.stringValue}).isEmpty {
            // 내가 모르는 키가 나오면
                let value =  try extraContainer.decode(String.self, forKey: CustomCodingKeys(stringValue: key.stringValue)!)
                self.etc[key.stringValue] = value
            }
        }
    }
}
🎯 Detail
  • CodingKey는 json을 뜯어서 String 값이면 첫번째 init?(stringValue: String) 을 통해 객체를 생성한다. (json의 key는 무조건 String이기 때문에 intValue는 return nil을 해도 무방하다.)
struct CustomCodingKeys: CodingKey {
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }
        
        var intValue: Int? { return nil }
        init?(intValue: Int) { return nil }
    }
  • 컨테이너에서 allKeys를 for-in문으로 돌려보면 json에서 뜯어낸 모든 key를 확인할 수 있다. 이를 이용하면 내가 모르는 key값을 획득할 수 있다
for key in extraContainer.allKeys {
	print(key)
}

/*
결과값
CustomCodingKeys(stringValue: "key1", intValue: nil)
CustomCodingKeys(stringValue: "description", intValue: nil)
CustomCodingKeys(stringValue: "key2", intValue: nil)
CustomCodingKeys(stringValue: "name", intValue: nil)
CustomCodingKeys(stringValue: "point", intValue: nil)
CustomCodingKeys(stringValue: "point", intValue: nil)
CustomCodingKeys(stringValue: "name", intValue: nil)
*/
  • key 값을 통해 decode한 후에 내가 원하는 property에 바인딩해주면 끝. 나는 편의상 String type으로만 decode하였는데 타입이 다양할 경우 더 추가해주면 된다.
if CodingKeys.allCases.filter({ $0.rawValue == key.stringValue}).isEmpty {
  // 내가 모르는 키가 나오면
  let value =  try extraContainer.decode(String.self, forKey: CustomCodingKeys(stringValue: key.stringValue)!)
  self.etc[key.stringValue] = value
}
  • 결과는 아래와 같이 나온다.
Product(name: "Banana", point: 200, description: Optional("A banana grown in Ecuador."), etc: ["key2": "222", "key1": "111"])
Product(name: "Orange", point: 100, description: nil, etc: [:])
  • 분명 더 나은 방법이 있을 것이다.(피드백 환영입니다 🤪)

해결하기 2) 기본값 주기

위의 코드에서는 var description 이 optional 타입이고 decode를 할 때에도 decodeIfPresent() 함수를 사용한다. response 안에 해당 값이 존재하지 않을 수 있기 때문이다. 값이 존재하지 않을 경우 nil로 바인딩 해주기 위해서이다.

struct Product: Decodable {
    // ...
    var description: String?
    var etc: [String:Any] = [:]
    
    public init(from decoder: Decoder) throws {
       //...
        self.description = try defaultContainer.decodeIfPresent(String.self, forKey: .description)
        
}

하지만 애초에 타입별로 기본 값을 정해줄 수 있다면 nil을 걱정하지 않아도 된다. 이는 위의 dynamic key를 상대하는 것보다 훨씬 간단하다. try? 를 통해 nil이 걸리면 default값을 사용하도록 ?? 로 처리한다.

struct Product: Decodable {
    // ...
    var description: String
    var etc: [String:Any] = [:]
    
    public init(from decoder: Decoder) throws {
       //...
        self.description = (try? defaultContainer.decode(String.self, forKey: .description)) ?? "default description"

/*
결과값
Product(name: "Banana", point: 200, description: "A banana grown in Ecuador.", etc: ["key2": "222", "key1": "111"])
Product(name: "Orange", point: 100, description: "default description", etc: [:])
*/

}

정리하며 🍒

참고자료 😇 친절한 애플 공식 문서
https://developer.apple.com/documentation/foundation/archives_and_serialization/using_json_with_custom_types

개인적으로 Codable을 사용하다가 열받는 지점이 몇 가지 있었는데 그 중 한 가지를 정리해보았다. 다음에는 Depth가 다른 json 값을 merge하기 위해 nestedContainer 를 사용하는 방법을 정리해보려 한다.

profile
느려도 한 걸음 씩 끝까지

0개의 댓글