iOS 스토어 제출 구성 · Apple 로그인 · 푸시 · Firebase 설정 연결

This commit is contained in:
Sn-Kinos
2026-10-02 22:01:31 +09:00
parent 9dd43ca405
commit 79d4682e65
11 changed files with 276 additions and 6 deletions
+2
View File
@@ -55,6 +55,8 @@ key.properties
*.mobileprovision
**/GoogleService-Info.plist
**/google-services.json
# GoogleService-Info.plist 에서 생성한다 — tool/ios/inject_url_scheme.py
ios/config/Google.xcconfig
# FVM Version Cache
.fvm/
+26 -2
View File
@@ -100,8 +100,32 @@ export DEVELOPER_DIR=~/Downloads/Xcode-beta.app/Contents/Developer
fvm flutter build ios --release --no-codesign
```
**서명은 Apple Developer 계정이 있어야 한다.** 계정 없이 여기까지는 확인되므로,
TestFlight 제출에 남은 것은 **서명과 업로드뿐**이다.
### 제출 빌드 — App Store Connect
서명 팀은 **Fancake Inc. `V28G543VK5`** 다(`Runner.xcodeproj` 의 `DEVELOPMENT_TEAM`,
`ios/ExportOptions.plist` 의 `teamID`). 번들 ID `kr.co.fanuzu.app`.
| 항목 | 파일 | 없으면 |
|---|---|---|
| Sign in with Apple · 푸시 환경 | `ios/Runner/Runner.entitlements` | Apple 로그인이 동작하지 않고 심사에서 반려된다 |
| 암호화 사용 신고 | `Info.plist` `ITSAppUsesNonExemptEncryption` | 업로드마다 수동 응답을 요구한다 |
| Google 콜백 스킴 | `Info.plist` + `ios/config/Google.xcconfig` | Google 로그인 콜백이 돌아오지 않는다 |
| Firebase 설정 | `ios/Runner/GoogleService-Info.plist` (번들 리소스) | 목 모드로 뜨고 Google 로그인 client ID 를 못 읽는다 |
| 내보내기 설정 | `ios/ExportOptions.plist` | `-exportArchive` 가 대화형으로 묻는다 |
`GoogleService-Info.plist` 와 `ios/config/Google.xcconfig` 는 저장소에 넣지 않는다.
Xcode 프로젝트가 `GoogleService-Info.plist` 를 리소스로 참조하므로 **파일이 없으면 iOS 빌드가 멈춘다.**
```bash
# 1. Firebase 콘솔에서 받은 iOS 설정 파일을 둔다
cp <받은 파일> ios/Runner/GoogleService-Info.plist
# 2. Google 콜백 스킴을 뽑는다 → ios/config/Google.xcconfig
python3 tool/ios/inject_url_scheme.py
# 3. 아카이브 · IPA
fvm flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
```
배포 프로파일이 없으면 `xcodebuild` 로 나눠 `-allowProvisioningUpdates` 를 붙인다.
### 정리해야 할 것 — CocoaPods / SwiftPM 이중 상태
+1 -1
View File
@@ -103,7 +103,7 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|---|---|---|---|
| S1 | Firebase 프로젝트(dev/prod) 개설 | — | 아래 S2 를 만들 수 없다 |
| S2 | `google-services.json` | `app/android/app/google-services.json` | 목 모드로 기동. Google 로그인 차단 |
| S2 | `GoogleService-Info.plist` | `app/ios/Runner/GoogleService-Info.plist` | 목 모드로 기동. Google·Apple 로그인 차단 |
| S2 | `GoogleService-Info.plist` | `app/ios/Runner/GoogleService-Info.plist` | iOS 빌드가 멈춘다(번들 리소스로 참조). 투입 절차는 `BUILD.md` 「제출 빌드」 |
> S2 투입 시 Android 는 `android/settings.gradle.kts` 에 `com.google.gms.google-services` 플러그인
> 추가가 함께 필요하다. 지금은 설정 파일이 없어 플러그인을 적용하지 않았다 —
+40
View File
@@ -0,0 +1,40 @@
<?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">
<!--
TestFlight · App Store 업로드용 내보내기 설정.
`xcodebuild -exportArchive` 가 이 파일을 읽는다. 없으면 대화형으로 물어보므로
자동 배포가 불가능하다 — 계정이 열린 뒤에 만들면 그날 하루를 여기서 쓴다.
-->
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<!-- Fancake Inc. -->
<key>teamID</key>
<string>V28G543VK5</string>
<!--
Xcode 가 프로비저닝 프로파일을 알아서 만들고 갱신한다. 수동으로 두면
인증서가 만료될 때마다 배포가 멈춘다.
-->
<key>signingStyle</key>
<string>automatic</string>
<!-- 심벌을 함께 올려야 크래시 로그가 사람이 읽을 수 있는 형태로 온다. -->
<key>uploadSymbols</key>
<true/>
<!-- 비트코드는 Xcode 14 부터 폐지되었다. -->
<key>uploadBitcode</key>
<false/>
<!--
앱 씬 크기 최적화. 기기별로 필요한 자산만 내려가 설치 용량이 줄어든다.
-->
<key>thinning</key>
<string>&lt;thin-for-all-variants&gt;</string>
</dict>
</plist>
+4
View File
@@ -1,2 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
// Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다.
// `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다.
#include? "../config/Google.xcconfig"
+4
View File
@@ -1,2 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
// Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다.
// `#include?` 는 파일이 없어도 빌드를 멈추지 않는다 — 그때는 Google 로그인만 동작하지 않는다.
#include? "../config/Google.xcconfig"
+10 -3
View File
@@ -15,6 +15,7 @@
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
87BFB00ABC23EA249E841B59 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CBE157220A7C382F845775F0 /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
EC46D318C684524E6F9C925F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */; };
@@ -64,6 +65,7 @@
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A68C3B80D45981965B89CA40 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
AB2C33122C7A95220B242430 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -149,6 +151,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
4F2B0E1A2C3D4E5F60718293 /* GoogleService-Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -275,6 +278,7 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
4F2B0E1A2C3D4E5F60718294 /* GoogleService-Info.plist in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -467,7 +471,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = P36BV95LP5;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -650,7 +655,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = P36BV95LP5;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
@@ -673,7 +679,8 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = P36BV95LP5;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
DEVELOPMENT_TEAM = V28G543VK5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
+24
View File
@@ -66,5 +66,29 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<!--
암호화 사용 신고. HTTPS 만 쓰므로 면제 대상이다.
이 키가 없으면 업로드할 때마다 App Store Connect 가 수동 응답을 요구한다.
-->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!--
Google 로그인 콜백 URL 스킴. 값은 GoogleService-Info.plist 의 REVERSED_CLIENT_ID 이며
tool/ios/inject_url_scheme.py 가 ios/config/Google.xcconfig 로 뽑아 둔다.
-->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>google-sign-in</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(GOOGLE_REVERSED_CLIENT_ID)</string>
</array>
</dict>
</array>
</dict>
</plist>
+21
View File
@@ -0,0 +1,21 @@
<?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>
<!--
Sign in with Apple. 소셜 로그인을 제공하면 Apple 심사 필수 조건이다.
이 Capability 가 없으면 Apple 로그인 코드가 있어도 동작하지 않는다.
-->
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<!--
푸시 환경 선언. Firebase Auth 가 APNs 등록 API 를 링크하므로 선언해 둔다.
App Store 내보내기 때 Xcode 가 production 으로 바꿔 서명한다.
-->
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+86
View File
@@ -0,0 +1,86 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// iOS 스토어 제출 구성 검사.
///
/// 여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다. 문제는 업로드·심사·로그인
/// 시점에야 드러나고, 그때는 원인이 코드처럼 보인다. 파일을 문자열로 읽어
/// 키가 있는지만 본다 — 값의 해석은 빌드가 한다.
const _teamId = 'V28G543VK5'; // Fancake Inc.
String _read(String path) {
final file = File(path);
expect(file.existsSync(), isTrue, reason: '$path 가 없습니다');
return file.readAsStringSync();
}
void main() {
group('iOS — 서명', () {
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로 서명한다', () {
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
.allMatches(pbx)
.map((m) => m.group(1))
.toSet();
expect(teams, {_teamId});
expect(_read('ios/ExportOptions.plist'), contains(_teamId));
});
test('Sign in with Apple Capability 가 서명에 연결되어 있다', () {
expect(
_read('ios/Runner/Runner.entitlements'),
contains('com.apple.developer.applesignin'),
);
expect(
'CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;'
.allMatches(_read('ios/Runner.xcodeproj/project.pbxproj'))
.length,
3, // Debug · Release · Profile
);
});
});
group('iOS — 제출 선언과 Firebase 설정', () {
late final String plist = _read('ios/Runner/Info.plist');
test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () {
expect(plist, contains('ITSAppUsesNonExemptEncryption'));
});
test('Google 로그인 콜백 스킴이 설정 파일 값으로 채워진다', () {
expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)'));
for (final c in const ['Debug', 'Release']) {
expect(
_read('ios/Flutter/$c.xcconfig'),
contains('#include? "../config/Google.xcconfig"'),
reason: c,
);
}
});
test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () {
// 번들에 없으면 Firebase 가 목 모드로 뜨고 Google 로그인 client ID 도 못 읽는다.
expect(
_read('ios/Runner.xcodeproj/project.pbxproj'),
contains('/* GoogleService-Info.plist in Resources */,'),
);
});
});
test('설정·서명 자산을 git 이 추적하지 않는다', () {
final result = Process.runSync('git', [
'ls-files',
'--',
'ios/Runner/GoogleService-Info.plist',
'ios/config/Google.xcconfig',
'android/app/google-services.json',
'*.p12',
'*.p8',
'*.mobileprovision',
]);
expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다');
expect((result.stdout as String).trim(), isEmpty);
});
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""GoogleService-Info.plist 의 REVERSED_CLIENT_ID 를 Xcode 빌드 설정에 넣는다.
**왜 스크립트인가.** Google 로그인 콜백 스킴은 Firebase 프로젝트마다 다르고,
Info.plist 에 손으로 옮기면 오타 한 글자에 로그인이 **조용히** 실패한다.
오류도 뜨지 않고 콜백만 돌아오지 않아 원인을 찾는 데 한나절이 든다.
설정 파일이 투입될 때 한 번 돌리면 `ios/config/Google.xcconfig` 가 만들어지고,
Info.plist 의 `$(GOOGLE_REVERSED_CLIENT_ID)` 가 그 값으로 치환된다.
실행: python3 tool/ios/inject_url_scheme.py
"""
from __future__ import annotations
import plistlib
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "ios/Runner/GoogleService-Info.plist"
OUT = ROOT / "ios/config/Google.xcconfig"
def main() -> int:
if not SOURCE.exists():
print(
f"설정 파일이 아직 없습니다: {SOURCE.relative_to(ROOT)}\n"
"Firebase 콘솔에서 iOS 앱 설정 파일을 받아 그 자리에 두십시오.",
file=sys.stderr,
)
return 1
with SOURCE.open("rb") as f:
data = plistlib.load(f)
reversed_id = data.get("REVERSED_CLIENT_ID")
if not reversed_id:
# Firebase 콘솔에서 Google 로그인을 켜지 않으면 이 키가 빠진 채로 나온다.
print(
"REVERSED_CLIENT_ID 가 없습니다. Firebase 콘솔에서 "
"Authentication → Google 제공자를 켠 뒤 설정 파일을 다시 받으십시오.",
file=sys.stderr,
)
return 1
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
"// 자동 생성 — tool/ios/inject_url_scheme.py\n"
"// GoogleService-Info.plist 가 바뀌면 다시 돌리십시오.\n"
f"GOOGLE_REVERSED_CLIENT_ID = {reversed_id}\n",
encoding="utf-8",
)
print(f"기록: {OUT.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())