EventKit 기반 애플 캘린더·미리 알림 ⇄ 옵시디언 양방향 동기화 앱 개발기
들어가며
나는 일정을 두 곳에서 본다. 휴대폰에서는 TimeBlocks로 하루를 블록 단위로 잡고, Mac에서는 옵시디언 일기에 하루를 적는다. 옵시디언의 Day Planner 플러그인이 일기에 적은 일정을 사이드바 타임라인으로 보여 준다.
문제는 같은 일정을 두 번 적어야 한다는 것이었다. Claude에게 "화요일 3시 피부과"라고 말하면 옵시디언 일기에는 들어가지만 휴대폰에는 없다. 반대로 휴대폰에서 잡은 일정은 옵시디언에 없다. 두 기록은 금방 어긋났다.
그래서 둘을 잇는 Mac 앱을 만들었다. 이름은 CalendarSync다. 5분마다 애플 캘린더·미리 알림과 옵시디언을 양쪽 방향으로 맞춘다. Swift 파일 8개, 1,537줄이다.
구조: 애플 캘린더를 가운데에 둔다
TimeBlocks에는 외부 연동 API가 없다. 대신 유료판이 애플 캘린더·미리 알림과 연동된다. 그래서 애플 캘린더를 허브로 두고, 내가 만들 구간을 하나로 줄였다.
TimeBlocks ⇄ 애플 캘린더(iCloud 7개) ⇄ CalendarSync(Mac, 5분마다) ⇄ 옵시디언 일기 "## 일정"
TimeBlocks ⇄ 애플 미리 알림(7개) ⇄ CalendarSync ⇄ 옵시디언 todo.md "## 할 일"TimeBlocks ⇄ 애플 캘린더, 휴대폰 ⇄ iCloud 구간은 애플이 맞춘다. 내가 만들 것은 애플 캘린더 ⇄ 옵시디언 하나다. Mac에서는 EventKit으로 캘린더와 미리 알림을 읽고 쓸 수 있어서 Swift를 골랐다.
| 파일 | 역할 |
|---|---|
Sync.swift | 일정 3-way 비교, 계획 세우기·실행 |
ReminderSync.swift | 할 일 ⇄ 미리 알림 |
EventKitStore.swift | EventKit 읽기·쓰기 |
Model.swift | 일기 줄 파싱, 상태 파일 |
Migration.swift | 기존 캘린더 정리(내보내기·재분류·이동) |
SelfTest.swift, E2ETest.swift | 가짜 캘린더 테스트, 실제 캘린더 왕복 테스트 |
main.swift | 명령줄 옵션 |
옵시디언 쪽 형식: 새 문법을 만들지 않는다
일기의 ## 일정 섹션은 이렇게 생겼다.
## 일정
- [ ] 07:00 - 08:00 필라테스 #건강 ^ek-q3m8vn
- [ ] 15:00 - 16:00 피부과 ^ek-ab12cd
- 하위 줄은 메모 (동기화 안 함)
- [-] ~~19:00 - 19:30 저녁 약속~~ (캘린더에서 삭제됨)형식을 정할 때 원칙은 이미 있는 문법을 빌리는 것이었다.
- 줄 형식은 Day Planner 형식 그대로. 동기화된 일정이 사이드바 타임라인에 바로 보인다.
- 태그가 곧 캘린더.
#건강이면 "건강" 캘린더, 태그가 없으면 기본 캘린더(생활)로 간다. - 연결 표시는 옵시디언 블록 ID. 줄 끝
^ek-…가 이 줄과 캘린더 일정을 잇는다. 블록 ID 문법이라 읽기 모드에서는 보이지 않고 Day Planner도 제목에서 뺀다. 별도 ID 칸을 만들지 않았다.
할 일도 같다. todo.md의 - [ ] 정처기 접수 #공부 📅 2026-10-02 ^rm-…가 미리 알림 "공부" 목록의 할 일과 이어진다. 옵시디언에서 체크하면 미리 알림도 완료되고, 휴대폰에서 완료하면 옵시디언도 [x]가 된다.
설계 1: 3-way 비교와 충돌 규칙
일기에는 15시, 캘린더에는 16시라고 적혀 있다면 어느 쪽이 맞을까? 두 쪽만 비교해서는 알 수 없다. 누가 바꿨는지 모르기 때문이다.
그래서 상태 파일에 연결 표시(^ek-…) ↔ 캘린더 일정 ID ↔ 마지막으로 맞춘 모습을 저장하고, 매번 일기와 캘린더를 각각 이 기준과 비교한다.
| 일기 | 캘린더 | 판단 |
|---|---|---|
| 기준과 같음 | 기준과 다름 | 캘린더에서 바뀜 → 일기에 반영 |
| 기준과 다름 | 기준과 같음 | 일기에서 바뀜 → 캘린더에 반영 |
| 기준과 다름 | 기준과 다름 | 양쪽이 바뀜 → 캘린더 우선 |
양쪽이 동시에 바뀌면 캘린더를 따른다. 알림과 다른 기기에 이미 퍼진 쪽이 캘린더이기 때문이다. 이때 일기에서 고친 내용은 사라진다. 그래도 휴대폰 알림과 일기가 서로 다른 시간을 말하고 있는 것보다는 낫다고 봤다.
반복 일정은 캘린더에서는 하나지만 일기에서는 날마다 한 줄이다. 그래서 일정ID|회차 시각을 키로 써서 회차마다 한 줄씩 잇는다.
설계 2: 지우지 않는 쪽으로 기운 안전장치
동기화 도구가 잘못 돌면 일정이 사라진다. 그래서 기본값을 전부 "덜 지우는 쪽"으로 잡았다.
| 장치 | 동작 | 막는 사고 |
|---|---|---|
| 일기 쪽 삭제 대신 취소선 | 캘린더에서 지운 일정은 일기에서 [-]·취소선으로만 표시 | 일정 아래 적어 둔 메모가 함께 사라짐 |
| 날짜 이동 시 하위 줄 동반 | 휴대폰에서 날짜를 옮기면 하위 메모도 같이 옮김 | 메모가 남아 바로 위 일정에 붙음 (실제로 겪은 버그) |
| 타이핑 중 건너뛰기 | 20초 안에 수정한 일기가 있으면 그 회차는 손대지 않음 | 쓰는 도중 덮어쓰기 |
| 삭제 한도 | 한 번에 10건 넘게 지우게 되면 아무것도 하지 않고 멈춤 | 상태 파일 손상·볼트 경로 변경 시 전체 삭제 |
| 계획 → 검사 → 실행 | 바꿀 목록을 먼저 만들고 검사를 통과하면 실행. --dry-run으로 목록만 볼 수 있음 | 예상하지 못한 대량 변경 |
| 범위 제한 | 지난 7일 ~ 앞으로 60일만 맞춤 | 오래된 기록 훼손 |
반대로 일기에서 줄을 지우면 캘린더 일정도 지운다. 그건 내가 직접 한 행동이기 때문이다. 정리하면, 자동으로 생긴 변경은 조심스럽게 다루고 내가 직접 한 변경은 그대로 따른다.
macOS에서 배운 것
- 캘린더 권한은 앱 번들에 준다. 명령줄 실행 파일에 캘린더 권한(TCC)을 주면 불안정했다. 작은
.app으로 감싸고 Info.plist에NSCalendarsFullAccessUsageDescription,NSRemindersFullAccessUsageDescription을 넣은 뒤 ad-hoc 서명했다. 다시 빌드해도 권한이 유지된다. - 주기 실행은 launchd.
StartInterval 300으로 5분마다 실행하고, 로그는~/Library/Logs/에 남긴다. 바뀐 것이 있을 때만 로그를 남기기 때문에 로그가 그대로 변경 이력이 된다. - 미리 알림 읽기는 비동기다.
fetchReminders는 콜백으로 결과를 준다. 명령줄 도구에서는 세마포어로 기다린다.
테스트: 실제 캘린더를 건드리기 전에
일정을 다루는 도구라 실제 캘린더로 바로 시험하기 어려웠다. 그래서 두 단계로 나눴다.
--self-test: 메모리 안의 가짜 캘린더 + 임시 볼트로 시나리오 14개, 검사 39개를 돌린다. 일기 → 캘린더, 캘린더 → 일기, 시간 수정, 날짜 이동(하위 메모 포함), 양쪽 삭제, 동시 수정, 미리보기, 삭제 한도, 태그, 범위 밖 일정, 할 일 ⇄ 미리 알림까지 다룬다.--e2e-test: 실제 애플 캘린더와 미리 알림에 임시 캘린더를 만들어 왕복을 확인하고, 끝나면 지운다.
$ calendar-sync --self-test
1. 일기에 적은 일정 → 캘린더
1-1. 태그로 캘린더 지정
2. 두 번째 실행은 아무것도 안 바꿈
...
11. 한 번에 너무 많이 지우면 중단
12. 기간 밖 일정은 건드리지 않음
13. 할 일 ⇄ 미리 알림
모든 검사 통과"두 번째 실행은 아무것도 안 바꿈"(멱등성)을 따로 시나리오로 뺀 데는 이유가 있다. 5분마다 도는 도구가 실행할 때마다 뭔가를 조금씩 바꾸면, 모르는 사이에 기록이 틀어진다.
덤: 10년 묵은 캘린더 489건 정리
동기화를 붙이려고 iCloud 캘린더를 열어 보니 상태가 엉망이었다. 캘린더가 10개(시험, 행사, 처리해야 할 일…)였고, 2016년부터 2025년까지 일정이 489건 있었다. "시험" 캘린더에 치과, 카페 아르바이트, 생일이 섞여 있었다.
--export로 일정 489건을 표로 뽑았다.- 제목 키워드 규칙으로 대부분을 다시 분류하고, 애매한 것만 직접 확인했다.
- 생활·업무·공부·프로젝트·건강·약속·여가 7개 분류로 옮기고, 빈 캘린더를 지웠다.
실행 전 미리보기에서 "옮김 1건"이 나왔다. 489건이어야 했다. 원인은 줄바꿈이었다. 분류표를 만든 Python csv.writer는 줄 끝을 \r\n으로 쓰는데, Swift String은 \r\n을 문자 하나(grapheme cluster)로 본다. 그래서 split(separator: "\n")이 줄을 하나도 나누지 못했다. components(separatedBy: .newlines)로 바꿔 해결했다. 미리보기를 먼저 돌리지 않았다면 실제로 옮기는 단계에서야 알았을 것이다.
이슈 정리
| 이슈 | 원인 | 해결 |
|---|---|---|
| Swift가 TSV 489줄 중 1줄만 읽음 | \r\n이 grapheme cluster 하나 | components(separatedBy: .newlines) |
| EventKit으로 지운 캘린더가 iCloud에 남음 | Mac에서는 지워졌다고 나오지만 서버에 반영 안 됨 | 캘린더 데몬을 재시작해 서버 상태를 다시 받고, 일정 0건일 때만 다시 삭제 |
| 날짜를 옮기면 하위 메모가 위 일정에 붙음 | 일정 줄만 옮김 | 하위 줄을 함께 옮김 |
2주 운영 결과 (2026-09-30 ~ 10-11)
동기화 로그(바뀐 것이 있을 때만 기록)를 집계했다.
| 방향 | 추가 | 수정 | 삭제 |
|---|---|---|---|
| 옵시디언 일기 → 애플 캘린더 | 5 | 2 | 0 |
| 애플 캘린더 → 옵시디언 일기 | 5 | 0 | 0 |
| 옵시디언 할 일 → 미리 알림 | 25 | 10 | 1 |
| 미리 알림 → 옵시디언 할 일 | 0 | 7 | 0 |
- 변경이 있었던 회차는 24번, 8일에 걸쳐 있었다. 일정 12건, 할 일 43건이 내가 따로 옮기지 않아도 오갔다.
- 삭제 한도에 걸려 멈춘 적은 없었고, 오류 로그(
calendar-sync.err.log)는 비어 있다. - "미리 알림 → 할 일"의 수정 7건은 모두 미리 알림 쪽(휴대폰·TimeBlocks)에서 완료 체크한 것이다. 할 일을 체크하러 Mac을 켤 필요가 없어졌다.
한계와 다음 과제
- Mac이 켜져 있어야 옵시디언과 맞춰진다. 휴대폰끼리는 iCloud로 항상 맞지만, 옵시디언 쪽 반영은 Mac이 깨어 있을 때만 일어난다.
- 미리 알림의 반복, 하위 할 일, 메모는 아직 맞추지 않는다.
- 충돌 시 일기 쪽 변경을 버리는 규칙은 2주 동안 실제로 발동한 적이 없다. 발동했을 때 버린 내용을 일기에 남길지 검토할 생각이다.
마치며
만들면서 가장 오래 붙잡고 있던 질문은 "무엇을 지우지 않을까"였다. 동기화 도구는 잘 돌 때보다 잘못 돌 때가 더 문제다. 3-way 비교, 취소선, 삭제 한도, 타이핑 중 건너뛰기, 멱등성 검사는 모두 그 질문에서 나왔다.