Swift / DynamicMemeberLookup

iOS 앱개발 공부

목록 보기
7/30
post-thumbnail

🧠 핵심 요약

@dynamicMemberLookup은 Swift 4.2에서 도입된 속성(Attribute)으로, 특정 타입이 존재하지 않는 멤버(프로퍼티)에 접근하려고 할 때 컴파일러 오류를 발생시키는 대신, 사용자 정의 로직을 실행하도록 허용한다.
즉, 런타임에서 멤버 접근을 '가로채서' 처리할 수 있게 해준다.

// 예시 코드

@dynamicMemberLookup
struct JSONWrapper {
	let json: [String: Any]
    
    subscript(dynamicMember member: String) -> Any? {
    	return json[member]
    }
}

let wrapper = JSONWrapper(["randomNumber":100])
print(wrapper.randomNumber) // 출력: 100

✔️ 선행 지식

1) subscript란?

subscriptCollection 프로토콜이 요구하는 함수이다.

public protocol Collection: Sequence {
	//...
	subscript(position: Index) -> Element { get } 
}

이는 우리가 흔히 사용하는 Array와 같은 컬렉션 타입들이 준수하고 있는 프로토콜로, arr[0]과 같은 접근을 통해 해당 컬렉션 타입이 가지고 있는 멤버에 접근할 수 있게 도와주는 함수이다.

@dynamicMemberLookup은 항상 subscript(dynamicMember member: String)라는 메서드를 구현해야 하기 때문에 어떤 식으로 동작하는지 이해하기 위해서는 subscript에 대한 사전 이해가 필수이다.

2) KeyPath란?

KeyPath는 Swift 4에서 도입된 기능으로, 타입에 정의된 특정 프로퍼티에 대한 강력하게 타입이 지정된 참조를 생성한다.
@dynamicMemberLookup이 런타임에 문자열 기반으로 멤버에 접근하는 것과 달리, KeyPath컴파일 타임에 안전하게 프로퍼티를 참조한다.

KeyPath의 기본 형식

KeyPath\(백슬래시)로 시작하며, 루트 타입과 그 뒤에 연결된 프로퍼티 이름들을 포함한다.

  • \Root.value
  • \Root.property1.property2

KeyPath의 종류

  • KeyPath<Root, Value>: 읽기 전용 프로퍼티 참조(가장 일반적)
  • WritableKeyPath<Root, Value>: 읽기/쓰기가 가능한 프로퍼티 참조
  • ReferenceWritableKeyPath<Root, Value>: 참조 타입(클래스)의 읽기/쓰기가 가능한 프로퍼티 참조

사용 예시

주로 정렬, 필터링, UI 바인딩 등에서 활용된다.

struct Person {
    var name: String
    let age: Int
    var address: Address
}

struct Address {
    var city: String
    var street: String
}

let person1 = Person(name: "Alice", age: 30, address: Address(city: "Seoul", street: "Gangnam-daero"))
let person2 = Person(name: "Bob", age: 25, address: Address(city: "Busan", street: "Haeundae-ro"))
let person3 = Person(name: "Charlie", age: 35, address: Address(city: "Seoul", street: "Teheran-ro"))

var people = [person1, person2, person3]

// 1. KeyPath를 사용한 값 접근
let nameKeyPath = \Person.name // KeyPath<Person, String>
let ageKeyPath = \Person.age   // KeyPath<Person, Int> (읽기 전용)
let cityKeyPath = \Person.address.city // KeyPath<Person, String>

print(people[0][keyPath: nameKeyPath]) // "Alice"
print(people[1][keyPath: ageKeyPath])  // 25
print(people[2][keyPath: cityKeyPath]) // "Seoul"

// 2. WritableKeyPath를 사용한 값 변경
let writableNameKeyPath = \Person.name // WritableKeyPath<Person, String>
people[0][keyPath: writableNameKeyPath] = "Alicia"
print(people[0].name) // "Alicia"

// 3. 정렬에 활용
// 나이 순으로 정렬 (오름차순)
let sortedByAge = people.sorted(by: { $0[keyPath: ageKeyPath] < $1[keyPath: ageKeyPath] })
print(sortedByAge.map { $0.name }) // ["Bob", "Alicia", "Charlie"]

