[iOS] @dynamicMemberLookup를 잘쓰면 UIkit을 swiftUI처럼 쓸수있다고?!

Youth·2024년 8월 12일
2

TIL

목록 보기
20/21

2주간의 시험기간을 마치고 오랜만에 돌아온 킴스캐슬입니다:)
졸업은해야겠고 시험은 봐야하고 잘볼필요는없지만 아얘 공부를 안하자니 애매한 그런 2주를 보내고 왔습니다... 그동안 블로그를 조금 소홀이했네요...역시 다른전공으로 대학생활을 하면서 개발공부를 병행하기란 쉽지않네요 ㅠㅠ

제가 요즘 swiftUI로 프로젝트를 진행하고있는데
제가 가장 편했던 부분은 컴포넌트를 선언할때 였던 것 같습티다

물론 then을 쓴다면 swiftUI랑 비슷하게 쓸수있지만 저는 $0를 써야하는게 불편해서(자동완성이 안되니까요) 잘 안쓰고있습니다...ㅎㅎ

그래서 아마 이전 프로젝트에서는 같이 프로젝트를 진행하던 팀원이 Builder Pattern을 이용해서 컴포넌트 선언을 swiftUI스럽게 만들어줬던거같아요

import UIKit

final class LHButton: UIButton {
	...생략...

    @discardableResult
    func setBackgroundColor(color: Palette) -> Self {
        self.backgroundColor = .designSystem(color)
        return self
    }
}

이렇게 하면 아래처럼 컴포넌트 선언이 가능은 했습니다

let testButton = LHButton()
					.setBackgroundColor(.red)

근데 문제는 UIButton만 해도 내부에 속성이 한두개가 아닌데 그때마다 속성들을 설정해주는 @discardableResult메서드를 만들고 쓰는게 많이 불편해보이기도하고 비효율적으로 느껴졌었습니다

뭔가 분명히 좋은 방법이있을거같은데 매번 저렇게 메서드를 만들면 클래스에 정말 몇십개의 메서드가 필요해질수도있는거죠 컴포넌트가 한두개가아니니까(UILabel, UIButton등등등) 코드가 많아지고 클래스는 또 비대해질겁니다

그래서 오늘은 @dynamicMemberLookup이라는 어노테이션을 활용해 Builder Pattern으로 UIkit에서 swiftUI처럼 선언하는 방법에 대해서 소개해보려합니다

아이디어는 이렇게 시작합니다

만약에 UIButton이나 UILabel을 wrapping할수있는 클래스가 있고 그 내부에 UIButton이나 UILabel의 내부에있는 property에 한번에 접근할수있다면 swiftUI처럼 선언형으로 컴포넌트를 정의할수있지않을까?

약간 추상적인 아이디어일수도있는데 좀더 간단하게 설명을 해보면

기존의 컴포넌트 선언방식은 위 그림같았습니다
만약에 내부의 속성들이 UILabel을 반환해주는 메서드였다면 아래와같이 선언이 가능했을겁니다

text라는 메서드의 반환 타입이 UILabel이었을테니까요(나머지 메서드도 마찬가지였겠죠)
그러면 여기서 우리가 위와같이 선언하려면 한가지 조건이 필요하다라는걸 알게되었습니다

반환타입이 늘 같은 컴포넌트 타입이어야한다

UILabel같은 컴포넌트를 wrapping해주는 객체가 있어야한다는 의미는 위의 조건을 만족시키기 위한 방법이었던겁니다(이제 왜 wrapper객체가 필요한지 이해하셨으리라 생각이됩니다)

그리고 모양을 보면 input이 있는 메서드를 실행시킨 결과가 wrapper객체여야한다는걸 알수있습니다

다만 여기서는 .text("ㅎㅇ")를 보면 input parameter가있는 메서드를 실행시키거나 혹은 input이있는 클로저를 실행시키거나 하는것처럼 어떤방식을 사용하더라도 그결과는 wrapper객체가 반환되는 메서드의 실행형태여야한다는걸 알 수 있습니다

