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의 동기화 레이어 전체에 적용됩니다. 접근에 동의하지 않거나 다른 식으로 데어 본 적 있다면? 피드백 보드가 열려 있습니다.