2026년 8월 29일 · 5분 읽기
표만 남은 기기: 알 수 없는 것을 아는 것처럼 말한 화면
복구 코드가 어디에 있는지 화면이 단정하던 문구를 판정 가능한 것만 말하도록 고쳤다. 같은 실수를 세 번 저지른 자리와 그것을 막는 단언을 적는다.
계정을 잃은 뒤 자료를 되살리는 문은 복구 코드 하나뿐이다. 그래서 설정 화면은 그 코드가 어디에 있는지 한 줄로 말해야 한다. 그 한 줄을 쓰다가 세 번 틀렸다. 세 번 다 앱이 알 수 없는 것을 아는 것처럼 적은 문장이었다.
동기화가 끝났는지는 앱이 모른다
복구 코드를 iCloud 키체인 슬롯에 쓰면 다른 기기로 따라간다. 문제는 그것이 언제 갔는지를 앱이 확인할 방법이 없다는 것이다.
// app/JustSend/Sources/Crypto/RecoveryBackupStore.swift:52-56
/// 동기화 완료는 공개 API로 확인할 수 없다(2026-08-26 재확인: 키체인 토글을 읽는
/// API가 없고, `kSecAttrSynchronizable` 쓰기는 토글과 무관하게 성공한다). 그러나
/// **iCloud 계정이 없는 것은 판정할 수 있고**, 그때 동기화는 확실히 일어나지 않는다.
/// 그래서 규칙은 이렇다 — 알 수 없는 것은 단정하지 않고, 알 수 있는 실패는 말한다.
쓰기는 사용자가 iCloud 키체인을 껐어도 성공한다. 따라서 "백업됨"은 쓰기 성공으로 증명되지 않는다. 반대로 이 기기에 iCloud 계정이 아예 없다는 사실은 읽을 수 있고, 그때는 코드가 이 기기 밖으로 나가지 못한다고 단정해도 된다.
코드가 사는 자리는 둘이다
두 슬롯은 키체인 서비스 이름으로 갈라져 있다. 한쪽은 동기화되는 항목이고, 다른 쪽은 이 기기에만 남는 항목이다.
// app/JustSend/Sources/Crypto/RecoveryBackupStore.swift:310-320
/// 두면 조회 하나가 두 자리를 함께 긁어 가고, 어느 쪽이 나올지는 순서에 달린다.
private static let deviceOnlyService = "one.clab.justsend.e2ee.recovery.device"
/// 복구 코드를 이 기기에만 저장한다(userId 스코프). `ThisDeviceOnly` 접근성이
/// iCloud Keychain 동기화를 막는다 — 그것이 이 슬롯의 존재 이유다.
이 기기 전용 항목의 성질은 Apple 문서가 못 박아 둔다.
Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present.
여기서 사고가 난다. 코드는 새 기기로 오지 않는데, 어디에 두었는지 적어 둔 표는 온다. 표는 평범한 사용자 기본값에 있고 그것은 기기 백업으로 복원된다.
표만 남은 기기
그래서 화면이 무엇을 말할지 정하는 판정은 표가 아니라 값에서 시작해야 한다.
고친 뒤의 판정은 두 줄로 요약된다. 적어 둔 자리를 먼저 보되 그 자리에 값이 있을 때만 답으로 삼고, 비어 있으면 실제로 값이 있는 자리를 답으로 돌린다.
// app/JustSend/Sources/Crypto/RecoveryBackupStore.swift:170-181
func loadMode(userId: String) throws -> RecoveryBackupMode? {
// 적어 둔 자리를 먼저 보되 **값이 있을 때만** 그것을 답으로 삼는다.
if let recorded = RecoveryBackupStore.recordedMode(userId: userId),
try code(in: recorded, userId: userId) != nil
{
return recorded
}
// 표가 없거나 그 자리가 비었으면, 실제로 값이 있는 자리가 답이다.
if try RecoveryBackupStore.loadFromICloud(userId: userId) != nil { return .icloud }
if try RecoveryBackupStore.loadDeviceOnly(userId: userId) != nil { return .deviceOnly }
return nil
}
이 두 줄을 빼면 무엇이 벌어지는지는 짐작할 필요가 없다. 같은 스위트가 말해 준다.
$ xcodebuild test-without-building -only-testing:JustSendTests/RecoveryBackupChoiceTests
RecoveryBackupChoiceTests.swift:320: error: XCTAssertNil failed: "deviceOnly" - 코드가 없으면 백업 위치도 없다 - 표만 보고 '이 기기에만 있음'이라 말하면 거짓이다
Test Case 'testStaleModeRecordWithAnEmptySlotIsNotReportedAsBackedUp' failed (0.056 seconds).
Executed 17 tests, with 2 failures (0 unexpected) in 0.447 (0.454) seconds
새 폰을 든 사람이 설정을 열면 "이 기기에만 있음"이 서고, 화면은 코드가 있다고 판단해 만들기 대신 교체만 내놓는다. 없는 코드를 보려는 사람에게 없는 문을 내주는 셈이다.
세 문구는 서로 다른 것을 말한다
문구는 세 갈래다. 동기화 슬롯에 있고 계정이 있으면 "iCloud 슬롯에 있음"이라고만 적는다. 전달이 끝났다고는 말하지 않는다. 계정이 없으면 "이 기기 밖으로 못 나감"이라고 단정한다. 그것은 확실한 실패이기 때문이다. 슬롯이 비어 있으면 위치를 말하지 않는다.
같은 실수를 세 번 했다
한 화면에서 같은 종류의 단정이 세 번 났다. 매번 고친 자리 바로 옆이었다.
| 차수 | 어디서 | 무엇을 단정했나 |
|---|---|---|
| 1차 | 값 자리 | "iCloud에 백업됨" — 동기화 완료 |
| 2차 | 경고 각주 | "로그인하면 그때부터 받는다" — 로그인이 충분조건 |
| 3차 | 계정 판정 | 불리언 거짓을 계정 없음으로 읽었다 |
한 화면의 상태 값과 각주, 그리고 그 값을 만드는 판정은 한 덩어리다. 하나만 고치면 모순은 사라지지 않고 이동한다. 그것이 이 작업에서 가장 오래 걸린 부분이었고, 기능 구현이 아니라 한 문장이 사실인지 아닌지를 가리는 일이었다.
설정 목록의 복구 코드 자리는 145 → 1줄로 줄었다. 남은 한 줄은 상태만 말하고, 설명·복사·가리기·만들기·교체는 하위 페이지로 내려갔다.
재발을 막는 단언
같은 실수를 막는 것은 문서가 아니라 계약이다. 위의 실패를 낸 판을 되돌리고 같은 스위트를 돌리면 이렇게 끝난다.
$ xcodebuild test-without-building -only-testing:JustSendTests/RecoveryBackupChoiceTests
Test Case 'testStaleModeRecordWithAnEmptySlotIsNotReportedAsBackedUp' passed (0.020 seconds).
Executed 17 tests, with 0 failures (0 unexpected) in 0.515 (0.520) seconds
17건 가운데 하나는 주입한 가짜 저장소가 아니라 실제 키체인을 쓴다. 두 슬롯이 실제로 갈라져 있고 옮긴 뒤 조회가 산 자리를 집는다는 것을 시뮬레이터 키체인에 대고 확인한다.
주입한 가짜와 실제 키체인을 함께 돌린다
가짜 저장소만 쓰면 실패를 만들 수는 있어도 그 순서가 내가 쓴 것이라 증명이 약해진다. 반대로 실제 키체인만 쓰면 쓰기 실패나 읽기 불일치 같은 경로를 만들 수 없다. 그래서 둘을 함께 돌린다. 판정 규칙과 오류 처리는 주입한 구현으로, 두 슬롯이 실제로 갈라져 있다는 사실은 시뮬레이터 키체인으로 확인한다.
실패한 판의 그 테스트는 0.056 s에 떨어지고, 고친 판에서는 0.020 s에 통과한다. 스위트 전체는 0.515 s다. 같은 스위트, 같은 시뮬레이터, 다른 판정 한 줄이다.
남은 제약
여전히 판정할 수 없는 것이 남아 있다. iCloud 키체인이 켜져 있는지, 코드가 다른 기기에 실제로 도착했는지는 앱이 모른다. 그래서 화면은 "슬롯에 있다"까지만 말하고, 사용자에게 코드를 종이나 암호 관리자에 옮겨 적으라고 권한다.
이 기기 전용을 고른 사람에게는 기기를 바꾸면 코드가 오지 않는다는 사실을 미리 말한다. 그 경고를 읽고도 그 자리를 고르는 것은 정당한 선택이다. 앱이 할 일은 그 선택의 결과를 숨기지 않는 것이다. 한 가지 더 남는다. 이 화면은 코드가 어디 있는지 말하지만, 사용자가 그 코드를 실제로 옮겨 적었는지는 알 수 없다. 확인 표식은 사용자가 직접 누른 것이고, 코드를 회전시키면 그 표식은 지워진다. 새 코드를 아직 아무도 적지 않았는데 화면이 "백업함"으로 읽히는 것을 막기 위해서다. 여기서도 규칙은 같다. 앱이 아는 것만 말하고, 사람이 한 일은 사람이 눌러 알려 준다.