영화 볼 때 옆 모니터가 거슬려서 만든 macOS 앱 — CoreGraphics 전체화면 감지부터 Homebrew 배포까지

· 5분 읽기 GeekNews
목차

불편함은 가장 정직한 스펙이다. 기능 목록이 길지 않아도, 딱 그 한 가지를 해결하면 된다.

Veil macOS 앱 개발기 커버 이미지

멀티모니터 환경에서 영화를 볼 때마다 같은 일이 반복됐다. 왼쪽 모니터는 넷플릭스 전체화면, 오른쪽 모니터에는 슬랙 알림이 깜빡이고 브라우저 탭이 열려 있다. 시선이 자꾸 분산된다.

기존 앱들을 먼저 찾아봤다. BetterDisplay는 디스플레이 프로파일 관리 도구고, MonitorControl은 밝기·음량 컨트롤이다. 전체화면을 감지해서 다른 모니터를 자동으로 꺼주는 것에 딱 맞는 앱이 없었다.

직접 만들기로 했다. 요구사항은 단순했다: 전체화면 영상이 감지되면 다른 모니터를 검은 화면으로 덮는다. 전체화면이 끝나면 복원한다. 그게 전부다.

결과물은 Veil이다. 이름은 베일(veil) — 집중할 때 드리우는 커튼에서 따왔다. 끄는 게 아니라 가린다는 느낌이 맞았다. 메뉴바에만 존재하고, Dock 아이콘도 없고, 아이들 상태에서 CPU 점유율은 거의 0이다.

Veil 동작 방식 — 전체화면 감지 전후 비교


전체화면 감지 — CoreGraphics를 선택한 이유

macOS에서 다른 앱의 창 상태를 감지하는 방법은 크게 두 가지다.

AXObserver (Accessibility API) 는 특정 앱의 창 이벤트를 구독해 콜백을 받는 방식이다. 정확하지만, 감지할 앱을 미리 알고 있어야 한다. 대상이 늘어날수록 옵저버도 늘어나고, 앱이 재시작되면 재등록이 필요하다.

CGWindowListCopyWindowInfo 는 현재 스크린에 있는 모든 창의 메타데이터를 스냅샷으로 가져온다. 어떤 앱이 켜져 있든 상관없이 한 번의 호출로 전체 창 목록을 얻는다. 창 크기가 스크린 해상도와 일치하고 메뉴바 아래 레이어에 있으면 전체화면으로 판정한다.

Veil은 후자를 선택했다. “어떤 앱이 전체화면인지”가 아니라 “어떤 창이 스크린을 꽉 채우는지”를 보는 것이 더 자연스러운 접근이었다.

감지 타이밍은 이벤트 기반이다. NSWorkspace 노티피케이션을 구독해두고, 앱이 활성화/비활성화될 때 즉시 체크한다. Space 전환은 AX API가 새 Space 상태를 반영할 때까지 800ms를 기다린 뒤 체크한다. 폴링이 없으므로 전체화면 진입 → 오버레이 표시까지 사실상 즉각 반응한다.

한 가지 함정이 있었다. CGWindowListCopyWindowInfo는 권한 없이도 창 목록을 반환하지만, Accessibility 권한이 없으면 창 소유 앱 이름이 빈 문자열로 반환된다. 창은 감지되는데 어떤 앱의 창인지 알 수 없었다. 앱 최초 실행 시 권한을 요청하고, 한 번 허용하면 재실행 이후에도 유지된다.


앱 목록에서 제외 목록으로 — 설계의 반전

처음 구상한 UX는 “지원 앱 목록”이었다. Netflix, YouTube, VLC, IINA 같은 영상 앱들을 목록으로 보여주고, 각각 켜고 끌 수 있는 토글을 달았다.

구현하고 보니 문제가 보였다.

목록에 없는 앱은 전체화면이 돼도 반응하지 않는다. 새 앱을 쓸 때마다 사용자가 직접 추가해야 한다. 그보다 더 큰 문제가 있었다 — 앱 목록에 켜고 끌 수 있는 토글이 달려 있으면, 마치 Veil이 그 앱들을 감시·추적하는 것처럼 보인다. 실제로는 창 크기만 보는 건데, UX가 잘못된 인상을 줬다.

