제목: Swift: UserDefaults Protocol


스위프트3은 언어뿐만아니라 우리 코드베이스까지도 쓰나미같은 변경이 생겼는데, 이 글을 읽는 몇몇은 아직도 마이그레이션과 투쟁하고 있을지 모르겠다. 그러나 이런 모든 변경에도 stringly typed의 Foundation으로된 몇몇 API들이 남을 것인데, 이는 꽤 괜찮아 보이지만... 그렇지 않을 수도 있다.

이것은 일종의 애증관계인데, API에서 문자열의 유연성은 '애'이나, 그들이 가져오는 상속적인 이유때문에 이것을 사용해야 하는 것을 '증'한다. 신경쓰지 않으면 위험하게 작업하고 있는 것과 동일하다.

Foundation 프레임워크를 만드는 사람들은, 우리가 의도한대로 정확하게 미리 정의할 수 없게 해놓아서 우리는 stringly typed API를 쓸 수 있게 되었다. 그래서 그들의 모든 지혜로움, 능력, 지식으로 개발자로서 무한한 가능성으로 만들 수 있게 하려고 몇몇 API에서는 문자열을 사용하도록 해놓았다. 이것은 어둠의 비밀의 마법이다. (So in all their wisdom, power and knowledge, they decided to use strings in some of the APIs because of the unlimited possibilities it creates for us as developers. It’s either that or some type of dark arcane magic.)
(역자: stringly 타입의 API로 유연하게 사용할 수 있게 해놓은 장점을 말하는 중입니다.)

UserDefaults
오늘의 주제는 내가 iOS 개발을 하면서 배울때 처음으로 친숙해진 API 중 하나이다. 이것이 익숙하지 않는 사람들을 위해 설명하자면 UserDefaults는 한 이미지나 어플리케이션 세팅같은 작은 정보를 저장하지위한 간단한 영속 데이터 저장소이다. 어떤 사람들은 이것을 "다이어트한 코어데이터"라고 생각하기도하지만, 사람들이 그것의 대체물로 만드려 아무리 노력해도 이것은 견고하지 않다.

Stringly Typed API
UserDefaults.standard.set(true, forKey: “isUserLoggedIn”)
UserDefaults.standard.bool(forKey: "isUserLoggedIn")
일반적으로 앱에서 UserDefaults는 앱의 어디에서라도 값을 간단하게 영속적으로 저장(set)하고, 검색(retrieve)하며, 덮어쓰거나(override), 제거하는(remove) 할 수 있다. 그러나 조심하지 않으면 균일성이나 문맥이 없는채로 해볼 순 있겠지만 오타를 칠 가능성이 높아질 것이다. 이 포스팅에서는 UserDefaults의 일반적인 특징을 변형하여 커스터마이징 할 것이다.

상수를 이용하기
let key = "isUserLoggedIn"

UserDefaults.standard.set(true, forKey: key)

UserDefaults.standard.bool(forKey: key)
이런 이상한 트릭을 따라하면 일시적으로는 더 나은 코드를 작성할 수 있을 것이다. 문자열을 한번 이상 쓴다면 상수로 바꿔서 쓰는 규칙을 적용시켜보자. 아마 나에게 고마워 할지도 모르겠다.

그룹 상수
struct Constants {
    let isUserLoggedIn = "isUserLoggedIn"
}
...
UserDefaults.standard
   .set(true, forKey: Constants().isUserLoggedIn)
UserDefaults.standard
   .bool(forKey: Constants().isUserLoggedIn)
균일성을 유지하기에 더 도움이 되는 방법은, 한곳에 중요한 디폴트 상수를 모아놓는 것이다. 그래서 여기에 디폴트를 저장하고 참조할 수 있는 Constants 구조체를 만들었다.

또다른 좋은 팁에는, 디폴트로 작업할 때 특히 프로퍼티 이름에 그 값을 반영해놓는 것이다. 이렇게하면 코드를 단순화 시켜주고 전반적인 속성을 더 균일화시켜줄 것이다. 프로퍼티 이름을 복사하고 문자열 안에 붙여넣으면 타이핑을 줄일 수 있을 것이다.
let isUserLoggedIn = "isUserLoggedIn"