그런데 여기서 문제점이있습니다 UILabel을 wrapper객체로 감싸게되면
기존에는 UILabel객체에 바로 .으로 접근해서 UILabel객체.속성으로 접근하면 됐겠지만 wrapper객체 내부에 UILabel을 포함시키게되면 wrapper객체.UILabel객체.속성으로 접근해야하는 문제가 발생합니다

즉, 한가지 조건이 더필요한데
우리가 swiftUI처럼 UIkit의 컴포넌트를 선언하기위해서는 wrapper객체에서 UILabel같은 컴포넌트를 거쳐서 속성에접근하는것이아니라 wrapper객체에서 바로 컴포넌트의 속성에 접근할 수 있어야합니다

이 마지막 한가지 조건이 가장 중요한 핵심 아이디어가 됩니다
지금부터 객체 내부의 객체의 속성에 바로 접근할수있는 방법에 대해서 알아보겠습니다

KeyPath를 사용한 Dynamic Member Lookup

우리가 객체 내부의 객체의 속성에 바로접근하기 위해서는 keyPath를 활용한 Dynamic Member Lookup에 대해서 알아야합니다

KeyPath는 들어본거같기는한데 사실 잘 모르실수도있고 Dynamic member lookup는 정말 처음들어보셨을수도있습니다

그래서 최종적으로 KeyPath를 사용한 Dynamic Member Lookup을 이해하기 위해서 세부적인 개념에 대해서 알아보겠습니다

1. subscript

subscript라는 용어가 약간생소하실수도 있는데 사실 정말 우리가 자주보는 문법입니다

let dict: [String: Int] = ["하나":1, "둘":2]
let names: [String] = ["민수", "영희"]

이런 데이터가 존재할때 우리가 이렇게 데이터를 꺼내쓰죠

dict["하나"] // 1
names[1] // "영희"

여기서 보이는 ["하나"]나 [1]을 우리는 subscript라고 부릅니다
근데 우리는 항상 subscipt를 dictionary나 array에서 밖에 사용한적이없었죠

사실 우리가 custom해서 만들어서 쓸수도 있습니다 예시를 하나 들어볼게요

struct Book {
    var publishedYear: Int
    var name: String

    subscript(key: String) -> String {
        switch key { 
        case "year" :
            return String(publishedYear)
        case "name" :
            return name
        default :
            return "invalid"
        }
    }
}

var sweet = Book(publishedYear: 2024, namae: "책입니다")
sweet["year"]
sweet["name"] // "SweetFood"
sweet["book"] // "invalid"

우리가 어떤 구조체나 클래스에 subscript(키이름: 타입) -> 타입만 선언해주고 key에따라 어떤값이 반환되는지만 정의해주면 위 코드 아래부분처럼 subscipt문법을 활용해서 어떨때는 단순히 값을 반환해주는 용도로 어쩔때는 계산속성같은 용도로 사용할 수 있습니다

지금 이 글의 주제를 이해하기위해서 subscript에 대한 지식은 이정도면 충분합니다 []를 활용해서 데이터를 이리저리 만질수있다가 핵심입니다

2. Dynamic Memeber Lookup

그런데 subscript문법은 dictionary나 array가 아닌이상 뭐랄까요 조금 어색하다고 느끼실수도있습니다 오히려 우리는 .을 활용해서 속성에 접근하는게 오히려 구조체나 클래스에서는 익숙하죠

Dynamic Memeber Lookupsubscript의 []대신 .으로 어떤 데이터를 만질수있게 해주는 방식입니다

그래서 선언하는 방식조차 subscript와 비슷합니다. 같은 예시를 Dynamic Member Lookup으로 만들면 이렇게 됩니다

@dynamicMemberLookup
struct Book {
    var publishedYear: Int
    var name: String

    subscript(dynamicMember key: String) -> String {
        switch key {
        case "year" :
            return String(publishedYear)
        case "bookName" :
            return name
        default :
            return "invalid"
        }
    }
}

