스유에서 친근하게(?) 접할 수 있었던 프로퍼티래퍼.. @State, @Binding 등을 너무 당연하게 써왔었는데 UIKit을 하면서는 거의 까먹고 살았던듯합니다.
UserDefault에 저장하고 가져오는 코드를 개선하면서 프로퍼티래퍼Property Wrapper라는 것을 내가 직접 만들어 쓸 수 있구나를 알게 되었고 활용해 본 방법을 공유해 볼게요!
📍 Property Wrapper란?
일단 활용을 하려면 이게 뭔지부터 알아야 하니! 천천히 알아가 봅시다
Wrapper라고 하니 프로퍼티를 감싸고 있는 무언가라는 이미지가 그려지시나요??
넵 맞숩니다. 프로퍼티와 관련된 행동/작업들을 캡슐화한 것이라고 생각하면 됩니다!
그럼 또 프로퍼티와 관련된 행동/작업은 뭔데??
(( 프로퍼티는 그냥 그 객체에 대한 속성일 뿐인데 우리가 프로퍼티에 대한 행동을 생각해 왔던가?? 한다면.. 프로퍼티 래퍼를 사용해보기 전까지는 getter, setter 정도 생각했었던 것 같다.))
어떤 프로퍼티는 값을 가져오고 저장할 때 특정 행동을 가질 수 있는 것들이 있어서 아래 예시들을 보면 PropertyWrapper가 이해가 갈 것이다. 일단 예시들을 보기 전에 프로퍼티 래퍼를 왜 쓰는 건지 이유에 대해 짚고 넘어가자!
📍 Property Wrapper 왜 쓸까?
지금까지 property wrapper 없이도 모든 기능을 잘 구현해왔으니, 프로퍼티 래퍼를 사용하는 목적은 무엇일까 생각해 보았다.
특정 프로퍼티가 get, set을 할 때마다 가공해서 데이터를 저장(set)하거나 조회(get)하고 싶은 상황이 있을 때, 지금까지 나는 어떻게 했는가??? 지금까지는 getter, setter 내부에 원하는 작업을 넣어주거나 프로퍼티에 값을 저장하고 가져오는 시점에함수를 사용해서 가공해 주는 작업을 했다.
하지만 이렇게 가공해서 데이터를 저장하고 가져와주는 프로퍼티가여러 군데에서 사용된다면?? 가공해 주는 함수를 또 여러 번 호출해주어야 하거나 get, set클로저 내부에 작업이 반복되어 있어서 좀 찝찝..할 것이다.
대표적인 예가 UserDefault를 여러 번 사용할 때가 될 수 있는데, 저장할 때 UserDefaults.standard.set(), 저장되었던 값을 가져올 때 UserDefaults.standard.object()을 해주는 등 이런 작업이 반복적이다.
이런 식으로 구현을 하면서 불편한 감정이 들 때! 활용해 볼 수 있는 게 바로 프로퍼티 래퍼인 것!
Property Wrapper의 대표적인 목적으로는 반복되는 코드, 반복되는 작업을 줄이는 것이라고 볼 수 있는데, 우리는 이것을 있어 보이게 ㅎㅎ "보일러 플레이트를 줄인다"라고 할 수 있다.
* 보일러 플레이트 boilerplate
- 프로프래밍적 관점에서 코드를 작성하기 위해 항상 필요한 부분, 별 수정없이 여러 곳에서 재사용되며, 반복적으로 비슷한 형태를 띠는 코드패턴이나 템플릿을 바로 보일러 플레이트(boilerplate)라고 한다!
- 이런 보일러 플레이트 코드는 코드베이스의 일관성을 유지하고 재사용성을 높이기도 하지만 지나치게 반복되는 코드는 유지보수성을 떨어뜨리기도 한다.
📍 Property Wrapper 활용해보자
처음에는 프로퍼티래퍼의 필요성에 대해 잘 이해가 가지 않아서 여러 개의 예시를 찾아보았더니 이제 이해가 간다!
아마 아래 두 가지 케이스 정도를 이해하면 UserDefaults코드도 프로퍼티 래퍼로 개선하는데 무리가 없을 것이다.
1️⃣ case1 ) 저장해 둔 문자열에 대해 소문자로 읽어오는 프로퍼티 래퍼
- @Property Wrapper 키워드는 프로퍼티가 해야 할 행동을 정의하는 타입에 붙여준다.
- 일단 저장해놓고 싶은 값을 프로퍼티로 지정해 주고 (여기서는 word)
- @propertyWrapper 에서 반드시 구현해줘야 하는 wrappedValue라는 연산 프로퍼티에서 값을 저장하고 가져올 때 어떤 식의 가공/작업을 해줄지에 대해 작성해 주면 된다.
- 만약 wrappedValue 프로퍼티를 구현해주지 않으면 오류메시지가 뜰 것이다. Property wrapper type 'LowercaseWrapper' does not contain a non-static property named 'wrappedValue’
- 그리고 이러한 특정 성격을 띤 프로퍼티를 사용하고 싶을 때, 프로퍼티 정의부 앞에 우리가 정의한 커스텀 프로퍼티 래퍼를 넣어주면 완성이다!
- 이제 이 프로퍼티를 읽어오고 값을 세팅할 때 우리가 프로퍼티 래퍼에 정의한 대로 작동할 것이다.
@propertyWrapper
struct LowercaseWrapper{
private var word : String?
var wrappedValue: String? {
get {
guard let word else{return nil}
return word.lowercased()
}
set {
guard let newValue else{return }
word = newValue
}
}
}
class MakeWord {
@LowercaseWrapper private var word : String?
func editWord(text : String) {
word = text
print("🧡", word)
}
func getWord () {
print("❤️", word)
}
}
let wordInstance = MakeWord()
wordInstance.getWord() //nil
wordInstance.editWord(text: "UpperCamelCase")
wordInstance.getWord() //Optional("uppercamelcase")
wordInstance.editWord(text: "lowercase")
wordInstance.getWord() //Optional("lowercase")
이렇게만 보면 그냥 프로퍼티를 연산 프로퍼티로 정의해서 가져올 때마다 작업을 해주면 되는 게 아니냐? 라고 생각할 수 있다. but, 이런 작업을 하는 프로퍼티가 여러 class에 존재하게 된다면??
(( 프로퍼티 래퍼를 사용하지 않은 코드를 봄으로써 프로퍼티래퍼의 필요성을 깨달을 수 있으니, 아래 코드를 보자면)
- PersonLowercaseName, AddressLowercaseName 클래스에서 모두 소문자의 name을 가져오고 싶다면
- name을 set할 때는 _name이라는 임시 프로퍼티에 저장해 놓고 , name을 get 할 때는 소문자로 만들어주는 작업을 해준다
- 그럼 똑같은 행동을 담고 있는 프로퍼티임에도 동일한 코드가 반복되는 것을 확인할 수 있다.
class PersonLowercaseName {
private var _name : String = ""
var name : String {
get {
self._name.lowercased()
}
set {
self._name = newValue
}
}
}
class AddressLowercaseName {
private var _addressName : String = ""
var addressName : String {
get {
self._addressName.lowercased()
}
set {
self._addressName = newValue
}
}
}
-> 이럴 때 우리가 만들어놨던 프로퍼티 래퍼를 활용해 주면? 위에서 언급한 보일러 플레이트를 줄일 수 있는 장점도 여기서 확인할 수 있다. 코드가 아주 짧아질 수 있고 프로퍼티가 가지고 있는 행동도 명확해진다.
class PersonLowercaseName{
@LowercaseWrapper private var personName : String?
}
class AddressLowercaseName{
@LowercaseWrapper private var addressName : String?
}
이걸 이해했으면 조금만 더 응용해 보자
2️⃣ case2) 가로세로 CGFloat 10 이하인 사각형 size 만드는 프로퍼티 래퍼
저장할 땐(set) 가로세로 10 이상으로 저장할 순 있어도, 읽어올 땐(get) 가로세로 10 이하의 CGSize로만 읽어올 수 있도록 구현해 보자
@propertyWrapper
struct RectangleWrapper{
private var width : CGFloat?
private var height : CGFloat?
var wrappedValue: CGSize? {
get {
guard let width, let height else{return nil}
return CGSize(width: min(width,10), height: min(height,10))
}
set {
guard let newValue else{return }
width = newValue.width
height = newValue.height
}
}
}
class MakeRectangle {
@RectangleWrapper private var rectangle : CGSize?
func editRectSize(width : CGFloat, height : CGFloat) {
rectangle = CGSize(width: width, height: height)
print("🧡", rectangle)
}
func getRectSize () {
print("❤️", rectangle)
}
}
let rectanleInstance = MakeRectangle()
rectanleInstance.getRectSize() //nil
rectanleInstance.editRectSize(width: 7, height: 15)
rectanleInstance.getRectSize() //Optional((7.0, 10.0))
rectanleInstance.editRectSize(width: 20, height: 10)
rectanleInstance.getRectSize() //Optional((10.0, 10.0))
- 개인적으로 이 예제를 보고 프로퍼티래퍼를 잘 쓰면 정말 유용할 수 있겠다고 생각했다.
- 필요한 가공 부분을 프로퍼티래퍼에 함축해서 구현해 놓을 수 있고, 프로퍼티를 정의할 때 앞에 보이는 커스텀 프로퍼티 래퍼 키워드로 아주 명확하게 그 프로퍼티의 역할에 대해 알 수 있을 것이다.
📍 UserDefaults 코드 개선 과정
유저 디폴트에 값을 저장하고 가져오는 코드를 개선했던 과정에 대해 순서대로 설명해 보겠습니다. 아마 이렇게 보면 property wrapper를 사용해서 코드를 개선한 게 굉장히 깔끔해 보일 거예요 ㅎㅎ
일단 예를 들어 유저디폴트에 nickname과 signupDate 두 가지 항목을 저장한다고 해봅시다
1️⃣ 계산 프로퍼티로 정의
첫 번째로 구현했을 때는 UserDefaults의 extension내부에 연산프로퍼티로 특정 키값에 저장해 주거나 , 특정 키값에 저장되어 있는 것을 가져왔었습니다.
그래도 이때는 나름 하나의 블록 안에서 여러 유저디폴트 값들을 관리해주고 있고, get/set 할 때의 로직들을 묶어두어서 외부에서는 유저디폴트에 값을 저장하고 가져오는 메서드들은 사용하지 않도록해줘서 이만큼도 뿌듯했구요..ㅎㅎ
extension UserDefaults {
var nickname: String? {
get { UserDefaults.standard.string(forKey: "nickname")}
set { UserDefaults.standard.set(newValue, forKey: "nickname") }
}
var signupDate: Date? {
get { UserDefaults.standard.object(forKey: "signupDate") as! Date?}
set { UserDefaults.standard.set(newValue, forKey: "signupDate") }
}
}
2️⃣ 함수를 만들어 저장/조회
1번 방법으로 하다 보니 유저디폴트에 저장, 또는 조회 코드 부분을 찾고 싶을 때 추가로 시간을 들여야 했기에, "저장", "조회" 라는 키워드를 명시적으로 가지고 있는 메서드를 만들어주었습니다.
사실 별건 없지만 나중에 유지보수 차원에서는 코드를 찾고 고치는데 시간을 줄일 수 있다고 생각했습니다!
extension UserDefaults {
private var nickname: String? {
get { UserDefaults.standard.string(forKey: "nickname")}
set { UserDefaults.standard.set(newValue, forKey: "nickname") }
}
private var signupDate: Date? {
get { UserDefaults.standard.object(forKey: "signupDate") as! Date?}
set { UserDefaults.standard.set(newValue, forKey: "signupDate") }
}
//nickname
func saveNickname(_ nickname : String?) {
self.nickname = nickname
}
func getNickname() -> String? {
return self.nickname
}
//signupDate
func saveSignupDate(_ signupDate : Date?) {
self.signupDate = signupDate
}
func getSignupDate() -> Date {
return signupDate ?? Date()
}
}
3️⃣ - 1️⃣ property wrapper 사용해서 개선
다음 개선 방법으로는 드디어! 앞서 말했던 propertyWrapper를 사용해 보았는데요.
이 시점에는 제네릭을 사용해서 Int, String, Date등 여러 가지 타입을 담고 저장할 수 있도록 프로퍼티 래퍼를 만들었습니다!
@propertyWrapper
struct UserDefaultsWrapper<T> {
let key : String
var wrappedValue: T? {
get {
print("🌸프로퍼티 get🌸")
return UserDefaults.standard.object(forKey: key) as? T
}
set {
print("🌸프로퍼티 set🌸")
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
class TestClass{
@UserDefaultsWrapper(key: "nickname") var nickname : String?
@UserDefaultsWrapper(key : "signupDate") var signupDate : Date?
}
let class1 = TestClass()
print("class1.profile -> ", class1.nickname)
print("class1.profile -> ", class1.signupDate)
class1.nickname = "nickname1"
class1.signupDate = Date()
print("class1.profile -> ", class1.nickname)
print("class1.profile -> ", class1.signupDate)
3️⃣ - 2️⃣ 객체 형태도 저장할 수 있도록 property wrapper 구현
근데,, 만약에 여러 종류의 데이터를 하나의 키에 저장하고 싶다면? 즉, struct의 인스턴스를 Data 형태로 UserDefaults에 저장하고 조회하고 싶다면??
이런 형태로 값을 저장하고 가져오는 것도 하나의 프로퍼티로 해결할 수 있게 만들어봅시다!
아래 만들어본 프로퍼티 래퍼를 보면 String, Int, Date와 같은 기본 타입은 물론 구조체 인스턴스와 같은 커스텀 타입도 저장하고 조회할 수 있다는 것을 확인할 수 있습니다!
@propertyWrapper
struct UserDefaultsWrapper<T : Codable> {
let key : String
var wrappedValue: T? {
get {
guard let data = UserDefaults.standard.object(forKey: key) as? Data else {return nil}
let decoder = JSONDecoder()
let decodedObject = try? decoder.decode(T.self, from: data)
guard let decodedObject else {return nil}
return decodedObject
}
set {
let encoder = JSONEncoder()
if let encodedStruct = try? encoder.encode(newValue) {
UserDefaults.standard.setValue(encodedStruct, forKey: key)
}
}
}
}
struct PersonStruct : Codable {
let name : String
let age : Int
let regDate : Date
}
class TestClass{
@UserDefaultsWrapper(key : "string") var string : String?
@UserDefaultsWrapper(key : "personStruct") var personInfo : PersonStruct?
}
let class1 = TestClass()
//데이터 저장하기 전
//get -> Userdefault에 저장된 데이터 조회
print("class1.string -> ", class1.string) //nil
print("class1.personInfo -> ", class1.personInfo) //nil
//set -> Userdefault에 데이터 저장
let person1 = PersonStruct(name: "사람1", age: 1, regDate: Date())
class1.personInfo = person1
class1.string = "하하하 string 입니다"
//get
print("class1.string -> ", class1.string) //Optional("하하하 string 입니다")
print("class1.personInfo -> ", class1.personInfo?.name) //Optional("사람1")
이렇게 UserDefaults 코드를 점진적으로 개선해 보면서 보일러 플레이트를 줄일 수 있는 커스텀 프로퍼티 래퍼의 활용성에 대해 깨달을 수 있었습니다 ㅎㅎ 앞으로는 어떤 요소에 대해 저장하거나 값을 가져올 때 특정 행동/작업을 해준다면 property wrapper로 만들어줄 수 있지 않을까를 먼저 생각해보아야 할 것 같아요 :)