문맥 추가하기
struct Constants {
    struct Account

        let isUserLoggedIn = "isUserLoggedIn"
    }
}
...

UserDefaults.standard
  .set(true, forKey: Constants.Account().isUserLoggedIn)
UserDefaults.standard
   .bool(forKey: Constants.Account().isUserLoggedIn)
단지 Constants 구조체를 가지는 것만으로도 괜찮겠지만, 코드를 작성할 때 문맥을 제공해야함을 잊어서는 안된다. 여러분 자신을 포함한)함께 작업할 누군가에게 더 읽기 좋은 코드를 만들어야함을 목표로 하는것이 좋다.
Constants().token // Huh?
token의 의미가 무엇일까? 문맥에서 네임스페이스가 없기때문에, 이 코드베이스에 익숙하지 않은 누군가(혹은 미래의 이 코드를 관리하는 사람)가 token이 무엇인지 알아내려할때 고생할 것이다.
Constants.Authentication().token // better

초기화 피하기
struct Constants {
    struct Account
        let isUserLoggedIn = "isUserLoggedIn"
    }

    private init() { }
}
절때로 우리가 의도하지도 않았고 우리 Constants 구조체를 초기화시키고 싶지 않기 때문에, 생성자는 private로 선언되어야한다. 이거은 좀 더 예방적인 단계이지만 계속 추진하고 있는 방법이다. 최소한 적어도 static만 원할때도 실수로 인스턴스 프로퍼티를 선언하는 것을 막아줄 것이다. 그러나 static에 관해 말하자면... 다음을 보자.

static 변수들
struct Constants {
    struct Account
        static let isUserLoggedIn = "isUserLoggedIn"
    }
    ...
}
...

UserDefaults.standard
  .set(true, forKey: Constants.Account.isUserLoggedIn)
UserDefaults.standard
   .bool(forKey: Constants.Account.isUserLoggedIn)
키에 접근할때마다 주의해야하는 것이 있는데, 접근할때마다 이것이 속한 구조체를 초기화해야할 수 있다. 그러지말고 static 선언을 사용하면 한번만 초기화한다.

구조체를 저장 타입으로 정했기 때문에 class대신 static을 사용한다. 스위프트 컴파일러 법에 따르면 구조체는 class 프로퍼티 정의를 사용할 수 없다고 한다. 또한 class 프로퍼티에 static 선언을 사용하면 그 프로퍼티는 final class로 선언한 것과 같다.
final class name: String

static name: String
// final class == static

열거형 케이스로 더 적게 타이핑하기
enum Constants {
    enum Account : String {
        case isUserLoggedIn
    }
    ...
}
...

UserDefaults.standard
    .set(true, forKey: Constants.Account.isUserLoggedIn.rawValue)
UserDefaults.standard
    .bool(forKey: Constants.Account.isUserLoggedIn.rawValue)
이 포스트의 초반부에서 말했듯, 균일성을 위해 프로퍼티는 그 값을 반영해야한다고 했었다. 여기에 static let 대신 enum case를 써서 그 과정을 자동화하여 한걸을 더 나가볼 것이다.

여러분도 인지했듯, 우리는 String을 따르는 Account열거형을 만들었는데, 이것은 RawRepresentable 프로토콜을 따른다. 이렇게 한 이유는, case를 위한 rawValue를 제공하지 않으려면 디폴트로 케이스가 반영될 것이기 때문에 이 작업을 한다. 우리가 해야할 타이핑이나 복사/붙여넣기를 줄이면 더 편해질 것이다.
// Constants.Account.isUserLoggedIn.rawValue == "isUserLoggedIn"

위에는 지금까지 UserDefaults로 꽤 괜찮은 것들을 달성했지만, 멋진것을 했다고 하기엔 좀 부족해 보인다. 가장 큰 문제는 문자열을 입고 있을지라도 여전히 stringly typed API로 작업하고 있어서 여전히 우리 프로젝트에 문제가 생길 수 있다는 점이다.

