취미(부업…)로 만든 Flutter 앱에 fastlane + GitHub Actions로 배포 자동화를 붙였다. 결과부터 말하면, Android는 실제로 Google Play internal 트랙 업로드까지 완주해 파이프라인이 로컬 기준으로 완전히 검증됐다. iOS는 아직 Apple Developer 계정(돈…ㅠㅠ) 확인 단계에서 멈춰 있어 문법 검증까지만 마친 상태다
한쪽은 끝났고 한쪽은 멈춰 있는데도 파이프라인은 멀쩡히 굴러간다. 처음부터 “반쪽만 완성해도 안전하게 굴러가는 구조”를 목표로 잡았기 때문이다. 그 설계 덕분에 Android를 먼저 완주시키고 iOS는 나중에 합류시켜도 파이프라인이 깨지지 않는다
안전망과 실제 배포를 아예 다른 파일로 갈랐다
처음엔 워크플로 하나에 analyze, 빌드, 배포를 다 넣으려고 했다. 그러다 배포 시크릿이 아직 없다는 벽에 부딪혔다. 시크릿이 없으면 워크플로 전체가 실패하는데, 정작 확인하고 싶은 “빌드가 되는가”까지 같이 죽는 게 문제였다
그래서 두 파일로 갈랐다
.github/workflows/ci.yml # main push마다, 시크릿 불필요, 순수 안전망 .github/workflows/release.yml # v* 태그 push로만, 실제 스토어 배포
ci.yml은 main에 push할 때마다 돈다. flutter analyze와 양 플랫폼 빌드 시도(Android는 flutter build apk --debug, iOS는 flutter build ios --debug --no-codesign)만 한다. 시크릿이 필요 없으니 실패해도 배포엔 영향이 없다. 순수하게 “지금 코드가 빌드되는가”만 알려주는 신호다
ci.yml은 main에 push할 때마다 돈다. flutter analyze와 양 플랫폼 빌드 시도(Android는 flutter build apk --debug, iOS는 flutter build ios --debug --no-codesign)만 한다. 시크릿이 필요 없으니 실패해도 배포엔 영향이 없다. 순수하게 “지금 코드가 빌드되는가”만 알려주는 신호다
release.yml은 v* 태그를 push해야만 트리거된다. 여기서 Android와 iOS를 완전히 분리된 잡으로 두고, 한쪽이 준비 안 됐으면 그 잡만 조용히 스킵되게 만들었다
# release.yml의 iOS 잡
jobs:
ios:
if: ${{ secrets.MATCH_GIT_URL != '' }}
MATCH_GIT_URL 시크릿이 없으면 iOS 잡은 통째로 건너뛴다. 이 한 줄 덕분에 Android만 먼저 완성한 지금 상태에서도 태그를 밀면 Android만 배포되고 iOS는 얌전히 넘어간다. 반쪽만 완성해도 안전하다는 건 이런 뜻이다
CI 세 잡을 needs로 묶지 않은 이유
ci.yml에는 analyze, build-android, build-ios 세 잡이 있다. 처음엔 analyze가 통과해야 빌드로 넘어가게 needs:로 묶을까 고민했다. 안 묶었다. 오히려 묶으면 안 되는 구조였다. 파일명 컨벤션 같은 lint성 이슈로 analyze가 실패하면, 정작 확인하고 싶은 빌드 잡까지 스킵된다. 세 잡을 독립적으로 병렬 실행해서 각자 별개의 신호를 주게 두는 편이 안전망 역할에 맞다. analyze가 빨개도 빌드가 초록이면 “빌드는 된다”는 정보를 잃지 않는다. 이 판단이 옳았다는 건 곧바로 증명됐다. ci.yml을 처음 push하자 로컬에선 멀쩡하던 앱이 Linux 러너에서 빌드 실패로 튀어나왔다
로컬에선 되던 게 Linux CI에서만 터졌다
에러의 원인은 import 한 줄이었다.
import '../models/Food.dart'; // 실제 파일명은 food.dart
macOS 파일시스템(APFS)은 기본적으로 대소문자를 구분하지 않는다. 그래서 실제 파일명이 food.dart인데 Food.dart로 import해도 로컬 빌드와 스토어 빌드에서는 조용히 통과했다. 대소문자를 구분하는 Linux 러너에서는 “파일을 찾을 수 없음”으로 즉시 실패했다
이런 버그를 잡아내는 게 Linux 기반 CI 안전망의 핵심 가치다. 시크릿도 필요 없고 배포에 영향도 없는 잡 하나가, macOS 개발 환경이 평생 숨겨줬을 버그를 첫 push에서 드러냈다. 대소문자를 실제 파일명에 맞게 고치는 것으로 끝났다
Android부터 짠 건 순서 문제였다
iOS보다 Android를 먼저 스캐폴딩했다. 상대적으로 단순하고, 자격증명 없이도 문법 검증이 되기 때문이다
bundle exec fastlane lanes # 자격증명 없이 lane 문법만 먼저 확인
Android lane은 네 개로 잡았다. build, internal, production, 그리고 promote_to_production이다. 앞의 셋은 직관적이다. 문제는 네 번째였다
promote lane을 따로 둔 건 Play Console한테 한 번 거부당하고 나서다
처음엔 internal 트랙에 올린 뒤 production 트랙에도 똑같이 upload_to_play_store로 다시 올리면 될 거라 생각했다. Play Console은 같은 versionCode를 두 트랙에 각각 “새 업로드”로 두 번 올리는 걸 거부한다.internal로 검증한 다음 production으로 보내는 정석은 재업로드가 아니라 “승격(promote)”이었다. 그래서 재빌드도 재업로드도 하지 않고 이미 올라간 아티팩트를 승격만 하는 lane을 따로 뒀다
lane :promote_to_production do
upload_to_play_store(
track: "internal",
track_promote_to: "production",
skip_upload_aab: true,
skip_upload_apk: true
)
end
skip_upload_*: true가 핵심이다. 이미 internal에 있는 바로 그 아티팩트를 그대로 production으로 올려보낸다
업로드 키를 잃어버린 게 전체 일정의 병목이었다
이 앱은 예전에 스토어에 배포한 적이 있는데, 업로드 키스토어를 분실한 상태였다. 신규 앱이면 keytool로 새로 만들면 그만이지만, 이미 배포된 앱은 Play App Signing의 “업로드 키 재설정” 절차를 밟아야 한다. 새 키스토어를 만들고 인증서(.pem)를 추출해서 Play Console에 재설정을 요청하는 흐름이다. 여기서 배운 건 순서였다. 구글 승인은 몇 시간에서 며칠까지 걸린다. 이 대기시간이 전체 일정의 병목이 되므로, 다른 작업보다 재설정 요청부터 먼저 넣어야 했다. 승인을 기다리는 동안 GCP 서비스 계정 연결, 로컬 업로드 테스트를 병행하면 대기시간이 놀지 않는다
keytool에서 만난 자잘한 함정들
키스토어를 만드는 과정 자체도 매끄럽지 않았다. 조직 정보를 입력한 뒤 확인 질문에 영어로 yes를 쳤는데 프롬프트가 계속 처음으로 돌아갔다. 로케일이 한국어라 확인 프롬프트 기본값이 [아니오]였고, 영어 yes는 긍정으로 인식되지 않아 기본값으로 처리된 탓이었다. y 또는 예를 입력해야 넘어갔다.조직 정보(CN/OU/O/L/ST/C)는 뭘 넣어야 하나 잠깐 고민했는데, 아무 값이나 상관없었다. 구글이 검증하지 않는 메타데이터라 전부 Enter로 넘겨 Unknown으로 둬도 무방하다. 공개 신뢰 체계에 들어가는 TLS 인증서가 아니라 업로드 키/서명용이기 때문이다
정체불명 키스토어가 여러 개 생겼을 때 어느 게 맞는지 찾는 법
작업 중간에 키스토어 파일이 여러 개로 늘었다. 어느 게 Play Console에 인증서를 제출한 “맞는” 키스토어인지 헷갈렸다.
키스토어(.jks) 자체는 비밀번호 없이 열 수 없다. 하지만 이미 추출해둔 공개 인증서(.pem)는 비밀번호 없이 지문을 볼 수 있다. 최종적으로는 실제 빌드된 AAB를 서명한 인증서 지문과 대조하는 게 가장 확실하다.
unzip -p app-release.aab META-INF/UPLOAD.RSA > /tmp/sig.rsa openssl pkcs7 -inform DER -in /tmp/sig.rsa -print_certs -out /tmp/sig_cert.pem openssl x509 -in /tmp/sig_cert.pem -noout -fingerprint -sha256
keytool -printcert를 먼저 써보려 했는데, 특정 JDK/로케일 조합에서 IllegalFormatConversionException을 내는 버그가 있었다. openssl로 지문을 뽑는 쪽이 안전했다
시크릿을 다루는 규칙은 스스로에게 강제했다
자동화 작업은 비밀번호와 키를 계속 만진다. 원칙 하나를 처음부터 못박았다. 비밀번호는 절대 채팅창이나 AI 어시스턴트 입력창에 타이핑하지 않는다
printf "keystore password: "; read -s KS_PW # 셸 변수에만 담는다 gh secret set ANDROID_KEYSTORE_PASSWORD --body "$KS_PW" unset KS_PW
키스토어나 JSON 키처럼 파일 형태의 시크릿은 내용을 출력하지 않고 파일에서 바로 gh secret set < file로 넘긴다. 참고로 read -s -p "prompt" VAR는 특정 셸 환경에서 read: -p: no coprocess 에러가 나므로, 위처럼 프롬프트 출력과 read를 분리하면 피할 수 있다
iOS는 match와 자동 서명 복원이 핵심이었다
iOS는 Apple ID/2FA 대신 App Store Connect API 키로 인증하게 했다. match는 프라이빗 저장소에 인증서를 두고 CI와 로컬이 공유하는 방식이다. 저장소 주소는 하드코딩하지 않고 환경변수로 주입했다
# Matchfile git_url(ENV["MATCH_GIT_URL"])
가장 신경 쓴 부분은 서명 모드 복원이었다. match는 프로젝트를 Manual 서명으로 바꿔야 동작하는데, 로컬 Xcode 개발 환경은 Automatic 서명을 유지하고 싶었다. 그래서 lane 안에서 자동 서명을 껐다가 빌드한 뒤 다시 켜는데, 반드시 ensure 블록으로 감쌌다
disable_automatic_code_signing begin build_app(...) ensure enable_automatic_code_signing # 빌드 실패해도 반드시 복원 end
ensure를 빠뜨리면 빌드가 실패했을 때 프로젝트가 Manual 서명 상태로 남는다. 다음에 로컬에서 Xcode를 열었을 때 서명이 깨져 있으면 원인 찾느라 시간을 버린다. 빌드 성공 여부와 무관하게 원래 상태로 되돌아가야 했다. 결국 iOS는 여기까지 문법 검증만 하고 멈췄다. Apple Developer 멤버십 확인이 먼저인데 그 단계에서 보류됐기 때문이다. 이럴 때를 대비해 release.yml의 iOS 잡을 시크릿 존재 여부로 자동 스킵되게 만들어둔 게 앞서 말한 설계다
마지막 한 줄에서 막혔다가, 결국 업로드에 성공했다
Android는 로컬에서 fastlane android internal로 실제 업로드를 시도했다. 빌드, 서명, 서비스 계정 인증, Play Console 권한까지 전부 통과했는데 마지막 업로드 단계에서 이 에러가 떴다
The upload certificate ... is not yet valid because it has been recently reset
이 에러는 오히려 반가운 신호였다. 업로드 키 재설정 요청은 넣었지만 아직 구글 승인이 안 끝났다는 뜻이고, 이 에러가 나온다는 건 인증/권한/서명까지는 전부 올바르다는 뜻이기 때문이다. 승인만 끝나면 통과할 상태였다. 그리고 구글 승인이 떨어진 뒤 다시 돌리자 이 로그가 찍혔다
Successfully finished the upload to Google Play
internal 트랙에 실제로 업로드가 완료됐다. 막혔던 지점이 내 코드 문제가 아니라 외부 승인 대기였다는 게 이렇게 증명됐다. Android 파이프라인은 이제 빌드부터 스토어 업로드까지 로컬 기준으로 완전히 검증됐다.
가는 길에 Version code N has already been used 에러도 만났는데, 파이프라인 검증이 목적이라 pubspec.yaml의 build number만 +1 올려 넘겼다. 다만 버전 번호를 정하는 건 실제 앱의 다음 버전을 정하는 일이라, 임의로 바꾸지 않고 확인받고 올리는 편이 안전하다
CI 전용 GCP 프로젝트를 따로 판 이유
서비스 계정을 만들 때 GCP 프로젝트를 어디에 둘지 잠깐 고민했다. 학습용으로 클론해둔 프로젝트에 얹을 뻔했는데, 그건 언제 삭제해도 이상하지 않은 프로젝트였다. 실서비스 CI/CD를 그 위에 얹으면 나중에 정리할 때 파이프라인이 통째로 끊긴다
그래서 “실서비스 앱들의 CI 전용” GCP 프로젝트를 하나 파고, 그 안에서 앱마다 서비스 계정을 하나씩 만드는 구조로 잡았다. 키 하나가 유출돼도 그 앱 하나만 영향받도록 blast radius를 제한하는 목적이다. 서비스 계정 이메일은 로그인 가능한 사람 계정이 아니라 오직 JSON 키로만 인증되니, JSON 키는 비밀번호와 동급으로 다뤄야 한다
workflow_dispatch로 브랜치를 테스트하려다 막힌 것
작업 브랜치에서 release.yml을 gh workflow run으로 돌려보려 했더니 404 workflow not found가 떴다. workflow_dispatch는 워크플로 파일이 default 브랜치(main)에 이미 존재해야 트리거할 수 있다. feature 브랜치에만 있는 워크플로는 안 된다
검증할 땐 push 트리거 브랜치 목록에 작업 브랜치를 임시로 추가해서 실제 push로 실행시켰다
on:
push:
branches: [main, chore/ci-cd] # 검증 끝나면 [main]으로 되돌린다
아직 통하지 않은 부분을 인정하면
Android는 완주했지만 iOS는 아직 아니다. Apple Developer 멤버십 확인 단계에서 보류 중이라 App Store Connect API 키도 match 저장소도 미착수 상태다. 그래서 이 파이프라인 전체를 두고 “다 됐다”고 말할 수는 없다
그런데도 절반이 멈춘 채로 나머지 절반이 실제 스토어 업로드까지 완주했다는 게 이 작업의 핵심이다. 그게 가능한 이유는 준비 안 된 잡을 시크릿 존재 여부로 자동 스킵되게 만들어뒀기 때문이다. iOS 시크릿이 없어도 Android는 태그 하나로 배포된다. 반대로 나중에 iOS 시크릿만 채우면 코드를 건드리지 않고 iOS가 파이프라인에 합류한다
한 줄로 요약하면, 배포 자동화는 반쪽만 완성해도 안전하게 굴러가도록 설계할 때 실전에서 쓸모가 있다. 시크릿 없는 안전망 CI와 실제 배포를 다른 파일로 가르고, 준비 안 된 잡은 시크릿 존재 여부로 자동 스킵되게 만드는 것. 이 두 가지를 먼저 세우면, 한쪽 플랫폼이 몇 주 늦어도 파이프라인 전체가 인질로 잡히지 않는다
다음에 다른 앱에 배포 자동화를 붙인다면 나는 이 순서로 갈 것이다. 시크릿 없는 CI 안전망부터 세워 Linux 빌드로 숨은 버그를 털고, 승인에 시간 걸리는 키 재설정을 가장 먼저 요청하고, 나머지를 조건부 스킵 구조 위에 하나씩 얹는다