프로젝트 초기, Tuist를 통해 모듈화된 아키텍처는 잘 갖춰져 있었으나, 배포 과정은 전적으로 수작업에 의존했다. 개발자는 기능 개발 후 develop 브랜치에 코드를 병합하고, 수동으로 버전과 빌드 넘버를 올린 뒤 main 브랜치로 다시 병합했다. 그 후 Xcode에서 직접 아카이브(Archive)를 하고, App Store Connect에 업로드하여 빌드를 선택하고, TestFlight과 App Store에 배포하는 전 과정을 사람이 직접 수행했다.
이러한 수동 배포 방식은 다음과 같은 명확한 한계를 지니고 있었다.
이러한 문제를 근본적으로 해결하고, 개발자가 코드 작성과 리뷰에만 집중할 수 있는 환경을 만들기 위해 Fastlane과 GitHub Actions를 도입하여 CI/CD 파이프라인 구축을 시작했다.
목표는 명확했다.
CI/CD 파이프라인 구축은 단순히 툴을 연결하는 작업이 아니었다. 로컬 환경과 CI 환경의 차이, 인증, 보안 정책, 그리고 각 도구의 특성이 복합적으로 얽힌 문제들을 해결해나가는 과정의 연속이었다.
가장 먼저 마주한 문제는 환경의 비일관성이었다. 로컬과 GitHub Actions Runner의 Ruby, Bundler 버전이 달라 Gemfile.lock과의 충돌로 fastlane이 정상적으로 설치되지 않았다. 또한, Tuist로 생성된 프로젝트의 테스트 타겟이 메인 앱 타겟에 잘못 포함되어 있어 XCTest 관련 링크 에러가 발생했다.
초기 워크플로우는 curl 스크립트로 Tuist를 설치했다. 하지만 어느 날, 해당 스크립트의 URL(https://install.tuist.io)이 404 에러를 반환하며 파이프라인 전체가 멈췄다.
// ios.yml
...
- name: Install Tuist CLI
run: brew install tuist
...
fastlane match가 private 인증서 레포지토리에 접근하지 못하는 Error cloning certificates git repo 오류에 직면했다. GitHub Secrets에 MATCH_GIT_BASIC_AUTHORIZATION 토큰을 설정했음에도 문제가 지속됐다.
// MatchFile
...
username("JunnKyuu")
...
Git 인증 문제를 해결하자, 이번에는 app_store_connect_api_key 단계에서 invalid curve name 오류가 발생했다. 로그를 면밀히 분석한 결과, GitHub Actions가 Secrets 값을 전혀 불러오지 못하고 비어있는 상태로 전달하고 있음을 발견했다.
// ios.yml
...
on:
push:
branches:
- main
- develop
jobs:
# develop 브랜치 전용 TestFlight 배포 작업
beta:
name: TestFlight Deploy
runs-on: macos-14
# develop 브랜치에 push될 때만 실행되도록 조건을 설정
if: github.ref == 'refs/heads/develop' && !startsWith(github.event.head_commit.message, '[fastlane]')
...
모든 인증 문제를 해결하고 마주한 마지막 관문은 build_ios_app 단계의 코드 서명 오류였다.
이 딜레마를 통해 문제의 근본 원인이 Fastfile이 아닌, tuist generate로 생성된 Xcode 프로젝트 설계도 자체에 서명 정보가 전무하다는 것임을 깨달았다.
// Project.swift
...
// 서명 설정을 위한 Settings 객체
let settings: Settings = .settings(
base: [:],
configurations: [
.release(name: "Release", settings: [
"DEVELOPMENT_TEAM": "FN67GXC5GH", // 개발팀 ID
"CODE_SIGN_STYLE": "Manual",
"PROVISIONING_PROFILE_SPECIFIER": "match AppStore com.tomyongji.ios",
"CODE_SIGN_IDENTITY": "Apple Distribution"
]),
.debug(name: "Debug", settings: [:])
],
defaultSettings: .recommended
)
// ToMyongJi-iOS 타겟에만 settings 파라미터를 추가
let project = Project(
name: "ToMyongJi",
organizationName: "ToMyongJi",
packages: [
.remote(url: "https://github.com/Alamofire/Alamofire.git", requirement: .upToNextMajor(from: "5.0.0"))
],
targets: [
// MARK: - App Target
.target(
name: "ToMyongJi-iOS",
destinations: [.iPhone],
product: .app,
bundleId: "com.tomyongji.ios",
infoPlist: .file(path: "App/Resources/Info.plist"),
sources: ["App/Sources/**"],
resources: [
"UI/Resources/Assets.xcassets",
"UI/Resources/Fonts/**"
],
dependencies: [
.target(name: "Core"),
.target(name: "Feature"),
.target(name: "UI")
],
settings: settings
),
...
3개월간의 집요한 노력 끝에, 마침내 안정적이고 효율적인 CI/CD 파이프라인을 완성하는 데 성공했다. 지금은 혼자지만 나중에 합류할 iOS 개발 동료들은 이제 기능 개발 후 Pull Request를 머지하는 행동만으로 TestFlight 배포까지의 모든 과정을 시스템에 온전히 위임할 수 있게 되었다.
이 과정은 순탄치 않았다. 하나의 문제를 해결하면 연쇄적으로 다른 문제가 발생하는 상황 속에서 수없이 좌절을 마주했다. 하지만 포기하는 대신, 문제의 표면이 아닌 근본 원인을 찾기 위해 집요하게 파고들었다.
Apple, Tuist, Fastlane의 공식 문서를 깊이 파고들었고, 각 도구의 철학과 메커니즘을 이해했고, iOS 분야를 넘어 다른 직무의 선후배 동료들의 조언을 통해 새로운 관점을 얻었다. 또한, GPT와 Gemini와 같은 최신 AI 어시스턴트를 적극적으로 활용하여 디버깅 시간을 단축하고 해결의 실마리를 찾는 등, 동원할 수 있는 모든 자원을 전략적으로 활용했다.
이번 경험을 통해 얻은 가장 큰 수확은 단순히 자동화 시스템을 구축했다는 사실을 넘어선다.
이 경험은 앞으로 더 복잡한 자동화 요구사항이나 예상치 못한 문제에 직면했을 때, 흔들리지 않고 해결해 나갈 수 있는 단단한 기술적 자산이 될 것이라 확신한다.
이번 글이 나와 같은 문제를 겪고 있는 분들께 조금이라도 도움이 되었으면 한다.