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

손목에서 온 메모

~14 min · watchos, watchconnectivity, capture, exactly-once, daily-note, v4

Level 0식은 초고
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"아이폰에서 앱을 열어두고 해야 연동이 되면 워치 앱은 사실상 쓸모가 없어져." — 아빠, 2026-09-11

화면 하나, 들어가는 길 셋

레슨 4 에서 워치 얘기는 한 줄이었어. 손목에 생각을 받아쓰면 폰이 오늘 데일리 노트에 시각 붙은 한 줄로 넣어준다고. 보통 날엔 그 말 그대로야. 그런데 그 한 줄 밑에선 워치, 폰, 데일리 노트, office 가 차례로 바통을 넘겨. 이 레슨은 메모 하나를 따라 그 네 구간을 지금 코드가 도는 그대로 걸어가.

워치의 Rekindle(2026-09-14 출시)은 화면 하나에 버튼 하나야. Dictate a memo. 손목은 5초짜리 화면이고, 메모 말고 다른 컨트롤은 전부 생각과 손 사이에 끼어드는 방해물이거든. 마이크까지 가는 길은 셋이야. 첫째는 앱 안의 버튼. 둘째는 워치 페이스 컴플리케이션인데, 이건 버튼 그 자체야. 개수도 상태도 안 보여줘. 컴플리케이션은 watchOS 가 정한 예산대로만 새로 그려지고, 페이스에 낡은 숫자가 떠 있느니 아예 없는 게 낫거든. 누르면 rekindle://memo 가 열리고, 워치 앱은 그 URL 을 받자마자 받아쓰기를 띄워. 셋째는 액션 버튼이야. 액션 버튼에 앱을 바로 고를 수 있는 건 운동 앱과 다이빙 앱뿐이야. 그래서 Rekindle 같은 앱은 단축어를 하나 내놓고 그걸 통해 걸려(DictateMemoIntent, "Rekindle 메모"). 인텐트가 하는 일은 한 번 쓰고 버리는 실행 플래그를 세우고 앱을 여는 것뿐이야. 마이크는 처음 뜨는 뷰가 그 플래그를 한 번 읽고 열어. WatchKit 받아쓰기 화면은 보이는 인터페이스 컨트롤러가 있어야 뜨는데, 콜드 런치 순간엔 그게 아직 없거든.

받아쓰기는 SwiftUI 가 아니라 WatchKit 으로 가. watchOS 의 SwiftUI 엔 받아쓰기를 요청하는 방법 자체가 없고, 그냥 TextField 를 두면 시스템이 고른 입력 화면이 떠. 아빠 워치에선 그게 키보드였어. 그래서 가족 wrist kit 은 presentTextInputController(withSuggestions: nil, allowedInputMode: .plain) 을 불러. 빈 배열 말고 nil 을 넘겨야 마이크가 열려. 한영 전환은 그 시스템 화면 안에서 위로 쓸어 올리면 돼. 확인 단계는 없어. 잘못 들린 낱말은 폰에서 고치면 되지만 날아간 생각은 못 돌려받으니까. 받아쓰기를 못 쓰는 상황이면 화면이 그렇다고 말하고 입력칸을 내줘. 말하라던 버튼이 슬그머니 키보드로 바뀌진 않아.

워치가 먼저 적어둬

받아쓴 말은 손목에서 WristMemo 가 돼. 앞뒤 공백을 다듬은 본문, 워치에서 새로 만든 id(rk-memo-…), 말한 순간을 찍은 ISO 8601 UTC 시각, 그리고 지금 워치가 있는 시간대 식별자. 시각을 찍기 직전에 앱이 시스템 시간대를 다시 읽는데, 워치 앱은 비행 내내 잠들어 있다 깨어날 수도 있어서야. 이제 메모는 워치 자기 큐에 들어가. 워치 앱 Application Support 밑의 JSON 파일이야. 손목 밖으로 뭔가 나가는 건 그다음이야. 화면의 "Captured" 도 큐에 다 쓰고 나서야 떠. 워치가 쓰기를 못 하면 "Not saved" 밑에 이유와 함께 말한 내용이 그대로 남아. 워치가 못 적어둔 메모는 화면에서라도 보여주지 않으면 그냥 사라지니까.

