하루에도 10번 배포하는 Flutter 앱 CI/CD 구축하기

아테나스랩
아테나스랩 팀블로그
17 min readOct 17, 2022

안녕하세요 아테나스랩 클라이언트 챕터 Mason입니다.

아테나스랩 클라이언트 챕터에서는 Flutter를 이용해서 오늘학교 앱을 개발하고 있습니다. 또한 다른 직군의 팀원들과 협업을 통해 앱 기획에도 기여하고 있습니다.

이 글에서는 오늘학교에서 왜 CI/CD를 도입하게 되었는지, 여러 서비스들 중 어떤 서비스를 선택했는지, 그리고 실제로 어떻게 도입했는지에 대한 과정에 대해 이야기를 해보려고 합니다.

CI/CD란?

단어의 뜻은 코드에 대한 지속적인 통합(Continous Integration) 및 지속적인 배포(Continous Delivery)입니다.

나눠서 보자면 CI는 빌드 및 테스트 자동화하는 것, CD는 배포를 자동화하는 것이라고 볼 수 있습니다.

큰 틀에서는 앱 출시를 위한 과정을 자동화하는 과정입니다.

왜 도입했을까?

현재 오늘학교는 내부 테스트를 위한 배포를 파이어베이스 App Distribution을 통해 하고 있습니다. 기존에는 CI/CD가 제대로 구축이 되어 있지 않은 상황이었습니다. 결국 이 과정을 수동으로 하다 보니, 시간이 많이 소요되고 실수도 종종 발생했습니다. 한 가지 예로 개발 서버로 빌드한 앱이 출시될 뻔한 적도 있었습니다. 아찔한 상황이죠..

이러한 Human Error를 막고, 자동화를 통한 시간 절약을 하게 된다면 여러 장점이 생기게 됩니다. 내부 배포를 수월하게 해서 테스트가 용이해지고, 그로 인해 서비스 퀄리티를 높일 수 있습니다. 결국 유저에게 더 좋은 서비스를 제공하기 위해 CI/CD를 도입하게 됐습니다.

어떤 툴을 사용할까?

오늘학교 CI/CD는 Github Action과 Fastlane을 사용합니다. GitHub Action은 Github 레포지토리와 연동되어 사용할 수 있는 CI/CD 툴입니다. GitHub Action이 Jenkins에 비해 사용 설정이 간단하다는 점 뿐만 아니라, 오늘학교는 GitHub로 코드를 관리하고 있다는 점과 비교적 사용하기 쉬운 YAML 문법으로 workflow를 구성할 수 있다는 점을 고려하여 GitHub Action을 사용하기로 결정했습니다.

Fastlane은 iOS, Android의 배포 자동화 툴입니다. 저희는 Flutter를 이용해 두 플랫폼을 모두 지원하기 때문에 Fastlane을 이용하여 인증서 관리 및 배포하는데 이용합니다.

어떻게 GitHub Action을 이용할까?

GitHub Action은 Runner라는 가상 머신에서 동작합니다. GitHub-hosted Runner라고 GitHub에서 제공하는 머신이 있긴 한데…

좀 비쌉니다.. 저희 같은 경우는 iOS를 빌드해야하기 때문에 macOS를 사용해야 하는 상황이었으니까요. (이 글을 작성하는 시기에는 달러 환율이 어마어마 했습니다..)

다른 CI/CD 서비스 중에 Code Magic이라는 것도 있는데, 요것도 가격이 비슷했습니다.

그래서! 처음 구축하는 CI/CD이기도 하고, 테스트도 많이 발생할 것 같아서 Self-Hosted Runner라는 것을 사용하기로 했습니다. GitHub-hosted Runner는 GitHub에서 제공하는 클라우드지만, Self-Hosted Runner는 개인의 PC를 호스팅해 사용하는 방식입니다. Self-Hosted Runner의 특징은 아래와 같습니다.

  • 직접 서버를 호스팅해 사용할 수 있는 Runner
  • 현재 클라이언트 챕터의 PC를 호스팅해 사용하기로 함

