본문 바로가기
C.W.K.
Lesson 09 of 10 · published

폰은 집을 따라가

~14 min · mobile, migration, roles, keychain, ark

Level 0호기심
0 XP0/102 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

이전하는 날 폰은 다시 못 빌드해

가족 iOS 앱은 전부 표를 품고 컴파일돼. 멤버마다 어느 Mac 에서 도는지, HTTPS twin 이 어느 MagicDNS 이름과 포트에서 답하는지. 그 표는 앱을 빌드한 날 가족이 돌던 Mac 들을 적고 있어. Mac 은 물어보는 그 순간 port ledger 를 읽어서 이전을 따라가(backend 트랙 레슨 9). 폰엔 ledger 도, 호스트 프로필 파일도 없어. 스위치하는 날 아침 호텔에서 다시 빌드할 길도 없고. 그냥 두면 이전이 옛 피파 머신의 문을 다 닫은 뒤에도 아빠 폰의 앱들은 전부 옛 머신으로 계속 전화를 걸었을 거야.

이 구멍은 방주에서 먼저 드러났어. 2026-10-20 말라가. 피파의 방주 stage 1 은 스위치가 WebUI 에만 있는 채로 닫혔고(backend 트랙 레슨 10), 아빠가 당연한 걸 물었어.

근데 방주 모드 stage 1 에 폰 앱도 포함돼야 하는 거 아니야? 지금 웹UI에만 방주 모드가 있어. 폰은 여전히 office로 연결할 텐데? — 아빠

맞는 말이었어. 폰을 방주로 돌리는 길은 앱 하나하나의 Custom host 에 방주 주소를 직접 쳐 넣는 것뿐이었거든. 2026-10-19부터 10-21 사이에 폰은 셋을 배웠어. 방주가 어디 있는지, 어느 Mac 이 피파 머신인지, 그리고 지금 가리키는 Mac 의 호스트 프로필. 셋 다 기기 안의 가족 앱이 전부 읽을 수 있는 자리에 둬.

층은 셋, 순서는 하나

순서는 kit 이 한 번만 적어 두고, 가족 앱이 푸는 주소는 전부 그 순서를 거쳐. 아빠의 방주 프리셋이 켜져 있으면 그게 제일 위, 그 아래 가족의 역할, 맨 아래가 컴파일된 표. kit 의 말로는 이래. 방주는 모든 호스트를 Mac 한 대로 옮기고, 역할은 역할 하나를 옮기고, 컴파일된 표는 앱을 빌드한 그날이야. iOS 에선 방주 프리셋이 켜져 있는 동안엔 역할을 아예 안 봐.

역할은 피파 머신이 GET /api/family/roles 로 내주는 문서야. 버전 하나, 그리고 pippa 와 workbench 마다 머신, fleet label, registry 가 알면 MagicDNS 이름과 tailnet IPv4. 이건 따로 버전을 단 엔드포인트야. /api/host-profile 에 새 키를 넣지 않았어. 호스트 프로필의 Swift 파서는 모르는 키를 거절하고, 그러면 이미 깔린 폰 앱이 전부 프로필을 통째로 버렸을 테니까. 다른 버전의 역할 문서를 만난 클라이언트는 문서를 통째로 거절해. 그래서 옛 앱이 나중 모양을 반만 읽는 일은 없어.

멤버가 전부 피파 머신을 따라가진 않아. port ledger 가 역할로, 즉 @pippa 로 적어 두고 HTTPS twin 으로 가족한테 서비스하는 멤버만 따라가. twin 이 없는 멤버는 자기 Mac 의 것이라 그 자리에 남아(Cinder 의 Photoshop 사이드카, Mac 마다 있는 Smolder 와 Firebrand 게이트웨이). 다른 머신에 묶어 둔 멤버도 표가 정한 자리에 남고.

화면 앞으로 나올 때마다 묻고, 맞는 Mac 의 답만 믿어

