개발 블로그로 돌아가기 함정 보고서

전방 호환 Codable: 옛 클라이언트는 절대 파괴할 수 없어야 한다

SharkTTY는 저장된 SSH 호스트를 기기 간에 동기화합니다. 앱에 새 연결 유형을 추가하던 중, 답이 꽤 불편한 질문 하나를 스스로에게 던졌습니다. 새 앱이 쓴 호스트 레코드가 아직 옛 앱을 돌리는 기기에 도착하면 무슨 일이 벌어지는가? 지극히 평범한 Swift Codable 구성에서의 답은 이랬습니다. 옛 기기의 동기화가 조용히 얼어붙는다 — 그리고 그걸 가장 뻔한 방법으로 「고치면」 더 나빠진다. 옛 기기가 새 기기의 데이터를 파괴하기 시작한다. 두 실패 모두 특이한 코드가 필요 없습니다. 순전히 기본 동작에서 나옵니다.

실패 하나: 미래를 거부하는 enum

어느 Swift 코드베이스에나 어딘가에는 있는 구성입니다:

enum ConnectionMethod: String, Codable { case ssh, mosh }

struct Host: Codable {
    let id: UUID
    var name: String
    var connectionMethod: ConnectionMethod
    // …
}

// 동기화 레이어 어딘가에서:
guard let hosts = try? decoder.decode([Host].self, from: doc) else { return }

String 원시값의 Codable enum은 닫힌 세계입니다. 모르는 원시값은 폴백하지 않고 throw합니다. 그래서 새 앱이 "connectionMethod": "et"를 쓰는 날부터 모든 옛 독자는 그 한 레코드에서 throw하고 — 목록 전체가 한 덩어리로 디코딩되기 때문에 못 읽는 한 건은 곧 한 건도 없음을 뜻합니다. try?가 오류를 삼키고, 가져오기는 일찍 반환하고, 기기는 낡은 목록을 영원히 붙듭니다. 크래시도, 로그 한 줄도, 배너도 없습니다. 동기화는 그냥… 동기화이기를 멈춥니다. 아직 업데이트하지 않은 모든 기기에서 — 그리고 실제 배포 기간에는 그게 대부분의 기기입니다.

실패 둘: 데이터를 파괴하는 「관용적」 디코더

반사적인 수정은 느슨한 디코딩입니다. 모르는 값이면 기본값을 고른다:

let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh   // 관용적… 그러나 손실

이건 동결을 고치는 대신 지뢰를 심습니다. 동기화 시스템은 재인코딩을 합니다. 옛 앱이 다른 호스트를 편집하고 목록 전체를 직렬화해 올립니다 — last-writer-wins. 그런데 그 앱은 "et".ssh로 디코딩했으니 되써 넣는 것도 그것이고, 자기에게 프로퍼티가 없던 필드들 — 새 레코드의 추가 키들 — 은 디코딩 시점에 이미 Codable이 버렸으니 업로드에도 없습니다. 뒤처진 기기 한 대에서의 편집 한 번으로, 새 기기의 설정이 계정 전체에서 조용히 옛 모양으로 다시 쓰입니다. 동결은 그래도 데이터를 지켰습니다. 관용적 디코더는 그것을 공손하게 지워 버립니다.

계약: 옛 코드는 새 코드가 쓴 것을 절대 파괴할 수 없어야 한다

Protobuf는 수십 년 전에 미지 필드 보존으로 이 문제를 풀었습니다. Swift의 Codable은 도구는 주지만 기본값은 주지 않습니다. 두 수로 그것을 재현합니다.

와이어의 진실을 저장하고, 타입 뷰를 노출한다. 각 enum 필드는 원시 문자열을 저장 프로퍼티로 유지하고, 타입화된 enum은 표시 전용 폴백을 가진 계산 프로퍼티가 됩니다. 모르는 값을 읽으면 표시만 우아하게 낮추고, 되써 넣을 때는 정확히 재현합니다. 그것을 덮어쓰는 건 오직 사용자의 의도적 편집 — setter — 뿐입니다:

private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
    get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh }  // 폴백은 「표시」용
    set { connectionMethodRaw = newValue.rawValue }                  // 의도적 편집만 이긴다
}

이해 못 하는 키를 보관한다. 문자열 키 컨테이너로 한 번 더 훑어, 내 것이 아닌 키를 전부 JSONValue 주머니에 담습니다(null·bool·int·double·string·array·object 일곱 케이스짜리 작은 enum — Double보다 Int64를 먼저 시도하세요. 아니면 포트 번호가 부동소수점으로 돌아옵니다). encode(to:)는 아는 필드를 쓴 다음, 주머니를 그대로 재생합니다:

// 디코딩: 미래를 거둬 둔다
let known = Set(CodingKeys.allCases.map(\.rawValue))
let any = try decoder.container(keyedBy: AnyCodingKey.self)
for key in any.allKeys where !known.contains(key.stringValue) {
    unknownFields[key.stringValue] = try any.decode(JSONValue.self, forKey: key)
}

// 인코딩: 미래를 손대지 않고 돌려준다
var any = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in unknownFields {
    try any.encode(value, forKey: AnyCodingKey(stringValue: key))
}

둘 다 갖추면 옛 클라이언트는 안전한 통로가 됩니다. 보여줄 수 있는 건 보여주고, 못 보여주는 건 그대로 왕복시킵니다. 버전이 섞여 돌아가는 기기들 — 모든 릴리스 전후 몇 주간의 세상 기본값 — 은 더 이상 위험 요소가 아닙니다.

테스트로 계약을 못 박기

계약 전체를 떠받치는 건 테스트 셋입니다. 왕복 픽스처: 「미래 버전」의 레코드 — 모르는 enum 값, 모르는 스칼라, 모르는 중첩 객체 — 가 오류 없이 디코딩되고, 재인코딩 후 원래 키가 전부 온전해야 합니다. 의도적 편집 테스트: 타입 프로퍼티에 값을 대입하면 원시값은 덮어써지되 미지 키들은 살아남아야 합니다. 그리고 골든 테스트: 아는 필드만 가진 레코드는 옛 코드의 출력과 바이트 단위로 같아야 합니다 — unknownFields 키도, …Raw 같은 저장 이름도 와이어로 새면 안 됩니다. 이 마지막 하나가 있어야 포맷을 몰래 갈라놓지 않고 내부를 리팩터링할 수 있습니다.

끝으로 정직한 경고 하나. 이 중 어느 것도 이미 출시된 클라이언트를 고치지 못합니다. 그들의 디코더는 여전히 미래를 거부합니다. 이행은 지루하고 피할 수 없습니다 — 관용적이고 보존하는 디코더를 먼저 실어 내보내고, 퍼질 때까지 기다린 뒤, 그제서야 새 값이 흐르게 하세요. 스키마 진화란 직렬화의 옷을 입은 순서 문제입니다.

이 보강은 다음 업데이트에서 SharkTTY의 동기화 레이어 전체에 적용됩니다. 접근에 동의하지 않거나 다른 식으로 데어 본 적 있다면? 피드백 보드가 열려 있습니다.