우리 팀은 React Native로 앱을 개발해 Android와 iOS 각각 빌드 후 배포하는 구조였다.
서버는 개발/운영으로 나뉘어 있었지만, 내가 입사했을 당시에는 단일 패키지에서 필요에 따라 개발/운영 서버를 번갈아 적용해가며 배포하는 방식이었다.
나는 Android 개발을 주로 담당하고 있었는데, 개발 서버가 적용된 APK를 따로 빌드해서 Google Drive 같은 경로로 전달하는 방식이 비효율적이라고 느꼈다. 그래서 Android도 패키지를 분리하고, App Distribution까지 활용해 배포 프로세스를 개선하는 리팩토링을 진행했다. → 관련 포스팅
개선 후의 현 배포 구조는 다음과 같다.
Android
iOS
앱 개발팀은 팀장님, iOS 개발자, 나(Android 개발자) 이렇게 세 명으로 이루어진 소규모 팀이다.
React Native 기반이라 플랫폼은 공유하지만, 각자 주력 플랫폼이 있어 나는 Android, 다른 분은 iOS 쪽을 중심으로 개발과 테스트를 진행한다. 배포도 각각 맡아서 진행했다.
문제는 타 플랫폼 담당자의 부재 상황이었다.
물론 문서화된 절차와 인터넷 자료를 참고하면 가능은 하지만, 익숙하지 않은 플랫폼이기에 시간도 더 걸리고, 검증도 여러 번 하게되는 듯 했다. 이는 곧 휴먼 에러 리스크 증가와 팀 생산성 저하로 이어질 가능성이 있었다.
사실 Android도 패키지 분리를 했지만, React Native 내부에서 사용하는 .env
, .env.prod
, .env.dev
파일까지 productFlavor
로 완전히 제어할 수는 없었다.
react-native-config
에서 제공하는 스크립트(ENVFILE=.env.example
)를 적용해보려 했지만, 프로젝트 설정 문제로 정상 동작하지 않았다. 이미 상용화된 앱을 대규모 리팩토링하기엔 리스크가 크다고 판단했다.
그래서 차선책으로 빌드 타입에 따라 .env.prod
나 .env.dev
를 .env
로 복사한 뒤 빌드하는 방식을 택했다.
하지만 빌드 과정 자체가 복잡한 상황에서 이 과정을 매번 수동으로 작업하면 캐시 누락 같은 휴먼 에러가 발생할 수 있었다.
즉, 반복적이고 단순하지만 "제대로 빌드됐는지" 확인까지 해야 하는 비효율적 작업이었다. 그래서 이 부분을 GitLab CI/CD로 자동화하기로 했다. 그리고 빌드 아티팩트 내부에 올바른 서버 주소(API URL)가 포함되어 있는지 자동으로 검증하는 단계를 추가했다.
마침 서비스가 리브랜딩 기간을 거치며 특히 QA 기간에 시간이 여유로워 팀장님께 제안 후 시작했다.😀
사이드 프로젝트를 진행할 당시 GitHub Actions로 Android Native 앱 CI/CD를 구축한 경험이 있었는데, GitLab은 Runner 등록부터 시작해야 해서 진입장벽이 더 높게 느껴졌다.
Project > Settings > CI/CD > Runners > New project runner
클릭Runner 종류: Project / Instance / Group runners
중 Project runner를 로컬 환경에 등록
Project Runner 선택 이유
macOS Runner 필요성
2-1. 여기서 살짝 당황했는데, ssh 주소가 여전히 localhost로 되어있어 발생한 페이지였다. 주소창에서 localhost 부분만 https 로 설정해둔 주소로 바꾸면 오류없이 정상적인 페이지가 보인다.
localhost
주소 문제Runner 등록 과정에서 주소가 localhost
로 설정되어 있어 몇 번 혼란이 있었다. 이는 기존 회사 GitLab 설정에서 SSH 및 HTTP 주소가 모두 localhost
로 잡혀 있었기 때문이다.
localhost
를 실제 GitLab 주소로 변경하는 방식으로도 임시 해결이 가능했다.Menu > Admin > Settings > General > Visibility and access controls
- Custom Git clone URL for HTTP(s) 항목 수정
출처: https://alakjkj.tistory.com/104#google_vignette
CI/CD 파이프라인을 구축하며 복잡한 iOS 배포를 자동화하기 위해 Fastlane을 도입했다. 반면, Android 배포는 Fastlane 도입 이전에 이미 공식 문서를 참고하여 CLI 방식으로 구축을 완료했다.
전체 파이프라인은 .gitlab-ci.yml
에서 정의하고, 각 플랫폼/배포 환경별 파이프라인을 별도의 파일로 분리하여 관리했다.
.gitlab-ci.yml
ci
├─ pipeline-android-dev-app-distribution.yml
├─ pipeline-android-release-playstore.yml
└─ pipeline-ios-deploy-testflight.yml
파일명 | 대상 | 배포처 | 단계 |
---|---|---|---|
pipeline-android-dev-app-distribution.yml | Android (개발) | Firebase App Distribution | build , test (API_URL_DEV 검증) , deploy |
pipeline-android-release-playstore.yml | Android (운영) | Google Play Store | version (버전 업데이트 및 Push) , build , test (API_URL_PROD 검증) , deploy |
pipeline-ios-deploy-testflight.yml | iOS (개발/운영) | TestFlight | version , build , test (API_URL_DEV/PROD 검증) , deploy |
이전 사이드 프로젝트에서는 브랜치별로 Pull Request 기반의 자동 실행 워크플로우를 설계했지만, 현재 프로젝트의 상황은 조금 달랐다.
브랜치 규칙 고려: release
, develop
브랜치 대신 major 버전 단위 브랜치가 생성되는 규칙을 따랐다.
MR 비활용: 작업물을 합칠 때 GitLab 내 MR 활용보다는 로컬에서 주로 Merge 작업을 진행했다. 따라서 MR 기반 자동 실행은 적합하지 않다.
이러한 배경을 바탕으로 다음과 같이 CI/CD 파이프라인을 설계했다.
자동 실행이 아닌 필요할 때만 파이프라인 실행 (수동 트리거 방식)
gitlab-ci.yml
파일이 존재할 예정이므로, 매번 Push할 때마다 파이프라인이 생성되는 것은 비효율적이라고 판단했다.Android와 iOS 버전 관리 차이 반영
build.gradle
에 자동 반영되도록 했다.메인 파일은 수동으로 하위 파이프라인을 트리거하는 역할을 한다.
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "web"'
when: always
- when: never
stages:
- trigger
# Android: app distribution - 개발서버 .apk 배포
trigger_android_dev_pipeline:
stage: trigger
trigger:
include:
- local: 'ci/pipeline-android-dev-app-distribution.yml'
strategy: depend # 트리거된 파이프라인이 끝날 때까지 기다림 (종속적 실행)
forward:
yaml_variables: true # 변수를 하위 파이프라인에 전달
pipeline_variables: true # 파이프라인 변수도 전달
rules:
- if: '$CI_PIPELINE_SOURCE == "web"'
when: manual
allow_failure: true
variables:
PIPELINE_TYPE: "dev"
# Android: google play store - 운영서버 .aab 배포
trigger_android_release_pipeline:
stage: trigger
trigger:
include:
- local: 'ci/pipeline-android-release-playstore.yml'
strategy: depend
forward:
yaml_variables: true
pipeline_variables: true
rules:
- if: '$CI_PIPELINE_SOURCE == "web"'
when: manual
allow_failure: true
variables:
PIPELINE_TYPE: "release"
# iOS: TestFlight - $IOS_DEPLOY_TYPE 에 따른 운영서버 / 개발서버 배포
trigger_ios_testflight_pipeline:
stage: trigger
trigger:
include:
- local: 'ci/pipeline-ios-deploy-testflight.yml'
strategy: depend
forward:
yaml_variables: true
pipeline_variables: true
rules:
- if: '$CI_PIPELINE_SOURCE == "web"'
when: manual
allow_failure: true
가장 먼저 Android 개발 서버용 App Distribution 배포를 구현했다. test 단계에서 APK 내부의 환경 변수를 검증한다.
# App Distribution 에 개발서버 어플 배포
stages:
- build
- test
- deploy
# 1. 빌드
build_for_android_dev:
... (기존 코드 유지)
script:
# dev 환경으로 전환
- cp .env.dev .env
- cd android && ENVFILE=.env.dev ./gradlew assembleDevelopmentRelease && cd ..
artifacts:
...
# 2. 테스트 (API_URL 검증)
test_build_env_dev:
stage: test
needs: ["build_for_android_dev"]
image: openjdk:17
script:
- echo "=== 빌드 아티팩트 검증 시작 ==="
# APK 파일이 존재하는지 확인
- APK_PATH=$(ls android/app/build/outputs/apk/development/release/*.apk 2>/dev/null | sort | tail -n 1)
...
# APK 안에서 API_URL 검증 확인
- |
if [ -n "$APK_PATH" ]; then
echo "검증: APK -> $APK_PATH";
unzip -p "$APK_PATH" assets/index.android.bundle | grep "$API_URL_DEV" \
|| { echo "ERROR: APK에 dev API URL 없음"; exit 1; }
fi
- echo "=== 빌드 검증 성공 ==="
...
# 3. 배포
deploy_app_distribution:
...
before_script:
- npm install -g firebase-tools
# Firebase 인증 JSON 키를 CI/CD 변수로 주입
- echo "$FIREBASE_SERVICE_ACCOUNT" | base64 -d > ${CI_PROJECT_DIR}/firebase-key.json
- export GOOGLE_APPLICATION_CREDENTIALS="${CI_PROJECT_DIR}/firebase-key.json"
script:
...
# Firebase App Distribution 배포
firebase appdistribution:distribute "$APK_PATH" \
--app "$FIREBASE_APP_ID" \
--groups "$TEST_GROUP_VALUE" \
--release-notes "$AOS_RELEASE_NOTES"
...
이후 iOS TestFlight 배포를 구현하면서 fastlane
을 접했는데, firebase_app_distribution(...)
을 통해 App Distribution도 지원한다는 걸 알게 되었다.
이미 Android CLI 배포는 검증까지 마친 상태라 그대로 두었지만, 추후 기회가 되면 fastlane
으로 통합할 예정이다.
Android 운영 환경(Google Play Store) 배포는 버전 업데이트(Git Push) → 빌드 → 검증 → 배포의 4단계로 구성된다. Fastfile 내에서 Gradle 파일에 직접 접근하여 버전 업데이트를 실행하며, 실제 배포 과정은 Fastlane을 활용한다.
# This file contains the fastlane.tools configuration
# ... (생략)
default_platform(:android)
platform :android do
# 버전 업데이트
desc "Bump version (patch/minor/major)"
lane :bump_version do |options|
... (버전 업데이트 코드)
# 파일 업데이트
sh("sed -i '' 's/^VERSION_CODE=.*/VERSION_CODE=#{new_version_code}/' #{gradle_file}")
sh("sed -i '' 's/^VERSION_NAME=.*/VERSION_NAME=#{new_version_name}/' #{gradle_file}")
UI.message "✅ Updated VERSION_CODE=#{new_version_code}, VERSION_NAME=#{new_version_name}"
end
# AAB 파일 빌드
desc "Build AAB"
lane :build do
...
end
# play store 배포
desc "Deploy only"
lane :deploy do |options|
upload_to_play_store(
track: "production",
aab: "app/build/outputs/bundle/operationRelease/app-operation-release.aab",
json_key_data: ENV["GOOGLE_PLAY_JSON"]
)
end
end
json_key_file("./fastlane/play-store-credentials.json")
package_name("packaged이름")
Android 버전 업데이트 후 자동으로 Git Commit & Push를 수행한다. CI/CD 전용 CI_TOKEN을 사용하여 리모트 URL을 변경 후 Push한다.
# Google Play Console 에 운영서버 어플 배포
stages:
- version
...
# 1. 버전 bump 및 커밋
bump_version:
stage: version
...
script:
...
# 3. git commit & push
git config --global user.email "ci-bot@example.com"
git config --global user.name "CI Bot"
if git diff --quiet android/gradle.properties; then
echo "No version changes detected"
else
# 실제 GitLab 주소와 프로젝트 경로는 'GitLab_프로젝트_주소'로 대체
git remote set-url origin "http://ci-bot:${CI_TOKEN}@GitLab_서버_주소:포트/GitLab_프로젝트_경로.git"
echo "Current remote URL:"
git remote get-url origin
git add android/gradle.properties
git commit -m "CI: project - Android 'bump ${AOS_BUMP_TYPE} version' 운영 배포"
git push origin HEAD:$CI_COMMIT_REF_NAME
echo "CI_COMMIT_REF_NAME: $CI_COMMIT_REF_NAME"
echo "[Android] Version bumped and pushed successfully"
fi
...
# 3. 테스트 (AAB 내 API_URL 검증)
test_build_env_prod:
stage: test
needs: ["build_aab"]
image: openjdk:17
script:
...
# AAB 안에서 API_URL 확인
- |
if [ -n "$AAB_PATH" ]; then
echo "검증: AAB -> $AAB_PATH";
unzip -p "$AAB_PATH" base/assets/index.android.bundle | grep "$API_URL_PROD" \
|| { echo "ERROR: AAB에 API_URL 없음"; exit 1; };
fi
- echo "=== 빌드 검증 성공 ==="
...
# 4. 배포
deploy_playstore:
...
script:
# .env.prod 환경설정 적용
- |
echo "Deploying to PlayStore with type: $AOS_BUMP_TYPE"
cd android && bundle exec fastlane android deploy type:$AOS_BUMP_TYPE && cd ..
...
참고: https://imleaf.tistory.com/102
https://docs.fastlane.tools/actions/upload_to_play_store/
iOS 배포는 Fastlane을 활용하여 TestFlight로 진행한다. 이 과정 역시 Android와 마찬가지로 빌드 후 IPA 내부의 환경 변수 검증 단계를 포함한다.
Android 배포와 다른 점은, 하나의 TestFlight 경로를 사용하며, CI/CD Variables를 통해 개발 서버와 운영 서버를 구분하여 빌드 및 배포한다는 점이다.
# ...
default_platform(:ios)
platform :ios do
# Marketing Version 업데이트
desc "Set Marketing and Build version"
lane :set_version do |options|
...
end
desc "Build only"
lane :build_ios do
build_app(
...
)
end
desc "Upload testflight"
lane :upload_testflight do
upload_to_testflight(
...
)
end
end
# TestFlight 개발서버/운영서버 어플 배포
stages:
...
# 1. 버전 업데이트 및 git push
version_for_ios:
stage: version
...
script:
...
# 3. git commit & push
PBXPROJ_PATH=ios/seedpayments.xcodeproj/project.pbxproj
git config --global user.email "ci-bot@example.com"
git config --global user.name "CI Bot"
if git diff --quiet $PBXPROJ_PATH; then
echo "No version changes detected"
else
# 실제 GitLab 주소와 프로젝트 경로는 'GitLab_프로젝트_주소'로 대체
git remote set-url origin "http://ci-bot:${CI_TOKEN}@GitLab_서버_주소:포트/GitLab_프로젝트_경로.git"
echo "Current remote URL:"
git remote get-url origin
git add $PBXPROJ_PATH
git commit -m "CI: project - iOS v${IOS_NEW_VERSION} 운영 배포"
git push origin HEAD:$CI_COMMIT_REF_NAME
echo "CI_COMMIT_REF_NAME: $CI_COMMIT_REF_NAME"
echo "[iOS] Version bumped and pushed successfully"
fi
...
# 3. 테스트 (IPA 내 API_URL 검증)
test_build_ios:
stage: test
...
script:
...
# IPA 내부에서 API_URL 확인
- |
if [ -n "$IPA_PATH" ]; then
echo "검증: IPA -> $IPA_PATH"
unzip -o "$IPA_PATH" -d ipa_unzip > /dev/null
APP_NAME=$(ls ipa_unzip/Payload)
echo "APP_NAME: $APP_NAME"
if [ "$IOS_DEPLOY_TYPE" == "prod" ]; then
grep "$API_URL_PROD" "ipa_unzip/Payload/$APP_NAME/main.jsbundle" \
|| { echo "ERROR: IPA에 API_URL 없음"; exit 1; }
else
grep "$API_URL_DEV" "ipa_unzip/Payload/$APP_NAME/main.jsbundle" \
|| { echo "ERROR: IPA에 API_URL 없음"; exit 1; }
fi
fi
- echo "=== iOS 빌드 검증 성공 ==="
...
Fastlane을 사용하여 TestFlight 배포를 시도할 때, Failed to get authorization for username
과 같은 인증 오류가 발생하며 배포에 실패했다.
구글링을 통해 이 오류는 2단계 인증 환경에서 일반 Apple ID 비밀번호 대신 앱 암호를 사용해야 발생함을 확인했다. 이에 따라 FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
환경 변수에 앱 암호를 설정하여 반영하니 정상적으로 작동했다.
CI/CD 파이프라인에서 사용되는 환경 변수 목록이다. GitLab의 Settings > CI/CD > Variables에 저장하여 사용하고 있다.
구분 | 변수명 | 설명 및 획득 경로 | 비고 |
---|---|---|---|
변동 값 | API_URL_DEV | 개발 서버 API 주소 | 배포 환경 설정에 사용 |
API_URL_PROD | 운영 서버 API 주소 | 빌드 검증 단계에서 사용됨 | |
고정 값 | CI_USER | CI/CD 작업 수행 시 Git 사용자 이름 (ci-bot ) | Git 커밋에 사용 |
CI_TOKEN | GitLab API 접근 토큰 (glpat-... ) | Settings > Access Tokens에서 발급 후, CI/CD 파이프라인의 Git Push 권한에 사용 | |
Firebase | FIREBASE_APP_ID | Firebase 프로젝트 앱 ID | Firebase 콘솔 > 프로젝트 설정에서 확인 |
FIREBASE_SERVICE_ACCOUNT | Base64 인코딩된 Firebase 서비스 계정 JSON | IAM 및 관리자 > 서비스 계정에서 키 파일을 생성 후 Base64 인코딩하여 등록 | |
Google Play | GOOGLE_PLAY_JSON | Base64 인코딩된 Google Play 서비스 계정 JSON | Play Console API 접근 자격 증명 파일을 Base64 인코딩하여 등록 |
CI_USER
, CI_TOKEN
: Project > Settings > Access tokens
FIREBASE_APP_ID
: Firebase → 프로젝트 설정 → 앱 ID
FIREBASE_SERVICE_ACCOUNT
: IAM 및 관리자 > 서비스 계정 > FastlaneClient
기존에는 운영/개발 서버 빌드 과정에서 캐싱 문제로 빌드 시간이 오래 걸렸고, 같은 작업을 반복하면서도 매번 수동 검증을 해야 했다.
이 과정을 GitLab CI/CD로 자동화한 덕분에, 지금은 Android/iOS 모두 환경별 배포 시간을 빠르게 단축하고 반영할 수 있게 됐다.
결과적으로 휴가나 부재 상황에도 팀 전체가 안정적으로 대응할 수 있게 되었고, QA 시간 낭비도 크게 줄일 수 있었다. 이번 자동화는 단순히 '편의성 증대'를 넘어서, 휴먼 에러를 방지하고 팀의 안정성과 생산성을 끌어올린 중요한 계기가 됐다.