본문 바로가기
C.W.K.
Stream
Lesson 05 of 05 · published

화면이 필요 없는 문들

~12 min · native-capture, ios, watchos, share-sheet, spool, intents

Level 0식은 화로
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"붙잡은 생각으로 가는 최단 경로는, 앱을 아예 안 여는 경로야."

브라우저가 가질 수 없는 세 가지

지난 네 레슨은 웹에서 캡처 계약을 가르쳤어. 필수 항목 없음, local-first outbox, composer 삼총사, 두 진실 위치. 그 계약은 PWA 에 살고, PWA 는 남아. 브라우저를 열 수 있는 모든 기기에서 설치 제로인 표면이니까. 근데 캡처 욕구 셋은 OS 레벨이야. tailnet PWA 로는 절대 안 돼. 다른 앱이 쥐고 있는 걸 받는 share sheet, 파일 피커가 아니라 캡처 표면 그 자체인 카메라, 그리고 iOS 가 못 쫓아내는 저장소. 메모리 압박에서 시스템이 회수해 갈 수 없는 큐. '기기 저장소는 최종 창고가 아니라 임시 보관대' 라던 그 문장이 늘 기다리던 거야. 그래서 Vesta 는 두 번째 클라이언트를 키웠어. 네이티브 iOS/iPadOS 앱, 그리고 나중에 Apple Watch. 같은 엔진의 두 번째 클라이언트지, 두 번째 창고가 아냐.

새 문은 새 방이 아니라 같은 방의 새 입구야. 브라우저 composer·share sheet·intent·watch, 커밋하는 모든 표면이 같은 엔진에, 같은 두 날짜 진실 아래, 같은 append-only 로그로 커밋해. 새 문이 통과해야 할 시험은 기능 자랑이 아냐. 어떤 invariant 를 다시 벌어야 하냐야. local-first 저장, 도착 순간보다 캡처 순간의 진실, 화면 켜졌을 때만 위치. 계약을 못 지키는 문은 안 짓는 거야.

문들

이제 화면 없이 crumb 을 커밋하는 문이 세 개고, 네 번째 문은 여는 쪽을 가리켜. share sheet 는 다른 앱 안에서 crumb 을 커밋해. Day One 채택 노트가 깨끗한 통로가 없어 미뤄 뒀던 두 번째 캡처 채널이, 채팅 앱 우회 대신 extension 으로 풀렸어. 도착한 건 저장하고, 못 도착한 건 이름을 불러. 첨부 provider 하나가 영원히 안 응답해도, 영원히 매달리는 대신 시트는 나머지를 저장하고 어떤 조각을 놓쳤는지 정확히 말해. intent 는 Siri·Shortcuts·Action Button 이 아무것도 안 열고 받아쓴 조각을 큐에 바로 커밋하게 해. composer 보다 짧아. 그래서 Vesta 의 그 다섯 초 문장으로 가는, 플랫폼이 허락하는 최단 경로야. 그리고 Apple Watch 는 캡처뿐이야. 스트림도, 달력도, 저널 피커도 없어. 손목은 폰보다도 더 다섯 초짜리 표면이고, 캡처가 아닌 모든 컨트롤은 너와 생각 사이에 서 있어. widget 은 네 번째 문인데, 캡처는 안 해. 오늘 개수와 기기에서 아직 기다리는 것을 보여주고, 탭하면 composer 를 열어. 그게 widget 이 지킬 수 있는 정직한 약속이야. iOS 는 몇 초만 주고 키보드도 네트워크 보장도 없으니까, 공유 app 컨테이너만 읽고 엔진엔 절대 전화 안 걸어. 사무실에 전화 거는 타일은 오래됐거나, 비었거나, 빙글 돌 뿐이야. 진짜 화면 없는 캡처는 intent 에 있어.

두 디테일이 규율이 얼마나 깊은지 보여줘. watch 에선 받아쓰기가 바로 열려. WatchKit 입력 컨트롤러의 suggestions 파라미터에서 nil 이 짐을 져. 빈 배열은 '아무것도 없는 목록' 이라 목록 화면을 띄우고, nil 일 때만 '목록이 아예 없다' 가 돼서 마이크가 열려. 그리고 complication(시계 페이스의 작은 위젯)은 상태를 아예 안 보여주는 버튼이야. 페이스는 힐끗 읽는 곳이고, 타임라인은 캡처가 아니라 OS 스케줄로 새로고침되니까, 거기 찍힌 개수는 몇 분이면 낡은 숫자가 돼. 절대 안 여는 그 하나의 표면에서 낡은 숫자는 없는 것보다 나빠.

spool: 의도적인 갈림

