내 책상에는 Windows PC 한 대가 있다. iOS 앱은 Xcode 가 있어야 빌드되고, Xcode 는 맥에서만 돈다.

옮길 앱은 세 개였다. 「식단서랍」과 업무용 A/S 웹 서비스 앱은 TWA에서 WebView로 연재에서 Android WebView 앱으로 다시 지은 그 둘이다. 나머지 하나는 Unity 로 만든 파티게임 「오늘 누가 쏘냐?」다. 셋 다 Android 에만 있었다.

Android 에만 있어도 되는 줄 알았다. 그 생각이 깨진 건 커피 내기 자리에서였다. 「같이 하기」 방을 열고 다들 들어오라고 했더니 테이블에 놓인 폰의 절반이 iPhone 이었다. 그 사람들은 Play 스토어를 열 수 없으니 남의 폰을 넘겨 보며 구경만 했다. 정작 커피를 살지 말지 걸린 사람이 게임에 끼지 못하는 파티게임이었다.

돌아보니 나머지 두 앱도 사정이 같았다. 식단서랍은 가족이 같이 쓰는 달력인데, 식구 중 iPhone 을 쓰는 사람은 홈 화면의 웹 바로가기로 들어왔다. 식단은 같이 봐도 Android 앱이 해 주던 알림과 공유 받기는 그쪽에 없었다. A/S 앱도 현장에서 iPhone 을 쓰는 사람은 브라우저로 같은 화면을 열었고, 앱이 해 주던 알림과 사진 첨부, 파일 내려받기를 손으로 메웠다. 웹이 본체라 쓸 수는 있었다. 다만 Android 를 쓰는 사람과 같은 앱이라고 부르기는 어려웠다.

이 연재는 그 세 앱을 iPhone 으로 옮기는 준비기다. 아직 App Store 에 나간 앱은 없다. A/S 앱과 게임은 TestFlight 에 빌드를 올렸고, 식단서랍은 시뮬레이터에서 첫 화면을 띄운 뒤 업로드를 준비하고 있다. 첫 편은 코드보다 앞에 있는 이야기, 맥이 없는 책상에서 iOS 빌드를 어떻게 돌리느냐다.

같은 웹, 같은 게임, 다른 그릇

세 앱을 옮기는 방식은 Android 때 정해 둔 원칙을 그대로 따랐다. 웹이 본체인 앱은 웹을 한 줄도 고치지 않는다. 게임은 프로젝트를 둘로 나누지 않는다.

WebView 앱 둘은 Swift UIKit 에 WKWebView 를 얹었다. 웹 쪽 스크립트는 앱이 window.RecipeDrawerApp 같은 객체를 넣어 주는지만 보고 앱 안인지 판단한다. iOS 앱도 Android 와 똑같은 이름, 똑같은 메서드를 넣어 주면 웹은 상대가 어느 OS 인지 몰라도 된다. 그 다리를 iOS 에서 어떻게 놓았는지는 2편에서 다룬다.

게임은 사정이 다르다. 「같이 하기」는 같은 와이파이의 폰끼리 직접 붙는데, Android 방장에 iPhone 게스트가 들어올 수도 있다. 두 빌드가 같은 코드에서 나와야 서로 말이 통한다. 그래서 Unity 프로젝트는 하나로 두고 iOS 차이는 #if UNITY_IOS 분기와 작은 네이티브 조각으로 처리했다. 이건 3편 이야기다.

처음 손댄 건 A/S 앱이었다. 거기서 만든 구조와 스크립트를 식단서랍과 게임이 복사해 갔다. 앱마다 바뀌는 값, 번들 ID와 도메인, 브리지 이름, 색과 문구는 설계 문서에 표로 모아 두고, 새 앱을 시작할 때 그 표를 따라 바꿨다.

Xcode 프로젝트를 커밋하지 않는다

Windows 에서 iOS 코드를 쓰면 가장 먼저 걸리는 게 .xcodeproj 다. 이 파일은 Xcode 가 관리하는 거대한 목록이다. 소스 파일을 하나 추가하면 Xcode 가 그 안에 무작위 ID 를 단 항목을 여러 군데 끼워 넣는다. Xcode 가 없는 Windows 에서는 손으로 고쳐야 하고, 손으로 고친 .pbxproj 는 대개 맥에서 열리지 않는다.