우리는 주어진 것으로만 작업할 수 있다는 마음을 가진다. 스위프트는 아주 많이 멋진 언어이고, 우리가 배워왔던, 그리고 Objective-C를 작성해가면서 알고 있는 많은 것들을 도전해볼 수 있다. 이 API에 문법 슈거를 만들어보자.

API 목표
UserDefaults.standard.set(true, forKey: .isUserLoggedIn)
// APIGoals
남은 이야기에서는 일반적인 populus 대신, UserDefaults와 소통할때 더 괜찮게 작업할 수 있는 API를 우리 필요에 맞게 만들어보려 할 것이다. and what better way than to do so than making extensions with protocols.

BoolUserDefaultable
protocol BoolUserDefaultable {
    associatedType BoolDefaultKey : RawRepresentable
}
불리언 UserDefaults를 위한 프로토콜을 만들면서 시작해보자. 변수나 함수가 없는 간단한 프로토콜이다. 그러나 RawRepresentable을 따르는 BoolDefaultKey라는 associatedType을 지원하는데 왜 이렇게 했는지는 바로 다음에 이해할 수 있을 것이다.

익스텐션
extension BoolUserDefaultable
    where BoolDefaultKey.RawValue == String { ... }
만약 Crusty's Laws 프로토콜을 따르려는 계획이라면 프로토콜 익스텐션을 선언할 수 있다. 그러나 associatedTyperawValueString 타입이라는 익스텐션에만 제약하는 where절을 적용시켰다.
모든 프로토콜로, 동등하고 해당되는 프로토콜 익스텐션이 있다 - Crusty's Third Law
With every protocol, there is an equal and corresponding protocol extension

UserDefaults 세터
// BoolUserDefaultable extension
static func set(_ value: Bool, forKey key: BoolDefaultKey) {
    let key = key.rawValue
    UserDefaults.standard.set(value, forKey: key)
}

static func bool(forKey key: BoolDefaultKey) -> Bool {
    let key = key.rawValue
    return UserDefaults.standard.bool(forKey: key)
}
그렇다. 이것은 표준 UserDefaults를 감싼 간단한 API이다. 이렇게 하는 이유는 Key-Path로된 문자열을 보내는것 보다 간략한 enum case를 보내는게 가독성면에서 더 좋기 때문이다.
UserDefaults.set(false,
    forKey: Aint.Nobody.Got.Time.For.this.rawValue)

프로토콜 따르기
extension UserDefaults : BoolUserDefaultable {
    enum BoolDefaultKey : String {
        case isUserLoggedIn
    }
}
우리는 BoolDefaultable을 따르게 하기 위해 UserDefaults를 익스텐션하고 RawRepresentable (String)을 따르는 BoolDefaultKey라는 연관타입을 지원했다.
// Setter

UserDefaults.set(true, forKey: .isUserLoggedIn)

// Getter

UserDefaults.bool(forKey: .isUserLoggedIn)
다시 말하자면, 작업하는 표준에 우리것을 정의하는 것 대신 우리가 지원한 API로 도전하는 중이다. UserDefaults를 익스텐션하면서 우리 API와함께 문맥을 잃어버리기 때문이다. 만약 .isUserLoggedIn 말고 다른 키 였다면 무엇과 관련되었는지 이해할 수 있었을까?
UserDefaults.set(true, forKey: .isAccepted)
// Huh? isAccepted for what?
이 키는 매우 모호해서, 모든 범주의 어떤것이든 될 수 있다. 이것처럼 보이지 않더라도 문맥을 제공하는 것은 항상 유익할 것이다.
필요하지만 가지지 못한것 보다는, 필요없더라도 가지고 있는 편이 낫다.
어렵게 생각하지 말자. 문맥을 추가하는 것은 쉬운 일이다. 간단하게 키를 위한 네임스페이스를 만든다. 이 경우, isUserLoggedIn 키가 있는 곳인 Account 네임스페이스를 만들었다.
struct Account : BoolUserDefaultable {
    enum BoolDefaultKey : String {
        case isUserLoggedIn

    }
    ...
}
...
Account.set(true, forKey: .isUserLoggedIn)