폰이 받았다고 확인해주기 전까지 그 큐가 유일한 사본이야. 그래서 유일한 사본답게 다뤄. 항목이 큐에서 빠지는 건 transferUserInfo 가 에러 없이 didFinish 를 알려줄 때뿐이고, 보낸 순간엔 절대 안 빠져. 큐 파일이 안 읽히면 바이트를 그대로 둔 채 옆으로 옮기고 화면에 그 사실을 띄워. 빈 목록으로 덮어쓰는 일은 없어. 그리고 WristMemo 는 디코딩을 손으로 짜서, id 다음 필드마다 기본값을 줘. 나중 버전에서 필드가 하나 늘어도 업데이트가 그 전에 말해둔 메모를 삼키지 못하게 하려는 거야. 버튼 밑엔 정직한 숫자가 떠. 큐에 남은 게 있으면 "2 waiting for the phone" 처럼 아직 못 간 개수를 보여줘. 큐가 비면 '폰에 갔고 곧 노트로 정리된다'는 한 줄로 바뀌어.

전송은 기록이지 초인종이 아니야

transferUserInfo 는 오래 버티는 길이야. 시스템이 큐에 넣고, 순서대로 배달하고, 폰이 꺼져 있든 잠들어 있든 멀리 있든 붙들고 있어. 딱 하나 안 하는 게 폰 앱을 깨우는 거야. Apple 문서 표현으론 상대 앱은 다음에 실행될 때 델리게이트 콜백을 받아. 그게 실제로 무슨 뜻인지 아빠가 2026-09-11 에 쟀어. Rekindle 워치가 나오기 사흘 전, 가족 wrist kit 에서였어. 아이폰을 잠근 채 주머니에 넣고 워치에 말한 메시지가, 아빠가 직접 폰 앱을 열 때까지 512초를 그냥 기다렸어.

그래서 kit 은 초인종을 눌러. 전송할 때마다, 폰에 닿을 수 있으면 같은 봉투를 sendMessage 로 한 번 더 보내. 닿을 수 있다는 건 폰이 가까이 있고 부팅 뒤 한 번은 잠금이 풀렸다는 뜻이야. 앱이 떠 있을 필요는 없어. iOS 앱을 백그라운드로 깨우는 건 전송이 아니라 메시지야. sendMessage 도, 날것의 바이트를 보내는 쌍둥이 sendMessageData 도 깨워. 기록은 여전히 전송 쪽이고, 워치가 메모를 놓는 것도 전송의 didFinish 에서뿐이야. 초인종이 실패해도 잃는 건 없어. 전송은 전처럼 폰 앱이 다음에 뜰 때 도착하니까. 그리고 다시 내밀 때마다 초인종도 다시 울려. 세션이 활성화될 때, 폰에 다시 닿게 될 때, 남은 게 있는 채로 앱이 열릴 때. 그러니 메모 하나가 폰에 두세 번, 그 이상도 닿을 수 있어. 일부러 그렇게 만든 거고, 그걸 소화하는 건 다음 구간의 몫이야.

폰에는 문이 하나

폰의 수신기는 뷰가 아니라 앱의 init 에서 만들어지고 활성화돼. 초인종은 화면 하나 없이 앱을 백그라운드로 띄우거든. 메시지로 오든 전송으로 오든 같은 문으로 들어와. 문은 봉투를 메모로 열어보고, memo:<id> 를 키로 쓰는 seen 장부에 물어봐. 512개까지 기억하고, 캡처 장부 옆에 파일로 남는 장부야. 기억하는 id 면 메아리라서 Rekindle 코드가 돌기도 전에 버려. 처음 보는 id 면 커밋하고, 커밋이 성공한 뒤에야 장부에 적어.

