메뉴바가 앱으로 가득 차서, 결국 메뉴바 앱을 하나 더 만들었다

OTP·LLM 사용량·최근 파일·CI 상태를 아이콘 하나에 모으는 macOS 메뉴바 위젯 허브 BarShelf를 만든 이야기. 흩어져 있던 내 CLI 메뉴바 도구들을 하나의 스크립터블 플랫폼으로 묶는다는 아이디어에서 출발했다.

메뉴바가 앱으로 가득 차서, 결국 메뉴바 앱을 하나 더 만들었다

macOS 메뉴바가 언젠가부터 아이콘 전시장이 됐습니다. OTP를 보려고 하나, LLM 토큰을 얼마나 썼는지 보려고 하나, 배터리·클립보드·볼륨 미세조정에 각각 하나씩. 노트북 화면 폭이 좁으면 아이콘이 노치 뒤로 잘려서 보이지도 않죠. 문제를 해결하겠다고 메뉴바 앱을 하나 더 설치하는 게 이 세계의 유일한 처방이라는 게 좀 웃겼습니다.

라고 쓰면 그럴듯한데, 솔직히 말하면 순서가 반대였습니다. “메뉴바가 꽉 차서”는 나중에 붙인 이야기고, 진짜 씨앗은 따로 있었습니다. 저는 그전부터 file-stack, otpeek, aas 같은 메뉴바에 뭔가를 띄우는 작은 도구들을 하나씩 만들어 쓰고 있었습니다. 각각은 잘 돌지만 전부 따로 노는, 파편들이었죠. 어느 날 문득 이런 생각이 들었습니다. 이걸 매번 새 앱으로 만들 게 아니라, 스크립터블한 플랫폼 하나로 통합하면 안 되나? iOS의 단축어(Shortcuts)처럼, 소스와 표시 방식을 조합해서 위젯을 찍어내는 판을 깔면 되지 않나?

그래서 BarShelf를 만들었습니다. 아이콘 하나, 팝오버 하나에 내가 자주 흘깃대는 것들 — OTP 코드, LLM 사용량, 최근 파일, CI 상태 — 를 네이티브 위젯으로 모으는 macOS 앱입니다. 결과적으로는 흩어진 메뉴바 아이콘을 하나로 정리하는 물건이 됐으니, 사후 서사도 아예 거짓말은 아닌 셈입니다.

메뉴바 아이콘 하나에서 열리는 BarShelf 팝오버에 여러 위젯이 놓여 있는 모습

핵심 아이디어: CLI가 곧 API다

메뉴바 위젯을 만든다고 하면 보통 각 서비스마다 API를 연동하고, OAuth를 붙이고, SDK를 배우는 그림을 떠올립니다. 그런데 생각해보면 내가 터미널에서 이미 다 하고 있는 일이었습니다. LLM 사용량은 aas usage --json으로 보고, OTP는 otpeek으로 뽑고, CI 상태는 gh로 확인하고, 파드 상태는 kubectl로 봅니다. 전부 이미 존재하는, 이미 인증이 끝난, 이미 JSON을 뱉는 명령어들이죠.

그래서 BarShelf의 전제를 하나로 잡았습니다. 이미 갖고 있는 커맨드라인 도구를 그대로 위젯으로 만든다. 새 SDK도, 새 인증도 없습니다. 명령어를 실행하고 그 출력을 네이티브 뷰로 그려주는 게 전부입니다.

이 관점이 생기니까 위젯을 만드는 게 갑자기 단순해졌습니다. “도커 컨테이너 목록 위젯”은 그냥 docker ps --format json을 30초마다 실행해서 리스트로 그리는 것이고, 위젯 정의는 이 정도로 끝납니다.

{
"id": "dev.example.docker-ps",
"name": "Docker",
"source": { "kind": "exec", "command": ["docker", "ps", "--format", "json"], "output": "viewtree" },
"refresh": { "onOpen": true, "interval": 30 },
"permissions": { "exec": [{ "command": "docker", "allowedArgs": [["ps", "--format", "json"]] }] }
}

위젯을 만드는 세 가지 층위

처음엔 “명령어 실행 → 뷰 렌더링” 한 가지만 있으면 될 줄 알았는데, 만들다 보니 위젯마다 원하는 자유도가 달랐습니다. 그래서 결국 세 층위로 나뉘었습니다. 스케줄러도, 권한 프레임도, 렌더러도 다 같은데 데이터가 어디서 오느냐만 다른 구조입니다.

