LODY/기록

수동으로 하던 센디 iOS 앱 배포를 자동화하고 빌드 시간도 줄인 경험

박정환박정환

들어가며

안녕하세요, 센디 iOS엔지니어 로디입니다.

Sendy 제품 개발팀에서 일하면서 짧은 피드백 루프와 수동 iOS 배포가 겹치면 작업 흐름이 자주 끊겼습니다. QA와 PO, 디자이너 검증이 반복될수록 Staging 빌드를 다시 만드는 일이 늘었고, 그때마다 Xcode 앞에서 archive가 끝날 때까지 기다리는 시간이 함께 쌓였습니다. 팀에서 기록해 둔 Staging 배포 기준으로는 한 번 archive를 시작하면 평균 20분 정도가 그대로 지나갔습니다.

이 글에서는 왜 GitHub Actions로 배포 시작을 옮겼는지, 그리고 자동화 이후 드러난 build Job 병목을 CocoaPods 캐시와 ccache로 어떻게 줄였는지를 정리합니다. 코드 서명 구조 자체는 iOS 코드 서명 정리에 따로 두었습니다.


진행 배경

문제 정의

팀은 제품 스쿼드와 플랫폼 스쿼드로 나뉘어 움직였고, 협업 브랜치는 Gitflow에 가깝게 가져갔습니다. feature/*·hotfix/*에서 develop으로 모이고, 배포 전에는 release/*에서 후보를 모았으며, QA·내부 테스트는 staging/*, 스토어 직전 검증은 production/*로 갈랐습니다. staging/*는 버전 고정이 많았고, 필요한 경우 티켓 단위 검증에도 같은 경로를 재사용했습니다. 그래서 배포가 곧바로 릴리스 라인을 타기 전에 중간 검증을 한 번 더 거치는 편이었습니다.

iOS 앱 레포는 React Native 코드를 git submodule로 끌어오는 브라운필드 구조였습니다. Xcode에서 archive를 누르기 전에도 서브모듈 동기화, JS 의존성 설치, 환경 변수 생성, pod install까지 한 줄로 맞춰야 했습니다. 배포 대상도 Staging은 Firebase App Distribution, Production은 TestFlight로 갈라졌고, 그 차이는 Makefilesetup-stagingsetup-prod에 그대로 드러났습니다.

환경역할배포 대상
StagingQA와 내부 테스트Firebase App Distribution
Production실제 배포 전 검증TestFlight
setup-staging:
	$(call init_submodules)
	$(call create_links)
	$(call install_rn_deps)
	$(call generate_env_staging)
	$(call install_ios_deps)
 
setup-prod:
	$(call init_submodules)
	$(call create_links)
	$(call install_rn_deps)
	$(call generate_env_prod)
	$(call install_ios_deps)

가장 자주 돌던 건 Staging 배포였습니다. QA 이슈 확인이나 PO·디자이너 피드백 반영 후 검증마다 새 빌드가 필요했고, 그때마다 제 손이 들어갔습니다.

환경기존 배포 방식사람이 직접 하던 일
StagingAd Hoc + Firebase App Distributionarchive, .ipa export, 업로드, 링크 공유
ProductionApp Store 배포 + TestFlight빌드 번호 확인, archive, 업로드, 처리 상태 확인

배포 시간 자체보다 크게 느껴진 건 짧은 피드백 루프와 수동 배포가 겹치며 집중이 반복적으로 끊기는 구조였습니다. 여기에 한 가지 더 있었습니다. React Native 서브모듈, pod install, 네이티브 컴파일까지 한 번에 물고 가는 구조라서, 배포를 CI로 옮기는 순간 build Job 병목이 한꺼번에 드러날 가능성이 높았습니다. 결국 풀어야 할 문제는 두 겹이었습니다. 사람 손이 계속 들어가는 배포 절차를 없애는 일, 그다음 CI가 같은 작업을 반복하는 비용을 줄이는 일이었습니다.

개선 방안 도출

가장 단순한 대안부터 검토했습니다. 수동 절차를 유지한 채 체크리스트만 촘촘히 만드는 방법은 구현 비용은 거의 없지만, archive 시간과 컨텍스트 스위칭은 그대로였습니다. fastlane이나 shell로 로컬 배포만 줄이는 방법도 있었지만, 실행 주체가 개인 Mac에 남고 서명·빌드 번호를 팀 공통 절차로 고정하기 어렵습니다.

검토 끝에 브랜치 전략에 맞춰 GitHub Actions에서 배포를 시작하는 쪽이 가장 현실적이었습니다. 이미 브랜치가 협업의 기준점 역할을 하고 있었기 때문입니다. 배포 시작 조건만 CI로 옮겨도 archive를 지켜볼 시간과 배포 시작 책임을 개인 Mac에서 떼어낼 수 있었습니다.

다만 여기서 끝내면 build Job이 새 병목이 될 가능성이 컸습니다. 더 큰 macOS 러너만 쓰면 반복 설치·반복 컴파일 자체는 줄지 않습니다. DerivedData를 통째로 재사용하는 방법도 있지만, invalidation이 까다롭고 운영 안정성을 설명하기도 어렵습니다. 그래서 먼저 CI로 옮기고, 그다음 반복 비용이 큰 구간만 캐시로 줄이는 두 단계로 가기로 했습니다.


문제 해결 1단계: GitHub Actions로 배포 파이프라인 옮기기

트리거와 Job 분리

파이프라인은 환경별로 분리하되 구조는 최대한 같게 가져갔습니다. staging/* 브랜치 push는 Firebase App Distribution으로, production/* 브랜치 push는 TestFlight로 이어지도록 했습니다.

# staging-release.yml
on:
  push:
    branches:
      - staging/*
 
# production-release.yml
on:
  push:
    branches:
      - production/*

각 워크플로우에는 workflow_dispatch도 남겼습니다. 기본은 브랜치 push로 시작하되, 운영 중에는 수동 실행 fallback이 필요했기 때문입니다. concurrency도 같이 두었습니다. 같은 브랜치에서 새 빌드가 올라오면 이전 실행은 의미가 없고, 오래 걸리는 archive가 겹치면 러너 비용만 늘며 QA가 어떤 빌드를 봐야 할지도 헷갈립니다.

파이프라인은 한 Job에 몰지 않고 sync-provisioning, build, sign-export, distribute 또는 upload-testflight, notify 순으로 나누었고, 서명 관련 실패는 archive 전에 끝나게 했습니다.

프로파일 동기화와 빌드

프로비저닝 동기화는 Fastlane sigh로 처리했습니다. Ad Hoc과 TestFlight 프로파일이 왜 다르게 움직이는지는 앞서 링크한 코드 서명 글에 맡깁니다.

lane :sync_code_signing_staging do
  setup_api_key
 
  sigh(
    app_identifier: "com.venditz.sendy.ios.staging",
    adhoc: true,
    force: true,
    filename: "staging.mobileprovision",
    output_path: "./fastlane/profiles"
  )
end

force: true는 실무적인 선택이었습니다. Ad Hoc은 등록된 테스트 기기 구성이 바뀌면 프로파일을 다시 맞춰야 할 때가 있는데, 사람이 매번 기억해 갱신하기보다 배포 시작 시점에 동기화하는 편이 운영상 안전했습니다.

빌드 단계의 환경 준비는 Makefile로 통일했습니다.

- name: Setup Staging Environment
  run: |
    make setup-staging
 
- name: Build Archive
  run: |
    xcodebuild -workspace Sendy.xcworkspace \
      -scheme Sendy-Staging-RN \
      -configuration Debug \
      -archivePath $RUNNER_TEMP/Sendy.xcarchive \
      CODE_SIGNING_REQUIRED=NO \
      CODE_SIGNING_ALLOWED=NO \
      clean archive

archive를 서명 없이 먼저 만드는 이유는 빌드와 서명을 분리해 실패 지점을 좁히고, CPU를 많이 쓰는 단계와 자격 증명을 다루는 단계를 나누기 위해서였습니다. build Job은 macos-26-xlarge, 나머지 Job은 기본 러너로 나눴습니다.

Production은 배포 대상만 다르고 구조는 같게 두었고, TestFlight는 중복 빌드 번호를 허용하지 않아서 빌드 전에 다음 번호를 조회하는 단계를 넣었습니다.

- name: Get Next Build Number from TestFlight
  run: |
    OUTPUT=$(bundle exec fastlane get_next_build_number \
      app_identifier:"com.venditz.sendy.ios" 2>&1)

환경마다 다른 요구사항을 하나의 lane으로 억지로 합치지 않고, 공통 구조 위에 환경별 차이만 얹는 방식으로 정리했습니다.


개선 효과 측정

이 단계에서 가장 먼저 바뀐 것은 빌드 시간이 아니라 배포를 시작하는 방식이었습니다.

항목기존변경 후
Staging 시작Xcode archive 후 수동 exportstaging/* 브랜치 push
Production 시작빌드 번호 확인 후 수동 archiveproduction/* 브랜치 push
프로파일 갱신사람이 직접 확인배포 시작 시 자동 동기화
배포 결과 공유사람이 직접 전달워크플로우 알림

archive 자체가 즉시 빨라지지는 않았습니다. 대신 개발자가 archive가 끝날 때까지 Xcode 앞에 붙어 있을 필요가 거의 없어졌고, 배포 시작은 브랜치 push로 단순해졌습니다. QA가 다시 확인해 달라고 할 때 “한 번 더 올려보자”에 대한 심리적 비용이 줄었습니다.

동시에 새 문제도 드러났습니다. 이제는 사람이 아니라 CI가 기다렸고, 그 대기 시간의 대부분이 반복 설치와 반복 컴파일에 쓰였습니다.


단점과 예상치 못한 변수

파이프라인을 붙였다고 바로 끝나지는 않았습니다. React Native 서브모듈 때문에 빌드 전에 git submodule update, JS 의존성 설치, 환경 변수 생성, pod install이 함께 돌았고, 로컬에서 익숙한 준비 과정이 CI에서는 전부 빌드 시간으로 잡혔습니다.

Staging과 Production을 분리한 덕분에 흐름은 명확해졌지만, 스킴·설정·프로파일·배포 대상·빌드 번호 정책처럼 환경별 차이도 파이프라인에 그대로 반영해야 했습니다. 배포를 자동화한 뒤에는 오히려 build Job 시간이 더 눈에 띄었습니다. 예전에는 사람이 직접 archive를 돌리느라 체감이 분산됐는데, CI로 옮기고 나니 병목이 한곳으로 모였습니다.


문제 해결 2단계: CocoaPods와 ccache로 build Job 줄이기

자동화 이후 build Job은 서브모듈 동기화, JS 의존성 설치, 환경 변수 생성, pod install, xcodebuild archive로 이어졌습니다. 이 다섯 단계 중 대부분은 매 실행마다 크게 달라지지 않았습니다. Podfile.lock이 그대로인데도 pod install은 다시 돌았고, React Native 의존성이 바뀌지 않았어도 네이티브 컴파일은 반복됐습니다.

캐시는 build Job에만 걸었고, 최적화 범위도 배포 전체가 아니라 가장 오래 걸리던 build 단계로 한정했습니다. 먼저 본 것도 build Job 로그였습니다. 어디가 느린지 모른 채 캐시부터 넣으면 설명하기 어려운 최적화만 남기기 쉽기 때문입니다.

GitHub Actions 실행 기록을 기준으로, 같은 브랜치에서 의존성 변경 없이 반복 실행한 build Job의 단계별 소요 시간을 정리하면 아래와 같습니다.

단계소요 시간
pod install채울 빈칸
캐시 없는 build Job 전체채울 빈칸
ccache 적용 후 build Job채울 빈칸

채울 기준 — GitHub Actions 워크플로우 Actions 탭 → Staging 릴리스 워크플로우 최근 10회 실행 중 의존성 변경 없는 케이스만 골라 평균을 낸 값을 쓰면 됩니다. pod install 은 해당 스텝 로그, build Job 전체는 Job 소요 시간 기준.

CocoaPods 캐시

먼저 CocoaPods 캐시를 넣었습니다.

- name: Cache CocoaPods
  uses: actions/cache@v4
  with:
    path: |
      Pods
      ~/Library/Caches/CocoaPods
    key: pods-${{ runner.os }}-${{ hashFiles('**/Podfile.lock') }}
    restore-keys: |
      pods-${{ runner.os }}-

