프로퍼티 래퍼란? (Property Wrapper)

2020. 12. 9. 22:53iOS

프로퍼티 래퍼란?

SwiftUI를 공부하면서 @State, @Binding 등을 쓰면서 프로퍼티 래퍼가 존재하는지 알게 되었습니다. 그래서 어떤건지는 알고 사용해야할 것 같아서 SwiftUI를 공부하면서 같이 알아봤습니다.

A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property.

Swift 공식문서에는 프로퍼티 래퍼를 다음과 같이 설명하고 있습니다.

 

번역해보자면 프로퍼티 래퍼는 프로퍼티가 저장되는 방식을 관리하는 코드와 프로퍼티가 정의되는 코드 사이에 분리된 계층을 추가해줍니다. 프로퍼티 래퍼를 정의할 때, management code를 한번 작성하면 여러 프로퍼티에 적용하면서 재사용할 수 있습니다.

자세히는 무슨 말인지 모르겠지만, 로직코드를 하나의 struct, enum, class에 작성해서 다른 프로퍼티를 정의할 때 필요한 로직을 바로 재사용할 수 있게 하는 것 같습니다.

 

swift evolution에는 lazy, @NSCopying의 반복적인 패턴을 프로퍼티 래퍼로 라이브러리처럼 적용할 수 있다고 설명합니다.

프로퍼티를 정의할 때마다 매번 로직을 작성해야하는 보일러 플레이트 코드를 줄일 수 있고, 어노테이션(@)만 사용하면 바로 로직을 적용할 수 있어서 잘만 사용하면 꽤 유용하게 쓰일 것 같습니다.

바로 예제 작성하면서 다시 차근차근 알아보겠습니다.

@propertyWrapper
struct TwelveOrLess {
    private var number: Int

    init() {
        self.number = 0
    }

    var wrappedValue: Int {
        get { return number }
        set { number = min(newValue, 12)}
    }
}

wrappedValue는 프로퍼티 래퍼에서 꼭 필요한 프로퍼티입니다. 이게 없으면 컴파일 에러가 뜹니다. 위에 말한 management code가 아마 저기에 들어가는 것 같습니다. 이 struct는 12보다 작거나 같은 수만 set할 수 있는 프로퍼티 래퍼같습니다.

 

struct SmallRectangle {
    @TwelveOrLess var height: Int
    @TwelveOrLess var width: Int
}

var rectangle = SmallRectangle()
// Print : 0
print(rectangle.height)

rectangle.height = 10
// Print : 10
print(rectangle.height)

rectangle.height = 24
// Print : 12
print(rectangle.height)

rectangle.width = 12
// Print : 12
print(rectangle.width)

프로퍼티를 래퍼를 사용한 코드입니다. 프로퍼티 래퍼를 적용한 height과 width는 12보다 큰 수를 대입해도 12로 변환되는 모습을 보입니다. 언뜻보면 프로퍼티 옵저버인 didSet, willSet과 기능이 비슷한 것 같습니다.

 

비교를 해보면 didSetwillSet은 초기화를 해줘야하고, 초기화 시에는 작동이 되지 않습니다. 그리고 didSetwillSet으로 자기 자신을 변경하기 보다는 용어 옵저버에 걸맞게 값의 변화를 관찰하여 그에 반응하는 코드를 작성할 때 사용됩니다.

서로 같은 기능을 만들 수는 있지만 서로의 목적이 다른 것 같습니다.


어떻게 활용할까?

swift evolution에 여러 활용예시가 있는데 예시 하나를 보면서 알아보겠습니다.

@propertyWrapper
public struct Field<Value: DatabaseValue> {
  public let name: String
  private var record: DatabaseRecord?
  private var cachedValue: Value?

  public init(name: String) {
    self.name = name
  }

  public func configure(record: DatabaseRecord) {
    self.record = record
  }

  public var wrappedValue: Value {
    mutating get {
      if cachedValue == nil { fetch() }
      return cachedValue!
    }

    set {
      cachedValue = newValue
    }
  }