let book = Book(publishedYear: 2024, name: "책입니다")
book.year //"2024"
book.bookName //"책입니다"

헷갈리실수도있는데 자동완성이되지는 않습니다 메서드에서처럼 string값을 넣는것이고 .뒤에 넣는방식으로만 바뀐거라 한글자만 틀려도 default로 빠지게되어서 invaild가 출력되게됩니다

이름이 어려워보이고 익숙하지 않아보이지만 Dynamic Memeber Lookup는 그냥 subscript와 하는기능은 똑같은데 []가 아닌 .로 하는 친구라고 알고계시면 됩니다

3. keypath

다음은 keypath입니다

공식문서에서 정의 먼저 보겠습니다

특정 루트 유형에서 특정 결과 값 유형까지의 키 경로입니다.

라는데...뭔가 알고보면 알거같은데 글로만보면이해가 잘안될수도있습니다
그래서 실제 예시를 먼저 보는게 나을거같더라고요

기본적으로 keypath는 아래와같은 모양으로 생겼습니다

\타입이름.그타입의프로퍼티이름

물론 그타입의프로퍼티이름에 다른게들어갈수도있는데 여기서는 중요한내용이 아니니까 넘어갈게요 ㅎㅎ(궁금하시다면 더 찾아보세요!)

struct Book {
    var publishedYear: Int
    var name: String
}

여기서 name의 keypath는 어떻게될까요? \Book.name이 keypath가 될겁니다
즉, name이라는 프로퍼티에 접근하려면 어떤 path로 접근해야하는지에대한 정보를 담은 값(?)이라고 할 수 있습니다

뭐... name에 접근하고싶으면 Book타입의 객체에 name이라는 프로퍼티에 접근해야해라는 내용을 저렇게 축약해놓은거죠

그리고 이러한 keypath를 subscript문법으로 해당 프로퍼티에 접근도 가능합니다

struct Book {
    var publishedYear: Int
    var name: String
}

let book = Book(publishedYear: 2024, name: "책입니다")

let nameKeypath = \Book.name
print(book[keyPath: nameKeypath]) //책입니다

뭔가 사실 이렇게 보면 굳이굳이 이렇게쓸필요는 없어보이죠
그냥 book.name으로 접근하는게 훨씬 편해보이니까요(실제로도 이게 훨씬 편하죠 ㅎㅎ.. 부정하진 않겠습니다)


확실히 단일 keypath문법만 보면 큰 메리트가 없어보일수도있습니다
하지만 위에서 배운 subscript와 Dynamic Memeber Lookup와 keypath를 잘조합해서 사용한다면 아주 편리하게 builder를 구축할 수 있습니다

4. KeyPath를 사용한 Dynamic Member Lookup

우리가 위에서 말했던것처럼 UILabel같은걸 wrapper객체로감싸면 wrapper객체를거쳐 UILabel객체에 접근해 text에 접근할수있었습니다

만약에 이과정을 keyPath를 이용한다면 어떻게될까요

struct Wrapper {
    var label = UILabel()
    init() {
        label.text = "ㅎㅇ"
    }
}

let textKeyPath = \Wrapper.label.text
let wrapper = Wrapper()
print(wrapper[keyPath: textKeyPath])

대충 이렇게 접근할수있겠죠

근데 좀 불편해보입니다
우선 keypath가 길죠 그리고 subscript문법을 사용해아하는건 알겠지만(결국 객체에서 keypath를 사용해서 내부객체의 프로퍼티에접근을해야하니까요) []문법이 좀 불편합니다(.으로 쓰는것보다는 불편하죠 익숙하지 않으니까요 사용자정의 구조체에서는요)

그래서 이런생각이듭니다

wrapper.label.text가 아니라
wrapper.text 실행했을때 "ㅎㅇ"가 나오게하는 방법이 없을까???

우선 subscript문법이 아니라 .으로 접근을 하니까 Dynamic Member Lookup문법을 써야하는건 알거같죠

그리고 .뒤에 text가 들어가는걸 보면 이 text를 가지고 subscript문법을 실행시키면 될거같다는 생각을 할 수 있습니다