Pods/는 설치 결과를 재사용하게 하고, ~/Library/Caches/CocoaPods까지 같이 두면 캐시 미스가 나도 다시 내려받는 비용을 줄일 수 있습니다. 키는 Podfile.lock 해시로 두어 의존성이 바뀔 때만 새 캐시를 쓰도록 했습니다.

ccache

다음은 C++ 컴파일 캐시였습니다. React Native 쪽 node_modules 캐시를 먼저 붙이는 것도 가능했지만, 당시 병목과 무효화 기준을 함께 봤을 때 CocoaPods와 네이티브 컴파일이 더 설명 가능한 순서였습니다.

- name: Install ccache
  run: |
    brew install ccache
    echo "$(brew --prefix ccache)/libexec" >> $GITHUB_PATH
 
- name: Cache ccache
  uses: actions/cache@v4
  with:
    path: ~/Library/Caches/ccache
    key: ccache-${{ runner.os }}-${{ hashFiles('**/Podfile.lock') }}
    restore-keys: |
      ccache-${{ runner.os }}-

워크플로에서는 Install ccache와 위 두 캐시 단계를 make setup-staging 같은 환경 준비 전후에 맞춰 두었습니다. React Native 의존성에 포함된 네이티브 코드를 다시 컴파일하는 비용을 줄이기 위한 선택이었고, 같은 소스와 같은 컴파일 옵션이면 이전 결과를 재사용하는 편이 낫습니다.