그래서 프로젝트 파일을 아예 저장소에서 뺐다. 원본은 XcodeGen 의 project.yml 이다. 맥에서 xcodegen generate 를 돌리면 이 YAML 로 .xcodeproj 를 새로 만든다. .gitignore 첫 줄이 *.xcodeproj/ 다.

targets:
  RecipeDrawer:
    type: application
    platform: iOS
    # 폴더 전체를 지정 → GoogleService-Info.plist 가 있으면 자동으로 리소스에 포함 (없어도 빌드 성공)
    sources:
      - path: RecipeDrawer
        excludes:
          - Info.plist
          - RecipeDrawer.entitlements
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: kr.co.addsoft.recipedrawer
        MARKETING_VERSION: "1.6.0"
        CURRENT_PROJECT_VERSION: "1"
        CODE_SIGN_STYLE: Manual

소스는 파일 단위가 아니라 폴더 단위로 지정했다. Windows 에서 Swift 파일을 새로 만들어 폴더에 넣기만 하면 다음 생성 때 프로젝트에 들어간다. 프로젝트에 파일을 등록하는 일 자체가 사라진 셈이다.

폴더 지정은 비밀 파일을 다루는 데도 쓸모가 있었다. Firebase 설정 파일 GoogleService-Info.plist 는 저장소에 올리지 않는다. 빌드 스크립트가 맥의 별도 폴더에서 이 파일을 복사해 소스 폴더에 넣고, 폴더 전체가 소스이니 XcodeGen 이 리소스로 집어 간다. 파일이 없으면 넣지 않고 넘어간다. 그래도 빌드는 성공하고, 푸시만 꺼진 앱이 된다. Android 에서 google-services.json 이 없어도 빌드가 멈추지 않게 했던 것과 같은 생각이다.

project.yml 은 사람이 읽을 수 있는 크기다. 번들 ID, 버전, 최소 iOS 15, iPhone 전용, 서명 방식, Firebase 패키지가 한 화면에 들어온다. 리뷰할 때도 Xcode 화면을 열 필요 없이 diff 로 본다.

클라우드에서 책상 옆으로

맥이 없으니 처음에는 Codemagic 을 썼다. 클라우드의 맥에서 Xcode 빌드를 돌려 주는 서비스다. A/S 앱은 서명 없이 시뮬레이터용으로 컴파일만 해 보는 워크플로와, 서명해서 TestFlight 에 올리는 워크플로 두 개로 시작했다. 실제로 이걸로 첫 빌드 두 개를 TestFlight 에 올렸다.

그런데 Windows 에서 코드를 쓰는 입장에서는 컴파일 확인이 곧 빌드 서버 실행이었다. 오타 하나를 확인하려 해도 커밋하고, 푸시하고, 클라우드 맥이 뜨기를 기다려야 했다. 무료 빌드 시간은 계정 전체가 나눠 쓰는 500분이었다. WebView 앱 하나는 그 안에서 어떻게든 된다. 문제는 게임이었다. Codemagic 에서 Unity 를 돌리려면 Unity Plus 이상의 시리얼이 필요하고, Unity 빌드는 내보내기만 몇 분씩 걸린다. 세 앱이 같은 500분을 나눠 쓰는 그림이 나오지 않았다.

그래서 맥 미니를 한 대 들였다. 빌드 서버를 산 셈이다. 분 단위로 세지 않으니 컴파일 확인을 아무 때나 돌릴 수 있다. 작업은 여전히 Windows 에서 하고, 맥에는 SSH 로 명령만 보낸다. 맥은 저장소를 받아 스크립트를 실행하고 결과를 돌려준다.

스크립트는 앱마다 두 개다. compile-check.sh 는 서명 없이 시뮬레이터용으로 빌드만 하고, testflight.sh 는 프로파일 설치부터 빌드 번호 결정, Archive, 업로드까지 한다. 둘 다 첫 줄 근처가 같다.

set -euo pipefail
cd "$(dirname "$0")/.."
export PATH="/opt/homebrew/bin:$PATH"   # SSH 비대화형 셸에는 Homebrew(xcodegen) 경로가 없다