즉 Dynamic Member Lookup을 쓸때 아얘 내부객체의 프로퍼티를 인자로 받아서 \내부객체타입.받은내부객체의프로퍼티를 자동으로 만들어서 내부객체를 대상으로 subscript문법을 사용하면 되지않을까라는 생각이 들죠

아마 말로 이야기를 해서 헷갈리실수도있는데 코드를 보면 좀 선명해질거같습니다

@dynamicMemberLookup
struct Wrapper {
    var label = UILabel()
    init() {
        label.text = "ㅎㅇ"
    }
    
    subscript(dynamicMember path: KeyPath<UILabel, String?>) -> String? {
        return label[keyPath: path]
    }
    
}

let wrapper = Wrapper()
print(wrapper.text)

subscript(dynamicMember path: KeyPath<UILabel, String?>) -> String?이 코드를 보면 path를 .뒤에서 받을건데 KeyPath<UILabel, String?>은 \UILabel까지는 완성이되었고 이 뒤에 String?타입의 어떤 프로퍼티를 넣을래?가 비어있는 상태라고 생각하시면 편합니다

즉 우리는 UILabel의 String?타입인 text라는 프로퍼티를 .뒤에 적어주면
\UILabel.text라는 keypath가 완성되고 그 keypath를 label을 대상으로 subscript문법을 적용시켜주면 wrapper.text만으로도 UILabel에 접근해서 text라는 프로퍼티의 값을 get해올수있습니다

당연히 set이 가능한 프로퍼티라면 set도 되겟죠

지금은 UILabel밖에 적용이안되는데 모든 componet에 적용할수있게 generic을 살짝 첨부해보면 어떨까요

@dynamicMemberLookup
struct Wrapper<Component: NSObject> {
    var component: Component
    
    init(_ component: Component) {
        self.component = component
    }

    subscript<InstanceType>(dynamicMember path: KeyPath<Component, InstanceType>) -> InstanceType {
        return component[keyPath: path]
    }
    
}

let wrapper = Wrapper(UIButton())
print(wrapper.isEnabled)

wrapper를 생성할떄 내부에 어떤 컴포넌트가 있냐에 따라서 wrapper에서 모든 내부 객체의 프로퍼티에 접근이 가능해집니다

지금까지 말씀드린 내용들이 Builder를 구현하기위한 기본적인 개념과 아이디어였습니다! 정리를 해볼까요!

선언형으로 만들기위해서는 우선 반환타입이 늘 같은 컴포넌트 타입이어야한다를 지키기위해서 wrapper객체 내부에 UILabel이나 UIButton같은 컴포넌트 객체를 넣어줘야했습니다. 그런데 객체내부에 넣어줬더니 속성에 접근하려면 객체.객체.속성으로 접근해야해서 한번에 접근하기 위해서 KeyPath를 사용한 Dynamic Member Lookup를 활용해서 wrapper에서도 바로 내부 componet의 속성에 접근을 할수있게 구현했습니다

물론 아직 넘어야할 산이 몇가지 더있지만 큰 그림은 이렇습니다

Builder 구현

위의 전체적인 그림에서 가장 우선시 되어야하는건 UILabel이나 UIButton같은 Component가 Builder객체 내부에 포함되어야한다는 부분이었습니다

Builder(UILabel)이렇게 해도 전혀문제가 없지만 우리가 then을 쓰는방식처럼 사용하기 위해서 아래의 코드가 필요합니다

public protocol LayoutCompatible {
    associatedtype LayoutBase: AnyObject
    var with: Builder<LayoutBase> { get set }
}

extension LayoutCompatible where Self: AnyObject {
    public var with: Builder<Self> {
        get { Builder(self) }
        set {}
    }
}

extension NSObject: LayoutCompatible {}

NSObject라는 객체에 LayoutCompatible이라는 프로토콜을 채택함으로써 우리가 알고있는 UILabel이나 UIButton같은 Component는 모두 NSObject를 상속받고있기때문에 모든 컴포넌트들이 LayoutCompatible를 채택한것과 같은 상황이되고