사실 스펙에는 원래 층이 하나 더 있었습니다. builtinfile-stack 같은 걸 앱에 네이티브로 박아 넣는 0번째 층입니다. 파일 위젯은 드래그아웃과 QuickLook 썸네일이 필요해서 CLI로는 표현이 안 되니 네이티브로 이식하자는 게, 기획 라운드에서 에이전트 전원 일치로 합의된 항목이었죠. 그런데 workflow에 fs.directory 소스와 썸네일 서비스를 붙이고 나니 “최근 파일”이 그냥 workflow 위젯으로 표현됐습니다. 전원 일치였던 결정이 구현 과정에서 조용히 무효가 됐고, builtin은 매니페스트 스펙에 흔적만 남았습니다.

가장 얕은 건 exec입니다. 매니페스트에 명령어 하나 적으면 그 출력이 곧 뷰가 됩니다. 출력이 이미 뷰 트리(UINode) 형태면 그대로 그리고, 그냥 데이터면 빌트인 어댑터가 meter·list 같은 걸로 변환해줍니다. 번들로 들어있는 aas Usage(LLM 사용량 미터)나 OTPeek(카운트다운 링 달린 TOTP)가 이 방식입니다.

중간은 workflow입니다. 코드 없이 JSON DSL로 소스 → 변환 → 뷰를 선언합니다. ${…} 보간, forEach, 폴더를 훑어서 QuickLook 썸네일까지 뽑는 fs.directory 같은 게 들어있어서, 최근 파일 위젯처럼 파일을 드래그해서 밖으로 꺼내는 것도 코드 한 줄 없이 됩니다.

가장 깊은 건 script입니다. Deno 서브프로세스를 띄우고 JSON-RPC로 통신하는 TypeScript SDK인데, 호스트가 exec·storage·secret·timer를 중개해줍니다. 상태를 들고 있어야 하거나 진짜 로직이 필요할 때만 여기까지 내려가면 됩니다. 다만 Deno가 있어야 돌아가서, exec와 workflow 위젯은 의존성 없이 되지만 script만 별도 설치가 필요합니다. 프로토콜 자체는 언어에 종속되지 않게 설계해서, 같은 JSON-RPC 러너로 Python이나 Lua 러너를 붙이는 것도 열어뒀습니다 — 아직 만들진 않았지만요.

JSON 쓰기도 귀찮은 사람을 위해 Shortcuts 비슷한 비주얼 빌더도 넣었습니다. 소스(명령 실행/폴더 감시/고정 텍스트)를 고르고, 표시 방식(리스트·테이블·값·텍스트)을 고르면 라이브 프리뷰가 뜨고, 이름 붙이면 끝. JSON을 한 글자도 안 봐도 위젯이 나옵니다.

위젯 생성 마법사에서 표시 방식을 고르자 오른쪽에 라이브 프리뷰가 뜨는 비주얼 빌더 화면

위젯이 늘어나면 팝오버가 또 지저분해진다

여기서 자기모순이 하나 생깁니다. 흩어진 도구를 아이콘 하나로 모았더니, 이번엔 그 팝오버 안이 위젯으로 빽빽해지는 겁니다. 메뉴바 정리하겠다고 만든 앱이 스스로 다시 잡동사니 통이 되는 거죠. (원래 이름이 왜 bucket이었는지 알 만하죠.)

그래서 팝오버 안쪽에도 정리 수단을 넣어야 했습니다. 위젯을 버킷 단위로 페이지처럼 나눠서, 트랙패드로 스와이프해 페이지를 넘길 수 있게 했습니다. 자주 보는 건 상단 고정 줄(pinned row)에 박아두고, 위젯이 많아지면 ⌘F로 이름 검색해서 바로 찾습니다. 위젯마다 크기(XS·S·M·L)를 지정할 수 있어서, 값 하나만 보면 되는 건 작게, 리스트가 긴 건 크게 둘 수 있습니다.

LLM 사용량 미터, OTP 코드, 최근 파일 위젯이 세로로 쌓인 팝오버. 상단에 검색 아이콘과 2/3 페이지 표시, 하단에 페이지 점이 보인다

결국 이 앱의 절반은 “정보를 CLI에서 끌어오는 것”이고 나머지 절반은 “그 정보를 좁은 팝오버 안에 어떻게 안 지저분하게 놓느냐”였습니다. 후자가 생각보다 훨씬 오래 걸렸습니다.

의존성 0을 고집한 이유

BarShelf는 런타임 의존성이 없습니다. 순수 Swift / AppKit / SwiftUI고, Package.swift에 외부 패키지가 없습니다. 처음부터 이걸 규칙으로 잡은 건 취향 문제도 있지만, 메뉴바 상주 앱이라는 특성 때문이기도 했습니다. 하루 종일 백그라운드에 떠 있는 앱이 Electron 한 덩어리를 이고 있으면 그것부터가 “메뉴바를 가볍게 하겠다”는 원래 취지랑 안 맞으니까요.

