마지막 한 조각 — 업데이트를 어디에 올릴 것인가
1편에서는 Developer ID 인증서와 공증을, 2편에서는 Sparkle 서명 키를 준비했습니다. 앱을 서명하고, 공증하고, 업데이트 진위를 검증할 수단까지 갖춘 셈입니다.
그런데 2편에서 앱의 Info.plist에 적어 둔 SUFeedURL(https://updates.example.com/appcast.xml)이 가리키는 곳에는 아직 아무것도 없습니다. 이 마지막 편에서는 그 자리에 들어갈 업데이트 피드를 호스팅하고, 빌드 설정을 마무리해 사전 설정 전체를 끝냅니다.
1·2편과 마찬가지로 모든 이름·도메인(
FocusTimer,example.com,example-dev등)은 예시 값입니다. 실제로는 본인 정보로 바꿔 사용하세요.
왜 업데이트 저장소를 따로 두는가
자동 업데이트가 동작하려면 두 가지가 인터넷에 올라가 있어야 합니다.
appcast.xml— 어떤 버전이 최신인지, 어디서 받는지 알려주는 업데이트 피드.dmg— 실제 앱 설치 파일
여기서 중요한 제약이 있습니다. 앱 안의 Sparkle은 인증 없는 단순 HTTPS GET으로 이 파일들을 받습니다. 사용자 컴퓨터의 앱이 로그인 같은 절차 없이 곧바로 내려받을 수 있어야 한다는 뜻입니다.
많은 개발자가 앱의 **본 소스 저장소는 비공개(private)**로 둡니다. 그런데 비공개 저장소의 릴리스 파일은 인증을 요구하므로 Sparkle이 받지 못합니다. 그래서 흔히 쓰는 구조가 저장소 분리입니다.
- 본 저장소 (예:
FocusTimer) — 소스 코드. 비공개로 둬도 됨. - 업데이트 저장소 (예:
FocusTimer-updates) —appcast.xml만 호스팅. 반드시 공개(public).
이 글에서는 업데이트 저장소를 GitHub의 무료 정적 호스팅인 GitHub Pages로 운영합니다.
1단계 — 공개 업데이트 저장소 만들기
GitHub에서 새 저장소를 만듭니다.
- New repository 클릭
- 이름:
FocusTimer-updates— 이 이름은 잠시 후 피드 주소에 쓰이므로 대소문자까지 정확히 정합니다. - 소유자(Owner): 본인 계정 또는 조직 (예시:
example-dev) - 공개 범위: Public — Sparkle이 인증 없이 받아야 하므로 필수입니다.
- Add a README file 체크 (첫 커밋 편의)
- Create repository
2단계 — GitHub Pages 활성화
방금 만든 저장소를 정적 사이트로 띄웁니다.
- 저장소의 Settings → 왼쪽 메뉴 Pages
- Source: Deploy from a branch
- Branch:
main/(root)선택 → Save - 1~2분 후
https://example-dev.github.io/FocusTimer-updates/로 접속되면 성공입니다.
이 시점에 이미 업데이트 파일을 올릴 수 있는 공개 주소가 생긴 셈입니다. 하지만 한 단계가 더 남았습니다.
3단계 — 커스텀 도메인 연결
GitHub Pages 기본 주소(example-dev.github.io/...)를 그대로 써도 동작은 합니다. 그런데 이 주소를 앱의 SUFeedURL에 박아 버리면, 훗날 호스팅을 GitHub Pages에서 다른 곳으로 옮겨야 할 때 문제가 생깁니다. 이미 배포된 모든 사용자의 앱이 옛 주소를 바라보고 있기 때문입니다.
해법은 내가 통제하는 도메인을 한 겹 끼우는 것입니다. SUFeedURL을 https://updates.example.com/appcast.xml처럼 본인 도메인으로 두면, 나중에 호스팅을 옮길 때 DNS 설정 한 줄만 바꾸면 기존 사용자가 그대로 새 위치를 따라옵니다. 이 설정은 한 번만 해 두면 영구히 유효합니다.
3-1. DNS 레코드 추가
도메인(example.com)을 관리하는 DNS 제공자의 설정 화면에서 다음 레코드를 추가합니다.
| 항목 | 값 |
|---|---|
| Type | CNAME |
| Name | updates |
| Value | example-dev.github.io |
| TTL | 3600 (기본값) |
이렇게 하면 updates.example.com이라는 하위 도메인이 GitHub Pages를 가리키게 됩니다.
3-2. 저장소에 CNAME 파일 추가
업데이트 저장소(FocusTimer-updates) 루트에 CNAME이라는 파일을 만들고, 내용으로 도메인 한 줄만 적습니다.
updates.example.com
이 파일을 커밋하고 푸시합니다.
3-3. GitHub Pages에 도메인 등록
저장소의 Settings → Pages → Custom domain 칸에 updates.example.com을 입력하고 Save합니다. GitHub가 자동으로 HTTPS 인증서를 발급하며, 발급이 끝나면 Enforce HTTPS를 체크합니다.
3-4. 확인
DNS 전파와 인증서 발급에는 보통 10분 안팎 걸립니다. 잠시 후 다음 명령으로 확인합니다.
curl -I https://updates.example.com/appcast.xml
응답 첫 줄에 HTTP/2 200이 보이면 정상입니다. (아직 appcast.xml을 올리기 전이라면 404가 나올 수 있는데, 도메인·HTTPS 연결 자체는 다른 경로로도 확인할 수 있습니다. 핵심은 https://updates.example.com이 응답을 준다는 점입니다.)
참고로
.dmg설치 파일은 GitHub Pages가 아니라 GitHub Releases에 올리는 것이 일반적입니다. 큰 바이너리를 저장소에 직접 넣으면 저장소가 비대해지기 때문입니다. Releases는 Pages와 다른 경로로 제공되므로, 위 커스텀 도메인 설정과는 무관하게 그대로 둡니다.
4단계 — 업데이트 저장소를 로컬에 두기
릴리스 작업 때 appcast.xml을 수정·커밋하려면 업데이트 저장소를 로컬에도 받아 둬야 합니다. 본 프로젝트 폴더 안의 적당한 위치(예: release/updates)에 클론해 두면, 빌드·릴리스 스크립트가 한곳에서 두 저장소를 모두 다룰 수 있어 편리합니다.
git clone [email protected]:example-dev/FocusTimer-updates.git release/updates
이렇게 다른 저장소 안에 또 다른 저장소를 두는 경우, 본 저장소의 .gitignore에 release/updates/를 넣어 서로 간섭하지 않게 합니다.
5단계 — ExportOptions.plist
이제 빌드 쪽 설정입니다. Xcode의 xcodebuild -exportArchive 명령은 아카이브를 배포용 .app으로 내보낼 때 **ExportOptions.plist**라는 설정 파일을 읽습니다. 직접 배포(Developer ID 채널)용 내용은 다음과 같습니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>ABCDE12345</string>
</dict>
</plist>
method—developer-id. “Mac App Store가 아닌 직접 배포"라는 뜻입니다.signingStyle—automatic. 1편에서 발급한 인증서를 Xcode가 알아서 골라 쓰게 합니다.teamID— 1편에서 메모해 둔 Team ID.
이 파일을 프로젝트 안(예: release/ExportOptions.plist)에 한 번 만들어 두면 모든 릴리스에서 재사용합니다.
6단계 — 앱 측 설정 점검
마지막으로, 앱 프로젝트 자체에 들어가 있어야 하는 설정들을 정리합니다. 새 앱에 처음 Sparkle을 붙이는 경우 이 목록을 점검표로 쓰면 됩니다.
Info.plist
2편에서 추가한 Sparkle 키들입니다. Info.plist는 다음 키를 포함해야 합니다.
<key>SUFeedURL</key>
<string>https://updates.example.com/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>5vT3kQbA9mZ0wR1yX8cD2eF4gH6jK7lN0pS2uV5xW8c=</string>
SUFeedURL이 3단계에서 만든 커스텀 도메인을 가리키고, SUPublicEDKey가 2편에서 생성한 공개 키와 일치하는지 다시 확인하세요.
권한(Entitlements)
앱이 **샌드박스(App Sandbox)**를 사용한다면, Sparkle이 업데이트 설치를 위해 내부 서비스와 통신할 수 있도록 예외 권한이 필요합니다. FocusTimer.entitlements 파일에 다음 항목들이 들어갑니다.
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spks</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)-spki</string>
</array>
$(PRODUCT_BUNDLE_IDENTIFIER)는 빌드 시 Xcode가 실제 번들 식별자(com.example.FocusTimer)로 자동 치환합니다. 자세한 항목은 Sparkle 공식 샌드박싱 가이드를 참고하세요.
직접 배포 앱에 샌드박스가 필수는 아닙니다(샌드박스는 Mac App Store의 요구사항입니다). 샌드박스를 쓰지 않는 앱이라면 위
mach-lookup예외는 필요 없습니다. 다만 자동 업데이트가 네트워크를 쓰므로network.client권한과 Hardened Runtime은 공증을 위해 켜 두어야 합니다.
빌드 설정(Build Settings)
Xcode의 빌드 설정에서 다음을 확인합니다.
CODE_SIGN_ENTITLEMENTS— 위 권한(entitlements) 파일 경로ENABLE_HARDENED_RUNTIME = YES— 공증의 필수 조건ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES— 업데이트 확인용 네트워크 허용
시리즈 정리 — 사전 설정 완료
3편에 걸친 직접 배포 사전 설정이 모두 끝났습니다. 이제 손에 다음이 갖춰져 있습니다.
- ✅ (1편) Developer ID Application 인증서 + 공증용 자격 증명
- ✅ (2편) Sparkle EdDSA 서명 키 한 쌍 + 비공개 키 백업
- ✅ (3편) 커스텀 도메인으로 연결된 공개 업데이트 저장소 +
ExportOptions.plist+ 앱 측 설정
여기까지가 한 번만 하면 되는 설정입니다. 이 작업들은 앞으로 새 버전을 낼 때마다 다시 할 필요가 없습니다.
이제부터 새 버전을 배포하는 흐름은 매번 비슷하게 반복됩니다 — 아카이브 빌드 → ExportOptions.plist로 내보내기 → Developer ID 인증서로 서명 → notarytool로 공증 → .dmg 생성 → Sparkle 키로 서명 → appcast.xml 갱신 → GitHub Release에 .dmg 업로드. 이 반복 과정은 대부분 스크립트 하나로 자동화할 수 있으며, 그것은 별도의 글에서 다룰 주제입니다.
그중
.dmg만들기는 배경 이미지와 아이콘 배치 같은 디자인 요소가 얽혀 있어, 별도 시리즈 macOS 앱 배포용 DMG 디자인하기에서 자세히 다룹니다.
직접 배포는 처음 설정할 게 많아 막막해 보이지만, 핵심은 **“한 번 갖춰 두면 계속 재사용된다”**는 점입니다. App Store의 편의를 일부 포기하는 대신, 배포의 모든 과정을 스스로 통제하게 됩니다.