설계를 뒤집었다. 모든 전체화면 앱이 기본적으로 Veil을 트리거한다. 예외를 원하는 앱만 제외 목록에 추가한다.

이 방향이 맞다는 게 Zoom 케이스에서 확인됐다. 화상회의를 전체화면으로 쓸 때도 오버레이가 올라온다. 발표 화면을 공유 중이라면 오버레이가 방해가 된다. 예외 처리가 필요한 게 바로 이런 상황이다 — 영상 앱을 일일이 추가하는 게 아니라, Zoom처럼 예외적인 앱만 제외하면 된다.

결과적으로 설정 UI가 훨씬 단순해졌다. 제외 목록이 비어 있으면 “모든 전체화면 앱이 블랭킹을 트리거한다”는 상태고, 예외를 두고 싶을 때만 추가하면 된다. 토글 여러 개를 관리하는 게 아니라 하나의 예외 목록만 보면 된다.

Veil 모니터 상태 — 전체화면(녹색), 베일 적용 중(주황), 제외됨(회색)

모니터도 같은 방식으로 관리한다. 각 모니터는 세 가지 상태 중 하나다. 전체화면이 재생 중인 활성 모니터(녹색), 오버레이가 씌워진 모니터(주황), 사용자가 직접 제외한 모니터(회색). 메뉴바에서 토글 하나로 언제든 상태를 바꿀 수 있다.


오버레이와 FlipClock

모니터를 덮는 방법도 선택지가 둘이었다. CGDisplayCapture로 디스플레이 자체를 점유하거나, NSWindow로 검은 창을 올리거나. 전자는 강력하지만 복원 타이밍을 잘못 처리하면 디스플레이가 먹통이 될 수 있다. 실패해도 그냥 창 하나가 남는 NSWindow 쪽을 선택했다.

윈도우 레벨을 스크린 세이버 수준으로 올리고 배경을 검정으로 채운다. 한 가지 빠뜨리면 안 되는 설정이 있다 — collectionBehavior.canJoinAllSpaces를 넣지 않으면, Mission Control에서 스페이스를 전환할 때 오버레이가 사라진다. 첫 동작은 정상인데 스페이스를 한 번 바꾸고 나면 동작이 어긋나는, 재현하기 까다로운 버그였다.

완전히 검은 화면보다 시계를 하나 보여주면 더 유용하다. 오버레이 위에 FlipClock을 올렸다. 숫자가 바뀔 때 위쪽 절반이 내려오는 플립 애니메이션을 GeometryEffect로 처리했는데, 생각보다 까다로웠다. 12h/24h, 초 표시 설정은 메뉴바에서 즉시 토글할 수 있고 오버레이가 떠 있는 상태에서도 실시간으로 반영된다.


빌드와 배포 — XcodeGen, GitHub Actions, Homebrew

XcodeGen — 프로젝트 파일을 코드처럼 관리하기

macOS 앱은 Xcode로 개발한다. Xcode는 프로젝트 전체 설정을 .xcodeproj라는 파일 묶음으로 관리하는데, 이 파일이 문제다. Xcode를 열고 파일 하나만 추가해도 내부 정렬이 바뀌어 Git에 수십 줄의 diff가 생긴다. 실제 코드 변경과 Xcode가 자동으로 건드린 것을 구분하기 어렵다.

XcodeGen은 이 문제를 역방향으로 푼다. .xcodeproj를 직접 관리하는 대신, project.yml이라는 단순한 설정 파일을 작성하고 XcodeGen이 .xcodeproj를 그때그때 생성한다. Git에는 YAML 파일만 커밋하고, 생성된 .xcodeproj.gitignore에 넣는다. 설정이 바뀔 때 변경 내용이 YAML 한 줄로 표현된다.

GitHub Actions — 태그 하나로 릴리즈 완성