하지만 단점도 존재합니다.

  • PC가 항상 On이여야 함
  • 작업 수행 시 해당 PC 성능의 영향을 줌

찾아보니 고려해야 될 사항으로 맥 미니를 머신으로 사용하는 방법도 있는 걸 확인했습니다. 추후에..

GitHub Action을 이용해보자!

오늘학교 CI/CD Flow는 위와 같습니다.

먼저 개발자가 원격 저장소에 푸시하기 전에 Git Hooks를 이용한 Pre Push로 코드 검증을 한번 합니다.

Development, Release 브랜치에 코드가 머지될 경우 빌드 테스트가 동작하며, Firebase를 통해 내부 배포가 됩니다.

Master 브랜치에 코드가 머지될 경우 App Store, Play Store에 각각 배포가 됩니다.

이 글에서는 App Store 업로드를 정리해보겠습니다.

Fastlane Match

App Store에 앱을 업로드하기 위해서는 배포용 인증서가 필요합니다. 저희는 Self-Hosted Runner를 통해 각 팀원들의 PC에서 배포가 이뤄지고 있습니다. 인증서를 자동으로 동기화 해주는 툴인 Match를 이용하면, 기기 환경과 상관없이 앱을 배포하기 위한 인증을 하는 과정인 코드 사이닝을 할 수 있습니다.

즉 특정 원격 저장소에 앱 배포에 필요한 인증서를 저장한 후에는, 어느 기기에서 액션이 실행되어도 해당 인증서를 사용하게 됩니다. Match에 관한 설정은 간단하게 할 수 있기 때문에 이 글에서는 생략하도록 하겠습니다.

[iOS] fastlane 이용한 배포 자동화 (match 편)

본격적으로 Action을 작성해보자(1)

오늘학교에서는 Freezed를 이용해 코드를 생성하므로, Build runner라는 동작이 필요합니다. 현재 이 부분에서 시간이 많이 소요되고 있어 이 동작을 하나로 분리했습니다.

이 때 한 가지 문제점이 있었는데 Build runner를 수행하는 Runner와 배포 Job을 수행하는 Runner가 다른 경우입니다. Build runner 실행 후 생성된 파일들은 해당 Runner에만 존재하기 때문에, 다른 Runner에서는 해당 파일이 없어 Job이 실패했습니다.

그렇다고 배포를 하는 Job에 Build Runner를 수행하면 매번 Build Runner가 수행되어 많은 시간이 들기 때문에 고민을 했습니다.

이에 따라 Runner 사이에 파일을 주고 받을 수 있는 방법을 찾던 중 캐시 action을 이용하기로 했습니다.

아래는 Build Runner를 수행하는 Job 중 일부입니다.

- name: build runner에 의한 코드 생성 ------------------------------(1)
run: flutter pub run build_runner build --delete-conflicting-outputs

- name: 코드 포매팅 검사 # (해결방법 : 터미널에서 flutter format . 입력 후 커밋) -----(2)
run: flutter format --set-exit-if-changed .

- name: lib 디렉토리 캐시 -----------------------------------------(3)
uses: actions/cache@v2
with:
path: ./lib
key: ${{ runner.os }}-${{ github.sha }}-build-lib -----------(4)

(1)번 Action에서 Build runner를 수행하면 필요한 파일들이 생성됩니다.

그 후 (2)번 Action에서 코드 포맷을 검사하게 되고,

(3)번에서 소스 디렉토리를 캐시하게 되는데, 이 때 키 값을 고유한 값으로 하기 위해서 다음과 같은 변수를 이용해 키 값을 설정했습니다.