대신 대가가 있었습니다. 리스트, 테이블, 미터, 값 표시 같은 렌더링을 전부 SwiftUI로 직접 그려야 했고, 위젯 매니페스트를 파싱하고 검증하는 것도 다 손으로 짰습니다. 그래도 다크모드·SF Symbols·vibrancy가 공짜로 따라오는 건 네이티브의 확실한 보상이었습니다.

이 원칙은 배포까지 따라왔습니다. 자동 업데이트에는 보통 Sparkle을 쓰는데, 프레임워크 하나를 들이는 것도 “의존성 0”과 맞지 않아 접었습니다. 대신 들어간 건 106줄짜리 업데이트 체커입니다. GitHub 릴리스를 확인해서 새 버전이 나오면 알려주고 릴리스 페이지를 열어주는 게 전부고, 자동 다운로드·교체는 소스 주석에 “intentionally out of scope”라고 박아두고 안 만들었습니다. 풀 오토 업데이터를 손으로 짜는 건 의존성 하나 아끼는 것보다 훨씬 비싼 일이니까요.

구조는 UI를 걷어낸 MenubucketCore(모델, 매니페스트/워크플로 파싱, 스케줄 정책)와 실제 앱 껍데기인 MenubucketApp, 그리고 독립 실행 파일 mbk로 나눴습니다. 코어를 UI에서 떼어낸 덕에 파싱·스케줄 로직은 앱을 띄우지 않고 테스트로 덮을 수 있었습니다. 눈치채셨겠지만 내부 코드명은 아직 menubucket입니다. 이 얘긴 뒤에서 하겠습니다.

신뢰 경계는 프로세스다

남이 만든 위젯을 설치해서 돌린다는 건, 남의 코드를 내 기계에서 실행한다는 뜻입니다. 메뉴바에 상주하면서 남의 스크립트를 태우는 앱이라면 이 부분을 대충 넘어갈 수 없었습니다.

여기서 기획 단계에 제일 크게 갈렸던 지점이 런타임이었습니다. Claude가 쓴 초안은 앱에 JavaScriptCore를 내장해서 가벼운 인터랙션 로직은 인프로세스로 돌리는 하이브리드를 밀었고, Codex 초안은 제3자 코드를 앱 프로세스 안에서 돌리지 않는 것 자체가 보안 경계라며 전부 Deno 서브프로세스(--deny-net --deny-run --deny-write)와 JSON-RPC로 일원화하자고 했습니다. 편해 보이는 건 전자였지만, 결론은 후자였습니다. 서드파티 코드가 앱 프로세스 안에서 한 줄이라도 도는 순간 신뢰 경계가 무너진다 — 그래서 내장 JSC는 통째로 버렸습니다. 프로세스 격리 하나만이 우리가 실제로 강제할 수 있는 유일한 경계였으니까요.

그렇게 원칙을 세 개로 정리했습니다. 첫째, 프로세스 격리가 신뢰 경계다. 서드파티 위젯 코드는 절대 앱 프로세스 안에서 돌지 않고, 별도 Deno 서브프로세스에서 실행되며 앱과는 JSON-RPC로만 대화합니다. 둘째, 선언 → 승인 → 강제. 위젯이 어떤 명령을 실행하고 무슨 권한을 원하는지 매니페스트에 미리 적게 하고, 첫 실행 때 그걸 보여주고 승인받고, 그다음부터 강제하면서 감사 로그를 남깁니다. 앞의 도커 예시에서 allowedArgs로 인자까지 화이트리스트한 게 그겁니다 — 셸을 거치지 않고 인자 배열 단위로 매칭하기 때문에 docker ps는 되지만 docker rm은 안 됩니다. 나중에 exec·fs.directory 말고 http 소스 kind를 추가할 때도 같은 원칙을 그대로 적용했습니다. 네트워크로 나가는 위젯은 host allowlist로 어떤 호스트에 붙는지까지 승인을 받아야 합니다.

세 번째 원칙은 좀 다른 결인데, 개인적으로 제일 아끼는 규칙입니다. UI는 절대 비지 않는다. 위젯이 실패해도 마지막으로 성공했던 렌더를 계속 들고 있고(stale-while-revalidate), 에러는 빈 팝오버가 아니라 배너로 보여줍니다. 여기에 더해 크래시 루프 차단기를 뒀습니다. 어떤 위젯이 5분 안에 세 번 넘어지면 자동으로 비활성화해서, 고장 난 위젯 하나가 팝오버 전체를 계속 흔드는 걸 막습니다. 예전에 카톡 대화 분석 서비스를 만들 때 “실패하면 실패했다고 보여줘야 한다, 빈 화면은 안 된다”는 걸 뼈저리게 배웠는데, 그 교훈이 여기까지 따라왔습니다.

