본문 바로가기

카테고리 없음

[Swift] Property Wrappers

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0258-property-wrappers.md

 

swift-evolution/proposals/0258-property-wrappers.md at main · swiftlang/swift-evolution

This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution

github.com

 

정의

프로퍼티 래퍼는

프로퍼티에 적용되는 공통 로직이나 반복되는 코드를 클래스, 구조체, 열거형 타입에 하나의 타입으로 캡슐화 하여 재사용할 수 있도록 하는 문법이다.

 

새로 만들어진 PropertyWrapper를 프로퍼티에 선언하면, 그 프로퍼티를 사용하는것 만으로 Wrapper 타입의 인스턴스에 접근하여 미리 구현된 기능을 쉽고 깔끔하게 재사용할 수 있게 된다.

 

SwiftUI의 @State, @ObservedObject 등이 프로퍼티 래퍼를 이용한 대표적인 래퍼이다.

 

이는 Swift 5.1에 도입된 문법으로, 이전에는 프로퍼티에 적용할 수 있는 Type들이 @IBOutlet, @objc, lazy 등이 있었다.

lazy는 프로퍼티에 붙이는것 만으로도 지연초기화를 가능하게 했다.

하지만 이같은 방법은 모든 유저가 만족할 수 있는 기능을 커스텀하게 사용할 수 없다.

 

따라서 프로퍼티에 적용할 수 있는 커스텀 속성을 직접 정의하고 활용하도록 한 것이 프로퍼티래퍼가 도입된 이유이다.

 

 


코드

 

PropertyWrapper는 일반적으로 이 구조로 정의된다.

/// 해당 타입이 PropertyWrapper 임을 표시
@propertyWrapper
struct WrapperName<Value> {
    
    private var value: Value
    
    /// 초기 값 설정
    init(wrappedValue: Value) {
        self.value = wrappedValue
    }
    
    
    /// PropertyWrapper를 사용하는 Propery가 접근하는 값
    var wrappedValue: Value {
        get {
            value
        }
        set {
            value = newValue
        }
    }
}

 

wrappedValue는 PropertyWrapper가 제공하는 가장 핵심적인 부분으로,

PropertyWrapper를 적용한 Property가 접근하는 실제 값을 정의 한다.

이 값을 통해 프로퍼티래퍼는 값을 읽거나 쓸 때 추가 동작을 정의할 수 있다.

 

wrappedValue는 PropertyWrapper를 통해 감싸진 Property에 대해 값을 읽고 쓰는 인터페이스 역할을 한다.

프로퍼티에 값이 할당되거나 접근될 때 

 

- Getter: 값을 가져올 때 wrappedValue의 get 코드가 실행된다.

- Setter: 값을 할당할 때 wrappedValue의 set 코드가 실행된다.

 

 

예시로 첫 글자 대문자를 만드는 Capitalized PropertyWrapper를 만든다.

@propertyWrapper
struct Capitalized {
    
    private var value: String
    
    init(wrappedValue: String) {
        self.value = wrappedValue.capitalized
    }
    
    var wrappedValue: String {
        get {
            value
        }
        
        set {
            value = newValue.capitalized
        }
    }
}

 

struct Example {
// 최초 init(wrappedValue:) 호출
    @Capitalized var name: String = "john doe"
}

var example = Example()

// 값 읽기: wrappedValue의 getter가 호출된다.
print(example.name) // "John Doe"

// 값 쓰기: wrappedValue의 setter가 호출됩니다.
example.name = "jane doe"
print(example.name) // "Jane Doe" (setter에서 값이 자동으로 변환됨)

 

wrappedValue가 중요한 이유는

  • 로직 분리: 반복적으로 사용되는 로직 (ex. String.caplitalized) 를 PropertyWrapper로 캡슐화하여 간결하고 재사용 가능한 코드로 만든다.
  • 자동 처리: 사용자는 프로퍼티를 읽거나 쓸 때 일반 프로퍼티처럼 사용하지만, PropertyWrapper는 내부적으로 동작을 추가로 수행한다.

 즉, 실제 값은 value에 저장되지만

사용자는 value에 접근하지 못하고 wrappedValue를 통해 값을 읽거나 씀으로써

wrappedValue에 중간 로직을 추가하여 동작을 제어할 수 있는것이다.

 

 

WrappedValue에 추가 로직을 넣는 예시) UserDefaults