pipefail 은 Codemagic 시절에 배운 것이다. xcodebuild ... | xcpretty 처럼 파이프로 로그를 다듬으면, 셸은 마지막 명령의 종료 코드만 본다. xcodebuild 가 실패해도 xcpretty 가 멀쩡히 끝나면 단계는 성공이다. 빨간 줄이 가득한 로그 아래에 초록 체크가 붙는다.

PATH 한 줄은 맥 미니로 옮기고 처음 맞은 함정이다. 맥에 직접 앉아 터미널을 열면 xcodegen 이 잘 돈다. SSH 로 명령 하나만 보내면 "command not found" 가 난다. 비대화형 SSH 셸은 로그인 셸 설정을 읽지 않아서 Homebrew 경로가 없기 때문이다.

더 오래 걸린 건 키체인이었다. 서명할 때 codesign 은 키체인에서 배포 인증서의 개인 키를 꺼낸다. 맥에 앉아 있으면 "이 키를 써도 되느냐"는 창이 뜨고 허용을 누르면 된다. SSH 셸에는 창을 띄울 화면이 없다. codesign 은 허락을 받지 못하고 오류 코드만 남긴 채 실패한다.

그래서 빌드 전용 키체인을 따로 만들었다. 비밀번호는 맥의 설정 폴더에 파일로 두고, 스크립트가 빌드 직전에 그 키체인을 연다. 인증서를 넣을 때 security set-key-partition-list 로 codesign 이 묻지 않고 키를 쓸 수 있게 미리 허락해 둔다. 빌드할 때는 OTHER_CODE_SIGN_FLAGS="--keychain ..." 로 이 키체인만 보게 한다. 업로드도 Xcode 에 Apple 계정으로 로그인하는 대신 App Store Connect API 키를 xcodebuild -exportArchive 에 넘긴다. 사람이 맥 앞에 앉아야 하는 단계를 하나씩 지웠다.

배포 인증서도 맥에서 새로 받았다. 팀당 배포 인증서는 3개까지라 앱마다 새로 만들 수는 없다. 맥에서 API 로 하나를 발급해 빌드 키체인에 넣고, 세 앱이 그 하나를 같이 쓴다. App Store 프로파일은 앱마다 따로 두되 이 인증서를 가리키게 만들었다.

API 를 부르는 일은 작은 Swift 스크립트 asc.swift 하나에 모았다. CryptoKit 으로 API 키에 ES256 서명을 해 10분짜리 토큰을 만들고, 가장 높은 빌드 번호 조회, 프로파일 내려받기, 프로파일 다시 만들기를 한다. 맥에 따로 설치할 도구가 없고 swift asc.swift latest-build ... 처럼 바로 실행된다. 프로파일은 빌드할 때마다 새로 받아 설치한다. Bundle ID 에 기능을 하나 켜면 기존 프로파일은 무효가 되는데, 매번 받아 오면 "어제는 됐는데" 가 생기지 않는다.

맥으로 옮기고 나니 A/S 앱의 컴파일 확인은 1분 안팎에 끝났고, 같은 스크립트로 TestFlight 업로드도 됐다.

Windows 에서 쓴 스크립트가 맥에서 멈출 때

스크립트는 Windows 에서 쓰고 git 으로 맥에 보낸다. 여기서 두 가지가 조용히 어긋난다.

하나는 줄 끝이다. Windows 의 git 은 설정에 따라 체크아웃할 때 줄 끝을 CRLF 로 바꾼다. 그 파일이 어떤 경로로든 맥에 CRLF 로 도착하면 첫 줄은 #!/bin/bash\r 이 된다. 셸은 bash\r 라는 이름의 인터프리터를 찾다가 실패한다. 오류 문구에 보이지 않는 \r 이 섞여 있어서 원인을 알아보기도 어렵다. 저장소 설정이나 편집기에 기대지 않고 저장소 자체에 못을 박았다.

# .gitattributes
*.sh text eol=lf

이 한 줄이 있으면 누가 어떤 설정으로 받든 .sh 는 LF 로 체크아웃된다.

