세션에 들어 있는 것
세션은 앱을 다시 켜도 아무 일 없었던 것처럼 느끼게 해주는 파일이야. 위치는 ~/Library/Application Support/cwkVoyager/ 안의 sessions/session.json이고, 모양은 값 세 개가 겹겹이 들어 있는 구조야.
SessionDocument: 스키마 버전이랑 창들.SessionWindow: 탭들, 맨 앞 탭의 인덱스, 플랫폼이 써준 창 프레임. 부모는 프레임을 문자열로 들고만 있고 읽지는 않아.SessionTab: id, 아이덴티티, 주소, 제목, 바이트로 된 페이지 자리(레슨 2), 고정 여부, 아이콘 주소, 피파한테 공유 중인지.
시크릿 창이랑 비공개 탭은 절대 안 써. 나머지는 탭마다 interactionState 바이트까지 다 써. 그래서 파일이 아빠 습관 따라 자라. 엔진 게이트에서 잰 페이지 하나의 자리는 624바이트였지만, 뒤로 가기 목록이 긴 탭은 더 많이 들고 있어. 2026-10-22 사용성 리뷰 때 아빠 세션 파일은 1.2 MB까지 커져 있었어.
탭을 탭으로 만드는 값인 id랑 아이덴티티, 그리고 목록 자체만 빼면, 나머지 필드는 전부 기본값을 갖고 디코딩돼. pinned가 없으면 false, title이 없으면 빈 문자열, 창의 선택 인덱스가 없으면 0. Swift가 자동으로 만들어주는 Decodable은 프로퍼티에 기본값이 있어도 키가 없으면 에러를 던져. 그러니 필드 하나만 늘려도 예전 세션이 전부 못 읽는 파일이 돼버려. 그 필드들이 생기기 전에 쓴 세션을 디코딩하는 테스트가 따로 있어. 이걸 받쳐주는 도우미 decode(_:forKey:or:)는 가족 공용 Swift 킷에서 와. Sibling Kit Quest에서 다뤄.
못 읽는 파일은 남겨두지, 덮어쓰지 않아
상태를 저장하는 앱에서 제일 위험한 한 줄은 "읽어보고, 안 되면 기본값 쓰고, 그리고 저장"이야. 읽기가 실패하면 그 한 줄이 아빠 탭의 유일한 사본을 아무 말도 없이 빈 문서로 갈아치워.
Voyager 세션 저장소는 그 길을 두 번 막아. 먼저 load는 못 읽는 파일을 옆으로 옮겨. 이름은 session.unreadable-<UTC 시각>.json. 그리고 빈 세션으로 시작하면서 창에 그렇다고 알려. 옮기는 것까지 실패하면 빈 세션으로 시작하되 파일은 그 자리에 두고, 이번 실행 동안은 세션 저장을 아예 멈춰. 다음으로, 그 밑에 있는 공용 문서 저장소는 디코딩 못 하는 파일을 덮어쓰길 거부해. 첫 번째 가드에 버그가 있어도 파일은 못 지워. 복구 방법은 아키텍처의 실패 표에 적혀 있어. 남겨둔 파일에서 손으로 되살리는 거야.
솔직하게 하나 짚자면, 앱은 두 경우를 알림 문구로 구별해. 문구에 will not be saved(저장 안 됨)가 들어 있는지 읽는 거야. 지금은 잘 돌고, 첫 번째 경우엔 테스트도 있어. 그래도 문구가 바뀌어도 버티려면 결과를 타입으로 돌려주는 쪽이 더 튼튼해.
언제 쓰냐면
탭을 열거나 옮기거나 고정할 때, 페이지가 제목을 바꾸거나 아이콘이 새로 올 때처럼, 세션이 신경 쓰는 변화는 전부 sessionChanged()를 불러. 앱 델리게이트가 그 호출들을 모아. 처음 호출이 2초 타이머를 켜고, 나머지는 거기에 얹혀 가. 타이머가 울리면 비공개 아닌 창 전부의 스냅숏 하나가 SessionWriter로 넘어가.
사용성 리뷰 전까지 그 쓰기는 메인 스레드에서 했어. 몇 초마다 제목이 바뀌는 페이지 하나만 있어도(안 읽은 개수 표시면 충분해), 메인 스레드는 그때마다 1메가바이트짜리 탭 자리를 만들고, 확인하고, 썼어. SessionWriter는 그걸 직렬 큐로 옮기고 규칙 하나를 더했어. 마지막으로 쓴 것과 같은 문서면 아예 안 써. 저장소가 쓰기를 거부하면 이유가 메인 액터로 돌아와. 창은 세션을 저장 못 했다고 알리고, 2초마다 또 실패하는 대신 이번 실행 동안은 저장을 멈춰.
앱이 기다리는 건 종료할 때뿐이야. applicationShouldTerminate는 SessionWriter한테 writeNow를 시켜. 같은 직렬 큐에서 돌아서 아직 대기 중인 쓰기 뒤에 붙고, 파일이 디스크에 닿아야 돌아와. 방문 기록도 그 순간 마지막 1초치를 털어내. 아빠가 창을 다 닫고 종료하면, 마지막에 닫은 창이 저장돼서 다음에 그 창이 돌아와.
SIGTERM도 종료야
설치 스크립트는 돌고 있는 Voyager를 바꿔 끼울 때 SIGTERM을 보내. 그리고 0.25초 간격으로 40번, 최대 10초를 확인하며 기다렸다가, 그래도 안 끝나면 요란하게 실패해. SIGTERM이 그냥 프로세스를 죽였다면 설치할 때마다 세션이 날아갔을 거야. 그래서 앱은 기본 핸들러를 무시하고, 메인 큐의 디스패치 소스로 신호를 듣고, 이번 종료가 신호 종료라고 표시한 다음 NSApp.terminate를 불러. 메뉴로 종료할 때랑 같은 길이야. 세션도 그대로 써져. 딱 하나 다른 건, 신호 종료는 "다운로드가 아직 진행 중인데 그래도 종료할래?"를 절대 안 묻는다는 거야. 답해줄 사람이 없는 질문은 설치 실패로 끝나거든.
돌아오는 길
restore는 창마다 SessionWindow에서 다시 지어. 비공개 탭이 섞여 들어왔으면 건너뛰어. 그사이 아이덴티티가 지워진 탭은 기본 아이덴티티로 열리고 저장된 자리는 버려. 그 바이트는 이제 없는 저장소 거였으니까. 고정 탭은 레슨 1에서 본 분할로 앞에 세워. 그리고 딱 한 탭만 골라서 깨워. 나머지는 제목이랑 아이콘을 단 채 계속 자. 그래서 탭 40개짜리 창을 되살려도 탭 쪽 웹 뷰는 많아야 하나로 시작해. 나머지는 아빠가 누를 때마다 하나씩, 두고 간 자리에서 깨어나.