(4)번에 runner.os는 단어 그대로 Runner의 OS 종류를 나타내는 값이며, gitHub.sha는 해당 커밋의 해시 값으로 해당 액션을 발생한 커밋에서는 동일한 값입니다. 따라서 다른 Job을 수행하는 Runner에서도 같은 값을 갖게 됩니다.

그렇게 생성된 키 값의 예시는 아래와 같습니다.

  • macOS-b06963a12a[…]096-build-lib

다른 Job을 수행하는 Runner에서는 위 키 값을 사용해 캐시된 파일들을 가져옵니다.

본격적으로 Action을 작성해보자(2)

이제 Build Runner도 실행했고, 파일도 정상적으로 생성했으니 배포하는 Job을 확인해보겠습니다.

몇가지 부분만 확인해보면,

ios-appstore-release:
needs: build
name: IOS 앱스토어 배포
if: github.ref_name == 'master' && github.event_name == 'push'
runs-on: self-hosted

이 Job은 Master에 코드가 머지됐을 때, build라는 Job이 수행된 후 동작합니다.

- name: lib 디렉토리 캐시 가져오기
uses: actions/cache@v2
with:
path: ./lib
key: ${{ runner.os }}-${{ github.sha }}-build-lib

캐시를 가져오는 코드입니다. 캐시를 가져오는 코드는 캐시에 저장하는 부분과 같습니다.

즉, 해당 키 값으로 저장한 데이터가 없는 경우에는 캐시에 저장을 하며, 값이 있는 경우 캐시에서 데이터를 가져옵니다.

- name: app store 배포
run: cd ios && fastlane release
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}

실제 Fastlane을 이용해 앱 배포를 하는 action입니다.

여러 Password 및 값을 GitHub 저장소 Secrets에 등록한 후 사용합니다.

전체 코드는 아래와 같습니다.

ios-appstore-release:
needs: build
name: IOS 앱스토어 배포
if: github.ref_name == 'master' && github.event_name == 'push'
runs-on: self-hosted
steps:
- uses: actions/checkout@v2
with:
token: ${{ secrets.GIT_HUB_TOKEN }}
- run: git config pull.rebase false
- uses: actions/setup-java@v1
with:
java-version: '14.x'

- uses: subosito/flutter-action@v1.5.3
with:
flutter-version: '2.5.3'

- name: lib 디렉토리 캐시 가져오기
uses: actions/cache@v2
with:
path: ./lib
key: ${{ runner.os }}-${{ github.sha }}-build-lib

- name: .gitignore 파일 복사
run: unzip -P ${{ secrets.SECRETS_PASSWORD }} secrets.zip

- name: 캐시된 빌드 파일 초기화
run: flutter clean .

- name: 패키지 다운로드
run: flutter pub get

- name: Pod 초기화
run: rm -f ios/Podfile.lock && rm -rf ios/Pods

- name: Pod repository 업데이트
run: cd ios && pod install --repo-update

- name: IOS Prod 빌드
run: flutter build ios --release --no-codesign --flavor production -t lib/main_prod.dart

- name: fastlane 설치
run: arch -arm64 brew install fastlane

- name: app store 배포
run: cd ios && fastlane release
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}

- name: Build 버전 업 git push
run: |
git pull --ff-only
git commit -am "[Bot] Set iOS Master version"
git push

Fastlane 코드를 작성해보자

각 동작을 확인해보면,

lane :increase_version do
yaml_file_path = "../../pubspec.yaml"
data = YAML.load_file(yaml_file_path)
version = data["version"]
version_number = version.split("+")[0]

sh "echo VERSION_CODE=#{version_number} >> $GITHUB_ENV"

increment_version_number(version_number: version_number)
end

현재 pubspec.yaml에 적혀있는 버전으로 App Store에 올릴 버전을 지정하는 부분입니다.

이 코드 덕분에 pubspec.yaml만 수정하면 iOS, AOS 둘 다 같은 버전으로 각 스토어에 배포가 됩니다.

