본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 02. 22:43

포커스 가드(Focus Guard)를 구현했지만, macOS Tahoe가 이를 세 번이나 무력화했습니다

요약

macOS Tahoe 업데이트 이후 AppleScript와 스크린샷 명령이 의도치 않게 포커스를 탈취하는 현상을 분석합니다. Safari MCP 개발자가 겪은 레이스 컨디션과 포커스 가드 무력화 문제를 다룹니다.

핵심 포인트

  • macOS Tahoe에서 AppleScript 명령 시 앱이 암묵적으로 활성화됨
  • 스크린샷 명령(`screencapture`)이 윈도우 포커스를 강제로 가져옴
  • 성능 최적화를 위한 핫 패스(Fast Path)에 포커스 가드가 누락됨
  • 백그라운드 에이전트 구현 시 OS 레벨의 포커스 변화 대응 필요

Slack에서 메시지를 타이핑하고 있습니다. 그동안 AI 에이전트는 백그라운드(background)에서 페이지를 읽거나, 스크린샷을 찍거나, 탭을 탐색하는 등의 작업을 수행하고 있습니다. 문장을 입력하던 도중, Safari가 갑자기 포그라운드(foreground)로 튀어나옵니다. 당신이 입력한 세 번의 키 입력이 메시지 창이 아닌 Safari의 주소창에 입력됩니다. 생각의 흐름이 끊깁니다. Alt-Tab을 눌러 다시 돌아옵니다. 몇 초 후, 똑같은 일이 다시 발생합니다.

이것이 바로 포커스 탈취(focus theft)이며, "백그라운드" 자동화 도구가 할 수 있는 가장 화나는 행동입니다. Safari MCP의 핵심 약속은, 당신이 다른 앱에서 계속 작업하는 동안 에이전트가 당신이 이미 로그인해 있는 실제 Safari를 제어한다는 것입니다. 만약 에이전트가 몇 번의 호출마다 포그라운드를 가로챈다면, 그 약속은 깨진 것입니다.

정말 뼈아픈 부분은 이것입니다: 저는 이미 포커스 가드(focus guard)를 가지고 있었습니다. Safari를 건드리기 전에 최상위 앱을 저장하고, 작업 후에는 다시 복구하는 기능입니다. 저는 이 기능을 몇 달 전에 이미 배포했습니다. 하지만 macOS Tahoe에서는 이 기능이 조용히 무용지물이 되었습니다. 마침내 이를 수정하기 위해 자리에 앉았을 때, 그것은 단 하나의 버그가 아니었습니다. 동일한 파이프라인(pipeline) 내에 존재하는 세 개의 독립적인 레이스 컨디션(race condition) 윈도우였고, 여기에 제 코드가 사용자(user)와 싸우고 있는 네 번째 버그까지 있었습니다.

Tahoe가 다른 이유

저장/복구 가드(save/restore guard)는 다음과 같은 가정하에 작성되었습니다: Safari는 내가 명령할 때만 앞으로 나온다. 이전 macOS 버전에서는 대략 사실이었습니다. 하지만 Tahoe에서는 그렇지 않습니다.

이제 Safari는 AppleScript가 자신의 윈도우 중 하나를 변경할 때마다 — set URL, set bounds, set current tab 등 — 암묵적으로 스스로를 활성화합니다. 이 명령들 중 어느 것도 "앞으로 나오라"고 말하지 않습니다. 하지만 Tahoe는 어쨌든 Safari를 앞으로 가져옵니다. 또한 screencapture -l<windowID> 명령은 캡처 자체를 위해 대상 윈도우를 앞으로 깜빡이며 가져옵니다.

따라서 모든 safari_navigate, 모든 safari_snapshot, 모든 스크린샷은 제 가드가 포착하도록 설계되지 않은, 포커스를 훔치는 완전히 새로운 방식을 갖게 되었습니다. 가드가 틀린 것이 아니었습니다. 가드가 딛고 있던 지면이 움직여 버린 것입니다.

격차 1: 핫 패스(hot path)에는 가드가 전혀 없었습니다

제가 처음 발견한 것은 책상에 머리를 박고 싶게 만드는 종류의 버그였습니다.

Safari MCP에는 AppleScript를 실행하는 두 가지 방법이 있습니다. 하나는 osascript로, 서브프로세스 래퍼(subprocess wrapper)이며 속도는 느리지만 최상위 앱(frontmost app)을 저장하고 복구하는 기능을 수행했습니다. 다른 하나는 osascriptFast로, 지속적으로 실행되는 Swift 데몬(daemon)이며 호출당 약 5ms가 소요되어 서브프로세스를 생성하는 것보다 대략 18배 빠릅니다. 이것이 바로 핫 패스(hot path)입니다. safari_navigatesafari_snapshot이 이를 사용합니다. 탭 해상도 레이어, 프로필 창 탐지기, 그리고 수십 개의 내부 헬퍼(helpers)들이 모두 이 경로를 통해 흐릅니다.

osascriptFast에는 포커스 가드(focus guard)가 전혀 없었습니다.

결과적으로, 드물게 실행되는 느린 경로는 보호되었지만, 거의 모든 도구 호출(tool call)에서 실행되는 빠른 경로는 완전히 무방비 상태였습니다. 보호 장치가 정작 필요 없는 곳에는 존재하고, 꼭 필요한 곳에는 없었던 것입니다. 저는 osascriptFast에 미러 가드(mirror guard)를 추가했습니다. 이때 중첩된 호출(데몬을 통해 내부적으로 다시 호출되는 runJSLarge 등)이 이미 외부 가드에서 수행한 저장 및 복구 작업을 중복해서 수행하지 않도록 !_focusGuardActive 플래그를 사용하여 제어했습니다.