이번 단계는 가장 설명 가능한 최적화부터 넣는다는 쪽에 가까웠습니다.


개선 효과

적용 전후 build Job 시간은 아래처럼 바뀌었습니다.

시나리오build Job 소요 시간
캐시 없음채울 빈칸
ccache 히트채울 빈칸
Pods + ccache 히트채울 빈칸
의존성 변경 없는 빌드 단축율채울 빈칸 %

채울 기준

  • 시간 값: GitHub Actions Actions 탭에서 캐시 적용 전후 10회 평균. 캐시 없음은 "초기화 푸시" 또는 캐시 키를 수동 무효화한 실행으로 측정.
  • 단축율: (캐시 없음 − 캐시 적용) / 캐시 없음 × 100.

자동화로 줄인 것은 사람 대기 시간이었고, 이번 최적화로 줄인 것은 CI 대기 시간이었습니다. QA가 다시 확인해 달라고 했을 때 "한 번 더 배포해보자"가 덜 부담스러워졌고, release candidate를 한 번 더 돌려보는 판단 비용 자체가 내려갔습니다.


이후 방향성

여기까지는 배포 자동화와 build Job 최적화까지입니다. 다음 후보로는 React Native 쪽 node_modules / Metro 번들 캐시, Git 서브모듈 준비 비용 단축, 캐시 hit rate 관측 자동화가 남아 있습니다.


글을 마치며

배포 자동화는 한 번에 완성되는 작업이 아니었습니다. 손작업을 CI로 옮기고, 반복 비용을 줄인 뒤, 남은 병목을 다시 보는 순서로 정리해야 했습니다. 처음부터 완벽한 파이프라인을 그리려고 하기보다 "가장 눈에 띄는 병목부터, 설명 가능한 최적화 순서로" 가는 편이 운영 관점에서 안정적이었다는 점이 이번 작업에서 얻은 가장 큰 기준이었습니다.

읽어 주셔서 감사합니다.