[Swift] PropertyWrapper? 프로퍼티를 감싼 녀석을 알아보자 (feat. UserDefaults)

Deah (김준희)·2024년 6월 15일
0

Swift

목록 보기
1/2
post-thumbnail

안녕하세요. Deah 입니다.

오늘은 PropertyWrapper를 알아보고
PropertyWrapper를 통해 UserDefaults를 간단하게 활용하는 법을 정리해보려고 해요!

Property

먼저 Property가 뭘까요? (이후로는 편의상 프로퍼티라고 쓸게용!)
머리로는 알고 있지만 이걸 어떻게 설명하지... 싶으신 분은 손 들어보세요

🙋‍♀️ 저요~^^

Swift에서는 총 3가지의 프로퍼티가 존재합니다.

  • 저장 프로퍼티 (Stored Property)
  • 연산 프로퍼티 (Computed Property)
  • 타입 프로퍼티 (Type Property)

다들 한 번씩 들어보셨을 거 같은 이 녀석들 먼저 간단히 알아봅시당


Property
Properties associate values with a particular class, structure, or enumeration. Stored properties store constant and variable values as part of an instance, whereas computed properties calculate (rather than store) a value. Computed properties are provided by classes, structures, and enumerations. Stored properties are provided only by classes and structures.

공식 문서에서는 프로퍼티를 '어떤 값을 특정 클래스, 구조체, 열거형과 연결한다.' 라고 설명하고 있습니다.
그리고 3가지의 프로퍼티가 각각 어떤 역할을 하는지 간단히 알려주고 있네요!

Stored Property

먼저 저장 프로퍼티는 우리가 흔히 클래스나 구조체의 인스턴스에서 사용할 수 있는 변수나 상수를 말합니다.
즉, 값을 저장하기 위해 사용하는 녀석이라는 것이죵

class User {
	let name: String = "Stranger"
    var level: Int = 0
}

struct Person {
	let name: String = "Stranger"
   	var age: Int = 0
}

User라는 클래스에서 사용된 상수 name, 변수 level
그리고 Person이라는 구조체에서 사용된 상수 name, 변수 age 모두 저장 프로퍼티 입니다.

Computed Property

연산 프로퍼티는 클래스, 구조체, 열거형 모두에서 사용할 수 있어요.
저장 프로퍼티와 달리 값을 실질적으로 저장하지 않고, 다른 저장 프로퍼티의 값을 활용해 연산을 수행하거나 다른 프로퍼티에 값을 전달할 수 있는 녀석입니다.

struct Person {
	let name: String = "Stranger"
   	var age: Int = 0
    
    // 연산 프로퍼티
    var introduce: String {
    	get {
            return "제 이름은 \(name)이고, 나이는 \(age)살 입니다."
        }
        
        set {
            let result = newValue + "원"
            money = result
        }
    }
}
let deah = Person(name: "Deah", age: 100)
deah.introduce	// 제 이름은 Deah이고, 나이는 100살 입니다.

위에서 만들었던 Person 구조체에 연산 프로퍼티를 만들어보면 요런 형태가 될 거 같아요.

연산 프로퍼티는 getter와 setter로 구성되고, 또 값을 실제로 저장하지 않고 매번 연산하여 결과를 나타내기 때문에 항상 타입을 명시하고 var 키워드로 선언해주어야 한답니다.

Type Property

타입 프로퍼티는 연산 프로퍼티처럼 클래스, 구조체, 열거형 모두에서 사용할 수 있습니다.

앞서 살펴본 저장 프로퍼티와 연산 프로퍼티 모두 타입 프로퍼티로 활용할 수 있는데요!
타입 프로퍼티는 인스턴스에 종속되지 않고 선언된 클래스, 구조체, 열거형 자체에서 접근할 수 있습니다.

  • 저장 타입 프로퍼티
  • 연산 타입 프로퍼티
struct Person {
	static let country = "대한민국"   // 타입 저장 프로퍼티
	let name: String = "Stranger"	// 저장 프로퍼티
   	var age: Int = 0				// 저장 프로퍼티
    
    var introduce: String {			// 연산 프로퍼티
    	get {
            return "제 이름은 \(name)이고, 나이는 \(age)살 입니다."
        }
        
        set {
            let result = newValue + "원"
            money = result
        }
    }
    
    static var viva: String {		// 타입 연산 프로퍼티
    	return country + " 만세!"
    }
}
Person.country 	// 대한민국
Person.viva		// 대한민국 만세!
enum Level {
	static let low = "low"
    static let middle = "middle"
    static let high = "high"
}
Level.low	// low

