iOS vs Android 앱 서명 방식 정리
RN → Flutter 전환 시리즈. Shorebird 배포 파이프라인을 만들면서 CI에서 두 플랫폼의 코드 서명을 세팅해야 했다. iOS와 Android가 서명을 대하는 방식이 근본부터 달라서, 한 번 정리해두면 두고두고 도움이 된다.
두 플랫폼은 서명을 다르게 본다
한 줄 요약부터.
- Android: 개발자가 만든 keystore로 APK/AAB에 직접 서명한다. 키 관리 주체가 나(혹은 Play App Signing).
- iOS: Apple이 발급한 인증서(certificate) + 프로비저닝 프로파일(provisioning profile) 조합으로 서명한다. 애초에 Apple의 통제 아래 있다.
그래서 CI에서 준비하는 파일도, 실패하는 방식도 다르다.
Android: keystore 하나로 끝
Android는 릴리스 keystore 파일 하나와 그 비밀번호/alias만 있으면 된다. build.gradle의 signingConfigs.release가 local.properties(CI에선 key.properties)에서 값을 읽어 서명한다.
signingConfigs {
release {
def keystoreFileName = localProperties.getProperty('keystore.file')
if (keystoreFileName != null && file(keystoreFileName).exists()) {
storeFile file(keystoreFileName)
storePassword localProperties.getProperty('keystore.password', '')
keyAlias localProperties.getProperty('keystore.keyAlias', '')
keyPassword localProperties.getProperty('keystore.keyPassword', '')
} else {
// keystore 정보가 없으면 release 빌드 시점에 명확히 에러
logger.warn("⚠️ Release keystore not configured. Production build will fail.")
}
}
}
CI에서는 안전한 위치에 둔 keystore를 빌드 전에 프로젝트로 복사해 넣는다.
cp ~/github/myapp.keystore ./android/app/myapp.keystore
cp ~/github/key.properties ./android/key.properties || true
삽질: 파일 이름이 어긋났다
여기서 실제로 하루를 날린 버그. keystore 원본은 myapp.keystore인데, CI 스크립트가 이걸 store.keystore라는 이름으로 복사하고 있었다.
# 문제: 원본은 myapp.keystore 인데 store.keystore 로 복사
cp ~/github/myapp.keystore ./android/app/store.keystore
근데 build.gradle이 기대하는 파일명은 myapp.keystore. 이름이 어긋나니 gradle이 keystore를 못 찾고, release 빌드가 조용히(혹은 애매한 메시지로) 깨졌다. 고친 건 단순하다. 복사 대상 파일명을 실제 참조명과 일치시켰다.
# 수정: 참조하는 이름 그대로 복사
cp ~/github/myapp.keystore ./android/app/myapp.keystore
프로젝트 안에서 store니 myapp이니 이름이 섞여 있던 것도 이때 myapp으로 통일했다. 서명 파일 이름은 한 곳에서만 정하고 나머지는 그대로 따라가야 이런 미스가 안 난다.
💡 keystore와 비밀번호는 절대 저장소에 커밋하지 않는다. CI 러너의 보호된 경로(
~/github/…)나 secret에서 빌드 시점에만 주입한다.
iOS: 인증서 + 프로비저닝 + p8
iOS는 손이 더 간다. 준비물이 세 종류.
- 프로비저닝 프로파일 — 어떤 App ID / 기기 / 인증서로 서명할지 정의.
- 배포 인증서 — Apple이 발급한 distribution certificate.
- App Store Connect API 키(
.p8) — fastlane이 업로드/메타데이터를 다룰 때 인증용.
CI에서는 이것들을 credentials 디렉터리에 복사해 넣는다.
mkdir -p ./ios/credentials
cp ~/github/fastlane-apple-appstore-credentials.p8 ./ios/credentials/
그리고 빌드 시 어떤 프로파일로 서명할지를 ExportOptions.plist로 명시한다. iOS의 핵심이 이 파일이다.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>provisioningProfiles</key>
<dict>
<key>kr.myapp</key>
<string>MyApp-Distribution</string>
<key>kr.myapp.NotiflyNotificationExtension</key>
<string>MyApp-Distribution_NotificationExtension</string>
</dict>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>YOUR_TEAM_ID</string> <!-- 실제 팀 ID는 secret/치환 -->
</dict>
</plist>
포인트 두 가지.
- 번들 ID마다 프로파일을 지정한다. 앱 본체(
kr.myapp)뿐 아니라 알림 확장(NotificationExtension) 같은 extension도 각자 프로파일이 필요하다. 하나라도 빠지면 아카이브가 실패한다. signingStyle을manual로 둔다. CI에서는 자동 서명이 오히려 예측 불가라, 프로파일을 명시적으로 물리는 게 안전하다.
Shorebird release에도 이 plist를 그대로 물린다. (dev/prod용을 따로 뒀다.)
- name: 🚀 Shorebird Release iOS (production)
run: |
shorebird release ios \
--flutter-version=3.41.6 \
--flavor=production \
--export-options-plist=ios/ExportOptions-Production.plist \
--dart-define-from-file=env.production.json
삽질: 카카오 프로덕션 키 누락
iOS 쪽에서도 비슷한 “누락” 버그가 있었다. 프로덕션 빌드에서 카카오 로그인이 안 됐는데, iOS 프로덕션 설정에 카카오 네이티브 앱 키가 빠져 있었다. dev에는 있는데 prod에는 없던 케이스. 환경별로 키/설정을 나눠 관리하면 한쪽에만 빠지는 실수가 꼭 난다. 그래서 “prod에 있어야 할 키 목록”을 체크리스트로 못박았다.
dev / production 분리
Android든 iOS든, 우리는 서명 자산을 dev와 production으로 완전히 분리했다.
- Android: dev/alpha는 Firebase App Distribution용 credentials, production은 Play Store용 credentials.
- iOS: dev는
ExportOptions-Development.plist(+ development 프로파일), production은ExportOptions-Production.plist(+ distribution 프로파일).
이렇게 나눠두면 “내부 배포 빌드가 실수로 실사용자 서명으로 나가는” 사고를 구조적으로 막는다.
정리 표
| Android | iOS | |
|---|---|---|
| 서명 자산 | keystore(.keystore) + 비번/alias | 인증서 + 프로비저닝 프로파일 + .p8 |
| 서명 주체 | 개발자(또는 Play App Signing) | Apple 통제 |
| 빌드 설정 | build.gradle signingConfigs | ExportOptions.plist |
| 흔한 실패 | keystore 파일명/경로 불일치 | 프로파일/번들ID 매핑 누락 |
| 환경 분리 | credentials json 분리 | plist(dev/prod) 분리 |
결론
Android는 “내 keystore로 내가 서명”, iOS는 “Apple이 준 프로파일로 서명”이라는 근본 차이를 이해하면 CI 세팅이 한결 명료해진다. 실전에서 우리를 괴롭힌 건 거창한 게 아니라 이름 하나(store vs myapp), 키 하나(카카오 prod), 프로파일 매핑 하나 같은 사소한 불일치였다. 그래서 자산 이름과 환경별 목록을 한 곳에서 못박고, dev/prod를 물리적으로 분리하는 게 답이었다.