피파 앱은 화면 앞으로 나올 때마다 먼저 방주가 어디 있는지 묻고, 그다음 가족의 역할을 물어. 두 번째 질문은 기기가 아는 머신 전부한테 가. 간직한 역할이 지금 피파를 두는 곳, 간직한 피파 머신, 컴파일된 표의 Mac 전부. 문서는 자기를 내준 머신을 피파 머신이라고 적었을 때만 쳐줘. 이전을 안전하게 따라가게 하는 건 이 검사 하나야. 스위치 뒤엔 새 Mac 이 그렇게 답하고, 옛 Mac 의 피파는 멈춰 있어서 아무 답도 안 하고, 다른 Mac 에서 온 문서는 건너뛰어. 아무도 답하지 않으면 폰은 알던 걸 그대로 들고 있어. 그래서 가족에 닿지 못하는 날에도 앱은 돌아.

그 답이 피파 앱의 연결을 옮기면 PIN 도 따라가. 가족 PIN 은 비밀 하나라서, 옛 호스트에 저장한 PIN 을 새 호스트의 keychain 계정으로 복사해. 단, 새 호스트에 PIN 이 없을 때만이야. 있는 걸 덮어쓰진 않아. 아빠는 결과를 설정에서 봐. Pippa machine 줄에 폰이 지금 거는 Mac 의 label 이 있고, 피파가 그걸 언제 알려 줬는지, 그리고 아빠의 되돌리기 버튼 Use this build's default 가 있어. 누르면 배운 역할을 잊고, 피파가 다시 답할 때까지 빌드의 기본값을 따라. 그 줄을 믿으려면 먼저 치워야 할 대체값이 하나 있었어. label 코드는 표에 cwkPippa 줄이 없으면 고정된 머신 이름으로 떨어졌어. 지금 코드 주석에 따르면, 그 고정 대체값 때문에 옛 이름이 이전 뒤에도 화면에 살아남았대. 이젠 컴파일된 표의 피파 머신으로 떨어져.

원칙: 바뀌는 날 다시 빌드할 수 없는 클라이언트는 그 변화를 실행 중에, 그걸 아는 단 한 곳에서 배워야 해. 그리고 자기가 어디서 왔는지 증명하는 답만 믿어야 해. 아직 답은 하는 낡은 서버가, 아예 사라진 서버보다 더 나쁜 출처야.

가리키는 Mac 의 호스트 프로필

역할은 "어느 Mac 이 피파 머신이냐" 에 답해. Mac 엔 호스트 프로필도 있어. 가족 호스트 전부를 다른 Mac 으로 다시 매핑하는 파일이고, Mac 에서 방주가 도는 방식이 바로 그 파일이야. 폰엔 그런 파일이 없으니까 2026-10-19부터 피파 앱이 하나를 읽어 와. 앱을 켤 때, 그리고 저장된 endpoint 가 옮겨 갈 때마다 설정이 거는 호스트에 GET /api/host-profile 를 물어. ledger 가 아는 호스트(어떻게 적었든)나 loopback 이면 간직한 프로필을 지워. 그 밖의 호스트는 아빠가 폰을 돌려놓은 Mac 이니까(방주도 여기 들어가) 그 프로필을 간직해. 그 전엔 프로필을 PIN 연결 안에서만 받아들여서, 세션을 간직한 채 켜거나 설정에 custom host 를 저장해도 프로필을 새로 받지 않았어.

그 route 와 방주 주소 GET /api/host-profile/ark 는 deep lockdown 말고는 어느 모드에서든 PIN 없이 답해. 이게 필요한 앱들이 피파 세션을 안 들고 있거든. Easel, Haven, Inkwell, Loomis, Sizzle, Smolder, Spark, Firebrand 는 자기 엔진하고만 말하고, 그 옆에서 프로필을 읽어. 문을 여는 데 커밋이 둘 들었어. 첫 커밋은 cwkPippa 의 미들웨어 갈래에서 경로를 열었어. 그런데 MacBook 에서 피파 머신의 twin 으로 재 보니 바깥 폰은 전부 여전히 401 을 받았어. 가족 공용 인증 정책이 먼저 도는데, 그 정책은 이 경로를 들어 본 적이 없었거든. 닫힌 문 뒤에 열어 둔 문은 닫힌 문이야. 둘째 커밋이 가족 정책 자체에서 열었어.

방주 스위치 하나, 가족 keychain 그룹에