// 도시 순으로 정렬 (오름차순)
let sortedByCity = people.sorted(by: { $0[keyPath: cityKeyPath] < $1[keyPath: cityKeyPath] })
print(sortedByCity.map { $0.name }) // ["Bob", "Alicia", "Charlie"] (B, S, S 순)

// Swift 5.2부터는 KeyPath를 바로 정렬 클로저에 사용할 수 있습니다.
let sortedByAgeConcise = people.sorted(by: \.age)
print(sortedByAgeConcise.map { $0.name }) // ["Bob", "Alicia", "Charlie"]

// 4. UI 프레임워크 (예: SwiftUI)에서 바인딩에 활용
// SwiftUI에서는 @Binding과 함께 KeyPath를 사용하여 뷰와 모델의 프로퍼티를 연결합니다.
// TextField("Name", text: $person.name) 에서 $person.name이 WritableKeyPath와 유사한 역할을 합니다.

사용시 장점

  • 강력한 타입 안정성: 컴파일 타임에 프로퍼티의 존재 여부와 타입을 확인하여 런타임 오류를 방지한다. 오타가 있으면 컴파일 에러가 발생.
  • 자동 완성(Code Completion) 지원: Xcode가 KeyPath 작성 시 자동 완성을 제공하여 개발 생산성을 높인다.
  • 리팩토링 용이성: 프로퍼티 이름이 변경되면 KeyPath도 컴파일러에 의해 감지되어 업데이트를 유도한다.
  • 범용성: 정렬, 필터링, 데이터 변환 등 다양한 고차 함수에서 재사용 가능한 로직을 만들 수 있다.

사용시 단점

  • 컴파일 타임에 프로퍼티가 고정: @dynamicMemberLookup처럼 런타임에 임의의 문자열로 프로퍼티에 접근할 수는 없다. 즉, 프로퍼티는 컴파일 타임에 미리 정의되어 있어야 한다.

3) @dynamicMemberLookup의 작동 방식

@dynamicMemberLookup 속성은 subscript(dynamicMember member: String) 메서드를 정의하여 작동한다.
클래스, 구조체, 또는 열거형에 @dynamicMemberLookup을 표시하고 이 서브스크립트를 구현하면, 해당 타입의 인스턴스에서 정의되지 않은 멤버에 .dot syntax로 접근할 때 이 서브스크립트가 호출된다.
이 때, member 매개변수는 접근하려고 했던 멤버의 이름을 담고 있다.

여기서 subscript의 매개변수는 ExpressibleByStringLiteral 프로토콜을 준수하고 있거나, KeyPath인 경우에만 사용할 수 있다.
ExpressibleByStringLiteral은 문자열 리터럴(String Literal), 즉 코드에 직접 큰따옴표(")로 작성된 문자열을 사용해서 해당 타입의 인스턴스를 직접 생성할 수 있도록 해주는 프로토콜이다.
이 프로토콜을 채택하고 있는 대표적인 타입으로는 String, Character, StaticString, Selector 등이 있다.

// 예시
let string: String = "string" // 가능
let int: Int = "123" // 불가능

⭐️ 사용 방법

1) 기본적인 사용 방법

@dynamicMemberLookup는 주로 JSON 파싱, Wrapper 타입, 동적으로 프로퍼티에 접근해야 하는 DSL(Domain Specific Language) 등에서 유용하게 사용된다.

// 사용 예시
@dynamicMemberLookup
struct JSONWrapper {
    let json: [String: Any]

    // 정의되지 않은 멤버에 접근 시 호출되는 서브스크립트
    // `member`는 접근하려 했던 프로퍼티 이름 (예: "name", "age")
    subscript(dynamicMember member: String) -> Any? {
        return json[member] // 딕셔너리에서 해당 키를 찾아 값을 반환
    }

    // 체이닝을 위해 자기 자신을 반환하는 서브스크립트도 구현 가능
    subscript(dynamicMember member: String) -> JSONWrapper? {
        if let dict = json[member] as? [String: Any] {
            return JSONWrapper(json: dict)
        }
        return nil
    }
}

let jsonString = """
{
    "user": {
        "name": "Alice",
        "age": 30,
        "contact": {
            "email": "alice@example.com",
            "phone": "123-4567"
        }
    },
    "isAdmin": true
}
"""