만약에 LayoutCompatible을 채택한 객체가 클래스라면(AnyObject를 상속받고있으니까요) with라는 메서드를 가지게됩니다

with라는 메서드를 실행하면 Build라는 wrapper객체의 intalize에 self를 넣어줍니다(self는 UILabel이나 UIButton같은 컴포넌트가 되겠죠)

let testLabel = UILabel().with

이렇게 코드를작성하면 testLabel은 Build(UILabel())이 될겁니다
우리가 원하는 wrapper객체 내부에 UILabel이 있는 형태죠

이제 Builder라는 객체는 어떻게 구현할지 알아봅시다

@dynamicMemberLookup
public struct Builder<Base: AnyObject> {

    private let _build: () -> Base

    public init(_ build: @escaping () -> Base) {
        self._build = build
    }

    public init(_ base: Base) {
        self._build = { base }
    }
    
    public subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<Base, Value>) -> (Value) -> Builder<Base> {
        { [build = _build] value in
            Builder {
                let object = build()
                object[keyPath: keyPath] = value
                return object
            }
        }
    }

    public func build() -> Base {
        _build()
    }
}

코드를 보면 좀 의아한 부분이있죠

public init(_ build: @escaping () -> Base) {
    self._build = build
}

public init(_ base: Base) {
    self._build = { base }
}

생성자가 두개인데요
두번째 생성자는 with을 통해서 componet를 받았을때 그 컴포넌트를 반환해주는 클로저로 만들어서 private let _build: () -> Base에 넣어줍니다 클로저로되어있지만 결국은 wrapper인 Builder에 컴포넌트를 넣어놓는것과 같습니다

첫번째 생성자는 우리가 결국은 UILabel().with.text를 통해서 wrapper내부에있는 UILabel에 접근해서 최종적으로는 text에 접근하는데 이때 사실 우리가하고싶은건 get이 아니라 set을 하고싶은거잖아요?

그래서 value를 받아서 text가 value로 set된 최신버전의 UILabel이 들어있는 Builder를 반환해주기 위한 생성자입니다 우리가 하고싶은건

let testLabel = UILabel().with
					.text("ㅎㅇ")
                    .backgroundColor(.blue)

인데 .text("ㅎㅇ")로 인해 text에 ㅎㅇ가 들어간 UILabel이 들어있는 Builder를 반환해주고 여전히 builder이기때문에 UILabel의 내부속성으로 바로 .으로 접근을 할수있고 이번에는 background라는 속성으로 접근해서 .blue로 set된 UILabel을 가지고있는 Builder를 반환해줍니다

내부에 있는 subscript메서드의 작동을 한번 다시 정리해보겠습니다

  1. subscript인데 []가 아니라 .으로 접근가능한 이유는@dynamicMemberLookup이기 때문입니다

  1. .을 통해 Base의 Value라는 프로퍼티의 타입에 접근이 가능하다
    UILabel이 Base라면 UILabel에는 여러 프로퍼티가있음
    (예를들어 text는 String?타입의 프로퍼티고 background는 UIColor?타입의 프로퍼티임)그렇기에 프로퍼티의 타입이 여러개라 generic으로 감싸준것이다

  1. UILabel에 .으로 text나 background, font등에 접근이가능한데
    접근을 하면 그 결과는 "(Value) -> Builder"라는 클로저가 반환된다
    즉, let titleLable = UILabel().with.text까지했을때는 결과가 "(Value) -> Builder"타입의 클로저가 된다. 여기까지보면 우선 titleLabel의 타입은 "(Value) -> Builder"이기에 안된다
    왜냐면 결국 .text다음에 .background를 쓰려면 .text의 결과가 Builder여야하기 때문이다 그래서 "(Value) -> Builder"를 실행시켜야함 그리고 실행시킬때 인스턴스의 타입을 일치시켜야한다. (이게무슨소리냐면 text의 타입이 String이니까 string을 넣어야함(이건 당연))

  1. let titleLable = UILabel().with.text("안녕하세요")를 하면 결과가 Builder타입이다. 그래서 뒤에 또 .background이런걸 쓸수있다. 실행하면 우선 initalize에서 받아놨던 self(여기서는 UILabel객체가) object라는 지역변수에 할당된다. 그러면 object에는 UILabel객체가 들어가게된다.
    그리고 keypath를 통해서 UILabel의 인스턴스에 직접 접근을 한다 여기서는 text에 접근을 해서 "안녕"이라는 value값을 set해준다 그리고 "안녕"이 set된 UILabel을 반환해주는 클로저가 생성자로 들어간 Builder가 반환된다

  1. 그러면 다음으로 .background(.blue)를 실행했을떄 let object = build()를 실행하면 이번에는 object에 text에 "안녕"이 set된 UILabel이 반환되어서 변수에 할당된다