커밋은 일부러 하는 게 거의 없어. 데일리 노트는 열지도 않아. 폰의 다른 캡처 경로들도 같이 쓰는 App Group 드롭 박스에 캡처 하나를 써넣을 뿐이야. 폰의 공유 시트, Siri, 액션 버튼이 거기로 떨어지고, 앱 안의 받아쓰기도 노트가 당장 못 받는 순간엔 거기로 가. 이름은 메모 자신의 id, source: "watch", 생성 시각은 말한 순간, 시간대는 말한 곳의 시간대. 그러고 나서 알림 하나를 쏴서, 열려 있는 라이브러리가 바로 정리하게 해. 이렇게 몸을 사리는 건 kit 의 가장 날 선 규칙 때문이야. 폰이 그걸로 뭘 했든 워치는 전송의 didFinish 에서 메모를 놓아. 그러니 커밋이 예외를 던지면 그 메모는 어디에도 없게 돼. 커밋은 폰이 잠긴 채로, 백그라운드에서, WatchConnectivity 자기 큐 위에서도 성공해야 해. 공유 컨테이너에 디렉터리 하나 만드는 건 그걸 해내. 에디터에 열려 있을지도 모르는 노트를 고치는 건 그런 약속을 못 해.

말한 그 분에 꽂혀

드롭 박스는 기다리는 캡처를 전부 정리하는 바로 그 ingest 가 읽어. 라이브러리가 처음 뜰 때, 그 알림이 왔을 때, 폰 잠금이 풀릴 때, 앱이 앞으로 나올 때마다, sync 라운드가 닫힐 때, 아빠가 에디터에서 타이핑을 멈출 때. 쓰지 않고 기다리는 경우는 셋이야. 폰 파일을 아직 못 읽을 때, 라운드가 열려 있을 때, 아빠가 바로 그 줄이 들어갈 노트에 타이핑하고 있을 때. 파일은 재시동 뒤 첫 잠금 해제 전까지만 봉인돼. 한 번 풀린 뒤엔 화면이 잠겨 있어도 읽혀. 그게 아니면 데일리 노트 notes/YYYY-MM-DD.md 에 - HH:mm — 본문 한 줄을 덧붙여. 날짜와 분은 말한 순간, 말한 곳의 시간대로 정해. 세비야에서 밤 23:50 에 말한 메모가 다음 날 아침 폰을 열 때 정리되면, 그 줄은 전날 노트의 23:50 자리에 들어가. 생각이 있어야 할 자리가 거기니까.

딱 한 번만 들어가게 하는 장치는 여러 겹이고, 어느 한 겹만 믿진 않아. 드롭 박스는 id 하나에 디렉터리 하나라서, 같은 메모가 두 번 커밋돼도 같은 캡처를 다시 쓸 뿐이야. 문의 장부는 기억하는 메아리를 걸러내지만 512개를 넘으면 잊어. 그 둘 밑에 저장소 자기 장부가 있어. 이미 정리된 캡처를 id 별로 적어두고, 덧붙이기 전에 지금 붙일 바로 그 줄과 그 줄이 노트에 이미 몇 번 있었는지를 적어둬. 덧붙인 직후 장부에 적기 전에 앱이 죽으면, 다음 ingest 가 노트에서 그 줄을 세보고 이미 정리된 걸로 적어. 두 번 붙이지 않아.

그리고 office

메모가 정리되면 라이브러리에 바뀐 게 생겼다고 표시돼. 앱이 앞에 있으면, 쓰기가 10초 잠잠해진 뒤 라운드가 office 의 sync 엔진으로 가. 앱이 앞으로 나올 때도 바로 한 번 시도하고(1분에 한 번까지), 앞에서 물러날 땐 iOS 한테 백그라운드 시간을 받아 아직 안 보낸 걸 보내. 안 하는 건 백그라운드에 있는 채로 라운드를 새로 여는 거야. 초인종에 깨어나 뒤에서 메모를 정리한 앱한텐 라운드를 끝낼 시간이 없고, 하다 만 라운드는 중단된 sync 를 남기거든. 그 메모는 폰 디스크에서 기다렸다가 앱이 다음에 앞으로 나올 때 떠나. office 에 닿은 줄은 다른 레플리카들이 각자 다음 라운드에서 가져가고, Mac 라이브러리는 레플리카의 sync 가 착륙하면 노트 목록을 다시 읽어. 손목에서 큐로, 큐에서 폰으로, 폰에서 노트로, 노트에서 office 로. 워치가 office 에 직접 닿는 일은 없어. 워치는 tailnet 에 없으니까, 폰이 워치의 엔진이야.