mbk, 그리고 나 몰래 이름이 바뀐 이야기

위젯을 만들고 배포하는 흐름은 CLI로 정리했습니다. 이름은 mbk입니다.

Terminal window
mbk new my-widget --kind workflow # 템플릿에서 스캐폴딩
mbk validate ./my-widget # 매니페스트 + 워크플로 검증
mbk pack ./my-widget -o my.mbw # 배포용 번들로 압축
mbk install <url|path> # 레포 / 아카이브 / 딥링크에서 설치
mbk list # 설치된 위젯 목록

mbk install https://github.com/…처럼 깃허브 레포에서 바로 설치할 수도 있고, README에 barshelf://install?url=… 딥링크 배지를 박아두면 클릭 한 번으로도 설치됩니다.

그런데 mbk? 이게 이름 흔적입니다. 이 프로젝트는 원래 menubucket이라는 이름으로 시작했습니다 — 메뉴바를 담는 양동이(bucket)라는 뜻이었죠. CLI 이름 mbk, 코어 모듈 MenubucketCore가 다 거기서 나왔습니다. 그런데 “bucket”이 주는 느낌이 애매했습니다. 뭔가 잡동사니를 던져넣는 통 같달까. 내가 만들고 싶었던 건 흘깃 보면 정보가 딱 놓여 있는 선반(shelf)에 가까웠습니다. 그래서 BarShelf가 됐습니다. “메뉴바(Bar) 선반(Shelf).”

재미있는 건 이 개명을 메인 세션이 하지 않았다는 겁니다. 여러 작업 세션을 병렬로 굴리다 보니 리브랜딩이 다른 세션에서 진행됐고, 위젯 빌더를 만들던 메인 세션은 커밋을 올리려다가 “리브랜딩(BarShelf)이 다른 세션에서 진행됐더군요” 하고 뒤늦게 상황을 파악했습니다. 그 세션은 로고 컨셉 아트 PNG를 112장이나 만들어둔 상태였고(커밋에서는 뺐습니다), 이름만 바꾼 게 아니라 은근슬쩍 Mac App Store 방향으로 배포 설정을 틀어놓았더군요. 그날 17시 40분 커밋 메시지가 “support Mac App Store signing”입니다.

문제는 이게 창립 결정과 정면으로 부딪힌다는 거였습니다. 첫날 기획 라운드에서 세 에이전트가 전원 일치로 합의한 항목 중 하나가 “App Store는 임의 exec와 구조적 충돌 — Developer ID 공증이 1급”이었거든요. BarShelf의 핵심은 임의의 명령을 실행하고, 스크립트를 태우고, 커뮤니티 위젯을 설치하는 건데, 앱 샌드박스는 그걸 전부 죽입니다. 메인 세션이 이걸 “차단성 충돌”로 판정해 샌드박스 방향을 걷어냈고, 20시 2분에 Developer ID 서명과 노터라이즈 릴리스로 되돌아왔습니다. 두 시간 이십 분짜리 외도로 끝났지만, 하마터면 앱의 정체성을 스스로 무력화한 채 배포될 뻔했습니다. App Store가 왜 이런 종류의 앱과 구조적으로 안 맞는지를, 몸으로 배운 셈입니다.

이름을 늦게 바꾸면 이런 흔적이 남습니다. 앱은 BarShelf인데 내부 코드명은 menubucket이고, CLI는 mbk이고, 레포는 Open330/barshelf입니다. 리팩터로 전부 통일할 수도 있었지만, 돌아가는 코드에 손대서 이름만 맞추는 건 우선순위에서 계속 밀렸습니다. 솔직히 지금도 안 밀렸다는 건 아마 영원히 안 바꾼다는 뜻이겠죠.

”이틀 25커밋”의 실체: AI 여러 대를 병렬로 굴렸다

25개 커밋, 이틀. 기록상으로는 그렇게 짧습니다. 7월 7일 밤 11시 반에 Claude에게 “여러 menu bar 도구들을 만들었는데, 이번엔 이를 확장해서 스크립터블한 메뉴 아이템 뷰를 만들려고 한다”는 첫 메시지를 던졌고, 밤사이 기획이 돌았고, 8일 오전 11시 10분에 첫 커밋(M0–M2)이 찍혔습니다. 그날 하루에만 온보딩·비주얼 빌더·리브랜딩·노터라이즈 릴리스까지 18개 커밋이 쌓였고, 9일에 허브 윈도우와 위젯 테마, 인앱 위젯 관리를 얹었습니다. 이 속도가 가능했던 데는 두 가지 배경이 깔려 있습니다.

