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

UI 는 파일을 못 바꿔

~11 min · ui-cannot-mutate, snapshot, boundary

Level 0Finder 에서 헤매는 중
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"버튼이 할 일은 네가 원하는 걸 적어 넘기는 데서 끝나. 실행은 버튼 몫이 아니야."

단단하게 세운 벽

보통 앱에선 파일 옮기는 버튼 핸들러가 그냥 거기서 파일을 옮겨. Waygate 는 그걸 막아. 뷰든 메뉴든 단축키든 드롭이든, 파일을 바꿔달라는 요청이 오면 할 수 있는 건 딱 하나야. 무슨 작업인지 OperationPlan 으로 찍어서 엔진에 넘기는 것. UI 콜백에서 파일을 직접 만지는 코드는 나중에 고칠 버그가 아니라 구조 위반이고, 리뷰에서 바로 돌려보내는 종류야.

왜 벽을 세웠나

UI 를 못 믿어서가 아니라 UI 가 그 일을 할 자리가 아니라서야. 기록도, 손대기 직전 확인도, 메인 스레드 밖 실행도, 크래시 복구도 전부 엔진에 있어. 뷰 콜백에서 시작한 변경은 메인 스레드에서 돌고, 저널을 건너뛰고, 되살릴 방법이 없지. 벽이 있으면 어떤 경로로 시작했든 모든 변경이 그 보호를 물려받아. 변경이 실제로 일어나는 자리가 하나뿐이니까.

찍어두면 놓아도 돼

미묘하지만 중요한 이유가 하나 더 있어. 명령은 누른 순간의 활성 페인·탭·선택·목적지를 찍어둔 상태에 대고 풀려. 그래서 키를 누르고 한 박자 뒤에 포커스가 옮겨가도 — 다른 페인을 클릭하든 탭이 바뀌든 — 작업 대상은 네가 뜻한 그대로야. 계획이 의도를 얼려뒀으니 나중의 포커스 변화가 엉뚱한 폴더로 방향을 틀 수 없어.

UI 는 계획을 제출하고, 파일은 엔진만 바꿔. 모든 뷰와 메뉴와 단축키와 드롭이 의도를 계획으로 찍어 넘기는 데서 끝나. UI 코드가 파일시스템을 직접 만지면 구조 위반이야. 기록하고 다시 확인하고 메인 밖에서 돌리고 되살리는 게 엔진 한 곳뿐이니까.
포커스를 대상으로 삼으면 안 돼. '다른 페인으로 옮기기' 가 실행 시점에 활성 페인을 읽는다면, 그 사이 포커스가 바뀌었을 때 파일이 엉뚱한 데로 가. Waygate 는 명령을 누른 순간 찍어둔 상태에 대고 대상을 풀어. 키를 누를 때 눈에 보이던 게 실행되는 그거야.

Code

틀린 쪽과 맞는 쪽·swift
// WRONG -- a mutation from a UI callback. No journal, main-thread, no recovery.
@objc func moveButtonClicked() {
    try? FileManager.default.moveItem(at: selectedURL, to: otherPaneURL) // VIOLATION
}

// RIGHT -- snapshot intent, submit, let go.
@objc func moveButtonClicked() {
    let plan = OperationPlan(
        kind: .move,
        sources: activePane.selectionSnapshot(),     // frozen NOW
        destination: otherPane.locationSnapshot(),    // frozen NOW
        collision: .ask,
        expected: activePane.identitySnapshot()
    )
    Task { await engine.submit(plan) }               // the engine does the rest
}
명령은 찍어둔 상태에 대고 풀려·swift
struct CommandContext {   // captured at INVOCATION, immutable thereafter
    let activePaneID: PaneID
    let activeTabID: TabID
    let selection: [LocationReference]
    let destination: LocationReference?
}

// If the user clicks into the other pane a moment later, `ctx` is unchanged,
// so the operation still targets what they saw when they invoked it.
func perform(_ command: Command, _ ctx: CommandContext) async {
    let plan = command.plan(from: ctx)
    await engine.submit(plan)
}

External links

Exercise

네가 만든 앱에서 위험한 일을 직접 하는 UI 콜백을 찾아봐. 파일을 쓰거나, API 를 부르거나, 레코드를 지우는 자리. 종이에서 두 조각으로 나눠 다시 써봐. 의도를 찍는 부분, 그리고 그 일을 맡은 한 곳에 넘기는 부분. 나중에 상태가 바뀌어도 안 망가지려면 찍는 순간 뭘 붙잡아야 했어?
Hint
그 일이 기대는 걸 전부 그 순간에 얼려야 해. 대상, 선택, 목적지. 실행 전에 뭐든 바뀔 수 있으니까. 의도가 얼린 값으로 한 곳에 넘어가면 콜백은 바로 돌아와도 되고, 이후의 화면 변화가 방향을 틀 수 없어. 'UI 는 적고 엔진이 움직인다' 의 축소판이야.

Progress

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

댓글 0

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

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