macOS 알림을 셀프호스팅 Bark 서버로 전달하기
DevOps

macOS 알림을 셀프호스팅 Bark 서버로 전달하기

2026-07-271

맥에서 뜨는 알림을 아이폰에서도 받고 싶었습니다. 상용 푸시 서비스는 앱별 아이콘을 붙일 수 없고, 알림 본문이 남의 서버를 거칩니다. 그래서 셀프호스팅 Bark 서버로 맥의 모든 알림센터 알림을 그대로 흘려보내는 에이전트를 만들었습니다.

이 글에서는 알림센터 DB를 직접 읽는 방법, 그 과정에서 만난 TCC(전체 디스크 접근) 권한의 함정, 그리고 launchd로 상시 실행하는 구성을 정리합니다. 완성된 코드는 okdohyuk/bark-mac에 MIT 라이선스로 공개해 두었습니다.

Bark 서버는 예전에 홈 네트워크 젠킨스 무중단 배포 구축기에서 만들어 둔 미니PC 환경에 올렸습니다. 터미널 작업의 완료 알림은 muxpad 회고에서 다룬 것처럼 tmux 출력 스트림으로도 받고 있지만, 그건 터미널 안에서 나온 신호만 잡습니다. 이 글의 대상은 맥에 뜨는 알림센터 알림 전체입니다.

BarkNotify 아키텍처

알림센터 DB는 이사했습니다

맥의 알림은 SQLite DB에 쌓입니다. 검색하면 대부분 이 경로를 알려줍니다.

/var/folders/**/com.apple.notificationcenter/db2/db

macOS Sequoia(15) 이후로는 이 경로가 빈 껍데기입니다. 실제 데이터는 그룹 컨테이너로 옮겨졌습니다.

~/Library/Group Containers/group.com.apple.usernoted/db2/db

구조는 단순합니다. app 테이블이 app_id ↔ 번들 식별자를 매핑하고, record 테이블에 알림 한 건이 한 행씩 쌓입니다. 본문은 data 컬럼에 바이너리 plist로 들어 있습니다.

SELECT app_id, identifier FROM app;
SELECT rec_id, app_id, data FROM record WHERE rec_id > ? ORDER BY rec_id ASC LIMIT 200;

plist를 풀면 req 딕셔너리 아래에 제목(titl)·부제(subt)·본문(body)이 있습니다.

