[Swift] Struct을 [String: Any]로 변환할 때, Encodable의 Extension을 사용 해야 하나요? - 김종찬

2022-05-13

때는 바야흐로 얼마전, SwiftUI + Firebase 관련한 사내스터디를 진행하던 중 회원가입한 User의 데이터값을 Firebase로 보내야 하는 상황이었어요.

Firebase에서 요구되는 메소드의 파라미터 타입은 아래와 같은[String: Any] 였죠.

  var userData: [String: Any] = [
    "email": email,
    "uid": uid,
    "profileImgURL": profileImgURL
  ]

그런데, 본능?적이게도 당연히 아래와 같이 바꾸고 싶었어요.

  struct User {
    let email: String
    let uid: String
    let profileImgURL: String
  }
  
  var userData = User(email: email,
                      uid: uid,
                      profileImgURL: profileImgURL)

그리고 “그러면 User 구조체를 만들고 요구되는 타입이 [String: Any]이므로 Dictionary로 바꿔주면 되겠네.” 하면서 동료가 하나의 코드를 첨부했죠.

extension Encodable {
  func toDictionary() -> [String: Any]? {
    guard let object = try? JSONEncoder().encode(self) else { return nil }
    guard let dict = try? JSONSerialization.jsonObject(with: object) as? [String: Any] else { return nil }
    return dict
  }
}

그리고 사용은 이렇게요.

struct User: Encodable {
  let email: String
  let uid: String
  let profileImgURL: String
}

userData.toDictionary()

그런데 여기서 의문이 들었습니다.

“음.. 저거 꼭 Encodable에 확장해야 되나? 그냥 struct -> dictionary로 변환만 해주면 되는데 저렇게 해야 되나?” 라는 생각이 들었어요.

뭔가 아무 생각없이 기계적으로 사용만 했어서 그런지, Codable은 서버에서 받아오는 json 값을 decode 하는데만 사용한다라는 인식이 있었던 것 같아요.

그리고 한편으로는 Codable = Decodable & Encodable 인데 굳이 Encodable에 메소드를 확장하는 것은 뭔가 ISP(Interface Segregation Principle)를 위반하는 것 같기도 하고… 그런 생각이 들었습니다.

그래서 대안을 찾아보기로 했지요.

1. 하드코딩

struct User: Encodable {
  let email: String
  let uid: String
  let profileImgURL: String
  
  func toDictionary() -> [String: Any] {
    return ["email": email,
            "uid": uid,
            "profileImgURL": profileImgURL]
  }
}

음, 근데 이건 정상적으로 동작하나 확장성 또는 내부 프로퍼티의 변경에 취약하다는 문제가 있어요.

2. Mirror

두번째 대안은 Mirror 입니다. 저같은 경우는 iOS를 하면서 한번도 사용해보지 않은 구조체였어요.

이 녀석의 용도에 대해 결론부터 말씀드리면, init(reflecting:)을 사용하면 타입에 있는 저장 프로퍼티, 튜플, 컬렉션, 활성 열거형의 값을 가져올 수 있습니다.

struct User {
  let email: String
  let uid: String
  let profileImgURL: String
  
  let userArrays: [Int]
  let userTuple: (String, String)
  let phone: Device
  
  enum Device {
    case iPad
    case iPhone
  }
}


let user = User(email: "abc@gmail.com",
                uid: "abc",
                profileImgURL: "http://image.url",
                userArrays: [1,2,3,4,5],
                userTuple: ("t", "p"),
                phone: .iPad)

let mirror = Mirror(reflecting: user)

mirror.children.forEach {
  print($0)
}


/*
(label: Optional("email"), value: "abc@gmail.com")
(label: Optional("uid"), value: "abc")
(label: Optional("profileImgURL"), value: "http://image.url")
(label: Optional("userArrays"), value: [1, 2, 3, 4, 5])
(label: Optional("userTuple"), value: ("t", "p"))
(label: Optional("phone"), value: Note.User.Device.iPad)
*/

신기하죠?😄

상황에 따라서 요긴하게 써먹을 수 있을 것 같습니다.

어쨋건 이 녀석을 사용하면, 이게 되겠죠.

struct User {
  let email: String
  let uid: String
  let profileImgURL: String
  
  var asDictionary : [String:Any] {
    let mirror = Mirror(reflecting: self)
    let dict = Dictionary(uniqueKeysWithValues: mirror.children.lazy.map({ (label:String?, value:Any) -> (String, Any)? in
      guard let label = label else { return nil }
      return (label, value)
    }).compactMap { $0 })
    return dict
  }
}

print(user.asDictionary)
//["profileImgURL": "http://image.url", "uid": "abc", "email": "abc@gmail.com"]

음~ 근데 우리가 asDictionaryUser에만 사용할 건 아니잖아요? 어떤 protocol을 하나 만들고 그것을 확장해서 저 기능을 넣어야 하나? 고민을 좀 해봤습니다. 이렇게요.

protocol Reflectable { }

extension Reflectable {
  var asDictionary : [String:Any] {
    let mirror = Mirror(reflecting: self)
    let dict = Dictionary(uniqueKeysWithValues: mirror.children.lazy.map({ (label:String?, value:Any) -> (String, Any)? in
      guard let label = label else { return nil }
      return (label, value)
    }).compactMap { $0 })
    return dict
  }
}

struct User: Reflectable {
  let email: String
  let uid: String
  let profileImgURL: String
}

print(user.asDictionary)
//["profileImgURL": "http://image.url", "uid": "abc", "email": "abc@gmail.com"]

