Unity 에서 iOS 로 빌드하면 앱이 나오지 않는다. Xcode 프로젝트가 나온다. 그리고 그 프로젝트는 다음 빌드 때 통째로 다시 만들어진다.

이 사실이 낯설지 않았다. TWA에서 WebView로 연재 첫 편에서, Bubblewrap 이 프로젝트를 다시 생성할 때마다 손으로 고친 targetSdk 와 위치 권한이 지워지던 이야기를 했다. 이번에는 Unity 가 그 자리에 있었다. 내보낸 Xcode 프로젝트의 Info.plist 에 권한 문구를 손으로 넣으면, 다음 빌드에서 그 줄은 없다.

옮길 게임은 「오늘 누가 쏘냐?」다. 폰 하나를 돌려 가며 하거나 같은 와이파이의 폰끼리 붙어서 벌칙을 정하는 Unity 6 파티게임이고, Android 에는 이미 나가 있다. 1편에서 정한 대로 Unity 프로젝트는 나누지 않았다. Android 와 iOS 가 같은 코드에서 나와야 「같이 하기」에서 서로 말이 통한다. iOS 차이는 같은 저장소 안의 분기와 빌드 스크립트로만 처리했다.

두 에디터를 같은 버전으로

빌드는 맥 미니에서 한다. 맥에는 Unity 6000.6.7f1 과 Xcode 27.1 을 두었다. Unity Hub 없이, iOS Build Support 모듈은 공식 설치 패키지를 받아 서명을 확인한 뒤 에디터 폴더에 직접 풀었다.

Windows 쪽 프로젝트는 그보다 한 단계 낮은 6000.5 였다. 같은 프로젝트를 두 에디터가 번갈아 열면 버전이 다른 쪽이 에셋을 자기 형식으로 다시 직렬화하고, 그 차이가 커밋에 섞여 들어온다. 그래서 Windows 를 맥에 맞춰 올렸다. 올리자 Android 빌드 스크립트가 먼저 깨졌다. 6000.6 에서 AndroidPlatformIconKind 의 Round 와 Legacy 가 폐지돼 경고가 아니라 오류가 됐다. 최소 SDK 가 26 이라 적응형 아이콘만으로 충분했으니 두 설정을 지웠다. Android APK 가 여전히 빌드되는 것을 확인하고 iOS 로 넘어갔다.

맥에서는 다른 쪽이 깨졌다. 맥의 Unity 에는 iOS 모듈만 있고 Android 모듈이 없다. 그런데 Android 빌드 스크립트는 디버그 기호 설정과 적응형 아이콘 설정에 UnityEditor.Android 네임스페이스를 쓴다. Unity 의 에디터 스크립트는 한 어셈블리로 묶여 컴파일되니, 한 파일이 없는 모듈을 참조하면 같은 어셈블리의 iOS 빌드 스크립트도 함께 쓰러진다.