하나는 앞서 말한, 그동안 터미널에서 aas·otpeek·file-stack 같은 CLI를 만들어 쓰던 시간입니다. 위젯으로 감쌀 도구가 이미 손에 있었으니 통합할 대상이 처음부터 존재했죠.

다른 하나는 이 프로젝트를 처음부터 끝까지 여러 AI를 병렬로 오케스트레이션해서 만들었다는 점입니다. 첫날 밤 기획 라운드에서 Claude 둘(아키텍처·UX)과 Codex, Gemini가 같은 문제로 각자 기획서를 썼고, 네 문서를 합의/충돌 항목 단위로 병합한 문서가 이후 구현의 캐노니컬 스펙이 됐습니다. 앞의 “JSC 내장이냐 Deno냐”도 이 병합에서 정리된 충돌 중 하나였죠. 구현은 R01부터 R12까지 라운드를 나눠, Claude 서브에이전트와 Codex CLI가 파일 소유권을 겹치지 않게 나눠 맡는 방식으로 돌렸습니다. 여러 안을 동시에 놓고 비교할 수 있으니 결정이 빨랐습니다.

물론 매끄럽지만은 않았습니다. Gemini는 도중에 무료 티어가 끝나 인증이 죽은 채로 이탈했고, 그 몫은 Claude 에이전트가 넘겨받았습니다. Codex는 사용량 한도에 걸려 멈췄다가 리셋되면 복귀하기를 반복했고, 서브에이전트가 세션 한도(새벽 4시 20분 리셋)에 걸려 멈추면 메인 세션이 잔여분을 직접 마무리했습니다. “이틀”이라는 숫자 뒤에는 이 병렬 오케스트레이션의 난장판도 같이 깔려 있습니다.

그래서 지금은

지금 번들로 들어있는 위젯은 일곱 개입니다. 가장 작은 hello(UINode 트리를 뱉는 셸 스크립트 한 줄)부터, aas Usage, OTPeek, 최근 파일, 스크립트 클록, GitHub 상태, 다운로드 폴더 감시까지. 각각이 exec·workflow·script 세 층위를 하나씩 보여주는 예제이기도 합니다.

배포는 Developer ID로 서명하고 Apple 노터라이즈까지 걸었습니다. 릴리스에서 받아서 /Applications에 넣고 더블클릭하면 게이트키퍼 실랑이 없이 열립니다. Apple Silicon macOS 13 이상이면 돌아갑니다.

거친 부분도 솔직히 남아 있습니다. 릴리스는 arm64뿐이라 인텔 맥은 아직 못 챙겼고, 스크립트 위젯을 쓰려면 Deno를 따로 깔아야 합니다. 앞서 말한 내부 이름 불일치(앱은 BarShelf, 코드명은 menubucket, CLI는 mbk)도 그대로고요. 첫 커밋 40분 뒤에 이미 첫 버그 픽스 커밋이 찍혔다는 것도 적어둡니다. 슬래시 들어간 브랜치명이 잘려 404가 나고, 다운로드 상한이 너무 빡빡해 정상 아카이브를 거부하는 걸, 위젯 설치를 직접 돌려보다가 잡았습니다. 유닛테스트는 141개를 지나 지금 220개까지 늘었지만, 실사용 앞에서 테스트 커버리지는 늘 겸손해집니다. 이틀 만에 뼈대를 세운 물건이라, 남이 만든 위젯을 잔뜩 설치했을 때 스케줄러가 어떻게 버티는지는 아직 나 혼자서는 충분히 못 굴려봤습니다.

과장 없이 말하면 이건 갓 나온, 나 하나 편하자고 만든 도구입니다. 화려한 사용자 반응 같은 건 아직 없습니다. 다만 확실한 건, 이걸 만든 뒤로 내 메뉴바에서 아이콘 몇 개가 사라졌다는 것. 흩어진 도구를 하나로 묶는 게 원래 목적이었으니 일단 성공한 셈입니다.

터미널에서 하루에도 몇 번씩 두드리는 명령어가 있다면, 그걸 메뉴바 위젯으로 만들어보는 건 생각보다 훨씬 쉽습니다. BarShelf 레포에 위젯 스펙과 예제가 다 있습니다.

Comments

Loading comments...