동작은 같습니다. 꽤? 괜찮은 방법인 것 같아요.🥹

3. NSCoding, NSKeyedArchiver

고전이 나왔습니다. 일단 이 녀석들을 보기 전에 글 처음의 Encodable을 다시 보죠.

extension Encodable {
  func toDictionary() -> [String: Any]? {
    guard let object = try? JSONEncoder().encode(self) else { return nil }
    guard let dict = try? JSONSerialization.jsonObject(with: object) as? [String: Any] else { return nil }
    return dict
  }
}

우리가 하고 싶은 건 Strurt -> [String: Any] 입니다. 그런데 위 코드에서는 작업을 나눠서 진행해주고 있지요.

  1. Object를 Data 타입으로 변환
  2. Data 타입을 JSONSerialization을 통해 Foundation 객체로 변환 후 [String: Any]로 타입캐스팅

기존에 1,2 번에서는 Struct를 Data 타입으로 변환하진 않았었죠. 그 부분이 약간 다릅니다.

그런데 저기서 Encodable에 Extension을 하는 이유가 바로 아래에 Object를 Data 타입으로 변환하기 위해서 JSONEncoder() 클래스가 쓰이기 때문이겠죠. 우리는 이 부분을 JSONEncoder()을 사용하지 않고, NSKeyedArchiver()를 사용하는 대안을 살펴볼 겁니다.

NSKeyedArchiver는 추상 클래스인 NSCoder를 기반으로 한 클래스 입니다. 공식 문서 상으로는 키값을 가지고 아카이브에 객체로 저장되는 인코더라고 표현하고 있네요.

비슷한 녀석으로 NSArchiver가 있는데 차이점은 키값의 유무 정도인 것 같아요. 이미 Deprecated 되었기 때문에 이제는 사용하지 않습니다.

NSKeyedArchiver는 iOS 2.0부터 사용되었고, Swift 3까지는 원활히 사용되다가 Codable이 나온 Swift 4, iOS 8.0 이후로는 잘 사용되고 있지 않습니다.

그래도 어찌됐건 역할은 인코더의 역할을 하고 있어서 위의 코드를 사용한다면 아래처럼 될 수 있겠지요.

protocol NSCoderEncodable { }

extension NSCoderEncodable {
  func toDictionary() -> [String: Any]? {
    guard let object = try? NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: false) else { return nil }
    guard let dict = try? JSONSerialization.jsonObject(with: object) as? [String: Any] else { return nil }
    return dict
  }
}

struct User: NSCoderEncodable {
  let email: String
  let uid: String
  let profileImgURL: String
}

let user = User(email: "abc@gmail.com",
                uid: "abc",
                profileImgURL: "http://image.url")
                                
print(user.toDictionary())
//["profileImgURL": "http://image.url", "uid": "abc", "email": "abc@gmail.com"]

엄청나게 크게 바뀌는 부분은 없습니다. 단지 Object를 Data 타입으로 변환하기 위해서 JSONEncoder() 대신에 NSKeyedArchiver를 사용했을 뿐이죠.

아 그리고, 이미 워낙에 자주 쓰여서 아시겠지만 JSONSerialization는 Codable로 치면 디코더의 역할을 하는 녀석입니다. iOS 5.0 부터 사용되었고 8.0에 Codable이 나오기 전까지 잘 사용되었죠. 지금도 간간히 사용되고 있구요.

4. Encodable

돌고 돌아 다시 Encodable로 와버렸습니다. 튜닝의 끝은 순정이랬던가요. 결국에 전 다시 이쪽으로 돌아와버렸어요.

extension Encodable {
  func toDictionary() -> [String: Any]? {
    guard let object = try? JSONEncoder().encode(self) else { return nil }
    guard let dict = try? JSONSerialization.jsonObject(with: object) as? [String: Any] else { return nil }
    return dict
  }
}

결론적으로 이 방법은 나쁘지 않은 방법인 것 같습니다.

1번도 있고, 2번도 있고, 3번도 있지만 변화에 유연하고 확장성에도 대응하기 좋죠. 따로 프로토콜을 정의할 필요도 없구요.

겸사겸사 정리를 해보자면, 이 정도가 되겠네요.

  • NSCoder : 인코딩, 디코딩에 관련한 추상클래스
  • NSKeyedArchiver : NSCoder를 상속받아 실제로 인코딩, 디코딩에 관한 기능을 지원하는 클래스
  • JSONSerialization : Data 또는 json 타입을 Foundation 객체로 변환하는 기능을 지원하는 클래스
  • Encodable : 인코딩과 관련한 프로토콜
  • Decodable : 디코딩과 관련한 프로토콜
  • Codable : Decodable & Encodable
  • JSONEncoder : Object 타입을 Data 타입으로 변환하는 기능을 지원하는 클래스
  • JSONDecoder : Data 또는 json 타입을 Object 타입으로 변환하는 기능을 지원하는 클래스

결론은 삽질?..을 한 것이라고 볼 수도 있겠습니다만은.. 알고 쓰는거랑 모르고 쓰는 건 차이가 있겠죠? :)

그걸로 오늘의 위안을 삼아봅니다. 20000.

5. 참고

  • https://ios-development.tistory.com/720
  • https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types
  • https://developer.apple.com/documentation/swift/mirror
  • https://medium.com/@OutOfBedlam/%EC%8A%A4%EC%9C%84%ED%94%84%ED%8A%B8-json-encoder%EC%99%80-encodable-e61e55f9e535