본문 바로가기
C.W.K.
Lesson 05 of 06 · published

필드 하나 추가하는 것도 마이그레이션이야

~16 min · swift-for-apple, codable, persistence, migration

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"설정 파일을 지운 건 아무도 없어. 새 속성 하나 때문에 앱이 그 파일을 없는 것처럼 읽었을 뿐이야."

안 쓰이는 기본값

가족 iOS 앱 하나를 새로 빌드해서 켰더니, 엔진 주소는 초기화돼 있고 기기 id는 새로 발급돼 있었어. 마치 한 번도 설정한 적 없는 것처럼 말이야. 디스크의 설정 파일은 멀쩡했어. 바뀐 건 설정 구조체에 저장 속성 하나 추가한 것뿐이었고, 기본값도 아주 합리적이었지. var deviceName: String = "Spark".

여기엔 아무도 예상 못 하는 규칙이 숨어 있어. Swift가 자동으로 만들어주는 Decodable은 속성 기본값을 안 봐. 옵셔널이 아닌 속성엔 decode를 부르고, JSON에 키가 없으면 기본값이 있든 없든 DecodingError.keyNotFound를 던져. 옛 파일엔 deviceName이 없었으니 디코딩이 실패했어. 앱은 새 인스턴스로 넘어갔고, 다음에 저장할 때 그 기본값으로 진짜 파일을 덮어썼지. 옵셔널 속성은 decodeIfPresent로 읽혀서 안전해. 그래서 옵셔널 아닌 필드를 처음 추가하는 날까지 이게 안 드러나는 거고.

옛 파일도 받아주는 디코더를 직접 써

앱이 저장한 걸 나중에 더 새로운 빌드가 읽는 문서라면(설정, 초안, 큐, 의미 있는 캐시) init(from:)을 직접 써. 필드마다 decodeIfPresent로 읽고, 없으면 새 인스턴스의 값으로 채워. 필드마다 try?로 감싸두면, 타입이 바뀐 필드 하나가 나머지 문서까지 끌고 넘어지지 않아. 그러고 나서 테스트로 고정해둬. 하나는 이전 빌드가 쓴 JSON 문자열을 그대로 디코딩하는 테스트, 다른 하나는 {}가 기본값으로 디코딩되는지 보는 테스트야. 참고로 init(from:)을 직접 쓰면 멤버별 이니셜라이저가 사라지니까 init()을 다시 넣어줘.

비슷한 함정 세 가지

  • 타입이 엄격해지면 파일 전체가 위험해져. 가족 Mac 유틸리티 하나가 이름을 못 붙이는 키 코드를 거부하는 공유 단축키 타입을 도입했어. 단축키 하나로 보면 맞는 선택이야. 근데 안쪽 필드 하나가 던지면 감싸고 있는 문서 전체가 실패해. 저장된 매크로 목록에 든 단축키 하나 때문에 배열 전체가 실패했고, 저장소는 읽기에 실패하자 기본값으로 파일을 덮어써버렸어. 디코딩 에러는 저장소에서 잡고, 필드 단위로 대체값을 써.
  • 문서의 정체성 키엔 기본값을 주지 마. 킷 저장소 하나가 최상위 entries 배열을 기본값 []로 디코딩했어. 그러자 다른 키 밑에 큐를 저장하던 옛 앱의 파일이 빈 아웃박스로 읽혔어. 새 캡처가 하나만 들어와도 아직 안 보낸 항목을 전부 덮어쓸 뻔했지. 필드엔 기본값을 줘도, 이 파일이 네 거라는 걸 증명하는 키는 필수로 둬.
  • CodingKeys는 멤버 시그니처에 쓸 수 없어. 타입 멤버를 검사하는 도중에 합성되는 거라서, static func read(_ c: KeyedDecodingContainer<CodingKeys>) 같은 헬퍼는 "cannot find type 'CodingKeys' in scope"로 실패해. 헬퍼를 K: CodingKey 제네릭으로 만들거나, init(from:) 안에 지역 함수로 선언해.

Code

자동 디코딩은 옛 파일에서 던지고, 직접 쓴 디코딩은 값을 지켜내·swift
import Foundation

struct NaiveSettings: Codable {
    var host: String = "127.0.0.1"
    var port: Int = 8500
    var deviceName: String = "Spark"          // added in build 2
}

struct Settings: Codable {
    var host: String = "127.0.0.1"
    var port: Int = 8500
    var deviceName: String = "Spark"

    init() {}                                   // custom init(from:) removes the memberwise one

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let fresh = Settings()
        host = (try? values.decodeIfPresent(String.self, forKey: .host)).flatMap { $0 } ?? fresh.host
        port = (try? values.decodeIfPresent(Int.self, forKey: .port)).flatMap { $0 } ?? fresh.port
        deviceName = (try? values.decodeIfPresent(String.self, forKey: .deviceName)).flatMap { $0 }
            ?? fresh.deviceName
    }
}

let writtenByBuild1 = Data(#"{"host":"engine.example","port":9000}"#.utf8)

do {
    _ = try JSONDecoder().decode(NaiveSettings.self, from: writtenByBuild1)
} catch {
    print(error)   // DecodingError.keyNotFound: Key 'deviceName' not found ...
}

let kept = try JSONDecoder().decode(Settings.self, from: writtenByBuild1)
print(kept.host, kept.port, kept.deviceName)   // engine.example 9000 Spark

External links

Exercise

Spark에서 설정이 날아가는 걸 재현해봐. 필드 두 개인 버전으로 Settings 파일을 저장하고, 기본값 있는 옵셔널 아닌 세 번째 필드를 추가한 다음, 순진한 디코딩이 던지는 걸 보여줘. 이어서 너그러운 init(from:)을 쓰고 테스트 두 개를 만들어. 하나는 옛 JSON 문자열을 디코딩해서 옛 값이 전부 남았는지 확인하고, 다른 하나는 {}가 기본값으로 디코딩되는지 확인해. 마지막으로 파일 위에 기본값을 저장했을 코드 경로를 찾아서 거부하게 만들어.
Hint
옛 JSON은 빌드 1이 쓴 그대로 테스트 안에 문자열 리터럴로 둬. 새 타입으로 인코딩하고 다시 디코딩하는 테스트는 새 인코더랑 새 디코더가 서로 맞는다는 것밖에 증명 못 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.