    public var projectedValue: Self {
    get { self }
    set { self = newValue }
  }

  public func flush() {
    if let value = cachedValue {
      record!.flush(fieldName: name, value)
    }
  }

  public mutating func fetch() {
    cachedValue = record!.fetch(fieldName: name, type: Value.self)
  }
}

위 코드는 DB에 데이터를 접근할 때, 원하는 필드의 원하는 값을 가져오기 위한 중계 서비스인 것 같습니다. 프로퍼티 래퍼를 적용할 때, init(name:)을 통해 key값을 정하고, 그 key에 매칭되는 value를 캐싱메모리에서 확인 후 가져오는 프로퍼티를 정의할 수 있습니다.

위 코드의 프로퍼티를 다음과 같이 사용할 수 있습니다.

 

public struct Person: DatabaseModel {
  @Field(name: "first_name") public var firstName: String
  @Field(name: "last_name") public var lastName: String
  @Field(name: "date_of_birth") public var birthdate: Date
}

let jinswift = Person()
jinswift.firstName = "Jin"
jinswift.lastName = "Swift"
jinswift.birthdate = Date()

jinswift.$firstName.flush()

$표시는 projectedValue에 접근하는 키워드이며, wrappedValue말고도 접근할 수 있는 부수적인 변수입니다. 여기서는 프로퍼티래퍼를 정의한 struct자신을 projectedValue로 삼아서 메소드를 접근하기 위해 사용했습니다.


직접 활용해보기

프로퍼티 래퍼를 UserDefaults에 접근하는 struct에 적용해봤습니다.

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let userDefaults = UserDefaults.standard
    var isEmpty: Bool {
        return userDefaults.object(forKey: key) == nil
    }

    var wrappedValue: T? {
        get { return userDefaults.object(forKey: key) as? T }
        set { userDefaults.setValue(newValue, forKey: key) }
    }

    var projectedValue: Self {
        get { self }
        set { self = newValue }
    }

    mutating func delete() {
        userDefaults.removeObject(forKey: key)
    }

}

이 프로퍼티래퍼를 key값과 함께 적용하면 할당 된 값이 UserDefaults에 저장되고 delete()통해 삭제를 할 수 있습니다. 한번 적용한 프로퍼티를 작성해보겠습니다.

 

@UserDefault(key: "USER_INTO")
var name: String?

@UserDefault(key: "USER_INTO")
var resultName: String?

key값을 "USER_INFO"로 한 name과 resultName이 있습니다. 값은 키 값을 공유하기 때문에 아마 UserDefaults에 저장되는 값이 같을겁니다.

 

name = "jinSwift"
print(resultName ?? "nil")
// print - jinSwift

$name.delete()
print(resultName ?? "nil")
// print - nil

먼저 name에 "jinSwift"를 넣고 resultName을 print해보았습니다.


resultName에 아무 값도 할당하지 않았음에도 불구하고, wrappedValue의 get이 발동하여 UserDefaults에 저장된 값이 초기화시에 이미 할당이 되있습니다.

 

그래서 nil값이 출력되지 않고, jinSwift가 출력되었습니다. projectedValue를 통해 $name에서 delete()를 호출하면 역시 resultName도 값이 삭제됩니다.


결론

위의 예제말고도 swift evolution에는 많은 예제가 있습니다. 지금은 간단히 UserDefaults정도에 적용해보았지만 동일한 로직이 있는 프로퍼티들을 새롭게 만든 custom클래스에 참조하거나, willSet/didSet 등을 사용하지 않고 어노테이션(@) 하나로 많은 보일러플레이트를 제거할 수 있을 것 같습니다.

참고 사이트

Properties - The Swift Programming Language (Swift 5.3)

apple/swift-evolution

'iOS' 카테고리의 다른 글

unowned self vs weak self (캡쳐 리스트)-(1)  (1) 2021.01.11
클로저 캡쳐란? (Closure Capture)  (0) 2020.07.25