코딩 에이전트의 할 일을 애플 미리알림에 (1) — 되는 것, 안 되는 것, 5초의 함정
코딩 에이전트의 작업 상황을 맥 미리알림 앱으로 실시간 동기화하는 과정에서 발견한 AppleScript의 제약 사항과 5초 지연 문제를 해결한 경험을 공유합니다. 섹션과 태그는 제어할 수 없지만 깃발 기능을 활용해 진행 상황을 관리할 수 있으며, 항목 참조를 변수에 담지 않고 한 번에 속성을 수정하는 방식으로 성능을 9배 이상 개선했습니다

코딩 에이전트에게 여러 단계짜리 작업을 맡기면, 지금 몇 번째 단계를 하고 있는지가 터미널 스크롤 속에 묻힌다. 진행 상황이 보이지 않으니 끝날 때까지 기다리거나, 중간에 로그를 뒤져야 한다.
그래서 에이전트의 할 일 목록을 맥 기본 미리알림 앱에 실시간으로 띄우기로 했다. 별도 앱을 깔 필요가 없고, 아이폰에서도 그대로 보이고, AppleScript 로 제어할 수 있다는 세 가지가 이유였다.
결과부터 말하면 잘 돌아간다. 다만 가는 길에 쓰기 한 번이 5초씩 걸리는 함정이 있었고, 그 원인을 잘못 짚었다가 이분 탐색으로 다시 찾아냈다. 이 글은 그 탐색 기록이다. 실제 구현과 설정법은 다음 편에서 다룬다.
미리알림은 어디까지 스크립트로 제어되나
AppleScript 사전을 읽는 대신, 되는지 안 되는지를 하나씩 두들겨봤다. sdef 로 사전을 덤프하려 했더니 Xcode 가 필요하다며 거부당해서, 그냥 속성을 직접 조회하는 쪽이 빨랐다.
리스트 조회는 한 줄이면 된다.
osascript -e 'tell application "Reminders" to get name of every list'항목의 속성 전체를 덤프하면 무엇을 만질 수 있는지가 한눈에 나온다.
osascript -e 'tell application "Reminders" to get properties of (first reminder of list "Inbox")'priority:0, body:missing value, id:x-apple-reminder://...,
name:샘플, due date:missing value, allday due date:missing value,
creation date:date ..., completed:false, remind me date:missing value,
modification date:date ..., flagged:false, completion date:missing value,
container:list id ...이 목록이 곧 제어 가능한 범위다. 여기 있는 것만 읽고 쓸 수 있다.
되는 것
리스트 생성·삭제, 항목 생성·삭제, 이름·완료·깃발·우선순위 변경, 리스트 간 이동까지 전부 된다.
리스트 색상은 사전에 없는 것처럼 보이지만 실제로는 있다. 값 형식이 문서화가 잘 안 되어 있어 세 가지를 시도해봤는데, hex 문자열만 통했다.
# 통함
osascript -e 'tell application "Reminders" to set color of list "Inbox" to "#FF9500"'
# 안 통함 — missing value 로 남는다
osascript -e 'tell application "Reminders" to set color of list "Inbox" to "orange"'
# 에러 (-1700): RGB 리스트를 text 로 변환 못 함
osascript -e 'tell application "Reminders" to set color of list "Inbox" to {65535, 38000, 0}'항목을 다른 리스트로 옮기는 move 도 정상 동작한다.
osascript -e 'tell application "Reminders"
move (first reminder of list "A" whose name is "할 일") to list "B"
end tell'안 되는 것
섹션(칸반 열) 은 불가능하다. 최근 macOS 미리알림에는 리스트를 열로 나누는 섹션 기능이 있지만, 스크립트로는 손댈 수 없다.
osascript -e 'tell application "Reminders" to get every section of list "Inbox"'
# syntax error: 클래스 이름을 예상했지만 식별자를 발견했습니다. (-2741)section 클래스 자체가 사전에 없고, 위에서 본 항목 속성에도 섹션을 가리키는 필드가 없다(container 는 리스트만 가리킨다). 리스트를 묶는 그룹(폴더)도 마찬가지다. UI 에서 섹션을 만들어둬도 스크립트로 만든 항목을 그 열에 넣을 방법이 없다.
태그도 불가능하다. 이건 판정에 약간의 추리가 필요했다. 항목 속성에 태그 필드가 없는 건 확인했는데, 제목에 # 를 쓰면 미리알림이 태그로 파싱해줄 가능성이 남아 있었기 때문이다.
그래서 전체 항목을 훑어 name 에 # 가 든 것이 몇 건인지 셌다.
osascript -e 'tell application "Reminders"
set withHash to 0
set total to 0
repeat with L in lists
repeat with r in reminders of L
set total to total + 1
if (name of r) contains "#" then set withHash to withHash + 1
end repeat
end repeat
return "전체 " & total & "건 중 name 에 # 포함 " & withHash & "건"
end tell'결과는 549건 중 1건이었고, 그 1건은 방금 내가 테스트로 만든 항목이었다. 즉 UI 에서 태그를 단 기존 항목들은 name 에 # 가 남아 있지 않다 — 태그는 이름과 별개 저장소에 있고, 스크립트에는 노출되지 않는다. 반대로 내가 제목에 넣은 # 는 그냥 텍스트로 남았다. 파싱은 UI 입력일 때만 일어난다.
우회로 두 개도 막혔다. EventKit 직접 접근은 osascript 에서 권한 콜백이 아예 오지 않는다(TCC 승인 경로가 다르다).
// osascript -l JavaScript 로 실행
ObjC.import('EventKit');
const store = $.EKEventStore.alloc.init;
let granted = false, done = false;
store.requestFullAccessToRemindersWithCompletion((g) => { granted = g; done = true; });
// 런루프를 8초 돌려도 done 이 false 로 남는다미리알림 데이터베이스를 직접 읽는 것도 안 된다. ~/Library/Reminders 아래에 sqlite 파일이 보이지 않는다.
정리하면 이렇다.
| 대상 | 가능 여부 |
|---|---|
| 리스트 생성·삭제·색상 | 가능 (색상은 "#FF9500" 같은 hex 문자열) |
| 항목 생성·삭제·이동 | 가능 |
| 이름·완료·깃발·우선순위 | 가능 |
| 섹션(칸반 열) | 불가 |
| 태그 | 불가 (읽기조차 안 됨) |
| EventKit / SQLite | 불가 |
칸반 열을 못 쓰는 건 아쉬웠는데, 깃발이 대안이 됐다. 깃발은 리스트를 가로질러 사이드바의 "깃발 표시"에 모이기 때문에, 진행 중인 항목에만 깃발을 켜두면 그게 곧 "지금 뭘 하고 있나" 뷰가 된다. 세션을 여러 개 동시에 돌려도 각자의 현재 작업이 한 화면에 모인다.
5초의 함정
기능 탐색이 끝나고 실제 동기화 스크립트를 붙였더니, 항목 하나를 완료 처리하는 데 5.3초가 걸렸다. 다섯 개짜리 목록을 갱신하면 25초다. 초기 구현은 아예 20초 타임아웃에 걸려 항목이 절반만 반영되고 -609(연결이 유효하지 않습니다) 에러로 죽었다.
첫 번째 진단은 틀렸다
처음 세운 가설은 "완료 처리가 iCloud 동기화를 유발한다"였다. 그럴듯했다. 완료는 다른 기기에 즉시 반영되어야 하는 상태 변화니까.
이 가설을 반증한 건 깃발이었다. 깃발도 똑같이 동기화 대상인데 0.3초밖에 안 걸렸다.
# 0.30초
osascript -e 'tell application "Reminders" to set flagged of (first reminder of list "Inbox" whose body is "k") to true'
# 0.29초
osascript -e 'tell application "Reminders" to set priority of (first reminder of list "Inbox" whose body is "k") to 1'같은 계정, 같은 리스트, 같은 항목인데 어떤 속성은 0.3초고 어떤 속성은 5초라면 동기화 탓이 아니다. 확인 사살로 완료 상태를 10번 연속 토글해봤다.
for i in $(seq 1 10); do
v=$([ $((i % 2)) -eq 0 ] && echo false || echo true)
osascript -e "tell application \"Reminders\" to set completed of (first reminder of list \"Inbox\" whose body is \"k\") to $v"
done10회 전부 0.24~0.30초. completed 는 범인이 아니었다. 추정으로 원인을 적어둘 뻔했다.
이분 탐색
그렇다면 스크립트가 만들어내는 AppleScript 안에 범인이 있다는 뜻이다. 문장을 하나씩 걷어내며 시간을 쟀다.
-- 1) 원본: 10.79초
tell application "Reminders"
if not (exists list "Inbox") then make new list with properties {name:"Inbox"}
set color of list "Inbox" to "#FF9500"
set L to list "Inbox"
set r to (first reminder of L whose body is "k")
set name of r to "새 이름"
set completed of r to true
end tell-- 2) exists 검사와 color 설정을 뺌: 10.09초 (거의 안 줄었다)
tell application "Reminders"
set L to list "Inbox"
set r to (first reminder of L whose body is "k")
set name of r to "새 이름"
set completed of r to true
end tell-- 3) name 설정도 뺌: 5.40초 (정확히 절반)
tell application "Reminders"
set L to list "Inbox"
set r to (first reminder of L whose body is "k")
set completed of r to true
end tell-- 4) 참조를 변수에 담지 않고 한 문장으로: 0.25초
tell application "Reminders"
set completed of (first reminder of list "Inbox" whose body is "k") to true
end tell3번과 4번은 논리적으로 완전히 같은 일을 한다. 차이는 항목 참조를 변수 r 에 담았느냐뿐인데 21배 차이가 난다.
그리고 2번과 3번을 비교하면 패턴이 보인다. 변수를 통해 쓰는 속성이 하나 늘 때마다 5초씩 선형으로 증가한다. 속성 하나면 5.4초, 둘이면 10.1초.
원인은 이렇게 이해했다. first reminder of L whose body is "k" 를 변수에 담으면 그 변수는 값이 아니라 참조(specifier) 다. 그 참조로 속성을 쓸 때마다 앱이 조건을 다시 해석하고, 그 왕복에 고정 비용이 붙는다. 리스트를 변수에 담는 것(set L to list "Inbox")은 무해했다 — 항목 참조만 그렇다.
처방
여러 속성을 바꿔야 하면 set properties 로 한 번에 쓴다. 참조를 변수에 담지 않는 것이 핵심이다.
-- 0.37초. 이름·완료·깃발을 한꺼번에 바꾼다
tell application "Reminders"
set properties of (first reminder of list "Inbox" whose body is "k") to ¬
{name:"▶ 작업 중", completed:false, flagged:true}
end tell이 한 줄로 바꾼 뒤 실제 경로를 다시 쟀다.
| 동작 | 수정 전 | 수정 후 |
|---|---|---|
| 항목 5개 등록 | 1.3초 | 1.3초 |
| 진행 중 표시 / 완료 체크 | 5.3초 | 0.5~0.65초 |
약 9배 빨라졌고, 20초 타임아웃 문제도 함께 사라졌다. iCloud 를 끄는 방법을 찾을 필요도 없어졌다. (참고로 끌 수도 없다. 미리알림은 리스트 단위로 계정을 고르는 수단이 없어서, 로컬 저장을 쓰려면 iCloud 미리알림 자체를 꺼야 한다.)
덤으로 걸린 것들
탐색 중에 두 번 헛짚었는데 둘 다 기록해둘 만하다.
첫째, 측정 루프가 조용히 실패했다. zsh 는 변수를 확장할 때 단어 분리를 하지 않는다.
# zsh 에서는 "done 1" 이 통째로 한 개의 인자로 전달된다
for c in "done 1" "doing 2"; do
node script.mjs $c
donebash 라면 done 과 1 두 개로 쪼개지지만 zsh 는 그러지 않는다. 스크립트는 인자를 못 알아듣고 usage 를 뱉었는데, 나는 시간만 뽑아 보고 있어서 그 메시지를 놓쳤다. 측정값이 0.03초로 나온 걸 보고서야 이상함을 알아챘다.
둘째, AppleScript 소스를 파이프로 넘기면 안 된다.
# syntax error (-2740)
node gen-script.mjs | osascript
# 파일로 저장해 실행하거나 -e 로 넘긴다
node gen-script.mjs > /tmp/op.applescript && osascript /tmp/op.applescript정리
- 미리알림은 리스트·항목·완료·깃발·색상까지는 스크립트로 충분히 다룰 수 있다. 섹션과 태그는 포기해야 한다.
- 칸반 열을 못 쓰는 자리는 깃발이 대신할 수 있다. 리스트를 가로질러 모이므로 "지금 진행 중인 것" 뷰로 쓰기 좋다.
- 항목 참조를 변수에 담고 속성을 하나씩 쓰면 쓰기당 5초가 붙는다.
set properties로 한 문장에 묶어라. - 느린 이유를 추정으로 적어두지 마라. 그럴듯한 첫 가설(iCloud)은 인접한 다른 속성 하나를 재보는 것만으로 죽었다.
다음 편에서는 이걸 실제 하네스로 조립한다. 훅 배선, 여러 세션이 리스트 하나를 공유할 때의 격리 설계, 세션이 끝나거나 비정상 종료됐을 때의 정리 전략을 다룬다.