static BuildReport Build(bool aab, string output, string version)
{
    // Android 모듈 API 코드는 Android 가 활성 플랫폼일 때만 컴파일된다 → 빌드 중에 플랫폼을 바꾸지 않게 먼저 막는다
    if (EditorUserBuildSettings.activeBuildTarget != BuildTarget.Android)
    {
        Debug.LogError("[AndroidBuild] 먼저 Android 로 전환하세요 (배치 모드는 -buildTarget Android).");
        return null;
    }
    ...
#if UNITY_ANDROID
    UnityEditor.Android.UserBuildSettings.DebugSymbols.level = Unity.Android.Types.DebugSymbolLevel.SymbolTable;
#endif

Android 모듈 API 를 쓰는 줄을 #if UNITY_ANDROID 로 감쌌다. 이것만 하면 새 문제가 생긴다. 활성 플랫폼이 iOS 인 에디터에서 Android 빌드 메뉴를 누르면, 감싼 코드가 빠진 채로 빌드가 돌아 디버그 기호 없는 AAB 가 나올 수 있다. 그래서 빌드 함수 맨 앞에서 활성 플랫폼이 Android 가 아니면 빌드를 거절한다. iOS 빌드 스크립트도 같은 모양이다. iOS 쪽 후처리 코드는 #if UNITY_IOS 안에 있으니, 활성 플랫폼이 iOS 가 아니면 시작하지 않는다.

조용히 꺼지던 곳들

Android 전용 코드는 이미 #if UNITY_ANDROID && !UNITY_EDITOR 로 막혀 있었다. 그래서 iOS 빌드는 처음부터 컴파일은 됐다. 다만 그 안에 있던 기능이 iOS 에서 소리 없이 꺼진다. 찾아보니 여섯 곳이었다. 광고, 결과 이미지 공유, 진동, 초성 퀴즈의 음성 입력, 「같이 하기」의 방 찾기, 화면 방향.

음성 입력은 첫 iOS 빌드에서 꺼 둔 채로 가기로 했다. iOS 에서는 기능이 없다고 대답하니 「말하기」 버튼이 숨고 입력칸만 남는다. 광고 쪽 iOS 분기는 다른 글에서 따로 다룬다. 나머지 셋은 외부 패키지 없이 UIKit 을 부르는 Objective-C++ 파일 하나, Assets/Plugins/iOS/WhoPaysNative.mm 로 해결했다. C# 은 [DllImport("__Internal")] 로 이 함수들을 부른다. iOS 의 IL2CPP 빌드는 네이티브 코드를 앱 바이너리에 정적으로 링크하니, 라이브러리 이름 대신 __Internal 이다.

진동은 대응이 깔끔하게 떨어지지 않았다. Android 는 30ms 짧게, 320ms 길게 진동 길이를 정해 울린다. iOS 는 길이를 정할 수 없다. 대신 "어떤 느낌의 진동인가" 를 고른다.

#elif UNITY_IOS && !UNITY_EDITOR
        if (ms < 100) WhoPays_HapticTap();   // UIImpactFeedbackGenerator (Medium)
        else WhoPays_HapticBuzz();           // UINotificationFeedbackGenerator (Error)
#endif

100ms 를 기준으로 짧으면 버튼을 누를 때의 톡, 길면 당첨이나 폭발 때의 부르르로 나눴다. 부르르 자리에는 알림 진동 중 가장 강한 오류 진동을 썼다. Handheld.Vibrate() 도 있지만 길이도 세기도 고를 수 없어 쓰지 않았다.

공유는 Android 에서 결과 이미지를 MediaStore 에 저장하고 그 주소로 공유 인텐트를 보냈다. iOS 는 PNG 를 앱의 임시 캐시 폴더에 쓰고 UIActivityViewController 를 띄운다. 사진 앱 저장은 따로 만들지 않고 공유 창의 「이미지 저장」에 맡겼다. 여기서 하나 걸리는 게 iPad 였다. iPad 에서 공유 창은 팝오버로 뜨는데, 팝오버가 어디를 가리킬지 정해 주지 않으면 앱이 종료된다. 화면 아래 가운데의 1포인트 사각형을 기준으로 잡아 아래 방향 화살표로 띄웠다. 공유 창을 띄우지 못하면 텍스트 공유는 Android 와 같이 클립보드 복사로 물러난다.

화면 방향은 Android 의 규칙을 그대로 가져왔다. 폰은 세로 고정, 큰 화면은 자유 회전이다. 폰을 돌려 가며 하는 게임이라 기울일 때 화면이 같이 돌면 불편하다. Android 는 가장 짧은 변이 600dp 이상인지로 큰 화면을 판단했다. iOS 에서는 SystemInfo.deviceModel 이 iPad13,1 처럼 오니, iPad 로 시작하면 큰 화면이다. 플레이어 설정에서는 네 방향을 모두 열어 두고, 실행 중에 CanvasFitter 가 기기를 보고 막을 방향을 정한다. 설정에서 세로로 고정해 버리면 iPad 에서 돌릴 길이 없다.

Info.plist 는 코드가 쓴다

여기서 처음 이야기로 돌아온다. iOS 앱이 카메라나 사진, 로컬 네트워크를 쓰려면 Info.plist 에 "왜 쓰는지" 를 적은 문구가 있어야 한다. 없으면 권한을 묻는 순간 앱이 종료되거나 기능이 막힌다. 그리고 Unity 가 내보낸 Info.plist 는 빌드마다 새로 만들어진다.

그래서 문구를 넣는 일을 빌드의 일부로 만들었다. [PostProcessBuild] 콜백은 Unity 가 Xcode 프로젝트를 다 쓴 직후에 불린다. 여기서 PlistDocument 로 파일을 열어 값을 넣고 다시 쓴다.

[PostProcessBuild(100)]
public static void OnPostprocessBuild(BuildTarget buildTarget, string path)
{
    var plistPath = Path.Combine(path, "Info.plist");
    var plist = new PlistDocument();
    plist.ReadFromFile(plistPath);
    var root = plist.root;
    root.SetString("NSLocalNetworkUsageDescription", "같은 와이파이에 있는 친구 폰과 「같이 하기」 방을 찾고 연결하는 데 사용해요.");
    root.SetString("NSPhotoLibraryAddUsageDescription", "게임 결과 이미지를 사진 앱에 저장할 때 사용해요.");
    root.SetBoolean("ITSAppUsesNonExemptEncryption", false);
    plist.WriteToFile(plistPath);
}

문구는 심사에서 읽히는 글이라 쓰임새를 구체적으로 적었다. 로컬 네트워크 문구가 특히 중요했다. iOS 14 부터는 같은 와이파이의 기기에 접속하기 전에 「로컬 네트워크」 권한 창이 뜨고, 문구가 없으면 접속이 막힌다. 「같이 하기」 방장에 붙는 TCP 연결이 여기에 걸린다. 방 찾기에 쓰던 UDP 브로드캐스트는 사정이 더 까다롭다. iOS 에서 브로드캐스트를 주고받으려면 Apple 에 신청해서 승인받는 별도 권한이 필요하다. 승인 전에는 원래 있던 서브넷 TCP 스캔으로 방을 찾는다. 유니캐스트라 그 권한이 필요 없다.

ITSAppUsesNonExemptEncryption 은 권한이 아니다. 앱이 표준 HTTPS 외의 암호화를 쓰는지 묻는 수출 규정 항목인데, 이걸 적어 두지 않으면 빌드를 올릴 때마다 App Store Connect 가 같은 질문을 한다. 광고 추적 권한 문구도 이 콜백에서 함께 넣는다.

아이콘도 같은 이유로 빌드 코드가 넣는다. App Store 가 알파 채널 있는 아이콘을 거부할 수 있어 배경을 채운 1024 원본을 따로 만들었다.

앱만 서명한다

TestFlight 에 올리려면 App Store 배포 프로파일로 서명해야 한다. 처음 계획은 Xcode 자동 서명이었다. 그런데 1편에서 만든 맥의 서명 준비, 빌드 전용 키체인의 배포 인증서와 빌드마다 API 로 받아 오는 App Store 프로파일이 이미 있었다. 게임도 그 방식을 따랐다.

업로드 스크립트는 프로파일을 받아 UUID 와 팀 ID 를 꺼낸 뒤, 환경변수로 Unity 내보내기에 넘긴다. Unity 쪽은 그 값이 있을 때만 수동 서명으로 바꾼다.

string profile = Environment.GetEnvironmentVariable("WHOPAYS_IOS_PROFILE_UUID");
bool manual = !simulator && !string.IsNullOrEmpty(profile);
PlayerSettings.iOS.appleEnableAutomaticSigning = !manual;
PlayerSettings.iOS.iOSManualProvisioningProfileID = manual ? profile : "";
PlayerSettings.iOS.iOSManualProvisioningProfileType = ProvisioningProfileType.Distribution;

여기까지 하면 archive 가 실패한다. Unity 가 내보낸 프로젝트에는 앱 타깃 Unity-iPhone 말고 UnityFramework 타깃이 하나 더 있다. 게임 코드 대부분이 이 프레임워크에 들어간다. 프로파일은 앱의 번들 ID 에 묶인 것이라 프레임워크에 쓸 수 없는데, 프레임워크가 자기 서명을 하려 들면 팀과 프로파일을 요구하며 멈춘다.

그런데 프레임워크는 원래 따로 서명할 필요가 없다. 앱에 넣는(Embed) 단계에서 앱의 인증서로 다시 서명되기 때문이다. 그래서 앞의 Info.plist 후처리 콜백에서 Xcode 프로젝트 파일도 열어, 수동 서명 빌드일 때만 UnityFramework 타깃에 CODE_SIGNING_ALLOWED=NO 를 넣었다. 광고 SDK 가 끌고 오는 CocoaPods 타깃들도 같은 이유로 Podfile 의 post_install 에서 서명을 껐다. 서명은 앱 타깃 하나에서만 일어난다.

환경변수가 없으면 자동 서명이다. Windows 에서 가짜 UUID 로 내보내 보고, 만들어진 프로젝트에 수동 서명과 빌드 번호가 들어가는 것을 먼저 확인했다.

시뮬레이터에서 먼저

서명까지 가기 전에 화면부터 보고 싶었다. 시뮬레이터는 서명이 필요 없다. 다만 시뮬레이터용 빌드는 아예 다른 SDK 로 내보내야 한다. 실기기용 프로젝트를 시뮬레이터에서 돌릴 수는 없다.

Unity 빌드 진입점에 -simulator 인자를 두었다. 이 인자가 있으면 iOSSdkVersion.SimulatorSDK 로, 맥 미니가 Apple 실리콘이니 arm64 로 내보낸다. 실기기·TestFlight 용 프로젝트와 섞이지 않게 출력 폴더도 따로 둔다. -release 와 같이 쓰면 바로 실패한다. 시뮬레이터 빌드는 업로드할 수 없으니 출시용일 이유가 없다.

맥의 빌드 스크립트는 Unity 를 배치 모드로 돌려 내보내고, pod install 을 거쳐 xcodebuild 로 시뮬레이터 앱을 빌드한 뒤 simctl 로 설치, 실행, 스크린샷까지 찍는다. 여기서 함정이 하나 나왔다. 광고 SDK 의 pod 들이 최소 iOS 12.0 으로 들어오는데, Xcode 27 은 그 값으로 빌드하지 않는다. 앱은 iOS 15 가 최소이니 Podfile 의 post_install 에서 모든 pod 의 최소 버전을 15.0 으로 올렸다. 서명을 끄는 줄도 같은 블록에 있다.

iPhone 17 Pro Max 시뮬레이터에서 타이틀 화면이 떴다. 광고 동의 단계의 안내 창도 떴는데, 아직 Google 샘플 설정이라 영어로 나온다. 같은 스크립트를 수동 서명 경로로 돌려, 테스트 광고가 들어간 1.8 버전 첫 빌드를 TestFlight 에 올렸다.

아직 확인 전인 게 더 많다. 실기기에서 공유 창과 진동이 어떻게 느껴지는지, 로컬 네트워크 권한 창을 지나 iPhone 방장에 Android 게스트가 붙는지, 반대로도 되는지. 시뮬레이터로는 볼 수 없는 것들이라 TestFlight 내부 테스트로 넘어간다.

돌아보면 이번에도 결론은 Bubblewrap 때와 같았다. 생성되는 프로젝트를 손으로 고치지 않는다. 고칠 일이 있으면 생성하는 쪽, 여기서는 Unity 의 빌드 스크립트와 후처리 콜백에 적는다. 그러면 Xcode 프로젝트가 몇 번을 다시 태어나도 같은 모습으로 나온다. 다음 편부터는 iOS 에서만 생기는 일들이 이어진다. 앱이 꺼져 있을 때도 알림이 뜨게 하는 푸시부터 시작한다.