격차 2: 복구(restore)를 실제로 기다리지 않았습니다

핫 패스에 가드를 적용하자 포커스 탈취(focus theft) 현상이 '개선'되었습니다. 완전히 사라진 것은 아니었습니다. 여전히 가끔씩 깜빡임이 발생했습니다.

저장(save) 측면은 동기적(synchronous)이었기에 문제가 없었습니다. 하지만 복구(restore) 측면은 세 군데의 서로 다른 지점—osascript 서브프로세스 경로, runJSLarge, 그리고 스크린샷의 screencapture 경로—에서 _helperActivateApp(prev).catch(() => {}) 방식을 사용하고 있었습니다. 즉, 실행 후 결과를 기다리지 않는 '파이어 앤 포겟(fire-and-forget)' 방식이었습니다.

여기에 함정이 있습니다. 앱을 다시 활성화하는 것은 내부적으로 NSRunningApplication.activate()를 호출하는 것이며, 이는 **OS 레벨에서 비동기적(asynchronous)**으로 동작합니다. 이를 호출한다고 해서 즉시 포커스가 돌아온다는 의미는 아닙니다. 그것은 윈도우 서버(window server)의 스케줄에 따라 결국 포커스가 돌아올 것이라는 의미일 뿐입니다. '파이어 앤 포겟' 방식의 .catch()는 Safari가 여전히 최상위 앱인 상태에서 제 코드에 제어권을 반환했습니다. 그 후 5~50ms 동안 사용자의 키 입력이 Safari에 입력되는 구간이 발생했고, 곧이어 복구가 완료되면서 포커스가 다시 전면으로 튕겨져 돌아왔습니다. 이것이 바로 현상이 멈춤(freeze)이 아닌 깜빡임(flicker)처럼 느껴지게 만드는 정확한 이유입니다.

이제 세 사이트 모두 await restoreFocusIfStolen(prev)를 호출합니다. 포커스 복구(focus restore)는 '실행 후 방치(fire-and-forget)' 방식으로 처리할 수 없습니다. 핵심은 바로 타이밍입니다.

간극 3: Tahoe는 활성화를 조용히 거부할 수 있습니다

await를 사용했음에도 불구하고 여전히 완벽하지 않았으며, 이 문제는 믿기까지 가장 오랜 시간이 걸렸습니다.

Tahoe에서는 윈도우 서버(window-server) 정책이 NSRunningApplication.activate()를 조용히 차단할 수 있습니다. 함수를 호출해도 예외(throw)가 발생하지 않고, 에러를 반환하지도 않으며, 앱은 단순히 전면으로 나오지 않습니다. 저장된 앱은 Safari 뒤에 그대로 갇혀 있고, 무언가 실패했다는 신호조차 없습니다.

따라서 포커스 복구는 신뢰할 수 있는 단일 호출로 끝나서는 안 됩니다. 이제 restoreFocusIfStolen은 다음과 같이 동작합니다:

  1. 저장된 번들(bundle)을 활성화(Activate)합니다.
  2. 5ms 동안 대기합니다 — Tahoe가 활성화 명령을 준수할 수 있도록 약간의 시간이 필요합니다.
  3. 최상위 앱(frontmost app)을 다시 읽어(Re-reads) 실제로 작동했는지 확인합니다.
  4. Safari가 여전히 최상위에 있는 경우에만 _helperHideSafari()로 폴백(fallback)합니다.

이 폴백이 아주 멋진 트릭입니다. 올바른 앱을 앞으로 밀어내려고 싸우는 대신, Safari를 숨겨버리는 것입니다. 그러면 OS가 Z-순서(z-order)에서 다음 앱을 자동으로 선택하게 되는데, 그 앱이 바로 우리가 저장해둔 앱입니다. Tahoe에서는 "올바른 앱을 최상위로 만들기"는 신뢰할 수 없지만, "Safari를 최상위가 아니게 만들기"는 신뢰할 수 있습니다. 결과는 같지만, 사용하는 동사가 반대인 셈입니다.

보너스 버그: 내가 만든 폴링(poll)이 사용자와 싸우고 있었다

그 후, 저는 이 수정 사항으로 인해 새로운 버그를 일으켰습니다.

서버는 사용자가 Safari 프로필 창을 전환하는 것을 감지하기 위해 3초마다 tell application "Safari" to return name of window N을 폴링(poll)합니다. osascriptFast에 포커스 가드(focus-guard)가 적용되는 순간, 이 읽기 전용 폴링은 3초마다 '저장 → 감지 → 복구'로 이어지는 전체 과정을 실행하기 시작했습니다.

사용자가 무언가를 읽기 위해 의도적으로 Safari를 클릭하는 상황을 상상해 보세요. 3초 이내에 제 폴링이 실행되어 Safari가 현재 최상위임을 확인하고, "Safari가 포커스를 훔쳤다"라고 판단한 뒤, Safari에 머물고 싶어 하는 사용자의 의도에 반하여 숨기기 폴백(hide fallback)을 실행합니다. 저의 포커스 보호 기능이 3초마다 작동하는 포커스 방해(sabotage) 기능이 되어버린 것입니다.

해당 폴링(poll)은 읽기 전용(read-only)입니다. 창 이름을 읽어올 뿐이며, Safari를 활성화할 수 없다는 것이 증명되었습니다. 따라서 이제는 noFocusGuard: true를 전달하여 가드(guard) 기능을 완전히 제외합니다. 가드는 Safari를 *변형(mutate)*하는 호출에 적용되어야 하며, 수동적인 읽기 작업에는 결코 적용되어서는 안 됩니다.

교훈

저는

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0