근데 이렇게까지 헀을때의 최종 결과는 Builder타입이지만 우리가 원하는건 Builder내부에 있는 객체타입이 변수에 할당되기를 원하기에 마지막에 .build()라는 메서드를 통해 self를 반환해서 최종적으로 만들어진 UILabel을 반환해주게됩니다

최종적으로 위의 코드와 순서를 거치면 우리는 컴포넌트를 아래와같이 선언형으로 만들어낼수있습니다

let titleLable = UILabel().with
  .text("ㅎㅇ")
  .textColor(.red)
  .backgroundColor(.blue)
  .cornerRadius(10)
  .translatesAutoresizingMaskIntoConstraints(false)
  .build()

각각의 속성마다 메서드를 만들필요도 없고 선언형으로 사용할수있습니다 물론 with와 build를 사용해줘야하지만 wrapper로 감싸기위해(with) 그리고 마지막으로 완성된 내부 객체를 꺼내오기위해(build) 꼭 필요한 과정이긴합니다 ㅎㅎ...

이렇게 아이디어를 구현하기위해 필요한 작동, 그 작동을 위한 필요한 세가지문법(subscript, Dynamic Memeber Lookup, keypath), 세가지문법을 잘 결합시켜서 KeyPath를 사용한 Dynamic Member Lookup를 만들고 이를 바탕으로 Builder를 만들어 UIkit에서 swiftUI처럼 컴포넌트를 선언형으로 만들어낼수있는 방법에 대해 알아봤습니다 ㅎㅎ


정말 길었던 포스팅이었네요...
오랜만에 쓰는데 이렇게 긴 포스팅을 쓰게되서 3개월은 늙어버린것같네요... 아마 오탈자가 되게많을거같은데 조금씩조금씩 고치면서 글도 조금씩 다듬어 보겠습니다 ㅎㅎ

이제 바쁜 일정도 끝이났고 다시 꾸준히 포스팅을 해봐야겠네요
오늘도 긴글 읽어주셔서 감사합니다

다음에도 흥미로운 주제로 돌아오겠습니다
그럼 20000!!!!!

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

6개의 댓글

comment-user-thumbnail
2024년 4월 26일

keyPath는 String이니까 keyPath 조작을 통해서 Wrapper에서도 프로퍼티에 접근하는 전략이 재밌네요
UILabel은 주로 사용하는 게 프로퍼티 형식이라 keyPath가 다들 비슷할 것 같은데
UIButton의 setTitle 같은 메서드는 어떻게 호출해야 할 지 바로 떠오르지는 않네요
마음이 급하셨던 건지 UILabel이 UILable로 되어 있는 게 좀 있습니다 (스크린샷도..ㅎ)

1개의 답글
comment-user-thumbnail
2024년 4월 26일

정확하진 않지만 SwiftUI의 Modifier가 대략 이런 방식으로 구성되어 있을 것 같군요 ㅎㅎ
좋은 아티클 남겨주셔서 감사합니다!! 잘 보고 갑니다 ㅎㅎ 🙇‍♂️🙇

1개의 답글
comment-user-thumbnail
2024년 8월 6일

잘봤습니다잇~!

1개의 답글