타입을 모를때 디코딩 처리하기

사내 프로젝트에서 사용자 이벤트 추적을 위해 Amplitude를 사용하는데, Amplitude에서는 이벤트 키와 같이 properties를 같이 로깅할 수 있다.
이때 properties의 타입이 [String: Any]인데, 서버사이드에서 이미 렌더링하는 값들이 있다보니 properties에서 요구하는 원본값들을 가져오기 어려웠고, 결국 서버에서 로깅을 위한 객체를 내려주기로 했다.
따라서 오브젝트를 [String: Any]로 바꿔주어야 한다. 그런데 오브젝트 안이 어떤 키로 내려올지 정해지지 않고, 타입 또한 달라진다.

디코딩

JSON으로 서버와 통신하고 있기 때문에 Decodable한 객체의 값을 [String: Any]를 목표로 하여 바꿔주어야 한다.
그런데 [String: Any]가 Decodable하지 않기 때문에 커스텀 디코딩을 해주어야 한다.

import Foundation

struct AnyCodable: Decodable {
    private let value: Any?
    
    init(from decoder: Decoder) throws {
        let singleValueContainer = try decoder.singleValueContainer()
        if let value = try? singleValueContainer.decode(Int.self) {
            self.value = value
        } else if let value = try? singleValueContainer.decode(Double.self) {
            self.value = value
        } else if let value = try? singleValueContainer.decode(String.self) {
            self.value = value
        } else if let value = try? singleValueContainer.decode(Bool.self) {
            self.value = value
        } else if let value = try? singleValueContainer.decode([AnyCodable].self) {
            self.value = value
        } else if let value = try? singleValueContainer.decode([String: AnyCodable].self) {
            self.value = value
        } else {
            self.value = nil
        }
    }
}

값의 타입도 모르기 때문에 Any로 선언해 value값을 채워주었다.
이렇게 하면 일단 타입을 모르는 채로 디코딩 처리를 할 수 있게 된다.

struct Response: Decodable {
    let log: [String: AnyCodable]
}

Decodable -> [String: Any]

그런데 이걸 그대로 사용하면 로깅되는 프로퍼티에서 AnyCodable 자체가 사용된다. 여기서 value를 꺼내 써야 하는데, 보통 이렇게 타입에 wrapping될때는 프로퍼티래퍼를 쓰겠지만 잘 되지 않았다.
AnyCodable의 value를 보고 한 번 값을 꺼내서 사용하면 될까 했는데, 오브젝트 타입에서 다시 문제가 된다. 따라서 다시 Encodable의 도움을 받아야 한다.
사실 그래서 처음엔 이름이 AnyDecodable이었는데 결국 이름이 AnyCodable이 되었다.

extension AnyCodable: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        if let value = self.value as? Encodable {
            try container.encode(value)
        }
    }
}

보통 iOS 프레임워크에서 userInfo란 이름으로 익명클래스처럼 사용되는 [String: Any]타입을 얻기 위해 JSONSerializer를 사용했다. 잘 되는듯 했는데 테스트해 보니 Bool값들이 전부 0과 1로 표현되고 있었다.
알아보니 JSONSerialization이 Bool타입을 NSNumber로 바꿔서 저장한다고 한다. 다행히도 CFGetTypeID(:_)에서 타입 비교로 NSNumber가 Bool타입인지 여부를 알 수 있었고, 명시적으로 Bool값으로 변환해 주었다.
object나 array같은 중첩이 있기 때문에 재귀로 처리했다.

extension Encodable {
    var dictionary: [String: Any] {
        let encoder = JSONEncoder()
        encoder.outputFormatting = .sortedKeys
        
        guard let data = try? encoder.encode(self) else {
            return [:]
        }
        
        guard let serialized = try? JSONSerialization.jsonObject(
            with: data,
            options: .allowFragments
        ) else {
            return [:]
        }
        
        let boolInspected = self.boolInspected(serialized)
        
        guard var dictionay = boolInspected as? [String: Any] else {
            return [:]
        }
        
        return dictionay
    }
    
    /// Bool 타입이 JSONSerialization에서 Any가 될 때 NSNumber로 처리됨, 타입으로 검증해 명시적 Bool타입으로 변경
    private func boolInspected(_ value: Any) -> Any {
        if let dictValue = value as? [String: Any] {
            return dictValue.mapValues { value in
                self.boolInspected(value)
            }
        } else if let arrayValue = value as? [Any] {
            return arrayValue.map { value in
                self.boolInspected(value)
            }
        } else if let boolValue = value as? NSNumber, CFGetTypeID(boolValue) == CFBooleanGetTypeID() {
            return boolValue.boolValue
        } else {
            return value
        }
    }
}

마무리

마지막으로 모든 Response 내에서 [String: AnyCodable] 타입을 계속 선언해야 한다는 점이 조금 거슬려서 다시한번 SingleValueContainer를 사용해 Codable 적용시 타입을 숨겨주었고, 새로운 타입을 로깅함수의 인자로 받았다.
이러면 조금 더 명확한 의도를 나타낼 수 있게 된다.

public struct ServerEventLog: Codable {
    private let log: [String: AnyCodable]
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        self.log = try container.decode([String: AnyCodable].self)
    }
    
    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.loggilogng)
    }
}

struct Response: Decodable {
    let log: ServerEventLog
}

/// Amplitude Logger
public func log(event: AmplitudeEvent, log: ServerEventLog? = nil) {
    let eventKey = event.eventKey
    let eventProperties = log.dictionary
    
    Amplitude.instance().logEvent(
        eventKey,
        withEventProperties: eventProperties
    )
}

확인해보면 Object, Array 모두 잘 나오고 있다.

let json = """
{
    "logging": {
        "array": [
            123,
            "foo",
            false,
            {
                "arrayObjectBool": false
            }
        ],
        "bool": true,
        "double": 3.141592,
        "int": 0,
        "object": {
            "objectArray": [
                1,
                "2",
                true,
            ],
            "objectBool": false,
            "objectDouble": 0.0001,
            "objectInt": 1
        },
        "string": "Hello, World!",
    }
}
"""

let response = try! JSONDecoder().decode(
    Response.self,
    from: json.data(using: .utf8)!
)

response.log.forEach { (k, v) in
    print("🔑: \(k): 🎁 \(v)")
}

/*
🔑: array: 🎁 [123, foo, false, ["arrayObjectBool": false]]
🔑: bool: 🎁 true
🔑: double: 🎁 3.141592
🔑: int: 🎁 0
🔑: object: 🎁 ["objectArray": [1, 2, true], "objectBool": false, "objectDouble": 0.0001, "objectInt": 1]
🔑: string: 🎁 Hello, World!
*/