func parse(_ data: Data) -> (titl: String, subt: String, body: String) {
    guard !data.isEmpty,
          let pl = try? PropertyListSerialization.propertyList(
              from: data, options: [], format: nil) as? [String: Any],
          let req = pl["req"] as? [String: Any] else { return ("", "", "") }
    func s(_ k: String) -> String {
        (req[k] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
    }
    return (s("titl"), s("subt"), s("body"))
}

여기까지는 쉽습니다. 문제는 이 DB를 읽을 수 있느냐입니다.

TCC는 launchd로 띄운 스크립트에 전파되지 않습니다

이 DB는 TCC의 전체 디스크 접근 권한(Full Disk Access, 이하 FDA) 으로 보호됩니다. 터미널에 FDA를 주고 파이썬 스크립트를 돌리면 잘 읽힙니다. 그래서 처음에는 파이썬 폴러를 짜고 launchd에 등록했습니다.

그리고 바로 막혔습니다. 로그에는 계속 이 줄만 찍혔습니다.

DB 열기 실패(전체 디스크 접근 권한 필요)

원인은 TCC의 책임 프로세스(responsible process) 개념입니다. macOS는 권한을 "실행된 바이너리"가 아니라 "그 실행에 책임이 있는 프로세스"에 귀속시킵니다.

  • 터미널에서 python3 poller.py → 책임 프로세스는 터미널. 터미널의 FDA가 적용됩니다.
  • launchd가 python3 poller.py를 띄움 → 책임 프로세스는 launchd. 터미널의 FDA와 무관하고, /usr/bin/python3에 FDA를 줘도 시스템 바이너리라 그 권한이 안정적으로 유지되지 않습니다.

즉 셸 스크립트·파이썬·osascript 같은 인터프리터를 거치는 방식은 launchd 상시 실행과 FDA를 동시에 만족시킬 수 없습니다.

TCC 권한 전파 비교

📌 해결책은 하나였습니다. 서명된 네이티브 앱이 자기 자신으로 DB를 직접 읽게 하는 것입니다. 그러면 책임 프로세스가 그 앱 자신이 되고, 시스템 설정에서 그 앱에 부여한 FDA가 그대로 적용됩니다.

서명된 Swift 앱으로 다시 만들기

본체를 Swift 단일 파일로 다시 썼습니다. SQLite3를 읽기 전용으로 열고, 3초 주기로 새 행만 가져옵니다.

let dbPath = FileManager.default.homeDirectoryForCurrentUser
    .appendingPathComponent("Library/Group Containers/group.com.apple.usernoted/db2/db")

func openDB() -> OpaquePointer? {
    var db: OpaquePointer?
    if sqlite3_open_v2(dbPath.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK {
        sqlite3_busy_timeout(db, 3000)
        return db
    }
    if let db = db { sqlite3_close(db) }
    return nil
}

빌드는 swiftc로 바이너리를 만들고 .app 번들로 감싼 뒤 Apple Development 인증서로 서명합니다. 로컬에서만 실행하므로 공증(notarization)은 필요 없습니다.

SIGN_ID="$(security find-identity -v -p codesigning \
  | awk -F'"' '/Apple Development/{print $2; exit}')"

xcrun swiftc -O -o "$TMPBIN" Sources/main.swift
mkdir -p "$APP/Contents/MacOS"
cp "$TMPBIN" "$APP/Contents/MacOS/BarkNotify"
cp Info.plist "$APP/Contents/Info.plist"

codesign --force --sign "$SIGN_ID" --timestamp=none "$APP"

⚠ 여기서 중요한 성질이 하나 있습니다. 같은 인증서 + 같은 bundle id로 재빌드하면 FDA가 유지됩니다. 코드를 고치고 다시 빌드해도 권한을 다시 줄 필요가 없습니다. 반대로 bundle id를 바꾸면 FDA를 다시 부여해야 합니다. 개발 중에 이걸 모르면 "왜 갑자기 또 안 읽히지"로 한참 헤맵니다.

새 알림만 골라내기

폴링이라 커서 관리가 필요합니다. 마지막으로 처리한 rec_id를 파일에 저장하고, 그보다 큰 행만 읽습니다.

첫 실행 시에는 과거 알림을 전부 쏘면 안 되므로 MAX(rec_id)로 커서를 초기화만 하고 넘어갑니다. 알림센터가 비워져 rec_id가 되감기는 경우도 있어서 역전 감지도 넣었습니다.

let mx = scalarInt(db, "SELECT MAX(rec_id) FROM record")
if last < 0 {                       // 첫 실행: 과거 알림 미전송
    last = mx; saveState(last); return
}
if mx < last {                      // DB가 비워짐: 커서 보정
    log("커서 리셋 \(last)->\(mx)"); last = mx; saveState(last)
}

전송에 실패하면 커서를 전진시키지 않고 다음 주기에 재시도합니다. 알림을 흘리는 것보다 중복이 낫다는 판단입니다.

알림을 알림답게 만드는 세 가지 파라미터

DB에서 텍스트만 꺼내 보내면 "어느 앱에서 온 알림인지" 알 수 없는 밋밋한 푸시가 됩니다. Bark의 세 파라미터로 채웠습니다.

파라미터용도구현
group앱별 그룹 묶기NSWorkspace로 앱 표시 이름을 얻어 macOS-<앱이름>
icon알림에 출처 앱 아이콘아이콘을 PNG로 추출해 webhook에 1회 업로드 후 조회 URL
url탭하면 아이폰 대응 앱 열기번들 ID → iOS URL scheme 매핑 테이블

아이콘은 NSWorkspace에서 앱 아이콘을 꺼내 128×128 PNG로 저장합니다.

if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bid) {
    let img = NSWorkspace.shared.icon(forFile: appURL.path)
    var rect = NSRect(x: 0, y: 0, width: 128, height: 128)
    if let cg = img.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
        let rep = NSBitmapImageRep(cgImage: cg)
        let png = rep.representation(using: .png, properties: [:])
        // 로컬 캐시 후 webhook에 최초 1회 업로드
    }
}

업로드 완료 목록은 메모리가 아니라 파일에 영구 기록합니다. 그러지 않으면 재부팅할 때마다 모든 앱 아이콘을 다시 올립니다.

이미지 업로드·조회 webhook은 새로 만들지 않았습니다. n8n으로 만든 이미지 서비스를 그대로 재사용했습니다. MinIO에 저장하고 크기를 맞춰 돌려주는 구성이라, 아이콘을 올리고 조회 URL을 받는 용도로 바로 쓸 수 있었습니다.

딥링크는 맥 앱 번들 ID를 iOS 앱의 URL scheme으로 바꿔주는 정적 매핑입니다.

let deepLinkMap: [String: String] = [
    "com.apple.mail": "message://",
    "com.apple.ical": "calshow://",
    "com.apple.notes": "mobilenotes://",
    "ru.keepcoder.telegram": "tg://",
    // Universal Link: 앱이 있으면 앱, 없으면 웹
    "com.openai.chat": "https://chatgpt.com",
]