Property Wrapper

돌고 돌아온 오늘의 주인공! 프로퍼티 래퍼Swift 5.1에서 새롭게 등장했어요.
말 그대로 해석하면 프로퍼티를 감싼 녀석인데...

프로퍼티를 감쌌다?


Property Wrapper
A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property. For example, if you have properties that provide thread-safety checks or store their underlying data in a database, you have to write that code on every property. When you use a property wrapper, you write the management code once when you define the wrapper, and then reuse that management code by applying it to multiple properties.

공식 문서에서 프로퍼티 래퍼는 프로퍼티가 저장되는 방법을 관리하는 코드와 프로퍼티를 정의하는 코드를 분리해주는 계층을 추가해준다고 설명하고 있네요.

예를 들어, 스레드 안정성 검사를 제공하거나 어떤 프로퍼티를 DB에 저장해야 할 경우 관련 로직을 프로퍼티마다 매번 정의해주어야 할텐데요! 이 과정이 번거로우니 프로퍼티 래퍼를 통해 관리할 코드를 정의하고 원하는 프로퍼티에 적용해서 사용하게끔 해주겠다~ 이 말입니다.

🧐 왜 쓰는 건데요?

프로퍼티 래퍼의 필요성은 반복되는 로직을 줄이고 프로퍼티를 편리하게 관리할 수 있다는 점에 있습니다.

만약 프로퍼티를 lazy하게 동작시키기 위해 필요한 로직이 100줄이 필요하다고 했을 때,
여러 개의 프로퍼티를 lazy하게 만들고 싶으면 100줄짜리 로직을 프로퍼티마다 반복해서 사용해주어야겠죠?

하지만 프로퍼티 래퍼를 활용해 불필요하게 반복되는 로직을 줄이고 프로퍼티를 쉽게 제어할 수 있게 됩니다.
(밑에서 UserDefaults 예제로 더 자세히 다뤄볼게용 ~!)

😵‍💫 그래도 이해가 안 간다구요.. 어렵다구요...

우리도 모르게 자주 사용하는 예제로 알아볼게요.

SwiftUI에서 데이터를 다룰 때 사용했던 @State, @Binding, @Published, @ObservedObject를 한 번쯤 보신 적 있으신가요?

요놈! 바로 이 녀석들이 프로퍼티 래퍼랍니다.


@State

@State를 설명하는 공식 문서에도 'A property Wrapper' 라고 되어있죵?
구조체로 선언된 State 앞에 @propertyWrapper를 붙여서 "얘 남들과 달리 좀 특별하게 감싸져 있는 애임~!" 이라고 알려주는 거예요.

@State는 SwiftUI에서 값의 변경을 감지해 View를 자동으로 업데이트 할 때 자주 사용하게 되는데
왜? 어떻게? 이 녀석이 동작하는 걸까요!?

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct State<Value> : DynamicProperty {

    /// Creates a state property that stores an initial wrapped value.
    ///
    /// You don't call this initializer directly. Instead, SwiftUI
    /// calls it for you when you declare a property with the `@State`
    /// attribute and provide an initial value:
    ///
    ///     struct MyView: View {
    ///         @State private var isPlaying: Bool = false
    ///
    ///         // ...
    ///     }
    ///
    /// SwiftUI initializes the state's storage only once for each
    /// container instance that you declare. In the above code, SwiftUI
    /// creates `isPlaying` only the first time it initializes a particular
    /// instance of `MyView`. On the other hand, each instance of `MyView`
    /// creates a distinct instance of the state. For example, each of
    /// the views in the following ``VStack`` has its own `isPlaying` value:
    ///
    ///     var body: some View {
    ///         VStack {
    ///             MyView()
    ///             MyView()
    ///         }
    ///     }
    ///
    /// - Parameter value: An initial value to store in the state
    ///   property.
    public init(wrappedValue value: Value)

    /// Creates a state property that stores an initial value.
    ///
    /// This initializer has the same behavior as the ``init(wrappedValue:)``
    /// initializer. See that initializer for more information.
    ///
    /// - Parameter value: An initial value to store in the state
    ///   property.
    public init(initialValue value: Value)

    /// The underlying value referenced by the state variable.
    ///
    /// This property provides primary access to the value's data. However, you
    /// don't typically access `wrappedValue` explicitly. Instead, you gain
    /// access to the wrapped value by referring to the property variable that
    /// you create with the `@State` attribute.
    ///
    /// In the following example, the button's label depends on the value of
    /// `isPlaying` and the button's action toggles the value of `isPlaying`.
    /// Both of these accesses implicitly access the state property's wrapped
    /// value:
    ///
    ///     struct PlayButton: View {
    ///         @State private var isPlaying: Bool = false
    ///
    ///         var body: some View {
    ///             Button(isPlaying ? "Pause" : "Play") {
    ///                 isPlaying.toggle()
    ///             }
    ///         }
    ///     }
    ///
    public var wrappedValue: Value { get nonmutating set }

    /// A binding to the state value.
    ///
    /// Use the projected value to get a ``Binding`` to the stored value. The
    /// binding provides a two-way connection to the stored value. To access
    /// the `projectedValue`, prefix the property variable with a dollar
    /// sign (`$`).
    ///
    /// In the following example, `PlayerView` projects a binding of the state
    /// property `isPlaying` to the `PlayButton` view using `$isPlaying`. That
    /// enables the play button to both read and write the value:
    ///
    ///     struct PlayerView: View {
    ///         var episode: Episode
    ///         @State private var isPlaying: Bool = false
    ///
    ///         var body: some View {
    ///             VStack {
    ///                 Text(episode.title)
    ///                     .foregroundStyle(isPlaying ? .primary : .secondary)
    ///                 PlayButton(isPlaying: $isPlaying)
    ///             }
    ///         }
    ///     }
    ///
    public var projectedValue: Binding<Value> { get }
}

