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가 몇 번 바뀌었는지에 대한 추가 로직도 정의할 수 있다.