dynamicMemberLookup 이용해서 간단하게 nested property 이용하기

김가영·2025년 3월 18일
0

swift

목록 보기
5/9

Introduction

다국어 작업을 위해 아래와 같은 형식으로 다국어 값들을 내려받기로 되었다.

{
     "id":10000,
     "localization": {
        "title":{
           "ko":"추천",
           "en":"For You"
        },
        "subtitle":{
           "ko":"추천",
           "en":"For You"
        }
     }
  }
struct Response: Decodable {
		let id: Int,
		let localization: Localization
		
		struct Localization: Decodable {
			let title: [String: String]
			let subtitle: [String: String]
		}
}

response.localization.title["ko"]

그 말은 매번 모든 struct에 위와 같은 형식을 해줘야 한다는 말인데, 이런 nested property를 좀 더 쉽게 이용할 수 있는 방법이 없을까 해서 고민해봤다.

dynamicMemberLookup

@dynamicMemberLookup
struct DynamicStruct {
    let dictionary = ["someDynamicMember": 325,
                      "someOtherMember": 787]
    // required
    // String 말고 KeyPath를 이용할 수도 있음. 자세한 건 아래에서
    subscript(dynamicMember member: String) -> Int {
        return dictionary[member] ?? 1054
    }
}
let s = DynamicStruct()

// let dynamic = s.dictionary["someDynamicMember"] 원래는 이렇게 해야하는 것을
let dynamic = s.someDynamicMember // 이렇게할 수 있다.

// s.someWrongMember 대신 이렇게 오타가 날 수는 있다. 
// -> 이건 subscript(dynamicMember)를 string 대신 KeyPath로 이용하면 해결된다.

Implementation

  • subscript(dynamicMember:) 를 필수적으로 구현해야 한다. argument 로는 KeyPath, WritableKeyPath, ReferenceWritableKeyPath, ExpressibleByStringLiteral(string)를 이용할 수 있다.
    • KeyPath: 어떤 타입의 속성을 참조할 수 있는 경로 (ex: \.Car.model)
    • WritableKeyPath: child of KeyPath, 속성값을 변경할 수 있게 한다.
      struct Car {
          var model: String
          var year: Int
      }
      
      var myCar = Car(model: "Sonata", year: 2020)
      let modelKeyPath = \Car.model // WritableKeyPath<Car, String>
      
      // WritableKeyPath를 사용하여 값 변경, Car가 class인 경우 ReferenceWritableKeyPath
      myCar[keyPath: modelKeyPath] = "Grandeur"
    • ReferenceWritableKeyPath: 참조타입(클래스)의 속성을 변경할 수 있는 경로
  • function이나 computed property에는 접근이 불가.

프로토콜 정의, @dynamicMemberLookup 구현

@dynamicMemberLookup
protocol Localizable: Decodable {
    associatedtype LocalizableProperty
    var localization: LocalizableProperty { get }
    subscript<T>(dynamicMember keyPath: KeyPath<LocalizableProperty, T>) -> T { get }
}
extension Localizable {
    subscript<T>(dynamicMember keyPath: KeyPath<LocalizableProperty, T>) -> T {
            localization[keyPath: keyPath]
    }
}
@propertyWrapper
struct Localized: Decodable {
    var projectedValue: [String: String]
    var wrappedValue: String? {
        self.projectedValue[Locale.current.languageCode ?? ""]
    }
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        self.projectedValue = try container.decode([String: String].self)
    }
    init(_ value: [String: String] = [:]) {
        self.projectedValue = value
    }
}
extension KeyedDecodingContainer {
    func decode(_ type: Localized.Type, forKey key: K) throws -> Localized {
        try decodeIfPresent(type, forKey: key) ?? .init()
    }
}

usage

struct Response: Localizable, Decodable {
    let id: Int
    let localization: LocalizableProperty
    
    struct LocalizableProperty: Decodable {
        @Localized
        var title: String?
    }
}
// 사용할 때는
print(menu.title) // 추천
print(menu.$title["en"]) // For You

full code with test

import Foundation

@dynamicMemberLookup
protocol Localizable: Decodable {
    associatedtype LocalizableProperty
    var localization: LocalizableProperty { get }
    subscript<T>(dynamicMember keyPath: KeyPath<LocalizableProperty, T>) -> T { get }
}
extension Localizable {
    subscript<T>(dynamicMember keyPath: KeyPath<LocalizableProperty, T>) -> T {
            localization[keyPath: keyPath]
    }
}
@propertyWrapper
struct Localized: Decodable {
    var projectedValue: [String: String]
    var wrappedValue: String? {
        self.projectedValue[Locale.current.languageCode ?? ""]
    }
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        self.projectedValue = try container.decode([String: String].self)
    }
    init(_ value: [String: String] = [:]) {
        self.projectedValue = value
    }
}
extension KeyedDecodingContainer {
    func decode(_ type: Localized.Type, forKey key: K) throws -> Localized {
        try decodeIfPresent(type, forKey: key) ?? .init()
    }
}

struct Response: Localizable, Decodable {
    var id: Int
    var localization: LocalizableProperty
    
    struct LocalizableProperty: Decodable {
        @Localized
        var title: String?
    }
}

let jsonData = """
{
    "id":10000,
    "localization":{
        "title":{
            "ko":"추천",
            "en":"For You"
        }
    }
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
let response = try decoder.decode(Response.self, from: jsonData)

print(response.title)
print(response.$title["en"])

다른 방법들

https://ilya.puchka.me/decoding-nested-values-with-property-wrappers/

  • propertyWrapper에서 [KeyPath] 를 인자로 받은 후, 이를 순회하면서 container를 만들어서 decode 하는 방법.
  • 개인적으로 시도해봤을 땐 잘 안됐다.

swift macro

  • 매크로는 코드를 직접 추가해주기 때문에 Decodable의 init(from:) 을 직접 구현하는 방법으로 해결이 가능하다.
  • 다만 swift에서 기본으로 제공해주는 init(from:) 함수를 처음부터 다 구현해야 하므로 작업 범위나 난이도가 올라간다. 또한 매크로를 생성하는데 사용되는 swiftSyntax를 새롭게 배워야 하므로 러닝커브가 높다.
  • 별도 패키지로 생성해서 프로젝트에 추가해야 하기 때문에 패키지 관리 등의 공수가 추가된다.
profile
개발블로그

0개의 댓글

관련 채용 정보