@propertyWrapper
struct UserDefault<Value> {
    private let key: String
    private var defaultValue: Value
    
    init(key: String, wrappedValue: Value) {
        self.key = key
        self.defaultValue = wrappedValue
        
        // UserDefaults에 값이 없으면 기본값 저장
        if UserDefaults.standard.value(forKey: key) == nil {
            UserDefaults.standard.set(wrappedValue, forKey: key)
        }
    }
    
    var wrappedValue: Value {
        get {
            UserDefaults.standard.value(forKey: key) as? Value ?? defaultValue
        }
        set {
            UserDefaults.standard.set(newValue, forKey: key)
        }
    }
}

 

실제 데이터는 key, defaultValue 지만

WrappedValue에 UserDefaults에 관한 중간 로직을 넣는다.

 

struct CapitalizedView: View {
    
    /// 프로퍼티래퍼에 기본 값을 선언
    @UserDefault(key: "hi", wrappedValue: "안녕") var hi
    
    var body: some View {
        Text(hi)
    }
}

 


자동생성 코드

PropertyWrapper가 적용된 프로퍼티는 실제로는 컴파일러에 의해 다음과 같이 치환된다.

    // 작성한 코드
    @UserDefault(key: "hi", wrappedValue: "안녕") var hi
    
    // 치환된 코드
    private var _hi: UserDefault<String> = UserDefault<String>(key: "hi", wrappedValue: "안녕")
    var hi: String {
        get {
            return _hi.wrappedValue
        }
        nonmutating set { _hi.wrappedValue = newValue } 
    }

 

실제로는 Wrapper 타입의 인스턴스가 만들어져 저장 프로퍼티에 저장되고,

개발자가 선언한 hi는 Wrapper에 구현된 wrappedValue에 접근하고 있는 것 이다.

그래서 언더바(_)가 붙은 해당 변수명을 이용하면 직접 Wrapper 타입에 접근해서 .wrappedValue에 접근할 수 있다.


 

ProjectedValue

PropertyWrapper를 사용할 때

기본적으로 값을 반환하거나 설정하는 wrappedValue를 제공하는데,

추가적으로 PropertyWrapper의 추가적인 정보나 동작을 외부에서 사용하고 싶은 경우가 있을 수 있다.

이 때 ProjectedValue를 사용한다.

 

projectedValue는 PropertyWrapper 내부의

추가적인 상태, 메타데이터, 또는 기능을 노출하고 이를 외부에서 사용할 수 있도록 제공하는 기능이다.

 

외부에서는 $속성명을 통해 projectedValue에 접근한다.

 

쉽게 말하면 기본 기능인 조회, 설정은 wrappedValue로 가능한데,

외부에서 뭔가 더 하고싶을 때 추가적으로 정의

 

@propertyWrapper
struct Binding<Value> {
    private var value: Value
    private let update: (Value) -> Void

    init(wrappedValue: Value, onUpdate: @escaping (Value) -> Void) {
        self.value = wrappedValue
        self.update = onUpdate
    }

    var wrappedValue: Value {
        get { value }
        set {
            value = newValue
            update(newValue)
        }
    }

    var projectedValue: Binding<Value> {
        self
    }
}

struct Example {
    @Binding(onUpdate: { print("Updated to: \($0)") }) var name: String = "Initial"
}

var example = Example()
example.name = "Changed"      // "Updated to: Changed"

let projected = example.$name // Binding 인스턴스를 통해 값에 접근하고 처리 가능
projected.wrappedValue = "Final" // "Updated to: Final"

 

여기서는 $name을 통해 projectedValue에 접근하고, 직접 값을 변경해버릴 수도 있다.

 

@propertyWrapper
struct Tracked<Value> {
    private var value: Value
    private(set) var changes: Int = 0

    init(wrappedValue: Value) {
        self.value = wrappedValue
    }

    var wrappedValue: Value {
        get { value }
        set {
            changes += 1
            value = newValue
        }
    }

    var projectedValue: Int {
        changes
    }
}

struct Example {
    @Tracked var number: Int = 0
}

var example = Example()
example.number = 10
example.number = 20

print(example.number)      // 20
print(example.$number)     // 2 (변경 횟수)

 

이런식으로 wrappedValue와 분리되어 wrappedValue가 몇 번 바뀌었는지에 대한 추가 로직도 정의할 수 있다.