match(type: "appstore", readonly: true)
setup_ci()
sh "git pull --ff-only origin master --tags -f"
increase_version
increment_build_number

앱을 빌드하기 전에 세팅을 하는 부분입니다. match를 통해 인증서를 가져오며, 원격 저장소에 있는 코드를 pull 합니다.

그 후 위에 작성한 함수인 increase_version을 호출하고 빌드 넘버를 자동으로 1 올려줍니다.

build_app(
clean: true,
scheme: "Runner",
archive_path: "./build/Runner.xcarchive",
configuration: "Release-production",
export_method: "app-store",
output_directory: "./build/Runner"
)
app_store_connect_api_key(
key_id: "[Key_ID]",
issuer_id: "[Issuer_ID]",
key_filepath: "[KEY FILE PATH]"
)
upload_to_app_store(
skip_metadata: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false
)

앱을 빌드하고, 스토어에 올리는 내용입니다. app_store_connect_api_key은 App Store Connect에 저장된 정보를 입력하면 됩니다. key_filepath의 경우 p8 파일의 경로를 지정해주면 됩니다.

위 Job이 성공적으로 수행되면 App Store Connect에 심사를 제출할 수 있는 상태로 업로드가 완료됩니다.

전체 코드는 아래와 같습니다.

lane :release do
lane :increase_version do
yaml_file_path = "../../pubspec.yaml"
data = YAML.load_file(yaml_file_path)
version = data["version"]
version_number = version.split("+")[0]

sh "echo VERSION_CODE=#{version_number} >> $GITHUB_ENV"

increment_version_number(version_number: version_number)
end

match(type: "appstore", readonly: true)
setup_ci()
sh "git pull --ff-only origin master --tags -f"
increase_version
increment_build_number
build_app(
clean: true,
scheme: "Runner",
archive_path: "./build/Runner.xcarchive",
configuration: "Release-production",
export_method: "app-store",
output_directory: "./build/Runner"
)
app_store_connect_api_key(
key_id: "[Key_ID]",
issuer_id: "[Issuer_ID]",
key_filepath: "[KEY FILE PATH]"
)
upload_to_app_store(
skip_metadata: true,
skip_screenshots: true,
precheck_include_in_app_purchases: false
)
version = get_version_number
send_slack({"version": version})
end

마치며

CI/CD 구축을 완료한 후 얻게 된 점은 아래와 같습니다.

  1. PR 프로세스 확립
  • 모든 Check 통과해야 Merge 가능하도록 수정

2. GitHub Action 소요 시간 단축

  • 기존
약 11분 수행, Runner 최대 4개 동시 사용
  • 개선 후
약 6분 수행, Runner 최대 3개 동시 사용
  • PR시 소요 시간 50% 감소, 최대 사용하는 Runner 수 4개 → 3개로 감소!

3. Firebase 자동 배포

  • development 브랜치 merge 발생 시
스테이징, 프로덕션 내부 배포
  • 기존 앱 내부 배포 iOS, AOS 프로덕션만 4시간 정도 소요
    변경 후 iOS, AOS 프로덕션 스테이징 모두 배포하는데 18분 소요! (약 3시간 40분 단축)

4. App Store, Play Store 자동 배포

  • master 브랜치 merge 발생 시
  • 기존 실수도 잦고 오래 걸리던 작업을 약 13분 안에 자동으로 작업
  • 심사 제출 및 심사 후 배포는 개발자가 직접 제출하는 방식으로 적용 (안전성)

오늘학교 개발챕터는 각 구성원들이 CI/CD를 도입하는 등 새로운 시도를 주도적으로 하고 있습니다. 이번 CI/CD 도입도 그러한 팀 분위기 속에서 진행했습니다.

또한 오늘학교에서는 GitHub Action을 통해 여러 Workflow를 사용하고 있습니다.

나중에 기회가 된다면 해당 코드에 관한 글도 작성해보겠습니다.

--

--