길다 ^0^
하지만...! 주석을 보면 아주 친절하게 설명해주고 있답니다? (ㅎㅎ)

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
	public init(wrappedValue value: Value)
    public init(initialValue value: Value)
    public var wrappedValue: Value { get nonmutating set }
    public var projectedValue: Binding<Value> { get }
}

주석을 제외하고 간단히 살펴보면,
@State 안에는 2개의 초기화 메서드와 wrappedValue, projectedValue가 선언되어 있고 각각의 역할은 아래와 같을 거 같아요!

  • init : Generic으로 선언한 Value를 받아서 값을 초기화 해주는 역할을 수행
  • wrappedValue : 값에 접근하거나, 값을 업데이트할 때 사용하는 저장 프로퍼티
  • projectedValue : 값을 Binding 타입으로 감싸서 접근할 수 있도록 제공

참고로 @State는 DynamicProperty 프로토콜을 채택하고 있는데,
DynimicProperty 프로토콜은 update 함수를 가지고 있습니다.

Updates the underlying value of the stored value.
SwiftUI calls this function before rendering a view's View/body-swift.property to ensure the view has the most recent value.
(저장 프로퍼티의 기본 값을 업데이트하고, View가 렌더링 되기 전에 이 함수를 호출하여 View에 최신 값이 보여질 수 있도록 합니다.)

값이 바뀔 때마다 바뀐 값으로 업데이트 해주고, 해당 값을 View에 반영해주는 역할 같죵?
우리가 @State를 사용해 선언한 데이터들이 변경될 때 마다 View가 자동으로 변경사항을 확인하고 렌더링해주는 동작이 update 함수를 통해 처리되고 있는 거 같네요!


UserDefaultsManager

프로퍼티 래퍼가 어떻게 구성되어 있는지 살펴보았으니 이제 적용하는 법을 단계적으로 알아볼게용!
UserDefaults에 닉네임과 나이를 저장할 때 필요한 로직을 프로퍼티 래퍼로 감싸 중복 코드를 줄여보는 과정을 위주로 봐주시면 좋을 거 같습니다.

enum Key: String {
	case nick
    case age
    case isUser
}

Step 1

UserDefaults

가장 기본적인 방법으로 UserDefaults의 standard 인스턴스에서 set 함수로 값을 저장하고,
원하는 데이터 타입으로 가져오는 방법입니다.

// 저장하기
UserDefaults.standard.set("Deah", forKey: Key.nick.rawValue)
UserDefaults.standard.set(100, forKey: Key.age.rawValue)
UserDefaults.standard.set(true, forKey: Key.isUser.rawValue)

// 불러오기
let nick = UserDefaults.standard.string(forKey: Key.nick.rawValue)
let age = UserDefaults.standard.integer(forKey: Key.age.rawValue)
let isUSer = UserDefaults.standard.bool(forKey: Key.isUser.rawValue)

print(nick)		// Optional("Deah")
print(age)		// 100
priint(isUser)	// true

UserDefaults는 Key-Value 형태로 저장되기 때문에 원하는 키 값을 넣어주고, 키에 저장될 데이터도 함께 set 함수의 파라미터로 넘겨주면 쉽게 저장할 수 있습니다.

