AI-NATIVE 시리즈 · AGENT ENGINEERING
데스크톱 앱 제어: 네 개의 문, 그리고 어떤 문을 에이전트가 통과할지는 앱이 정한다
무엇을 다루는가. 데스크톱 자동화를 시작하기 전에 반드시 던져야 할 질문 하나에 답하는 방법이다: 이 앱에서 실제로 열려 있는 문은 무엇인가? 네 개의 문 중 어떤 문이 열려 있는지는 앱이 정한다. 이 선택이 자동화가 다음 업데이트를 견딜 수 있을지 결정한다.
왜 중요한가. 팀들은 자신들이 있다고 생각한 문을 전제로 에이전트를 만든다. 몇 달 뒤에야 실제로 열려 있는 문을 발견하고, 코드를 다시 쓴다. 측정은 몇 초면 끝난다. 대부분 스크립트로 제어할 수 있다고 여기는 네 개의 앱은, 실제로는 전혀 그렇지 않다.
이후에 할 수 있는 것. Mac의 어떤 앱이든 프로브로 확인하고, 그 앱의 단계(rung)를 파악할 수 있다. 시작 전에 내가 10줄을 쓸지 1만 줄을 쓸지, 그리고 만든 것이 내가 컴퓨터를 쓰는 동안에도 동작할 수 있을지 미리 알 수 있다.
데스크톱 앱 제어: 범위와 대상 독자
이 글은 에이전트를 데스크톱 애플리케이션—채팅 클라이언트, 디자인 툴, 혹은 API가 영원히 생기지 않을 내부용 앱—에 연결하려는 사람을 위한 안내서다. 여기서는 앱이 실제로 외부에 어떤 제어 지점을 노출하는지, 각 제어면이 개발자에게 요구하는 비용이 무엇인지, 그리고 내가 직접 프로브를 실행하며 얻은 두 가지 교훈을 다룬다. 클라우드 API는 논외다. 만약 원하는 앱에 진짜 API가 있다면, 그걸 쓰고 이 글은 덮어도 좋다. 아래의 모든 내용은 API가 없는 경우에만 해당한다.
이 글의 핵심은 한 장의 그림이다. 단순한 장식이 아니라, 실제로 써먹을 도구로서 소개한다.
한 장의 그림: 식당과 그 공급망
식당 주방은 여러 공급처와 거래하지만, 모두가 똑같이 편한 상대는 아니다.
가장 이상적인 공급처는 하역장까지 인쇄된 명세서와 함께 물건을 배달한다. 주방을 한 발짝도 나갈 필요가 없다. 이름만 부르면 된다—"화요일 밀가루 40킬로"—그리고 그 이름은 내년에도 똑같은 의미다.
두 번째로 나은 공급처는 전화 주문을 받는다. 원하는 것을 이름과 위치로 설명한다: 세 번째 선반에 있는 것, 'arborio'라고 라벨 붙은 것.arborio. 더 느리고, 선반이 가끔 재배치되지만, 여전히 좌표가 아니라 단어로 주문한다.
세 번째 공급처는 전화가 아예 없다. 직접 시장에 가서 눈으로 가판대를 찾고, 손가락으로 가리킨다. 이 방식도 가능하긴 하다. 하지만 반드시 본인이 대낮에 현장에 있어야 하고, 가판대가 옮겨지면 새로운 랜드마크를 찾아야 한다.
그리고 네 번째 공급처는 아예 문을 열지 않는다.
이 각각은 macOS 앱이 외부에 무엇을 노출하고 무엇을 거부하는지와 정확히 대응한다.
| 공급처 | 앱이 노출하는 것 | 대상을 지정하는 방식 | 업데이트 후에도 살아남는가? |
|---|---|---|---|
| 하역장 + 명세서 | R3 AppleScript dictionary (sdef) | 앱이 자체적으로 정의한 이름으로 | 대부분 그렇다 — 공식적으로 공개된 계약이기 때문이다 |
| 전화 주문 | R2 접근성 트리 (AX) | 역할과 제목으로 | 자주 그렇다 — UI가 재구성될 때까지 |
| 시장에 직접 가기 | R1 픽셀 + 인위적 클릭 | 좌표와 외형으로 | 드물다 — 리디자인은 전체 재작성으로 이어진다 |
| 닫힘 | R0 창 표면 없음 | — | — |
이 사다리가 실질적으로 의미 있는 이유는, 각 단이 서로 대체 불가하며 내가 선택할 수 없기 때문이다.앱은 내가 등장하기 훨씬 전에, 개발 단계에서 이미 어떤 문을 열지 결정해두었다.내가 진짜로 내릴 수 있는 결정은, 지금 당장 알아볼지 아니면 자동화를 다 짜고 나서야 알게 될지뿐이다.
그래서 나는 추측 대신 직접 측정했다
나는 작은 프로브를 만들었다 — 앱마다 네 가지 독립적인 체크를 수행한다. 이 체크들은 의도적으로 분리되어 있다. 앱이 한 가지에는 응답하고 다른 하나는 거부할 수 있기 때문이다. 이 조합이 전체 그림을 완성한다.
$ appctl survey WeChat Feishu Slack Telegram Finder Safari Terminal
⬜ Slack R0_UNREACHABLE
⬜ Telegram R0_UNREACHABLE
🟨 WeChat R1_VISION +MENU(63)
🟦 Feishu R2_ACCESSIBILITY +MENU(61)
🟩 Finder R3_SCRIPTABLE +MENU(152)
🟩 Safari R3_SCRIPTABLE
🟩 Terminal R3_SCRIPTABLE +MENU(132)
첫 번째 결과가 곱씹을 만한 가치가 있다.
네 개의 채팅 앱 중 어느 것도 AppleScript dictionary를 내놓지 않는다. WeChat, Lark/Feishu,
Slack, Telegram — sdef 네 개 모두에서 실패한다. Finder, Safari, Terminal은 모두 통과한다. 실제로 내가 일하는 앱들은 아래쪽 단에 있고, Apple이 제공하는 앱들은 맨 위 단에 있다.
이 단 하나의 사실만으로도 가장 흔한 계획이 무효화된다. 사람들이 복사하는 레퍼런스 구현 — Safari harness in CLI-Anything, 정말 훌륭하다 — Safari는 AppleScript dictionary가 있으니 AppleScript부터 시작한다. 이 패턴을 채팅 앱에 적용하면, 전혀 이전되지 않는다. '덜 잘 동작한다'가 아니라, 아예 동작하지 않는다.
예상 밖의 발견: 63개의 명령어, 창은 0개
WeChat이 흥미로운 이유는, 두 가지 질문에 서로 상반된 답을 내놓기 때문이다.
접근성 API에 WeChat의 창 개수를 물으면 이렇게 답한다 0. 반면, 실제로 픽셀을 합성하는 하위 계층인 window server에 물으면 9개라고 한다. 가장 큰 창은 1418×879 크기다. 창들은 실제로 존재한다. WeChat이 접근성 API에는 그 창들을 노출하지 않을 뿐이다.
여기서 멈추면 WeChat을 제어 불가로 단정하게 된다. 나 역시 거의 그렇게 할 뻔했다. 하지만 메뉴 바는 별도의 제어면이고, WeChat의 메뉴 바는 완전히 열려 있다: 7개 메뉴에 걸쳐 63개의 명령어가 포함되어 있고
Show Next Unread Chat, Chats, Contacts 그리고
Favorites.
클릭할 수 없어도 명령은 내릴 수 있다. 앱은 전체 창 트리를 거부하면서도, 좌표가 전혀 필요 없는 완전한 명령 인터페이스를 제공할 수 있다. '비어 있는 AX 트리'를 '제어 불가'로 간주하면, 열려 있던 유일한 문을 스스로 닫는 셈이다.
식당에 비유하자면: 그 가판대에는 전화도 없고 창고도 보여주지 않는다. 하지만 앞에 못으로 박아둔 인쇄 메뉴가 있어, 번호로 주문할 수 있다. 이 방식은 상차장에 비하면 불편하지만, 아무것도 없는 것보다는 훨씬 낫고, 메뉴 명령은 이름으로 지정되기 때문에 매우 안정적이다.
두 번째 발견: 숨겨진 창은 사진을 찍으면 아무것도 나오지 않는다
WeChat은 R1에 위치한다. 즉, 읽으려면 픽셀을 캡처해야 한다. 앱이 트레이에 숨어 있어도 메인 창은 1418×879 크기로 열거된다. 나는 낙관했다. 창을 id로 캡처해서 읽으면 사용자를 방해하지 않아도 되겠다고 생각했다.
캡처는 성공했다. 결과는 94KB짜리 완전히 흰 이미지.
오프스크린 창은 열거는 가능하지만 사진을 찍을 수는 없다; 렌더링된 픽셀이 없으니 넘겨줄 것도 없다. 이는 우회할 수 있는 버그가 아니라, 스케줄링까지 영향을 미치는 제약이다:
비전 계층(R1_VISION) 제어는 사용자가 없는 상태에서는 절대 동작하지 않는다. 앱이 반드시 화면에 떠 있어야 하므로, 에이전트가 사용자와 디스플레이 자원을 두고 경쟁하게 된다. 사용자가 일하는 오후 3시에 WeChat을 조용히 읽는 자동화는 이런 방식으로는 애초에 불가능하다.
그래서 이 프로브는 계층만 알려주는 데 그치지 않는다. R1이 항상 사용자가 지켜보는 상태에서만 동작함을 명확히 기록하고, 그 이유까지 설명한다. 바로 이 점이 내가 실제로 기여했다고 생각하는 부분이다.
계층은 '획득'이 아니라 '상한'으로 결정된다
이런 분류기를 만들면 흔히 'WeChat: controllable.'(WeChat: 제어 가능) 같은 능력치를 내보이고 싶어진다. 하지만 그 문장은, 그 주장을 떠받치는 가장 약한 고리만큼의 가치만 가진다. 나는 예전에 "capping a guarantee at its weakest assumption"(가장 약한 가정에서 보증의 상한을 정한다)에 대해 쓴 적이 있다. 이 규율이 잡아내는 실패는 바로 이것이다: 누군가 바라는 수준에서 보증을 주장하지만, 실제로는 증거가 뒷받침하는 수준까지만 주장해야 한다.
그래서 이 프로브도 그 규율을 따른다. 계층을 단정하지 않고 — 네 가지 측정값에서 도출(derives)한 뒤, 측정값이 강제하는 모든 상한을 적용한다:
🟨 WeChat R1_VISION
+MENU — 63 commands, no coordinates needed
unattended: NO — needs the screen
⚠ capped: every window is offscreen: capture returns a blank image, so the
app must be brought on screen before it can be read
⚠ capped: vision control competes with the human for the display — it cannot
run unattended while the machine is in use
단순히 종료(closed)된 앱은 R0 '프로브 결과 불확정'이라는 상한과 함께 보고된다 — '실행 중 아님'과 '제어 불가'는 서로 다른 사실이기 때문이다. 이 둘을 혼동했다면 위의 근거로 Slack과 Telegram을 아예 탈락시켰을 것이다. 상한은 README의 문구가 아니라 테스트 코드로 강제된다. 마감과 맞붙어도 살아남는 유일한 규칙 적용 방식이다.
실제로 돌려보니 깨진 것들 — 맥에서 가장 쉬운 앱까지 포함해서
첫 탐색 프로브에서 직접 피처를 기록하고, 그에 맞춰 분류기를 작성해, 19개의 테스트를 모두 통과시켰다. 기분이 좋았다. 그런데 실시간 백엔드를 같은 앱에 연결해보니 문제가 생겼다.
두 가지가 잘못됐고, 하나는 꽤 민망했다.
Finder는 R1_VISION.으로 나왔다. Finder — 이 머신에서 가장 스크립트화가 잘 된 앱이자, 1993년부터 대표적인 AppleScript 타깃이다. 내 번들 조회는 /Applications만 스캔했고, Finder는
/System/Library/CoreServices. 딕셔너리를 찾지 못했으니 읽을 수도 없었고, 결국 최상위 단계가 최하위로 떨어졌다. 쉬운 사례를 잘못 분류하는 분류기는 없는 것보다 더 위험하다. 신뢰하기 때문이다.
Feishu는 아예 번들로 인식되지 않았다. 해당 프로세스 이름은 Feishu;
애플리케이션 번들은 Lark.app. 이름에서 경로를 추정해도 아무것도 나오지 않는다.
둘 다 원인도, 해결책도 같다: 실행 중인 프로세스에게 자신의 위치를 직접 물어라. 이름으로 추측하지 말 것. 프로세스는 정확히 알고 있다. 시스템이 알려줄 정보를 내가 굳이 추론하고 있었던 셈이다.
그리고 이 둘 모두 fixture를 직접 기록했기 때문에만 발견할 수 있었다 — 내가 임의로 만든 가짜 fixture였다면, 내 잘못된 가정을 영원히 뒷받침했을 것이다. 내가 손으로 기록한 숫자들도 세부적으로는 틀렸다. WeChat의 메뉴 명령을 19개로 썼지만 실제는 63개였고, 창 개수도 23개로 썼지만 필터링된 실제 값은 9개였다. 이제 fixture에는 실제 출력값이 복사되어 들어간다.--json.
직접 만들어도 20분이면 충분하다
필요한 것은 프로브 네 개와 순수 함수 하나뿐이다. 표준 라이브러리와 운영체제 기본 도구 외에는 아무 의존성도 없다.
- 스크립팅. 실행
sdef /Applications/Foo.app. 딕셔너리가 있으면 3단계다.NSAppleScriptEnabledInfo.plist의 flag는 무시하라 — 앱마다 제각각 설정하니 기록만 하고 판단 근거로 삼지 마라. - 접근성. System Events에
count of windows를 요청하라. 접근성 권한이 필요하다. 권한이 없으면 에러가 발생하는데, 이 에러는 반드시 있는 그대로 보고해야 한다. "창 없음"으로 번역하면 안 된다 — 둘은 전혀 다른 의미다. - 메뉴 바. 항목을 나열하고 자식 개수를 세라 한 번의 왕복 호출로. Electron 앱 기준, 메뉴 하나당 한 번의 호출에 약 3초가 걸렸고 전체 프로브는 21초가 소요됐다.
- 윈도우 서버. AX API가 거부한 창을 확인할 수 있는 유일한 방법이다. macOS 15+에서는 작은 Swift 헬퍼가 필요하다. 그 이유는
CGWindowListCreateImage가 폐기되었고, 표준 라이브러리로는 창 목록에 접근할 수 있는 경로가 없기 때문이다.
이제 분류한다. 응답한 제어 표면 중 가장 높은 단계가 최종 등급이 되고, 메뉴는 별도로 기록한다. 충족되지 않은 모든 전제조건은 각주가 아니라 상한(cap)으로 처리한다. 분류기는 순수 함수로 유지한다 — I/O 없이 — 그래서 핵심 로직은 Mac 없이도 테스트할 수 있다.
이 방법이 알려주지 않는 것
과장된 게이트는 악용을 부르니, 이 등급이 실제로 볼 수 없는 한계를 분명히 밝힌다.
이 방식은 도달 가능성만 측정한다. 실제 사용 가능성은 따로다. Feishu는 R2에서 라이브 트리를 노출한다. 최상위에는 AXGroup와 버튼 세 개가 있다. Electron 앱은 보조 클라이언트가 요청하기 전까지 실제 트리를 숨긴다. 도달 가능하다는 건 작업의 출발점일 뿐, 결승선이 아니다.
이 방식은 허용 여부에 대해 아무것도 말해주지 않는다. 앱의 서비스 약관, 고용주의 정책, 그리고 대화 상대 모두 프로브로는 측정할 수 없는 권한을 가진다. 자신의 클라이언트를 자기 컴퓨터에서 제어하는 것은 접근성의 일상적 사용 — 모든 스크린 리더가 하는 일이다. 계정을 대규모로 자동화하는 것은 완전히 별개의 문제이고, 답도 다르다. 프로브는 당신이 들어가면 안 되는 문이 열려 있다고도 아무렇지 않게 알려줄 것이다.
이 결과는 그저 한 시점의 스냅샷이다. 등급은 변한다. 앱은 소규모 업데이트에서 접근성 지원을 추가하거나 뺄 수 있다. 측정이 저렴한 이유는, 인용하지 말고 필요할 때마다 다시 실행하라는 뜻이다.
그리고 솔직한 한계: 등급은 비용만 알려줄 뿐, 가치까지 말해주진 않는다. WeChat이 비전 기반에 63개 명령 메뉴만 노출한다는 사실은, 자동화할 가치가 있는지에 대해선 아무런 답이 아니다. 다만 자동화를 결정했을 때 드는 비용만 알려주고, 이 숫자는 대부분 맨 마지막에야 깨닫는다.
일반화되는 구조
구체적 발견은 macOS의 채팅 앱에 관한 것이고, 언젠가는 구식이 된다. 그러나 그 밑에 깔린 구조는 바뀌지 않는다.
당신이 직접 통제하지 않는 무언가를 자동화하려 할 때마다, 노출될 수 있는 인터페이스의 사다리가 존재한다. 자신의 등급을 고를 수는 없고, 가장 저렴한 첫 번째 행동은 구현에 들어가기 전에 자신이 어느 단계에 있는지 미리 측정하는 것이다. 실패 패턴은 늘 같다. 누군가는 로딩 도크를 당연하게 여기고 몇 달간 코드를 짠다. 그리고 통합 단계에서야 공급업체에 전화가 없다는 걸 깨닫는다.
20분만 먼저 프로브를 돌렸다면, 진작에 알 수 있었던 사실이다.
관련 글
appctl 한 대의 Mac(macOS 26 구동)에서 직접 측정한 결과이며, 기록된
fixture를 이용하면 오프라인에서도 재현할 수 있다.appctl --fake probe <app>. 앱은 내 것이지만, 그 문은 모두의 것이다.