Swift / Property Wrapper

iOS 앱개발 공부

목록 보기
8/30

🧠 핵심 요약

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(상태 관리)

  • 목적: 뷰의 로컬 상태를 나타낼 때 사용
  • 정의: 뷰 내에서 소유하고 있는 간단한 값 타입의 데이터를 저장하고, 이 값이 변경될 때 해당 뷰를 자동으로 다시 그리게 함.
  • 작동 방식: SwiftUI 프레임워크가 관리하는 특별한 저장 공간에 저장됨 -> 값이 변경되면, 해당 값을 사용하는 뷰를 자동으로 무효화(invalidate)하고 다시 렌더링(re-render) 함

2. @Binding(양방향 바인딩)

  • 목적: 값 타입 데이터에 대한 양방향 연결을 제공
  • 정의: 데이터를 소유하지 않은 뷰가 상위 뷰 또는 다른 데이터 소스가 소유한 데이터에 대한 참조를 통해 값을 읽고 쓸 수 있도록 해줌.
  • 작동 방식: @Binding으로 선언된 프로퍼티는 값을 직접 저장하지 않고, 다른 곳에서 @State 등으로 선언된 원본 데이터에 대한 '참조' 역할을 함. 바인딩을 통해 값을 변경하면 원본 데이터가 변경되고, 원본데이터가 변경되면 바인딩을 통해 값을 사용하는 뷰가 업데이트 되는 방식

3. @Published(객체 내 변경 알림)

  • 목적: 프로퍼티의 값이 변경된 것을 옵저버에게 알림
  • 정의: ObservableObject 프로토콜을 준수하는 클래스 내부의 특정 프로퍼티가 변경될 때마다, 이 객체를 @ObservedObject, @StateObject, @EnvironmentObject 등으로 감시하는 뷰에 변경을 알림.
  • 작동 방식: @Published로 선언된 프로퍼티의 값이 변경되면, 해당 ObservableObject가 변경 알림(publisher)을 발행하고, 이를 구독하는 모든 @ObservedObject 등의 뷰들이 업데이트 됨.

4. @ObservedObject(참조 타입 객체 감시)

  • 목적: 복잡한 데이터 모델이나 여러 뷰에서 공유될 수 있는 데이터를 관리할 때 사용
  • 정의: 참조 타입으로 정의된 객체에서 발생하는 변경 사항을 뷰가 감지하고 반응하도록 함.
  • 작동 방식: @ObservedObjectObservableObject 프로토콜을 준수하는 클래스 인스턴스에 사용됨. ObservableObject 내부의 @Published로 선언된 프로퍼티가 변경되면, ObservedObject로 해당 인스턴스를 감시하는 뷰가 자동으로 업데이트 됨.

5. @StateObject(추가: iOS 14+에서 @ObservedObject의 보완)

  • 목적: 참조 타입의 값을 소유(@State와 유사)
  • 정의: @ObservedObject가 외부에서 주입받은 객체를 감시하는 데 사용되는 반면, @StateObject는 뷰가 참조 타입 객체(ObservableObject)를 직접 "소유"하고 그 생명주기를 관리할 때 사용한다.
  • 작동 방식: @StateObject로 선언된 객체는 해당 뷰가 처음 생성될 때 딱 한 번만 초기화되며, 뷰가 다시 렌더링되더라도 객체가 재생성되지 않고 그 상태를 유지한다. 뷰가 메모리에서 해제될 때 객체도 함께 해제된다.

✅ 실제 사용 예시

1) 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)
        }
    }
}

// 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)

2) 특정 범위 내에서만 값을 허용하는 프로퍼티 래퍼

@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

📌 프로퍼티 래퍼의 장/단점

1) 장점

  • 코드 재사용성: 일반적인 프로퍼티 로직(유효성 검사, 영속성 저장, 스레드 안정성 등)을 추상화하여 여러 프로퍼티에 쉽게 적용할 수 있다.
  • 가독성 향상: 프로퍼티 정의 옆에 @WrapperName만 붙이면 되므로, 어떤 로직이 적용되었는지 한눈에 파악하기 쉽다.
  • 보일러플레이트 코드 감소: didSet, willSet 등을 사용하여 각 프로퍼티마다 반복적으로 작성해야 했던 코드들을 줄일 수 있다.
  • 테스트 용이성: 래핑된 로직이 독립적인 타입으로 분리되므로 테스트하기 용이하다.

2) 단점

  • 학습 곡선: 초보 개발자에게는 개념이 다소 복잡하게 느껴질 수 있다.
  • 과도한 사용: 모든 프로퍼티에 래퍼를 적용하는 것은 오히려 코드를 불필요하게 복잡하게 만들 수 있으므로 적절한 사용처를 파악하는 것이 중요하다.

✨ 프로퍼티 래퍼의 초기화 방식

@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는 프로퍼티 래퍼를 사용할 때 두 가지 방식의 초기화를 제공한다.

래핑된 값(wrappedValue)을 직접 초기화

가장 일반적인 사용법으로, 프로퍼티 래퍼가 래핑하는 값의 타입으로 인스턴스를 초기화하는 방식이다.
이 경우 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을 통해 접했던 속성인데, 직접 만들어서 사용한 적은 거의 없는 것 같다...
이번에 복습을 통해 확실히 지식을 익혔으니 앞으로 프로퍼티 값을 변경하는 보일러플레이트 코드가 있다면 프로퍼티 래퍼로 캡슐화 하는 것을 고려해야겠다고 생각했다.

profile
이유있는 코드를 쓰자!!

0개의 댓글