Code

메모 하나가 바통을 넘기는 길·text
WATCH   버튼 | 컴플리케이션 rekindle://memo | 액션 버튼 -> 단축어
        WristDictation (WatchKit, suggestions: nil) -> 받아쓴 말
        WristMemo  id rk-memo-..., spoken_at (UTC), timezone
        outbox.enqueue  -> 워치 자기 큐에 기록            ("Captured")
        transferUserInfo  기록: 큐에 들어가고, 순서대로, 붙들려
        sendMessage       초인종: 메시지가 폰 앱을 깨워
        큐에서 빼기        전송의 didFinish 에서만

PHONE   WristReceiver, App.init 에서 활성화 (뷰 필요 없음)
        문: seen 장부 "memo:<id>"  -> 메아리면 버림
        커밋: CaptureDropBox (App Group), id = 메모 id,
              source "watch", createdAt = 말한 순간, zone = 말한 곳
        ingest: 파일 봉인 / 라운드 중 / 그 노트에 타이핑 중이면 대기
                notes/<말한 날>.md 에 "- HH:mm — 본문" 덧붙임
                캡처 장부: filed + appending (딱 한 번)

OFFICE  다음 라운드가 줄을 실어 가: 쓰기가 10초 잠잠한 뒤, 앞으로 나올 때,
        물러날 때 받은 시간 안에. 백그라운드에 있는 채로는 새로 안 열어
        다른 레플리카와 Mac 은 각자 라운드에서 가져가
폰의 문, 앱 init 에서 (줄여서)·swift
let receiver = environment.captureContainer.map { container in
  let seen = environment.captureLedger.deletingLastPathComponent()
    .appendingPathComponent("rekindle-wrist-seen.json")
  return WristReceiver<WristMemo>(commit: { memo in
    // Not the note: one capture in the shared box, keyed by the memo's own id.
    try CaptureDropBox(container: container).write(.init(id: memo.id, text: memo.text,
      source: "watch", createdAt: memo.spokenDate, timeZone: memo.timezone))
    NotificationCenter.default.post(name: .rekindleCaptureDropped, object: nil)
  }, ledger: WristSeenLedger(url: seen))
}
receiver?.activate()   // launch path, not onAppear: a ring may launch with no view
_wristReceiver = State(initialValue: receiver)   // kept: the session holds its delegate weakly

External links

Exercise

메모 하나를 따라가 봐. 마드리드에서 밤 23:52 에 워치로 메모를 받아써. 아이폰은 근처 가방 속에 잠겨 있어. 초인종은 폰에 닿았지만 화면은 밤새 잠긴 채야. 다음 날 아침 08:05, 여전히 마드리드에서 폰의 Rekindle 을 열어. (1) 워치의 'waiting' 숫자가 언제, 왜 0 이 되는지, (2) 메모가 폰의 문에 몇 번까지 닿을 수 있고 도착할 때마다 무슨 일이 생기는지, (3) 언제, 어느 데일리 노트의 몇 시 몇 분 자리에 줄이 붙는지, (4) 그 줄이 언제 office 에 닿는지 적어봐.
Hint
숫자는 초인종이 응답받을 때가 아니라 전송의 didFinish 가 올 때 줄어. 초인종과 전송 둘 다 문에 닿고, 두 번째는 seen 장부가 버리는 메아리야(장부가 잊었더라도 id 하나에 디렉터리 하나인 드롭 박스가 받아내). 폰은 그날 이미 잠금이 풀린 적이 있으니 화면이 잠겨 있어도 파일은 읽혀. 깨어난 앱의 라이브러리가 ingest 하면 그 자리에서 정리되고, 늦어도 08:05 에 앱을 열 때 정리돼. 어느 쪽이든 전날 노트에 '- 23:52 — …' 로 들어가. 백그라운드에 있는 채로는 라운드를 새로 안 여니까, office 에 닿는 건 08:05 에 앱을 앞으로 꺼냈을 때야(앞으로 나오면 바로 한 번 시도해. 10초 뒤 라운드는 아직 안 보낸 게 남았을 때만 떠나).

Progress

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

댓글 0

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

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