# 빌드 환경 — 실측 기록 (2026-08-31) 이 조합으로 **릴리스 APK·AAB 가 실제로 나오는 것을 확인**했다. 버전 조합이 어긋나면 Gradle 이 조용히 실패하지 않고 명확히 죽으므로 아래를 그대로 맞춘다. | 구성 | 값 | 비고 | |---|---|---| | Flutter | **3.47.2** | `fvm` 으로 고정 — `app/.fvmrc` | | JDK | **Temurin 17** | `~/.jdks/jdk-17.0.20.1+1/Contents/Home` | | Gradle | 8.14 | `android/gradle/wrapper/gradle-wrapper.properties` | | AGP | 8.11.1 | `android/settings.gradle.kts` | | Android SDK | `/opt/homebrew/share/android-commandlinetools` | `brew install --cask android-commandlinetools` | | build-tools | 35.0.1 | | | platform | android-34 · android-36 | 빌드가 34 를 추가로 받는다 | ## JDK 를 17 로 못 박은 이유 Android Studio 번들 JBR 은 **JDK 25** 이고 **Gradle 8.14 와 비호환**이다. 그대로 빌드하면 `Gradle build failed due to Java/Gradle incompatibility` 로 죽는다. Gradle 을 9.1+ 로 올리는 선택지도 있으나 AGP 8.11.1 과의 조합을 다시 검증해야 하므로, Flutter 권장인 JDK 17 을 쓰는 쪽을 택했다. ```bash fvm flutter config --jdk-dir ~/.jdks/jdk-17.0.20.1+1/Contents/Home ``` `brew install --cask temurin@17` 은 sudo 를 요구한다. sudo 없이 쓰려면 Adoptium tar.gz 를 `~/.jdks/` 에 풀면 된다. ## 테스트 ```bash export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools fvm flutter analyze fvm flutter test fvm flutter build apk --debug # 플러그인 네이티브 결합까지 본다 npm --prefix firebase/functions run build ``` **`pubspec.lock` 의 `firebase_core` 는 4.13.0 이어야 한다.** 4.14.0 이 올라오면 Android 컴파일이 깨진다. `pub get` 이 올려 버렸는지 게이트에서 함께 본다. ```bash grep -A 6 '^ firebase_core:' pubspec.lock | grep version # → 4.13.0 ``` **`analyze` 와 `test` 는 Android 네이티브 컴파일을 하지 않는다.** 의존성을 추가하거나 올리면 Dart 쪽은 멀쩡한데 Gradle 이 죽는 조합이 생긴다(실례 — `firebase_core` 가 4.14.0 으로 올라가 `firebase_auth` 6.5.7 의 `customAuthDomain` 에서 깨졌다). 그래서 의존성을 건드린 회차는 **디버그 APK 빌드까지 게이트에 넣는다.** 스모크 테스트가 `craft-allowlist.yaml` 의 `APP_TABS` 를 직접 읽어 앱의 `appTabs` 와 정확 일치를 검증한다(5탭 계약 대조). 파일은 아래 순서로 찾는다. 1. `$FANUZU_DESIGN_DIR/craft-allowlist.yaml` — 다른 위치의 파일을 가리킬 때 쓴다. 2. `app/design/craft-allowlist.yaml` — 앱 폴더 안의 사본. 3. 저장소 루트 `design/craft-allowlist.yaml`. 하나도 못 찾으면 통과가 아니라 실패한다 — 계약 대조가 조용히 사라지면 안 된다. 루트 파일을 고칠 때는 `app/design/` 사본도 함께 맞춘다. 두 파일이 함께 보이면 스모크 테스트가 `APP_TABS` 줄을 서로 비교해 다르면 실패한다. ## 빌드 ```bash export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools cd app fvm flutter build apk --release # build/app/outputs/flutter-apk/app-release.apk fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab ``` ## 서명 `android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다 (`android/app/build.gradle.kts`). 디버그 키로 조용히 서명된 산출물이 Play 에서 거부되는 것을 막기 위한 것이다. 키스토어는 `~/.fanuzu-signing/fanuzu-upload.p12` (PKCS12 · 별칭 `fanuzu-upload`). **저장소에 넣지 않는다** — `android/.gitignore` 가 `key.properties` 를, 루트 `.gitignore` 가 `*.p12` `*.jks` 를 막는다. 서명 확인: ```bash apksigner verify --print-certs build/app/outputs/flutter-apk/app-release.apk # Signer #1 certificate DN: CN=FANUZU, OU=Development, O=Sweech Co Ltd, L=Daegu, C=KR # Signer #1 certificate SHA-256 digest: 5e83f4c9cd35357b11561a5b82c47d300af8c31e33d80d94bc32a4f10f46dee7 ``` ## Firebase 앱 등록 — 식별자 `kr.co.fanuzu.app` 프로젝트는 `fanuzu-dev-b020d`(계정 `dev@sweech.io`). 2026-09-11 별첨 C 4번 확정으로 아래 두 앱을 새로 등록했다. 잠정 식별자(`kr.co.sweech.fancake`) 앱은 지우지 않고 남겨 두었다 — 그 식별자로 이미 설치된 테스트 빌드가 있고, Gradle 은 `google-services.json` 안에서 **패키지명으로 골라 쓰므로** 섞이지 않는다. | 플랫폼 | App ID | |---|---| | Android | `1:959364197461:android:6f1b79847ba8c3ed08b8dc` | | iOS | `1:959364197461:ios:716771ae8ece67ee08b8dc` | 설정 파일은 `firebase/setup-project.sh` 가 제자리에 내려받는다(둘 다 `.gitignore` 대상). ### SHA-1 지문은 firebase CLI 로 등록할 수 없다 firebase-tools 15 에 지문 명령이 없다. Firebase Management REST 로 넣었고, 등록된 값은 디버그 키스토어와 업로드 키스토어 둘이다. 지문을 다시 뽑으려면(JDK 17 의 keytool): ```bash keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA keytool -list -v -keystore ~/.fanuzu-signing/fanuzu-upload.p12 -alias fanuzu-upload -storetype PKCS12 | grep SHA ``` 등록값 4건(디버그·릴리스 × SHA-1·SHA-256): ``` SHA-1 debug 85:2B:1B:A8:93:4D:CB:AF:D0:B6:D6:56:AF:98:99:5D:02:FF:07:C1 SHA-1 release 58:27:8E:F0:80:B8:DA:6E:0E:E8:C1:70:F4:90:74:AD:5A:54:94:E8 SHA-256 debug 28:F1:75:B8:…:88:46:5E:AF SHA-256 release 5E:83:F4:C9:…:0F:46:DE:E7 ``` ### 설정 파일은 저장소에 없다 — 다시 받는 명령 `google-services.json` · `GoogleService-Info.plist` · `ios/config/Google.xcconfig` 셋 다 `.gitignore` 대상이다. 새 작업 폴더에서는 아래를 돌린다. ```bash firebase login:use dev@sweech.io firebase apps:sdkconfig ANDROID 1:959364197461:android:6f1b79847ba8c3ed08b8dc \ --project fanuzu-dev-b020d --out android/app/google-services.json firebase apps:sdkconfig IOS 1:959364197461:ios:716771ae8ece67ee08b8dc \ --project fanuzu-dev-b020d --out ios/Runner/GoogleService-Info.plist python3 tools/ios/inject_url_scheme.py # → ios/config/Google.xcconfig ``` > `apps:sdkconfig` 는 대상 파일이 이미 있으면 거부한다. 갱신하려면 먼저 지운다. Authentication 제공자는 이메일/비밀번호와 Google 둘 다 켜져 있다(2026-09-11). Google 을 켜야 `oauth_client` 와 `REVERSED_CLIENT_ID` 가 채워지며, 콘솔에서만 켤 수 있다. > **테스터 안내(식별자 전환 이후):** 새 빌드(`kr.co.fanuzu.app`)는 기존 설치본(`kr.co.sweech.fancake`)과 **별개의 앱으로 나란히 설치**됩니다. 기존 앱의 SharedPreferences · iOS 키체인 · 캐시된 소셜 자격 증명은 넘어오지 않으므로 다시 로그인해야 합니다. Firebase Authentication 계정은 프로젝트 단위라 그대로 살아 있어 같은 계정으로 들어갑니다. "로그아웃됐다" 는 오인 신고를 막기 위해 빌드 노트에도 같은 문장을 넣습니다. ## minSdk 는 29 — 문서와 일치한다 `android/app/build.gradle.kts` 의 `minSdk = 29` 다. 문서(`2주차_플러그인_선검증리포트` · 별첨 1)가 적은 **Android 10(API 29)** 과 같다. (이 절은 하한이 24 이던 때의 기록을 지운 것이다 — 값이 이미 29 이므로 「올려야 한다」는 서술이 사실과 달랐다.) ## 퀘스트 플러그인 3종 — 선검증리포트를 그대로 따른다 | 플러그인 | 버전 | 쓰는 곳 | 하한 | |---|---|---|---| | `pedometer` | 4.2.0 | 만보기 퀘스트(S-H-WALK) 걸음 수 | Android 16 · iOS 10 | | `geolocator` | **13.x 고정** | 위치 퀘스트(S-H-PLACE) 좌표·mock location | iOS 11 | | `permission_handler` | **12.x 고정** | 활동 인식 권한 | Android 24 · iOS 12 | | `shared_preferences` | 2.5.x | 걸음 기준선을 하루 단위로 보관 | 하한 영향 없음 | **두 버전을 올리지 않는 이유는 선검증리포트에 있다.** `geolocator 14.x` 는 `win32 6.x` 를 끌어와 `health 13.3.1` 과 공존하지 못하고(§2.2), `permission_handler 13.0.0` 은 Gradle 빌드가 깨진다(§2.3). 올리려면 그 두 항목을 다시 재고 리포트를 고쳐야 한다. `geofence_foreground_service` 는 **넣지 않았다.** 지오펜스 판정은 서버가 하고 (`fn-quest-verify-place`), 앱은 인증 시점의 좌표 한 점만 보낸다 — 상시 추적을 하지 않는다는 v1.6.1 13장을 지키려면 상주 서비스가 필요 없다. 권한은 `android/app/src/main/AndroidManifest.xml` 에 `ACTIVITY_RECOGNITION` · `ACCESS_FINE_LOCATION` · `ACCESS_COARSE_LOCATION` 셋이다. **`ACCESS_BACKGROUND_LOCATION` 은 요청하지 않는다.** iOS 사용 문구는 `Info.plist` 에 이미 들어 있다. --- ## iOS **2026-08-31 실증** — `flutter build ios --release --no-codesign` 통과. | 구성 | 값 | |---|---| | Xcode | **27.0** (`~/Downloads/Xcode-beta.app`) | | CocoaPods | 1.17.0 (`brew install cocoapods`) | | 산출물 | `build/ios/iphoneos/Runner.app` · 21.7MB · arm64 | | bundle id | `kr.co.fanuzu.app` · 0.1.0(1) · label FANUZU | | **MinimumOSVersion** | **15.0** — 문서(별첨 1 · 2주차 선검증)와 **일치** | `xcode-select --switch` 는 sudo 를 요구하므로 **`DEVELOPER_DIR` 로 우회**한다. ```bash export DEVELOPER_DIR=~/Downloads/Xcode-beta.app/Contents/Developer fvm flutter build ios --release --no-codesign ``` **서명은 Apple Developer 계정이 있어야 한다.** 계정 없이 여기까지는 확인되므로, TestFlight 제출에 남은 것은 **서명과 업로드뿐**이다. ### 정리해야 할 것 — CocoaPods / SwiftPM 이중 상태 빌드 중 Flutter 가 경고한다. > All plugins found for ios are Swift Packages, but your project still has CocoaPods > integration. … Removing CocoaPods integration will improve the project's build time. iOS 플러그인이 전부 Swift Package 로 옮겨졌는데 프로젝트에 CocoaPods 통합이 남아 있다. 동작에는 지장이 없으나 빌드 시간이 늘고 구성이 이중이다. `pod deintegrate` 와 `Debug.xcconfig` · `Release.xcconfig` 의 Pods include 제거가 필요하다. **11주차 최적화 구간에서 정리한다.** ## 서버 없이 로그인·원장을 돌려 보기 실 Firebase 프로젝트는 계정 명의가 확정되어야 열 수 있다(계정·권한 이전 별첨 A-3). 그 전까지는 **에뮬레이터를 물려 실제 왕복을 돈다.** 설정 파일 대신 에뮬레이터를 붙이는 것이라, 이 절차가 통과하면 설정 파일이 들어오는 날 코드 수정 없이 동작한다. ### 1. 에뮬레이터 기동 ``` cd firebase npm install # 최초 1회 npm --prefix functions run build # Functions 를 JS 로 빌드 firebase emulators:start --only auth,firestore,functions --project demo-fanuzu ``` 프로필 사진 업로드를 함께 돌려 보려면 `storage` 를 더한다(`firebase.json` 에 규칙과 포트 9199 가 들어 있다). ``` firebase emulators:start --only auth,firestore,functions,storage --project demo-fanuzu ``` **JDK 21 이상이 필요하다.** firebase-tools 15 가 그 아래를 거부한다. Gradle 은 Temurin 17 을 쓰므로 둘이 다르며, 에뮬레이터를 띄울 때만 `JAVA_HOME` 을 21+ 로 바꾼다. ```bash export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ``` ### 전역 `firebase` 가 조용히 죽으면 저장소 로컬 firebase-tools 를 쓴다 **증상** — `firebase emulators:exec` 가 출력을 한 줄도 남기지 않고 종료 코드 137(SIGKILL)로 끝난다. `firebase --version` 은 정상이고 `JAVA_HOME` 을 21 로 맞춰도 같다. 포트도 비어 있다. 에뮬레이터가 안 뜨는 것처럼 보이지만 원인은 CLI 래퍼다. **원인과 대처** — 전역 Homebrew `firebase`(15.29.0)에서만 나며, 저장소 devDependency 인 firebase-tools 로는 정상 기동한다. `firebase/test` 는 자기 `node_modules/.bin` 이 PATH 에 올라 이미 로컬 바이너리를 쓰고, `firebase/functions` 는 firebase-tools 를 직접 의존하지 않으므로 스크립트가 형제 경로를 가리킨다. ```bash cd firebase/functions && npm ci # 최초 1회 cd firebase/test && npm ci # firebase-tools 가 여기 들어 있다 npm --prefix firebase/functions run test:emulator # 로컬 바이너리로 돈다 cd firebase/test && npm run test:emulator ``` 직접 부를 때도 같다. ```bash ../test/node_modules/.bin/firebase emulators:exec \ --config ../firebase.json --only firestore --project demo-fanuzu "npm test" ``` > 비상 대안 — 그마저 막히면 에뮬레이터 JAR 를 직접 띄우고 > `FIRESTORE_EMULATOR_HOST=127.0.0.1:8080` 을 실어 테스트를 돌린다(규칙 적재는 회귀 테스트가 > REST 로 따로 한다): `"$JAVA_HOME/bin/java" -jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar --host=127.0.0.1 --port=8080` ### 2. 앱을 에뮬레이터에 붙이기 ``` flutter run --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 # Android 에뮬레이터 flutter run --dart-define=FANUZU_EMULATOR_HOST=127.0.0.1 # iOS 시뮬레이터 ``` `10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다. ### 2-1. 에뮬레이터는 **저장소 안의 firebase-tools** 로 띄운다 전역 Homebrew `firebase`(15.29.0)는 일부 환경에서 시작 배너만 찍고 **SIGKILL(exit 137)** 로 죽는다(`emulators:*` · `deploy`. `--version` 과 `projects:list` 는 정상). 저장소의 devDependency 를 쓰면 그대로 뜬다. ```bash npm --prefix firebase/test ci # 최초 1회 — firebase-tools 15.28.2 cd firebase JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ test/node_modules/.bin/firebase emulators:exec \ --only auth,firestore,functions --project fanuzu-dev-b020d "<명령>" ``` **프로젝트 ID 를 앱과 맞춘다.** `firebase.json` 이 `singleProjectMode: true` 라 다른 ID 의 요청은 서지 않는다. 설정 파일이 들어간 Android 빌드는 네이티브가 만든 `[DEFAULT]` 앱을 그대로 쓰므로 **실 프로젝트 ID(`fanuzu-dev-b020d`)** 로 붙는다. 에뮬레이터도 같은 ID 로 띄워야 앱이 넣은 값과 시드가 같은 칸에 들어간다. **시드는 `Authorization: Bearer owner` 로 넣는다.** 없으면 보안 규칙이 그대로 걸려 `quests` 쓰기가 마스터 전용으로 막히고, 시드가 조용히 사라진다. ### 2-2. Firestore 만 필요할 때 — CLI 없이 서버 단위 검증만 할 때는 받아 둔 jar 를 직접 띄워도 된다. ```bash java -Djava.net.preferIPv4Stack=true \ -jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar \ --host=0.0.0.0 --port=8080 & CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \ npm --prefix firebase/functions test ``` `CI=1` 이 필요하다. 없으면 vitest 가 대화형 모드로 남아 명령이 끝나지 않는다. **`-Djava.net.preferIPv4Stack=true` 를 빼지 마라.** 없으면 JVM 이 8080 을 IPv6 로만 열고, Android 에뮬레이터가 `10.0.2.2` 로 거는 연결이 **ECONNREFUSED** 로 떨어진다. 앱에서는 `[cloud_firestore/unavailable] client is offline` 으로만 보여 원인이 네트워크 설정처럼 읽힌다. ### 2-3. 앱을 붙여 한 바퀴 — 로그인 → 출석 → 원장 → 화면 Callable 까지 도는 확인이다. 기기 포트를 호스트로 넘기고 한 명령으로 돌린다. ```bash for p in 8080 9099 5001; do adb reverse tcp:$p tcp:$p; done cd firebase JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \ test/node_modules/.bin/firebase emulators:exec \ --only auth,firestore,functions --project fanuzu-dev-b020d \ "bash ../tool/emulator_quest_roundtrip.sh" ``` 스크립트가 퀘스트 정의·운영값을 넣고 `integration_test/quest_callable_emulator_test.dart` 를 돌린다. 확인하는 것은 **가입 → `fn-auth-oncreate` 계정·지갑 생성 → 출석 참여 → `fn-quest-attend` → 원장 1행 → 보유 POP → 목록 재조회 「보상완료」 → 2회차 E-POP-03 거부**다. ### 2-4. 서버가 쓴 값을 앱이 읽는지만 볼 때 ```bash CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \ npm --prefix firebase/functions test # 서버가 상태를 만든다 fvm flutter test integration_test/pop_loop_emulator_test.dart \ -d --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 ``` ### 3. 자동 검증 ``` flutter drive --driver=test_driver/perf_driver.dart \ --target=integration_test/auth_emulator_test.dart \ --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 --profile --no-dds -d ``` 가입 → 로그인 → 재로그인 → Auth 트리거의 계정·지갑 문서 생성 → 클라이언트 지갑 쓰기 차단까지 6건을 확인한다. ### 평문 HTTP 예외에 대하여 에뮬레이터는 평문 HTTP 로 뜨는데 Android 9 부터 평문이 기본 차단이다. 예외를 `android/app/src/{debug,profile}/` 에만 두었으므로 **릴리스 빌드에는 들어가지 않는다.** 확인: ``` unzip -p build/app/outputs/flutter-apk/app-release.apk AndroidManifest.xml \ | strings | grep -c networkSecurityConfig # 0 이어야 한다 ``` ## iOS 스토어 제출 준비 — 계정 전에 할 수 있는 것을 다 해 두었다 Apple Developer 계정은 발주사 명의 회신 대상이라(계정 이전 별첨 A-1) 서명과 업로드는 그날부터다. **그 외의 것을 미리 두지 않으면 계정이 열린 날 하루를 설정에 쓴다.** 아래는 이미 들어가 있다. | 항목 | 파일 | 없으면 어떻게 되는가 | |---|---|---| | Sign in with Apple Capability | `ios/Runner/Runner.entitlements` | Apple 로그인 코드가 있어도 동작하지 않고 심사에서 반려된다 | | 푸시 환경 선언 | 같은 파일 `aps-environment` | FCM 토큰이 발급되지 않는다 | | 암호화 사용 신고 | `Info.plist` `ITSAppUsesNonExemptEncryption` | 업로드마다 수동 응답을 요구하고 그동안 배포가 멈춘다 | | 권한 사용 문구 6종 | `Info.plist` `NS*UsageDescription` | **권한을 요청하는 순간 앱이 종료된다.** 크래시로 보이지만 원인은 이 파일이다 | | Google 콜백 스킴 | `Info.plist` + `ios/config/Google.xcconfig` | Google 로그인 콜백이 조용히 돌아오지 않는다 | | 내보내기 설정 | `ios/ExportOptions.plist` | `xcodebuild -exportArchive` 가 대화형으로 물어 자동 배포가 불가능하다 | | 업로드 절차 | `tools/ios/upload_testflight.sh` | 계정이 열린 날 명령을 처음부터 찾게 된다 | ### 업로드 절차 서명 팀은 발주사 Fancake Inc.(`V28G543VK5`)다. `ios/Runner.xcodeproj` 의 `DEVELOPMENT_TEAM` 과 `ios/ExportOptions.plist` 의 `teamID` 가 모두 이 값이어야 하고, `store_readiness_test` 가 지킨다. 다른 팀으로 서명하면 업로드가 그 팀의 App Store Connect 로 가 버린다. ``` # 1. Firebase 설정 파일(ios/Runner/GoogleService-Info.plist)을 둔다 — 없으면 빌드가 멈춘다 # 2. Google 콜백 스킴을 뽑는다 python3 tools/ios/inject_url_scheme.py # 3. 올린다 (Xcode 정식판 필요 — 베타 SDK 빌드는 App Store Connect 가 거부한다) export ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_KEY_PATH=... bash tools/ios/upload_testflight.sh ``` API 키가 없으면 Xcode 에 로그인된 계정으로 올린다: `xcodebuild -exportArchive -archivePath build/ios/archive/Runner.xcarchive -exportOptionsPlist ios/ExportOptions.plist -exportPath build/ios/ipa -allowProvisioningUpdates` (`ExportOptions.plist` 에 `destination` = `upload` 를 임시로 넣거나 생성된 IPA 를 Transporter 로 올린다.) ### 화면 회전을 막지 않은 이유 별첨 1 ① 예외 처리가 「백그라운드 복귀·**화면 회전** 시 렌더링 정상 재개」를 요구한다. Figma 는 세로 프레임만 있으나 계약 문서가 회전을 판정 항목으로 두었으므로 가로를 닫지 않는다.