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

살아남는 세션

~14 min · persistence, codable, concurrency, recovery

Level 0새 탭
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

세션에 들어 있는 것

세션은 앱을 다시 켜도 아무 일 없었던 것처럼 느끼게 해주는 파일이야. 위치는 ~/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개짜리 창을 되살려도 탭 쪽 웹 뷰는 많아야 하나로 시작해. 나머지는 아빠가 누를 때마다 하나씩, 두고 간 자리에서 깨어나.

원칙: 못 읽는 파일은 쓰레기가 아니라 증거야. 시각을 붙여서 옆으로 옮기고, 깨끗하게 시작하고, 그렇다고 말해. 그리고 쓰는 쪽은 자기가 이해 못 하는 걸 덮어쓰길 거부하게 만들어.

Code

cwkVoyager, Sources/VoyagerCore/Session.swift: 못 읽는 세션은 옆으로 옮기고, 덮어쓰지 않음 (원문 그대로, 98-117행)·swift
    /// The stored session. A file that will not read is moved beside itself
    /// under a stamped name and an empty session is returned with a notice.
    public func load(now: Date = Date()) -> Loaded {
        do {
            return Loaded(document: try store.load(materializeDefaults: false).document, notice: nil)
        } catch {
            let kept = url.deletingLastPathComponent()
                .appendingPathComponent("session.unreadable-\(Self.stamp(now)).json")
            do {
                try FileManager.default.moveItem(at: url, to: kept)
                return Loaded(
                    document: SessionDocument(),
                    notice: "The saved session could not be read and was kept as \(kept.lastPathComponent). Voyager started with an empty one.")
            } catch {
                return Loaded(
                    document: SessionDocument(),
                    notice: "The saved session could not be read. It is left untouched, so this session will not be saved.")
            }
        }
    }
cwkVoyager, Sources/VoyagerCore/Session.swift: 메인 스레드 밖에서, 바뀌었을 때만 쓰는 쓰기 담당 (원문 그대로, 139-173행)·swift
public final class SessionWriter: @unchecked Sendable {
    private let store: SessionStore
    private let queue = DispatchQueue(label: "com.cwk.voyager.session")
    /// The document last written; touched only on `queue`.
    private var written: SessionDocument?

    public init(store: SessionStore) {
        self.store = store
    }

    /// Writes `document` soon. `failed` hears why, on the main actor, when
    /// the store refused it.
    public func write(_ document: SessionDocument, failed: @escaping @MainActor @Sendable (String) -> Void) {
        queue.async { [self] in
            do {
                try save(document)
            } catch {
                let reason = error.localizedDescription
                Task { @MainActor in failed(reason) }
            }
        }
    }

    /// Writes `document` now, after any write still waiting, and returns when
    /// it is on disk.
    public func writeNow(_ document: SessionDocument) throws {
        try queue.sync { try save(document) }
    }

    private func save(_ document: SessionDocument) throws {
        guard document != written else { return }
        try store.save(document)
        written = document
    }
}
cwkVoyager, scripts/build-app.sh: 설치는 돌고 있는 앱한테 SIGTERM으로 종료를 청하고 10초를 기다림 (원문 그대로, 221-234행)·shell
# Voyager saves its session when it is asked to quit, and on SIGTERM. It is
# asked first; nothing is killed.
running_pid() { /usr/bin/pgrep -f '^/Applications/Voyager\.app/Contents/MacOS/Voyager' 2>/dev/null | /usr/bin/head -n 1 }
if [[ -n "$(running_pid)" ]]; then
  /bin/kill -TERM "$(running_pid)" 2>/dev/null || true
  for _ in {1..40}; do
    [[ -z "$(running_pid)" ]] && break
    /bin/sleep 0.25
  done
fi
if [[ -n "$(running_pid)" ]]; then
  print -u2 "HARD_FAIL Voyager is still running (a page may be holding a dialog open); quit it, then run this again"
  exit 1
fi

External links

Exercise

문서를 알아서 저장하는 앱(설정이든, 세션이든, 대기열이든) 하나를 골라서 일부러 망가뜨려봐. 파일에 쓰레기를 써넣고 앱을 켠 다음, 파일이 어떻게 되는지 봐. 갈아치워졌다면 읽는 길을 고쳐. 못 읽는 파일은 시각을 붙인 이름으로 옆에 옮기고, 앱은 기본값으로 시작하면서 사용자한테 알려주게. 그리고 저장하는 길은 디코딩 못 하는 파일을 덮어쓰지 못하게 막아. 둘 다 임시 디렉터리에서 테스트를 하나씩 써.
Hint
읽기가 뭘 돌려줬는지만 말고, 끝난 뒤 디스크에 뭐가 있는지를 테스트해. 남겨둔 파일엔 망가진 바이트가 정확히 그대로 있어야 하고, 옮긴 다음의 저장은 성공해야 해. 시각은 콜론 없는 UTC로 붙여. 그래야 어느 파일 시스템에서도 이름이 통해. 저장이 여러 스레드에서 일어날 수 있다면, 자주 바뀌는 제목 때문에 후회하기 전에 지금 직렬 큐 하나로 모아둬.

Progress

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

댓글 0

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

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