버전을 올릴 때마다 직접 빌드하고, 파일을 패키징하고, GitHub에 올리는 과정을 반복하고 싶지 않았다. git tag v0.3.0 && git push origin v0.3.0 한 줄을 치면 나머지는 자동으로 처리되는 게 목표였다.

태그 푸시가 GitHub Actions를 트리거하면 순서대로 실행된다. XcodeGen으로 프로젝트를 생성하고, xcodebuild로 릴리즈 빌드를 만들고, Veil.app을 zip으로 묶어 GitHub Release에 첨부한다.

Launch at Login도 이 시점에 추가했다. macOS 13+의 SMAppService를 쓰면 별도 헬퍼 앱 없이 메인 앱 자체를 로그인 항목으로 등록할 수 있다. 권한 다이얼로그도 없다.

Homebrew tap — 사용자가 찾아오는 경로를 만들기

GitHub Release에 zip이 올라왔다고 배포가 끝난 게 아니다. 다운로드 페이지를 알려주고, 직접 내려받아 Applications 폴더에 넣는 방식은 macOS 개발자에게 낯설다. brew install이 훨씬 자연스럽다.

Homebrew에는 두 가지 설치 방식이 있다. brew install은 CLI 도구나 라이브러리처럼 터미널에서 실행하는 것들에 쓴다. brew install --cask는 Veil처럼 .app 번들 형태의 GUI 앱에 쓴다. cask가 “통”이라는 뜻으로, 앱 전체를 담아 설치한다고 이해하면 된다.

문제는 Veil이 Homebrew 공식 저장소에 등록된 앱이 아니라는 점이다. 공식 등록은 심사 기준이 까다롭고 시간이 걸린다. Homebrew는 이를 위해 tap 이라는 서드파티 채널을 제공한다. 개인 GitHub 저장소를 Homebrew 소스로 등록하는 방식이다.

neocode24/homebrew-tap 저장소를 만들고 Casks/veil.rb 파일을 추가했다. 이 파일이 Homebrew에게 “Veil이 어디에 있고, 어떻게 설치하는지”를 알려주는 레시피다. 버전, 다운로드 URL, 체크섬, 설치 경로가 담겨 있다.

사용자가 아래 두 줄을 실행하면 Homebrew가 하는 일의 흐름은 이렇다.

brew tap neocode24/tap
brew install --cask veil
sequenceDiagram
    participant U as 사용자
    participant B as Homebrew
    participant T as homebrew-tap 저장소
    participant R as GitHub Releases

    U->>B: brew tap neocode24/tap
    B->>T: github.com/neocode24/homebrew-tap 등록
    U->>B: brew install --cask veil
    B->>T: Casks/veil.rb 조회
    T-->>B: 버전, URL, 체크섬 반환
    B->>R: Veil-v0.3.0.zip 다운로드
    B-->>U: /Applications/Veil.app 설치 완료

한 가지 남은 작업이 있다. veil.rb의 체크섬은 zip 파일 내용이 바뀔 때마다 직접 계산해 업데이트해야 한다. 지금은 릴리즈마다 수동으로 처리하고 있는데, 다음 버전에서 GitHub Actions가 릴리즈 직후 homebrew-tap을 자동으로 업데이트하도록 자동화할 예정이다.


마무리

스펙이 단순하면 구현도 단순하다. “전체화면이면 다른 모니터를 가려라” — 이 문장 하나가 전체 구조를 결정했다.

개발하면서 가장 많이 고민한 건 코드가 아니라 UX였다. 지원 앱 목록을 토글로 관리하는 방식이 직관적으로 보였지만, 막상 만들어보니 감시하는 것처럼 보인다는 느낌이 문제였다. 제외 목록으로 방향을 뒤집고 나서야 설정이 자연스러워졌다. 사이드 프로젝트에서도 UX 판단이 코드 판단만큼, 혹은 그보다 더 많이 일어난다.

Veil은 MIT 라이선스로 GitHub에 공개되어 있다.

brew tap neocode24/tap
brew install --cask veil

이어서 읽기