폰에서 방주 스위치는 공용 kit 의 프리셋 하나고, 기기 안의 가족 앱을 한 번에 다 옮겨. 피파 앱에서 켜면 Spark 도 Haven 도 나머지도 방주 Mac 으로 걸고, 끄면 ledger 로 돌아와. 피파 앱은 화면 앞으로 나올 때마다 피파한테 방주 주소를 배워 둬. 그래서 가족의 Mac 들이 답하지 않는 날에도 스위치가 돌아. 스위치를 혼자 켜는 일은 절대 없고. 켜는 순간 피파 앱의 연결이 바로 옮겨 가고 PIN 도 따라가서, 방주는 아무것도 안 물어.

첫 판은 프리셋을 새 App Group group.com.cwk.family 에 뒀어. 여러 앱이 데이터를 나눌 때 iOS 에서 흔히 쓰는 자리지. 40분 갔어. 새 App Group 은 Xcode 계정 세션으로 Apple 에 등록해야 하는데, 키로 도는 TestFlight 파이프라인은 그걸 못 하고, fleet 의 어느 Mac 에도 그런 세션이 없었어. 다음 archive 가 실패했을 거야. 그래서 프리셋은 keychain access group $(AppIdentifierPrefix)com.cwk.family 로 옮겼어. 팀 접두사가 붙은 그룹이라, 가족 앱이 서명받는 프로필에 이미 다 들어 있거든. 피파 앱은 자기 그룹을 맨 앞에 적어서, PIN 들은 늘 있던 자리에 그대로 있어. 항목은 첫 잠금 해제 뒤부터 읽혀서, 백그라운드에서 깨어난 앱도 스위치를 따라가. 앱마다 2초짜리 기억을 거쳐 읽으니까, 다른 앱이 스위치를 바꾸면 2초 안에 보여. 역할도 같은 그룹에 두 번째 항목으로 있어. 피파 앱 FamilyArk.swift 의 머리 주석은 2026-10-27 현재 아직 "App Group" 이라고 적혀 있어. entitlements 와 kit 은 keychain 그룹이라고 하고, 실제로 도는 건 그쪽이야.

2026-10-21, iOS 27 시뮬레이터에서 MacBook 을 방주 Mac 으로 두고 스위치를 리허설했어. 피파 앱은 피파 머신한테 방주 주소를 0.05초 만에 배웠어. 켜니까 피파 앱과 멤버 전부가 MacBook 으로 옮겨 갔고, 따로 떨어진 앱인 Spark 도 keychain 그룹에서 스위치를 따라가 거기 있는 자기 엔진에 닿았어(health 200). 끄니까 둘 다 돌아왔고. 붙잡아 둔 멤버, 그러니까 아빠가 켜기 전까지 방주가 꺼 두는 바깥 쓰기 멤버는 폰의 가족 점검에서 붙잡힌 걸로 읽혀. 그 twin 의 502 는 절대 답으로 안 쳐.

워치는 폰을 따라가

가족 워치 앱은 엔진에 전화를 걸지 않아. iPhone 이 걸고 손목으로 건네줘(이 트랙 레슨 4). 그러니 워치는 따로 할 게 없어. 폰의 주소가 옮겨 가면 손목의 말도 같이 옮겨 가. 손목은 처음부터 그 모양으로 지어졌고, 그 덕에 이전하는 날엔 따라 옮겨야 할 기기가 하나 줄어.

이전 뒤가 아니라 이전 전에

이전 계획은 이걸 관문으로 세웠어. 클라이언트가 역할을 배우기 전에 빌드한 앱은 옛 Mac 으로 계속 걸고, 스위치는 그 Mac 의 문을 닫아 버려. 그래서 스위치는 아빠 기기의 빌드가 역할을 들고 있을 때까지 기다리고, 계획은 그 전에 호환 매트릭스를 요구해. 옛 빌드 대 새 엔진, 새 빌드 대 옛 엔진, 되돌린 뒤의 새 빌드. 역할을 아는 빌드는 스위치 전에 호텔에서 케이블로 아빠 폰에 깔렸고(이 트랙 레슨 6), 스위치 뒤엔 TestFlight 로 나갔어.