설치되지 않은 scheme은 탭해도 아무 일도 일어나지 않으므로 무해합니다. 웹 푸시 알림은 출처가 _web_center_:web.<도메인> 형태로 들어오니, 도메인을 잘라 https://<도메인>으로 연결했습니다.

launchd 등록과 권한 부여 순서

LaunchAgent plist를 만들고 bootstrapenablekickstart 순서로 등록합니다.

<key>Label</key><string>com.example.bark-notify</string>
<key>ProgramArguments</key><array>
  <string>/Users/me/Applications/BarkNotify.app/Contents/MacOS/BarkNotify</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>ProcessType</key><string>Background</string>
launchctl bootstrap "gui/$(id -u)" "$PLIST"
launchctl enable   "gui/$(id -u)/com.example.bark-notify"
launchctl kickstart -k "gui/$(id -u)/com.example.bark-notify"

여기서 순서가 중요합니다. 앱을 먼저 설치·서명해 실체를 만든 다음 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한에 BarkNotify.app을 추가하고, 그 후 kickstart -k로 재시작해야 권한이 반영됩니다. 권한을 먼저 주려 해도 목록에 추가할 앱 자체가 없습니다.

검증은 알림 하나 띄워보면 끝납니다.

osascript -e 'display notification "테스트" with title "셋업검증"'
tail -f ~/Library/Logs/bark-notify.log

이 과정(빌드 → 서명 → LaunchAgent 등록 → 안내 출력)은 저장소의 install.sh 하나로 묶어뒀습니다. 설정 파일에 서버 주소와 device key만 채우면 나머지는 스크립트가 처리하고, 사람이 할 일은 시스템 설정에서 전체 디스크 접근 권한을 켜주는 것뿐입니다.

bash ~/.config/bark-mac/app-src/install.sh

한계와 보안

✅ 동작은 만족스럽지만, 구조상 분명한 한계가 있습니다.

  • 폴링 기반입니다. 알림 도착 이벤트를 구독하는 게 아니라 3초마다 record를 훑습니다. 에이전트가 죽어 있는 동안 온 알림은 전달되지 않습니다.
  • 알림 본문이 전부 전달됩니다. 2FA 코드, 메신저 내용까지 포함됩니다. 그래서 제외 목록(IGNORE_APPS)을 설정 키로 뺐고, 민감한 앱은 처음부터 제외하는 것을 기본 안내로 두었습니다.
  • 서버와 이미지 webhook은 셀프호스팅입니다. Bark는 종단간 암호화(ciphertext)도 지원하므로, APNs 구간까지 가리고 싶다면 그 옵션을 쓰면 됩니다.

자격증명은 저장소에 두지 않고 ~/.config/bark-mac/config.env(권한 600)에만 둡니다. 공개 저장소의 문서·설정 예시는 전부 bark.example.com 같은 placeholder로 바꿔두었습니다.

정리

  • 알림 DB 경로는 Sequoia 이후 group.com.apple.usernoted로 옮겨졌고 FDA로 보호됩니다.
  • FDA는 launchd로 띄운 인터프리터에 전파되지 않습니다. Team ID로 서명된 네이티브 앱이 직접 읽어야 합니다.
  • 같은 인증서·같은 bundle id면 재빌드해도 권한이 유지되고, bundle id를 바꾸면 재부여가 필요합니다.
  • 알림의 체감 품질은 group·icon·url 세 파라미터에서 갈립니다.

참고로 여기로 들어오는 알림 중 상당수는 AI 코딩 도구의 훅이 쏘는 작업 완료 신호입니다. 그 훅을 어떻게 구성했는지는 Claude Code 하네스로 개인 프로젝트 운영하기에 정리해 뒀습니다.

맥에서 뭔가 끝나면 아이폰이 울립니다. 빌드가 끝났는지 보려고 자리로 돌아가는 일이 없어졌습니다.

전체 코드와 설치 가이드는 okdohyuk/bark-mac 에 있습니다. Swift 소스 한 벌과 빌드·설치·제거 스크립트, 그리고 Bark 서버 구축 문서가 들어 있고, 저장소의 SETUP_PROMPT.md를 AI 코딩 도구에 그대로 붙여넣으면 다른 맥에도 같은 구성을 자동으로 올릴 수 있습니다. 자격증명은 저장소 밖(~/.config)에만 두는 구조라 포크해서 바로 쓰셔도 됩니다.

참고링크

macOS 알림을 셀프호스팅 Bark 서버로 전달하기