하지만 값을 저장하고 불러올 때마다 UserDefaults.standard 구문이 매번 반복되고 있는 모습이 불.편.하.시.지.않.나.요?
만약 UserDefaults에 저장할 값이 100개라면...? 해당 코드를 매번 반복해서 작성하는 것은 아주 아주 비효율적입니다. 🤯

Step 2

따라서 UserDefaultsManager라는 열거형을 선언해주고, 이 열거형 안에서 UserDefaults에 저장될 값을 다뤄볼게요.
연산 타입 프로퍼티를 통해 각 nick, age, isUser 값을 getter, setter로 구분하여 실행될 로직을 넣어줘보겠습니다.
(타입 프로퍼티는 열거형, 구조체, 클래스에서 모두 사용 가능하므로 굳이 열거형으로만 따라하지 않아도 됩니다-!)

enum UserDefaultsManager {
	static var nick: String {
        get {
            UserDefaults.standard.string(forKey: Key.nick.rawValue) ?? "Visitor"
        }
        
        set {
            UserDefaults.standard.set(newValue, forKey: Key.nick.rawValue)
        }
    }
    
    static var age: Int {
        get {
            UserDefaults.standard.integer(forKey: Key.age.rawValue)
        }
        
        set {
            UserDefaults.standard.set(newValue, forKey: Key.age.rawValue)
        }
    }
    
    static var isUser: Bool {
        get {
            UserDefaults.standard.bool(forKey: Key.isUser.rawValue)
        }
        
        set {
            UserDefaults.standard.set(newValue, forKey: Key.isUser.rawValue)
        }
    }
}

위처럼 만들어진 UserDefaultsManager를 사용해보면

// getter로 접근 (초기값!)
UserDefaultsManager.nick		// "Visitor"
UserDefaultsManager.age			// 0
UserDefaultsManager.isUser		// false

// setter로 값 변경
UserDefaultsManager.nick = "Deah"
UserDefaultsManager.age = 100
UserDefaultsManager.isUser = true

UserDefaultsManager.nick		// "Deah"
UserDefaultsManager.age			// 100
UserDefaultsManager.isUser		// true

UserDefaultsManager에 저장된 각 nick, age, isUser 타입 프로퍼티에 접근하는 코드만으로 저장된 값을 쉽게 가져올 수 있고, 새로운 값을 저장할 때는 타입 프로퍼티에 새로운 값을 할당해주면 setter를 통해 값이 저장되게 됩니다. (저장된 값이 없을 땐 초기값이 출력!)

반복되는 코드가 줄어드니 훨씬 간편해졌죵?

하지만 지금 단계에서도 저장하려는 값마다 매번 getter와 setter를 정의해주어야 합니다.
UserDefaults에 저장하려는 값의 타입이 다르면 매번 다른 메서드로 접근해야하기 때문에 유지보수나 확장성을 위해서 추상화를 해보면 좋을 거 같지 않나요?

Step 3

제네릭(Generic)을 통해 우리가 사용하려는 값의 타입을 유연하게 대응할 수 있도록 UserDefaultsWrapper를 만들어보겠습니다.

struct UserDefaultsWrapper<T> {
    let key: Key
    let defaultValue: T
    
    var myValue: T {
        get {
            UserDefaults.standard.object(forKey: key.rawValue) as? T ?? defaultValue
        }
        
        set {
            UserDefaults.standard.setValue(newValue, forKey: key.rawValue)
        }
    }
}

먼저 UserDefaults에 저장될 key 상수를 선언해주고, 사용할 타입을 채택해줍니다! (저는 만들어둔 Key 열거형 채택)
그리고 값이 없을 때를 대비해 defaultValue도 함께 선언해줍니다.

myValue라는 연산 프로퍼티를 통해 값을 핸들링 하는데,
이 때 getter 내에서는 UserDefaults.standard.object 메서드를 사용해 값을 가져오고 제네릭으로 받은 타입으로 타입 캐스팅을 하여 반환 할 거예요. 대신 값이 없을 경우는 defaultValue를 반환합니다!

사용해볼까요?

enum UserDefaultsManager {
    static var nick = UserDefaultsWrapper(key: .nick, defaultValue: "Visitor")
    static var age = UserDefaultsWrapper(key: .age, defaultValue: 0)
    static var isUser = UserDefaultsWrapper(key: .isUser, defaultValue: false)
}

UserDefaultsManager를 만들어주고,
그 안에서 UserDefafultsWrapper를 통해 우리가 UserDefaults에 저장할 key와 defaultValue를 넘겨줍니다.