Code

그대로 옮김, cwkSiblingKit CwkKit/Sources/CwkKitCore/FamilyRoles.swift, 2026-10-27 기준·swift
  /// Ask Pippa for the family's roles on every machine this device knows (the compiled
  /// table's, the kept roles' Pippa machine, and wherever Pippa resolves now) and keep the
  /// first document that names the machine that served it as the Pippa machine: after a
  /// move only the new Mac answers that way. Best effort: nothing answering keeps what the
  /// device knew. Returns the roles kept after the call.
  @discardableResult
  public static func refreshFamilyRoles(fetch: HostProfileFetch = fetchHostProfile,
                                        in store: any FamilyDocumentStore = familyRolesStore) async -> FamilyRoles? {
    let kept = familyRoles(in: store)
    guard let port = serviceTable["cwkPippa"]?.https else { return kept }
    var candidates: [(name: String, dns: String)] = []
    func add(_ name: String, _ dns: String?) {
      guard let dns, !dns.isEmpty, !candidates.contains(where: { $0.dns == dns }) else { return }
      candidates.append((name, dns))
    }
    if let current = placement("cwkPippa", roles: kept, profile: .none) {
      add(current, machines(with: .none, roles: kept)[current]?.magicDNS)
    }
    if let kept { add(kept.pippa.machine, kept.pippa.magicDNS) }
    for (name, host) in hostTable.sorted(by: { $0.key < $1.key }) { add(name, host.magicDNS) }
    for candidate in candidates {
      guard let url = URL(string: "https://\(candidate.dns):\(port)/api/family/roles"),
        let (status, body) = try? await fetch(url), (200..<300).contains(status),
        let roles = try? familyRoles(from: body)
      else { continue }
      let names = roles.pippa.machine == candidate.name || roles.pippa.magicDNS == candidate.dns
      guard names else { continue }
      return (try? learnFamilyRoles(from: body, in: store)) ?? kept
    }
    return kept
  }
폰이 묻는 것, 그리고 이기는 것 (스케치)·text
화면 앞으로 나올 때마다 (피파 앱, 실제 앱에서만)
  GET /api/host-profile/ark   -> 방주가 어디 있나; 간직함, 혼자 켜는 일 없음
  GET /api/family/roles       -> 기기가 아는 머신 전부에 물음
                                 자기를 내준 Mac 을 적었을 때만 간직

켤 때, 그리고 저장된 endpoint 가 옮겨 갈 때
  GET /api/host-profile       -> ledger 호스트나 loopback 이면 프로필 지움
                                 그 밖의 호스트(방주)면 그 프로필 간직

폰의 가족 앱이 거는 곳
  1. 아빠의 방주 프리셋, 켜져 있는 동안
  2. 가족의 역할 (@pippa 로 적힌, HTTPS twin 있는 멤버)
  3. 컴파일된 표, 앱을 빌드한 그날

App Group 이 아니라 가족 keychain 그룹에 둠
  com.cwk.family.ark / preset    com.cwk.family.home / roles
  첫 잠금 해제 뒤부터 읽힘       앱마다 2초짜리 기억

워치는 아무 데도 안 걺: 폰을 따라감

Exercise

서버 주소가 컴파일돼 들어간 클라이언트를 하나 골라 봐. 모바일 앱이든 CLI 든 데스크톱 앱이든. 그 서버가 다른 머신으로 옮겨 가는 날 무슨 일이 생기는지 적어 봐. 이미 깔린 빌드 중 뭐가 계속 돌고, 뭐가 옛 서버로 계속 걸고, 모든 기기가 새 빌드를 들기까지 얼마나 걸리는지. 그다음 실행 중에 답을 얻는 가장 작은 길을 설계해 봐. 클라이언트가 어디에 묻는지, 아무도 답하지 않으면 뭘 간직하는지, 그 답이 맞는 머신에서 왔다는 걸 어떻게 아는지.
Hint
중요한 검사는 누가 답했냐가 아니라, 그 답이 답을 내준 머신을 적고 있냐야. 그리고 두 층이 어긋날 때 어느 쪽이 이기는지는 이전 전에 정해 둬.

Progress

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

댓글 0

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

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