Property Wrapper(프로퍼티 래퍼)는 Swift 5.1에서 도입된 강력한 기능으로, 프로퍼티에 재사용 가능한 로직을 캡슐화하고 해당 로직을 프로퍼티 정의에 직접 적용할 수 있도록 해준다.
이를 통해 보일러플레이트 코드(여러 곳에서 거의 또는 전혀 변경하지 않고 재사용할 수 있는 컴퓨터 프로그래밍 코드 덩어리)를 줄이고 가독성을 높이며, 코드의 유지보수성을 향상시킬 수 있다.
프로퍼티 래퍼는 @propertyWrapper 속성이 붙은 struct, class, enum으로 정의된다.
이 타입은 반드시 wrappedValue라는 이름의 인스턴스 프로퍼티를 가져야 하며, wrappedValue는 래핑되는 실제 값(프로퍼티가 가질 값)을 나타낸다.
컴파일러는 프로퍼티 래퍼를 만나면, 해당 프로퍼티에 접근할 때 wrappedValue를 통해 접근하도록 코드를 변환한다.
// UserDefaults에 값을 저장하기 쉽게하는 프로퍼티 래퍼
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
init(key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
// UserDefaults에서 값을 읽어옴
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
// UserDefaults에 값을 저장
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
1. @State(상태 관리)
2. @Binding(양방향 바인딩)
@Binding으로 선언된 프로퍼티는 값을 직접 저장하지 않고, 다른 곳에서 @State 등으로 선언된 원본 데이터에 대한 '참조' 역할을 함. 바인딩을 통해 값을 변경하면 원본 데이터가 변경되고, 원본데이터가 변경되면 바인딩을 통해 값을 사용하는 뷰가 업데이트 되는 방식3. @Published(객체 내 변경 알림)
ObservableObject 프로토콜을 준수하는 클래스 내부의 특정 프로퍼티가 변경될 때마다, 이 객체를 @ObservedObject, @StateObject, @EnvironmentObject 등으로 감시하는 뷰에 변경을 알림.@Published로 선언된 프로퍼티의 값이 변경되면, 해당 ObservableObject가 변경 알림(publisher)을 발행하고, 이를 구독하는 모든 @ObservedObject 등의 뷰들이 업데이트 됨.4. @ObservedObject(참조 타입 객체 감시)
@ObservedObject는 ObservableObject 프로토콜을 준수하는 클래스 인스턴스에 사용됨. ObservableObject 내부의 @Published로 선언된 프로퍼티가 변경되면, ObservedObject로 해당 인스턴스를 감시하는 뷰가 자동으로 업데이트 됨.5. @StateObject(추가: iOS 14+에서 @ObservedObject의 보완)
@ObservedObject가 외부에서 주입받은 객체를 감시하는 데 사용되는 반면, @StateObject는 뷰가 참조 타입 객체(ObservableObject)를 직접 "소유"하고 그 생명주기를 관리할 때 사용한다.@StateObject로 선언된 객체는 해당 뷰가 처음 생성될 때 딱 한 번만 초기화되며, 뷰가 다시 렌더링되더라도 객체가 재생성되지 않고 그 상태를 유지한다. 뷰가 메모리에서 해제될 때 객체도 함께 해제된다.@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
init(key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
// UserDefaults에서 값을 읽어옴
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
// UserDefaults에 값을 저장
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
// UserDefault 프로퍼티 래퍼를 사용하는 구조체
struct AppSettings {
// @UserDefault를 사용하여 isFirstLaunch와 userName을 UserDefaults에 자동으로 저장/로드
@UserDefault(key: "is_first_launch", defaultValue: true)
var isFirstLaunch: Bool
@UserDefault(key: "user_name", defaultValue: "Guest")
var userName: String
@UserDefault(key: "app_version_code", defaultValue: 1.0)
var appVersionCode: Double
}
var settings = AppSettings()
print("Initial isFirstLaunch: \(settings.isFirstLaunch)") // true
print("Initial userName: \(settings.userName)") // Guest
settings.isFirstLaunch = false // UserDefaults에 "is_first_launch": false 저장
settings.userName = "Alice" // UserDefaults에 "user_name": "Alice" 저장
print("Updated isFirstLaunch: \(settings.isFirstLaunch)") // false
print("Updated userName: \(settings.userName)") // Alice
// 앱을 다시 시작해도 값이 유지됨 (UserDefaults 특성)
// settings = AppSettings()
// print("Re-initialized userName: \(settings.userName)") // Alice (if UserDefaults was actually reset)
@propertyWrapper
struct Clamped<Value: Comparable> {
private var value: Value
private let range: ClosedRange<Value>
init(wrappedValue: Value, _ range: ClosedRange<Value>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: Value {
get { return value }
set {
// 값이 범위 내에 있는지 확인하고 조정
value = min(max(newValue, range.lowerBound), range.upperBound)
}
}
}
struct GameCharacter {
@Clamped(0...100)
var health: Int = 100 // 초기값 100, 범위 0-100
@Clamped(0.0...1.0)
var opacity: Double = 0.5 // 초기값 0.5, 범위 0.0-1.0
}
var player = GameCharacter()
print("Initial health: \(player.health)") // 100
player.health = 120 // 120으로 설정하려 하지만, 100으로 클램프됨
print("After setting to 120, health: \(player.health)") // 100
player.health = -10 // -10으로 설정하려 하지만, 0으로 클램프됨
print("After setting to -10, health: \(player.health)") // 0
player.health = 50
print("After setting to 50, health: \(player.health)") // 50
player.opacity = 2.0 // 2.0으로 설정하려 하지만, 1.0으로 클램프됨
print("After setting opacity to 2.0, opacity: \(player.opacity)") // 1.0
@WrapperName만 붙이면 되므로, 어떤 로직이 적용되었는지 한눈에 파악하기 쉽다.didSet, willSet 등을 사용하여 각 프로퍼티마다 반복적으로 작성해야 했던 코드들을 줄일 수 있다.@propertyWrapper
struct Uppercase {
private var value: String = ""
var wrappedValue: String {
get { value }
set { value = newValue.uppercased() }
}
init(wrappedValue initailValue: String) {
self.wrappedValue = initailValue
}
}
struct View {
@Uppercase var test: String
}
let view = View(test: "test")
print(view.test) // TEST
위 코드는 프로퍼티 래퍼를 활용하여 String 타입의 데이터를 자동으로 .uppercased() 해주는 코드이다.
프로퍼티 래퍼를 구현할 때, wrappedValue를 구현하면 init은 굳이 만들어 줄 필요가 없는데, 대부분의 예시에서는 init도 만들어 주고 있다.
만약 init을 만들지 않는다면 어떻게 될까?

위 사진처럼 에러가 발생하게 된다.
에러의 내용은 매개변수에 Uppercase 타입이 들어가야 하는데, String 타입이 들어가 있다.라는 뜻이다.

실제로 인스턴스를 다시 생성하려고 보면 매개변수로 Uppercase 타입을 요구하는 것을 볼 수 있다.
분명 프로퍼티의 타입을 String으로 지정했는데, 왜 이런 현상이 발생하는 걸까?
이것은 프로퍼티 래퍼의 초기화 방식 때문이다.
Swift는 프로퍼티 래퍼를 사용할 때 두 가지 방식의 초기화를 제공한다.
가장 일반적인 사용법으로, 프로퍼티 래퍼가 래핑하는 값의 타입으로 인스턴스를 초기화하는 방식이다.
이 경우 Swift 컴파일러가 내부적으로 wrappedValue를 사용하여 프로퍼티 래퍼 인스턴스를 생성해준다.
struct View {
@Uppercase var test: String // wrappedValue가 String 타입이므로 String으로 초기화 가능
}
let view = View(test: "hello") // "hello"는 String 타입
print(view.test) // HELLO
이 방식이 작동하는 이유는 Swift가 @Uppercase var test: String을 만나면, View의 이니셜라이저에 test라는 이름의 String 타입 매개변수를 자동으로 추가해주기 때문이다.
이 매개변수로 전달된 "hello"는 Uppercase 래퍼의 init(wrappedValue: String) 이니셜라이저를 통해 Uppercase 인스턴스 내의 value 프로퍼티에 할당된다.
이 방식은 프로퍼티 래퍼 자체가 추가적인 초기화 매개변수를 가질 때 유용하다.
@propertyWrapper
struct Uppercase {
private var value: String = "" // 기본값
var wrappedValue: String {
get { value }
set { value = newValue.uppercased() }
}
// 초기화 메서드가 명시적으로 정의되지 않음
// 이 경우 Swift는 기본적으로 init(wrappedValue: String)을 제공
// 또한, 저장 프로퍼티 value에 기본값이 있으므로 init()도 제공될 수 있음
}
Uppercase 구조체에 init(wrappedValue: String)이 암시적으로 제공되므로, View(test: "hello")와 같이 String으로 초기화하는 것이 가능하다.
다만, 경우에 따라 String으로 초기화하는 것이 불가능할 수 있다. 이는 Swift의 멤버와이즈 이니셜라이저 자동 생성 규칙에 따라 @Uppercase가 자체적으로 제공하는 이니셜라이저를 통해 Uppercase 타입의 인스턴스를 직접 넘겨주도록 하는 이니셜라이저가 생성되었기 때문이다.
즉, Swift는 @Uppercase var test: String을 아래와 같이 확장된 형태로 보고 있을 가능성이 있다는 뜻이다.
struct View {
// 실제로는 다음과 같이 변환
// var _test: Uppercase // 프로퍼티 래퍼 인스턴스 자체
// var test: String { // wrappedValue에 대한 접근자
// get { _test.wrappedValue }
// set { _test.wrappedValue = newValue }
// }
@Uppercase var test: String
// 이니셜라이저 자동 생성 (멤버와이즈 이니셜라이저).
// init(test: String) // 래핑된 값을 받는 이니셜라이저
// init(_test: Uppercase) // 래퍼 인스턴스 자체를 받는 이니셜라이저 (Xcode가 제안한 형태와 유사)
// 실제로 개발자가 보게 되는 것은 첫 번째 형태
}
Xcode가 View(test: Uppercase) 형태의 이니셜라이저를 제안하는 것은, View의 자동 생성된 멤버와이즈 이니셜라이저 중 Uppercase 타입의 프로퍼티 래퍼 인스턴스를 직접 매개변수로 받는 형태가 존재하기 때문이다.
만약 이를 원하지 않는다면 프로퍼티 래퍼를 구현할 때 직접 init을 구현하는 것이 타입 안정성을 높일 수 있는 방법이다.
오늘은 프로퍼티 래퍼에 대해 복습 겸 내용 정리를 해보았다.
SwiftUI에서 주로 @State나 @Binding을 통해 접했던 속성인데, 직접 만들어서 사용한 적은 거의 없는 것 같다...
이번에 복습을 통해 확실히 지식을 익혔으니 앞으로 프로퍼티 값을 변경하는 보일러플레이트 코드가 있다면 프로퍼티 래퍼로 캡슐화 하는 것을 고려해야겠다고 생각했다.