UserDefaultsManager.nick.myValue		// "Visitor"
UserDefaultsManager.age.myValue			// 0
UserDefaultsManager.isUser.myValue		// false

UserDefaultsManager.nick.myValue = "Deah"
UserDefaultsManager.age.myValue = 100
UserDefaultsManager.isUser.myValue = true

UserDefaultsManager.nick.myValue		// "Deah"
UserDefaultsManager.age.myValue			// 100
UserDefaultsManager.isUser.myValue		// true

이렇게 되면 myValue라는 연산 프로퍼티에 접근해 값을 가져오거나 새로운 값으로 변경해서 사용할 수 있습니다.
매 프로퍼티마다 반복해서 작성했던 getter, setter를 단 한 번으로 줄인 걸 볼 수 있는 겁니당!!!!! (👏)
.
.
.

🧑‍🏫 혹시... 혹시... myValue를 매번 작성하는 건 안 불편한가요...?

🧑‍🏫 여기서부터가 진짜 '프로퍼티 래퍼'인데요...


Step 4

위에서 @State를 살펴봤을 때 앞에 @propertyWrapper 키워드가 붙여져있던 거 기억하시나요?
그리고 @State 내부에는 wrappedValueprojectedValue로 구성된 모습이었는데용

반복되는 myValue를 줄이기 위해서 드디어(!!!) @propertyWrapper를 사용해보겠습니다. 🤣

@propertyWrapper
struct UserDefaultsWrapper<T> {
    let key: Key
    let defaultValue: T
    
    var wrappedValue: T {
        get {
            UserDefaults.standard.object(forKey: key.rawValue) as? T ?? defaultValue
        }
        
        set {
            UserDefaults.standard.setValue(newValue, forKey: key.rawValue)
        }
    }
}

기존에 만들어두었던 UserDefaultsWrapper 앞에 @propertyWrapper를 작성해주고,
myValue로 만들었던 연산 프로퍼티의 변수명을 wrappedValue로 바꿔주겠습니다.

enum UserDefaultsManager {
    @UserDefaultsWrapper(key: .nick, defaultValue: "Visitor")
    static var nick
    
    @UserDefaultsWrapper(key: .age, defaultValue: 0)
    static var age
    
    @UserDefaultsWrapper(key: .isUser, defaultValue: false)
    static var isUser
}

기존에는 UserDefaultsWrapper 구조체의 인스턴스를 만들어 초기값을 전달해주는 방법으로 사용했다면,
@propertyWrapper 키워드를 사용해 다시 만든 UserDefaultWrapper는
사용하려는 프로퍼티 앞에 명시해주어 해당 값이 UserDefaultsWrapper로 감싸져있다고 알려주어 사용합니다.

이 모습은 마치 우리가 @State를 사용할 때와 같아보이지 않나요?

@State var nick: String = "Deah"
@UserDefaultsWrapper(key: .nick, defaultValue: "Visitor") static var nick

🤓 왜냐하면 둘 다 프로퍼티 래퍼이기 때문이죵 (= 당연한 말)

사용할 때는 타입 프로퍼티에 접근하는 것처럼 간편하게 사용할 수 있습니다.
반복해서 작성해줬던 myValue를 더이상 사용하지 않아도 되는 거지요!

UserDefaultsManager.nick		// "Visitor"
UserDefaultsManager.age			// 0
UserDefaultsManager.isUser		// false

UserDefaultsManager.nick = "Deah"
UserDefaultsManager.age = 100
UserDefaultsManager.isUser = true

UserDefaultsManager.nick		// "Deah"
UserDefaultsManager.age			// 100
UserDefaultsManager.isUser		// true

@propertyWrapper에서 wrappedValue는 기본값에 접근할 수 있도록 자동으로 처리되기 때문에 별도로 명시하지 않아도 되는 거랍니다.

Step 5

그런데 말입니다. 우리에게 남은 게 한 가지 더 있는데요?
바로 projectedValue 입니다!

@State에서 살펴봤듯이 프로퍼티 래퍼 내부에는 wrappedValueprojectedValue가 있었는데
우리는 방금 wrappedValue가 프로퍼티 래퍼로 선언된 변수의 기본값에 접근하는 용도인 걸 확인했어요.

그럼 projectedValue 무슨 용도인지 궁금해진단 말이죠?

public var wrappedValue: Value { get nonmutating set }
public var projectedValue: Binding<Value> { get }
profile
기록 중독 개발자의 기록하는 습관

0개의 댓글