"설정 파일을 지운 건 아무도 없어. 새 속성 하나 때문에 앱이 그 파일을 없는 것처럼 읽었을 뿐이야."
안 쓰이는 기본값
가족 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:)안에 지역 함수로 선언해.