충돌
let account = Account.BoolDefaultKey.isUserLoggedIn.rawValue
let default = UserDefaults.BoolDefaultKey.isUserLoggedIn.rawValue
// account == default
// "isUserLoggedIn" == "isUserLoggedIn"
같은 프로토콜을 따르고 같은 키 케이스를 제공하는 서로다른 두 타입을 가지는 것이 가능하다. 이걸 출시하기 전까지 해결하지 못하면 분명 이것이 새벽에 우리를 깨우는 버그가 될것이다. 다른 값을 바꾸는 키를 가지는 위험을 안고 갈 수 없다. 그러니 우리 키를 네임스페이스한 것으로 만들자.

네임스페이스로 만들기
protocol KeyNamespaceable { }
물론 우리는 스위프트 개발자니까 프로토콜을 만든다. 프로토콜은 우리가 직면한 문제를 풀때 제일 먼저 하게되는 시도일 것이다. 만약 프로토콜이 초콜릿 소스라면, 심지어 스테이크에까지도 어디든지 올려놓을 수 있다(역자: 왜하필 초콜릿 소스에 비유를 했는지.. 다목적 소스라면 역시 굴소스 아닌가요?). 이것이 우리가 프로토콜을 만들어가며 개발하는게 얼마나 좋은지 보여준다.
extension KeyNamespaceable {
    static func namespace<T>(_ key: T) -> String

    where T: RawRepresentable {
          return "\(Self.self).\(key.rawValue)"
    }
}
이 간단한 함수는 두 오젝트를 합친 문자열 보간법을 쓰고, 그 사이에 마침표로 구분했다. 클래스의 이름과 그 키의 rawValue이다. 이 함수는 RawRepresentable을 따르면 key 인자로 받을 수 있게 제네릭을 인자로 받도록 해놓았다.
// BoolUserDefaultable extension
static func set(_ value: Bool, forKey key: BoolDefaultKey) {
    let key = namespace(key)

    UserDefaults.standard.set(value, forKey: key)
}

static func bool(forKey key: BoolDefaultKey) -> Bool {
    let key = namespace(key)

    return UserDefaults.standard.bool(forKey: key)
}
...

let account = namespace(Account.BoolDefaultKey.isUserLoggedIn)

let default = namespace(UserDefaults.BoolDefaultKey.isUserLoggedIn)


// account != default

// "Account.isUserLoggedIn" != "UserDefaults.isUserLoggedIn"

문맥
우리가 이 프로토콜을 만들었기 때문에, UserDefaults API 사용으로부터 해방된 느낌을 받고, 아마 프로토콜의 힘에 취했을 것이다. 이렇게하여 우리은 키를 우리가 원하는 곳으로 옮겨 문맥을 만듦으로서 코드를 읽을때 이해할 수 있게 되었다.
Account.set(true, forKey: .isUserLoggedIn)
그러나 API가 완전히 이해되지 않게 문맥을 잃어버리기도 했다. 처음 보면 이 코드가, 불리언을 영속적으로 저장시키는지 아니면 UserDefaults에 넣는지에대한 아무런 정보도 주지 않는다. 따라서 모든 사이클을 보여주기위해 UserDefaults를 익스텐션하여 우리의 디폴트 타입을 그 안에 넣을 것이다.
extension UserDefaults {
    struct Account : BoolUserDefaultable { ... }
}
...

UserDefaults.Account.set(true, forKey: .isUserLoggedIn)
UserDefaults.Account.bool(forKey: .isUserLoggedIn)


NatashaTheRobot에게 감사의 말을 전한다. 9월에 try! Swift NYC에서 발표할 기회를 얻었었다. 내 발표가 녹화되어 Realm에서 이것을 남겨두었고 Speaker Deck에 슬라이드 자료가 있으니 확인해보자. 발표를 한 이례로 몇가지 배운점을 이 글에 반영했으며, 샘플코드는 Gist나 Playground
에 있다.


이 블로그는 공부하고 공유하는 목적으로 운영되고 있습니다. 번역글에대한 피드백은 언제나 환영이며, 좋은글 추천도 함께 받고 있습니다. 피드백은 

으로 보내주시면 됩니다.



WRITTEN BY
tucan.dev
개인 iOS 개발, tucan9389

,