if let jsonData = jsonString.data(using: .utf8),
   let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] {

    let data = JSONWrapper(json: jsonObject)

    // 정의되지 않은 멤버에 `.dot syntax`로 접근
    let userName = data.user?.name as? String // "Alice"
    let userAge = data.user?.age as? Int     // 30
    let userEmail = data.user?.contact?.email as? String // "alice@example.com"
    let isAdmin = data.isAdmin as? Bool      // true

    print("User Name: \(userName ?? "N/A")")
    print("User Age: \(userAge ?? -1)")
    print("User Email: \(userEmail ?? "N/A")")
    print("Is Admin: \(isAdmin ?? false)")

    // 존재하지 않는 멤버에 접근 시 nil 반환 (Any? 타입이므로)
    let nonExistent = data.user?.address as? String
    print("Non-existent address: \(nonExistent ?? "N/A")")
}

2) KeyPath와 함께 사용

dynamicMemeberLookupKeyPath를 결합하여 특정 유즈케이스에서 유연성을 높이면서도 일정 수준의 타입 안정성을 확보할 수 있다.
아래 예시는 dynamicMemeberLookup을 사용하여 MVVM 패턴에서 ViewModel을 구현한 것이다.

@dynamicMemberLookup
class ViewModel: ViewModelProtocol {
	enum Action {
    	case fetchExchangeRates
    }
    
    struct State {
    	var exchangedRates: [ExchangedRates]
    }
    
    private(set) var state: State
    
    func action(_ action: Action) { }
    
    subscript<T>(dynamicMember keyPath: KeyPath<State, T>) -> T {
    	state[keyPath: keyPath]
    }
}

// 초기화
let viewModel = ViewModel(initialState: State(exchangedRates: []))

// 액션 실행
viewModel.action(.fetchExchangeRates)

// 상태 접근 (동적 멤버 룩업 사용)
let rates = viewModel.exchangedRates // [ExchangedRates] 타입

이와 같이 구현하면 본래 viewModel.state.exchangedRates로 접근해야 할 프로퍼티를 viewModel.exchangeRates로 단축해서 사용할 수 있다.

3) dynamicMemberLookup과 KeyPath의 비교

dynamicMemberLookupKeyPath
목적정의되지 않은 멤버에 .dot syntax로 동적 접근 허용타입의 특정 프로퍼티에 대한 타입 안전 참조 생성
타입 안정성런타임에 결정, 컴파일 타입 안정성 낮음(오타 감지 어려움)컴파일 타임에 결정, 높은 타입 안정성
접근 방식subscript(dynamicMember:) 호출[keyPath: someKeyPath] 또는 .someKeyPath
사용 시점JSON 파싱, Wrapper, DSL 등 동적 멤버 이름이 필요한 경우정렬, 필터링, UI 바인딩 등 고정된 프로퍼티 참조가 필요한 경우
자동 완성미지원지원

🧩 장점 및 단점

1) 장점

  • 코드 가독성 향상: 런타임에 동적으로 결정될 프로퍼티에 .dot syntax를 사용하여 마치 실제 프로퍼티처럼 접근할 수 있어 코드가 더 자연스러워 보임
  • 유연성: 컴파일 타임에 알 수 없는 키에 접근해야 할 때 유용함.

2) 단점

  • 컴파일 타임 타입 안정성 부족: 접근하려는 멤버가 실제로 존재하는지 컴파일러가 확인해주지 않음. 오타가 있어도 컴파일 에러가 나지 않고 런타임에 nil이 반환되거나 크래시가 발생할 수 있음.
  • 자동 완성 미지원: .dot syntax로 접근하는 동적 멤버에 대해서는 Xcode가 자동 완성을 제공하지 않음.

📌 결론

@dynamicMemberLookup을 사용하면 정의되지 않은 프로퍼티에 대해 dot 문법으로 접근할 수 있다.
특히, 객체 내에 subcript(dynamicMember:)를 구현하면, 객체를 보다 유연하게 동작하도록 만들 수가 있기 때문에 상황에 따라 알맞게 사용하면 유연한 코드를 작성할 수 있다.
단일로 사용할 경우 컴파일 타입 안정성이 낮지만, KeyPath와 함께 사용하면 타입 안정성을 확보할 수 있다.

profile
이유있는 코드를 쓰자!!

0개의 댓글