저장 경로 레슨에서 만난 웹 outbox 는 JSON 문서 하나고, 그 상태기계는 kit 이 주인이야. 폰은 그 모양을 못 가져. 네이티브에만 있는 이유 때문이야. 쓰는 손이 여럿이거든. 앱, share extension, intent, 그리고 다음에 올 무엇이든. 읽고-고치고-쓰는 공유 문서 하나는 한 손의 커밋을 경쟁으로 잃을 수 있어. 그래서 네이티브 큐, spoolcrumb 하나당 디렉토리 하나야. manifest 를 마지막에 써. 쓰는 손들은 파일을 만들기만 하고, 다시 쓰는 건 앱뿐. 잠금도 없고 경쟁도 없어. 그리고 항목의 id 가 곧 client_request_id 야. 엔진의 중복 제거 키와 로컬 정체성이 하나라, 중간에 끊긴 전송은 언제든 다시 해도 안전해. 이게 마지막 트랙 규율의 축소판이야. 계약은 물려받고(local-first, 엔진이 돌아오면 흘려보내), 도메인이 진짜 다른 곳에서 갈라지고(쓰는 손이 여럿), 그 갈림을 이유와 함께 아키텍처 문서에 적어.

watch 는 자기 진실을 지켜

손목 레인엔 두 날짜 진실 규칙이 짐을 진다는 이 퀘스트에서 가장 깨끗한 증거가 있어. watch 는 tailnet 에 없고 앞으로도 없어. 닿을 엔진이 없으니까. 폰이 그 엔진이야. 말로 붙잡은 crumb 은 먼저 watch 자기 큐에 커밋되고, 그다음 시스템의 전송 큐를 타. 순서대로 배달되고, 폰이 꺼져 있거나 손 닿지 않는 곳에 있어도 살아남아. 그리고 전송이 도착했다고 보고할 때만 큐 항목이 지워져. 여기가 핵심이야. 폰이 마침내 그 crumb 을 커밋할 때, 도착 순간이 아니라 watch 의 캡처 진실로 찍어. 핸드오프는 몇 시간 걸릴 수 있는데, 도착했을 때 썼다고 주장하는 crumb 은 엉뚱한 날에 떨어질 테니까. captured_at 은 기기 진실이고, 중요한 기기는 네가 말을 건넨 그쪽이야.

엔진이 안 된다고 할 때

정직한 실패 대목이 하나 더 네이티브 문들과 함께 왔어. 엔진이 진짜 4xx 로 거부한 crumb 은 더 이상 큐 전체를 뒤에 세워 두지 않아. needs-attention 격리로 옆으로 비키고, 나머지 하루는 계속 흘러가. outbox 는 불안정한 네트워크를 이기려 있는 거지, 엔진이 영원히 안 받을 crumb 하나에 아침의 캡처 전부를 인질로 잡으려 있는 게 아니니까. 일시적인 답, 408·425·429 와 인증 짝 401/403 은 여전히 흘려보내기를 멈춰. 일부러야. 잘못된 세션은 잘못된 crumb 이 아니거든. 문들 자체도 같은 정직함을 따라. 네이티브 composer 는 처음에 삼총사 다리 둘이 빠진 채 나왔고, 아키텍처 문서는 삼총사 레슨의 그 문장 그대로 말했어. incomplete 으로 기록됐지, simplified 가 아니라. 못 채운 invariant 를 이름 붙여 열어 둔 거야. 삼총사가 완성될 때까지. 볼 수 있는 invariant 는 믿을 수 있는 invariant 야.

Code

네 개의 쓰는 손, 하나의 spool, 하나의 엔진·text
composer (in-app)  --+
share sheet        --+    writers only CREATE:
intent (Siri/etc.) --+--> spool/<id>/media...        a directory per crumb,
watch (via phone)  --+    spool/<id>/manifest.json   manifest written LAST
                                 |
                                 |  only the app rewrites — no lock, no race
                                 v
                      drain when the engine returns
                      item id == client_request_id
                      -> an interrupted send is always safe to repeat

External links

Exercise

Vesta 에 다섯 번째 문을 하나 지어봐. crumb 을 접수하는 이메일 주소라든지, 잠금 화면 음성 메모, 차 대시보드 버튼. 그 문이 그대로 물려받는 캡처 invariant 는 뭔지 나열해 봐(local-first 커밋, 두 날짜 진실, 필수 항목 없음). 그리고 spool 이 outbox 에서 갈라졌듯, 의도적으로 갈라져야 하는 한 군데를 찾아봐. 그다음 Vesta 의 아키텍처 문서가 하듯 그 갈림을 적어 봐. 뭐가 바뀌었고, 왜인지.
Hint
정직한 질문은 보통 기능이 아니라 정체성과 진실에 대한 거야. 그 문의 captured_at 은 누구 것이고, 엔진에 못 닿을 때 뭘 하지? 화면이 없는 문이라면, 위치 표본이든 확인이든, foreground-only 규칙이 이미 답을 정해 줬어.

Progress

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

댓글 0

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

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