다른 하나는 실행 권한이다. Windows 파일 시스템에는 실행 비트가 없다. Windows 에서 만든 .sh 를 커밋하면 git 은 그 파일을 100644 로 기록하고, 맥에서 받은 스크립트는 ./compile-check.sh 로 실행되지 않는다. 그래서 git 색인에 직접 실행 비트를 적었다. 저장소의 파일 목록을 보면 스크립트만 100755 로 올라가 있다. 내용은 한 글자도 바뀌지 않은, 모드만 바뀐 커밋이다.

맥 쪽 저장소는 빌드 전용 사본으로 정했다. 맥에서는 코드를 고치지 않는다. 스크립트는 받아서 돌리기만 하고, 게임 빌드 스크립트는 빌드 전에 원격 브랜치로 작업 트리를 되돌린다. 고칠 곳이 보이면 Windows 에서 고쳐 다시 보낸다. 고치는 곳이 한 군데여야 두 컴퓨터의 코드가 갈라지지 않는다.

아직 보이지 않는 번호

Android 를 옮기면서 versionCode 에 한 번 데었다. Play 는 한 번 쓴 번호를 다시 받지 않고, 기준은 내가 기억하는 번호가 아니라 Play 가 기억하는 번호였다. iOS 의 빌드 번호(CFBundleVersion)도 같다. 같은 버전 안에서 이미 올린 번호는 다시 올릴 수 없다.

그래서 처음부터 번호를 사람이 정하지 않게 했다. testflight.sh 는 App Store Connect API 로 이 앱의 모든 버전을 통틀어 가장 높은 빌드 번호를 묻고, 거기에 1을 더한다. TestFlight 만이 아니라 App Store 에 나간 빌드까지 포함한 값이다. 그 번호는 project.yml 을 고쳐 넣지 않고 빌드 명령의 인자 CURRENT_PROJECT_VERSION=$BUILD 로만 넘긴다. 저장소의 번호는 늘 1이고, 실제 번호는 빌드할 때 정해진다. 업로드마다 번호를 올리는 커밋이 생기지 않는다.

이걸로 끝난 줄 알았는데 게임에서 구멍이 보였다. 첫 TestFlight 빌드를 올리고 곧바로 한 번 더 올리려 하면 번호가 겹친다. 방금 올린 빌드는 Apple 쪽에서 처리가 시작될 때까지 API 목록에 나오지 않는다. 스크립트는 여전히 0을 최고 번호로 보고 1을 다시 쓴다.

LATEST=$(asc latest-build "$APPLE_ID")
# 방금 올린 빌드는 처리가 시작될 때까지 API 에 안 보인다 → 이 맥에 남은 archive 번호도 본다
for a in "$ARCHIVES"/WhoPays-*-*.xcarchive; do
    [ -e "$a" ] || continue
    n=${a##*-}; n=${n%.xcarchive}
    [ "$n" -gt "$LATEST" ] 2>/dev/null && LATEST=$n
done
BUILD=$((LATEST + 1))

Archive 는 앱이름-버전-빌드번호.xcarchive 로 맥에 남긴다. 그 파일 이름 끝의 숫자도 후보에 넣었다. API 가 아직 모르는 번호라도 이 맥이 만든 것이면 맥은 안다. 빌드하는 맥이 한 대뿐이라 쓸 수 있는 방법이다. 같은 이유로 같은 저장소를 두 곳에서 동시에 빌드하지 않는다는 규칙도 문서에 적었다.

게임은 이 스크립트로 1.8 버전의 첫 빌드를 TestFlight 에 올렸다. 아직 테스트 광고가 들어간 내부 테스트용이고, 실기기 확인은 지금부터다.

돌아보면 맥 미니가 해결한 건 빌드 시간만이 아니었다. 빌드가 내 손이 닿는 곳으로 오자 프로젝트 파일, 서명, 번호 같은 것들을 스크립트와 설정 파일로 끌어내릴 수 있었다. 맥 앞에 앉아서만 할 수 있는 일이 줄어들수록 Windows 책상에서 할 수 있는 일이 늘었다. 다음 편은 식단서랍을 WKWebView 에 담는 이야기다. Android 에서 놓았던 웹과 앱 사이의 다리를, 동기 호출이 안 되는 iOS 에서 다시 놓는 데서 시작한다. 푸시 알림, Sign in with Apple, App Store 심사 준비는 이 연재의 뒤쪽 편에서 따로 다룬다.