bedar-RN / iOS BUILD READINESS

iOS 빌드 리스크 정리

먼저 알아야 할 전제 — 왜 Android는 되는데 iOS는 안 되나

이 앱은 ios/·android/ 폴더를 레포에 두지 않는다(CNG 방식). 대신 app.json 설정만 남겨두고, 빌드할 때마다 expo prebuild가 Xcode·Gradle 프로젝트를 새로 만든다. 손으로 고친 네이티브 코드는 그때 전부 사라지므로, 계속 유지하려면 config plugin(prebuild 결과물을 자동으로 다시 고쳐주는 JS 스크립트)으로 심어야 한다.

이 레포에는 자체 제작 config plugin이 5개 있는데 5개 전부 Android만 고친다. 즉 지금 Android에서 동작하는 기능 중 상당수가 iOS에는 애초에 존재하지 않는다.

with-call-notification
카카오톡식 통화 수신 알림(응답/거절 버튼) + 잠금화면 위 표시 + 통화 중 상단 오버레이 바. Kotlin 네이티브 코드 4개를 prebuild마다 다시 심는다. → P1-2
with-livekit-setup
앱 시작 시 통화 오디오 엔진 초기화 코드를 주입. 없으면 통화 첫 시도에서 네이티브 크래시가 났던 이력이 있다. → P1-4
with-android-abi
APK에 4개 CPU 아키텍처를 모두 넣어 모든 안드로이드폰에 설치되게 고정. iOS 무관.
with-navbar-no-contrast
안드로이드 하단 내비게이션 바에 OS가 씌우는 회색 막 제거. iOS 무관.
with-updates-manifest-fresh
Gradle 캐시 버그로 새 이미지가 앱에 안 실리는 문제를 우회. iOS 무관.

등급 요약 카드를 누르면 아래 목록이 걸러진다

발견 항목

각 항목은 등장하는 것(이름 → 역할) → 왜 iOS에서 깨지나근거(실제 코드) → 조치 순서다. 제목 줄만 읽어도 무슨 일이 벌어지는지는 알 수 있게 했다.

P0-1 Firebase의 iOS 설정 파일(GoogleService-Info.plist)이 레포에 없다 푸시와 구글 로그인이 "우리 Firebase 프로젝트가 어디인지" 알아내는 파일. 이게 없으면 Xcode 프로젝트를 만드는 첫 단계에서 멈춘다. # 최우선확정

등장하는 것

GoogleService-Info.plist
Firebase가 발급하는 iOS 전용 설정 파일. 프로젝트 ID, API 키, 구글 로그인용 OAuth 클라이언트 ID가 들어있다. 안드로이드의 google-services.json과 같은 역할이고, 그 파일은 이미 레포에 있다.
@react-native-firebase/app
앱에서 Firebase를 켜는 패키지. 이 앱에선 FCM 푸시 때문에 들어와 있다.
@react-native-google-signin
구글 로그인 버튼의 네이티브 SDK. 로그인 후 받은 idToken을 bedar-api에 넘겨 자체 토큰으로 바꾼다.
URL scheme
구글 로그인 창이 끝나고 앱으로 되돌아오는 주소. 위 plist 안의 REVERSED_CLIENT_ID 값으로 만들어진다.

왜 iOS에서 깨지나

이 파일을 읽는 플러그인이 두 개다. @react-native-firebase/app(app.json:68)과 @react-native-google-signin(app.json:107). 후자는 옵션 없이 이름만 적혀 있어 Firebase에서 값을 읽어오는 경로로 동작한다.

즉 plist가 없으면 prebuild 단계에서 바로 실패하고, 억지로 넘겨도 되돌아올 주소가 없어 iOS 구글 로그인이 동작하지 않는다.

근거

// app.json:14 — 파일을 가리키고 있는데
"googleServicesFile": "./GoogleService-Info.plist"
// 실제 디렉터리에는 없음 (ls → MISSING)

// node_modules/@react-native-google-signin/.../withGoogleSignIn.js
options ? withGoogleSignInWithoutFirebase(...)   // 옵션 있으면 직접 지정
        : withGoogleSignIn(config)               // ← 현재 경로. plist 를 읽는다
            IOSConfig.Google.withGoogle
            IOSConfig.Google.withGoogleServicesFile
조치Firebase 콘솔 → 프로젝트 bedar-bf0ee에 iOS 앱(kr.bedar.app)을 추가하고 GoogleService-Info.plist를 받아 bedar-RN/ 루트에 둔다. .gitignore엔 걸려 있지만 .easignore에선 일부러 빼지 않았으므로 로컬에 두기만 하면 EAS 클라우드 빌드에 자동으로 올라간다(google-services.json과 동일한 방식).

딸린 확인: iOS 앱을 추가하면 iOS용 OAuth 클라이언트 ID가 새로 생긴다. 서버(bedar-api)가 구글 토큰을 검증할 때 웹 클라이언트 ID만 허용하고 있다면 iOS 것도 허용 목록에 넣어야 한다.

P0-2 OCR 라이브러리가 요구하는 iOS 최소 버전이 프로젝트 설정보다 높다 "iOS 15.5 이상에서만 쓸 수 있다"는 라이브러리를 iOS 15.1 설정에 넣어서 라이브러리 설치(pod install)가 거부된다. 그런데 그 버전을 올릴 도구가 레포에 없다. # 확정

등장하는 것

@react-native-ml-kit/
text-recognition
사진 속 한글을 기기 안에서 읽어내는 OCR. 이 앱에선 주소 검색에 쓴다 — 주소가 적힌 사진을 찍으면 글자를 뽑아 주소로 바꾼다(src/tools/ocr.tsAddressSearchModal). 한국어를 못 읽는 사용자를 위한 기능이다.
CocoaPods / podspec
iOS용 라이브러리 설치 도구. 각 라이브러리의 podspec 파일에 "iOS 몇 버전부터 쓸 수 있다"가 적혀 있고, 앱 설정이 그보다 낮으면 설치를 거부한다.
deployment target
앱이 지원하는 iOS 최소 버전. Expo SDK 54의 기본값은 15.1이다.
expo-build-properties
지금 설치돼 있지 않은 공식 플러그인. app.json에서 iOS 최소 버전 같은 네이티브 빌드 설정을 바꿀 수 있게 해준다. CNG 방식에선 이게 유일한 통로다.

왜 iOS에서 깨지나

Android는 이런 제약이 없어 그냥 빌드됐다. iOS는 CocoaPods가 The platform of the target ... is not compatible with RNMLKitTextRecognition으로 설치 자체를 중단한다.

근거

@react-native-ml-kit/text-recognition/RNMLKitTextRecognition.podspec
   s.platforms = { :ios => "15.5" }   ← 요구
expo-modules-core/ExpoModulesCore.podspec
   s.platforms = { :ios => '15.1' }   ← 현재 프로젝트
조치npx expo install expo-build-properties 로 설치한 뒤 app.json의 plugins에 아래를 추가한다.
["expo-build-properties", { "ios": { "deploymentTarget": "15.5" } }]
최소 버전을 15.5로 올리면 iOS 15.0~15.4 기기는 앱을 설치할 수 없게 된다(점유율은 매우 낮다). OCR을 iOS에서 포기하면 이 문제 자체가 없어진다 — P2-6 참고.
P0-3 빌드 설정 파일(eas.json)에 iOS 항목이 아예 없다 지금까지 안드로이드만 빌드해왔기 때문에, iOS는 "어떻게 빌드할지"가 한 줄도 안 적혀 있다. 애플 개발자 계정부터 새로 필요하다. # 확정

등장하는 것

eas.json
EAS Build(Expo의 클라우드 빌드 서비스)에 "이 프로필로 빌드해라"를 적어두는 파일. 맥 없이 iOS 앱을 만들 수 있게 해준다.
프로비저닝 프로파일 · 인증서
애플이 발급하는 서명 자격. 이게 있어야 실기에 설치되고 스토어에 올라간다. EAS가 대신 만들어줄 수 있다.
APNs 인증키(.p8)
애플 푸시 서버에 접속하는 열쇠. 이 앱은 Expo Push를 쓰므로 이 키를 EAS credentials에 등록해야 푸시가 나간다.
UDID 등록
iOS는 아무 폰에나 테스트 앱을 못 깐다. 기기 고유번호를 미리 등록해야 한다(eas device:create).

근거

eas.json  build.development / preview / production → 전부 "android" 키만
          submit.production → android(google-play-key.json) 만

필요한 준비물

  • Apple Developer Program 멤버십(연 $99) — 없으면 실기 설치조차 불가
  • App Store Connect에 앱 레코드 생성 + Bundle ID kr.bedar.app 등록
  • 배포 인증서 / 프로비저닝 프로파일 (EAS 대행 가능)
  • APNs 인증키(.p8)를 EAS credentials에 업로드
  • 테스트할 iPhone의 UDID 등록
  • 시뮬레이터로 확인하려면 "ios": { "simulator": true } 프로필 별도
P1-1 앱이 꺼져 있을 때 푸시를 받는 스위치가 안 켜져 있다 iOS는 "백그라운드에서 푸시를 받겠다"고 앱 설정에 명시해야만 꺼진 앱을 깨운다. 그 항목이 빠져 있어 백그라운드 통화 수신 코드가 실행 자체를 안 한다. # 최우선확정

등장하는 것

Info.plist
iOS 앱의 설정표. 권한 안내 문구, 백그라운드 동작 허용, 되돌아올 주소 등이 들어간다. 안드로이드의 AndroidManifest.xml에 해당한다.
UIBackgroundModes
Info.plist 안의 항목. 앱이 화면에 없을 때도 뭘 할 수 있는지를 나열한다. remote-notification은 "푸시로 앱을 깨울 수 있음", audio는 "소리를 계속 낼 수 있음"이다.
expo-notifications
푸시 권한 요청 + Expo Push 토큰 발급 + 안드로이드 알림 채널 등록을 담당. 서버는 이 토큰으로 알림을 쏜다.
setBackgroundMessageHandler
앱이 백그라운드·종료 상태일 때 푸시가 오면 실행되는 함수. 이 앱은 여기서 통화 수신 알림을 띄운다(src/tools/call-bg-handler.ts). index.js가 앱보다 먼저 등록할 만큼 중요한 경로다.

왜 iOS에서 깨지나

expo-notifications 플러그인은 옵션을 켜야만 UIBackgroundModes를 넣어준다. app.json:98–104에 그 옵션이 없어 항목이 통째로 빠진다. 그러면 iOS는 푸시가 와도 앱을 깨우지 않고, 위 핸들러는 한 번도 실행되지 않는다. 백그라운드·종료 상태의 call.invite(수신)·call.cancel(취소)이 전부 무동작.

근거

// expo-notifications/plugin/build/withNotificationsIOS.js:27
if (!enableBackgroundRemoteNotifications) {
    return config;      // ← 그냥 리턴. UIBackgroundModes 를 안 넣는다
}
조치app.json의 expo-notifications 설정에 옵션을 추가한다.
["expo-notifications", { ..., "enableBackgroundRemoteNotifications": true }]
다만 이걸 켜도 iOS 통화 수신은 여전히 안 된다 — 띄울 수신 화면 자체가 없기 때문이다(P1-2).
P1-2 통화 수신 화면·알림·오버레이가 전부 Android 전용 — iOS엔 존재하지 않는다 고객이 상담 전화를 받는 UI 일체가 Kotlin으로 짠 안드로이드 전용 코드다. iOS에선 크래시도 안 나고 그냥 아무 일도 안 일어난다. 애플 방식(CallKit)으로 새로 만들어야 하고 서버 발송 경로까지 바뀐다. # 최우선확정

등장하는 것

plugins/with-call-notification
(자체 제작)
Kotlin 파일 4개를 안드로이드 프로젝트에 심고, 앱 시작 코드에 등록하는 플러그인. 수신 알림·잠금화면 표시·통화 중 오버레이가 전부 여기서 나온다.
CallStyle 알림
안드로이드가 제공하는 전화 전용 알림 양식(큰 응답/거절 버튼). 카카오톡 통화 알림이 이 모양이다.
fullScreenIntent
안드로이드에서 잠금화면 위에 전체화면 수신 화면을 강제로 띄우는 방법. iOS엔 같은 개념이 없다.
SYSTEM_ALERT_WINDOW
안드로이드의 "다른 앱 위에 표시" 권한. 통화 중 상단에 떠 있는 바를 이걸로 그린다. iOS엔 대체 수단이 아예 없다.
CallKit
애플이 제공하는 "진짜 전화처럼 보이는 수신 화면" 프레임워크. 서드파티 앱이 잠금화면 전화 UI를 쓰려면 이것뿐이다.
PushKit / VoIP push
CallKit 전용 특수 푸시. 일반 푸시보다 우선 전달되지만, 받으면 반드시 CallKit을 띄워야 하고 안 띄우면 OS가 이후 수신을 끊어버린다.

왜 iOS에서 깨지나

플러그인이 하는 일은 세 가지 — Kotlin 파일 복사, 앱 시작 코드에 등록, 액티비티에 잠금화면 옵션 추가. 전부 안드로이드 전용 API다. iOS 대응물은 0개.

JS 쪽은 옵셔널 체이닝(?.)으로 호출해서 크래시는 나지 않는다. 대신 조용히 아무 일도 안 일어난다 — 이게 더 나쁘다. 로그도 안 남는다.

근거

src/tools/call-notification.ts:17,29   NativeModules.CallNotification → undefined
src/tools/call-overlay.ts:26           NativeModules.CallOverlay      → undefined
src/tools/call-permissions.ts:34,47    hasNative → false

결과적으로 iOS에서 사라지는 것: 수신 알림(응답/거절 버튼), 잠금화면 풀스크린 수신 화면, 통화 중 오버레이 바.

조치 — 이게 iOS 최대 공수다

같은 경험을 내려면 CallKit + PushKit을 새로 붙여야 한다. 그리고 이건 앱만의 일이 아니다 — VoIP push는 Expo Push로 못 보내고 APNs의 VoIP 토픽(kr.bedar.app.voip)에 별도 인증서로 직접 쏴야 하므로, bedar-api / bedar-admin의 통화 invite 발송 경로를 고쳐야 한다.

결정 필요iOS 1차 출시에 통화 수신을 넣을지부터 정해야 한다. 넣지 않는다면 통화 진입점을 숨기는 것보다, "앱을 켜 둔 상태에서만 통화 가능"으로 동작을 정의하고 안내 문구를 넣는 편이 낫다.
P1-3 통화 중 홈 버튼을 누르면 소리가 끊긴다 iOS는 "화면에서 내려가도 소리를 계속 내겠다"고 명시해야 한다. WebRTC 플러그인은 권한 문구만 넣어줄 뿐 그 항목을 안 넣는다. # 확정

등장하는 것

WebRTC
브라우저·앱에서 쓰는 실시간 음성/영상 통화 엔진. 이 앱의 CS 상담 통화가 이걸로 돌아간다.
@config-plugins/
react-native-webrtc
WebRTC를 iOS·Android 프로젝트에 설정해주는 공식 커뮤니티 플러그인.
UIBackgroundModes: audio
"화면에서 내려가도 소리를 계속 낼 수 있음" 표시. 음악 앱·통화 앱이 이걸 쓴다.

왜 iOS에서 깨지나

플러그인이 iOS에 하는 일은 두 가지뿐이다 — 카메라/마이크 권한 문구 넣기, bitcode 끄기. 백그라운드 오디오 항목은 넣지 않으므로, 통화 중 홈 버튼을 누르면 OS가 오디오 세션을 정지시킨다.

근거

// node_modules/@config-plugins/react-native-webrtc/build/withWebRTC.js
config = withPermissions(config, _props);   // iOS: 카메라/마이크 문구만
config = withBitcodeDisabled(config);       // iOS: bitcode off
// 나머지는 전부 Android 권한 목록
조치app.jsonios.infoPlistUIBackgroundModes: ["audio"]를 추가한다. 수신까지 하려면 "voip"도 함께 넣어야 한다(P1-2와 같이 처리).
P1-4 통화 오디오 엔진 초기화 코드가 Android에만 심어져 있다 안드로이드에선 이걸 빼먹어 통화 첫 시도에 크래시가 났던 이력이 있다. iOS엔 대응 코드가 없는데, iOS도 같은 초기화가 필요한지는 실기에서 확인해야 한다. # 확인필요

등장하는 것

@livekit/react-native
CS 상담 음성통화 자체. 통화방 접속, 마이크·스피커 연결을 담당한다(src/tools/use-cs-call.ts).
plugins/with-livekit-setup
(자체 제작)
앱 시작 지점에 LiveKitReactNative.setup() 한 줄을 주입하는 플러그인. 안드로이드에선 이게 없으면 통화 시도 시 네이티브 크래시가 났다(2026-06-23 실측).

왜 확인이 필요한가

이 플러그인은 안드로이드의 MainApplication.onCreate만 건드린다. iOS엔 대응 mod가 없다. iOS 쪽에서도 별도 오디오 세션 설정이 필요한지는 문서만으로는 확답이 안 되고, 실기 통화 1회면 판별된다.

검증 방법iOS 실기에서 CS 통화를 한 번 걸어본다. 연결은 되는데 소리가 안 나거나 즉시 종료되면 iOS용 AudioSession 설정을 추가해야 한다.
P1-5 푸시 관련 패키지 두 개가 iOS에서 서로 충돌할 수 있다 Expo 푸시와 Firebase 푸시가 둘 다 들어와 있다. iOS는 푸시 창구가 하나뿐이라 한쪽이 선점하면 다른 쪽이 토큰을 못 받는다. 게다가 실패해도 로그가 안 남는다. # 확인필요

등장하는 것

APNs
애플 푸시 서버. iOS 푸시는 무슨 방식을 쓰든 결국 여기를 거친다.
Expo Push 토큰
Expo가 중계해주는 푸시 주소. 서버는 이 토큰 하나로 보내고 Expo가 iOS(APNs)·안드로이드(FCM)로 나눠 보낸다. 이 앱의 기본 방식이다.
@react-native-firebase/
messaging
FCM을 직접 다루는 패키지. 이 앱에선 백그라운드 통화 수신 핸들러 하나 때문에 들어와 있다(P1-1).
firebase.json
RNFirebase의 iOS 세부 동작 설정 파일. 지금 레포에 없어서 전부 기본값으로 돈다.

왜 확인이 필요한가

  • RNFirebase messaging 플러그인은 Android 설정만 건드린다plugin/build/android/만 있고 iOS 디렉터리가 없다.
  • iOS에서 Expo Push 토큰을 받으려면 먼저 APNs 토큰을 받아야 하는데, RNFirebase가 그 창구를 선점하면 실패할 수 있다.

가장 큰 문제 — 실패가 안 보인다

// src/tools/push-token.ts:84-86
} catch {
  return null;      // ← 로그도 안 남는다
}
조치실기 검증 전에catch에 임시 로그를 넣을 것. 안 넣으면 iOS에서 푸시가 통째로 안 와도 원인을 추적할 단서가 없다.
P1-6 푸시 환경이 "개발용"으로 고정돼 있을 수 있다 iOS 푸시는 개발용·운영용 주소가 다르다. 설정을 안 하면 개발용이 기본값이라, 스토어 빌드에서 운영 푸시가 전부 실패하는 전형적인 함정이다. # 확인필요

등장하는 것

entitlements
애플이 승인해야 쓸 수 있는 기능 목록 파일(푸시·CallKit 등). 서명 자격과 짝이 맞아야 앱이 실행된다.
aps-environment
entitlements 안의 항목. development개발용(샌드박스) 푸시 서버, production이면 운영 서버를 쓴다. 둘은 토큰이 호환되지 않는다.

왜 확인이 필요한가

expo-notifications 플러그인의 기본값이 development이고, app.json에 별도 지정이 없다. 스토어·TestFlight 빌드에 이 값이 그대로 남으면 샌드박스 토큰이 발급돼 운영 푸시가 전부 실패한다.

다만 Xcode의 exportArchive 단계가 서명 자격 기준으로 이 값을 덮어쓰는 경우도 있어, 실제로 나온 .ipa의 entitlements를 직접 열어보는 것이 유일한 확답이다.

근거

// withNotificationsIOS.js:9   mode 기본값 = 'development'
config.modResults['aps-environment'] = mode;
조치 후보production 빌드에서 ["expo-notifications", { "mode": "production" }]로 덮어쓴다(app.config.js 분기 필요). 먼저 첫 TestFlight 빌드의 entitlements를 확인한 뒤 결정하면 된다.
P1-7 통화 중 음성 읽어주기(TTS)와 통화 오디오가 서로 먹을 수 있다 iOS는 앱 전체가 오디오 설정을 하나만 가진다. 통화 엔진·벨소리 모듈·TTS 세 개가 그 하나를 서로 뺏는 구조라 Android처럼 그냥 돌아갈 가능성은 낮다. # 확인필요

등장하는 것

AVAudioSession
iOS의 오디오 설정 하나짜리 전역 자원. "지금 앱이 녹음 중인지, 재생 중인지, 스피커인지 수화기인지"를 한 곳에서 정한다. 안드로이드는 스트림이 나뉘어 있어 이 문제가 없다.
react-native-tts
통화 중 번역된 상담원의 말을 음성으로 읽어주는 기능(use-cs-call.ts).
react-native-incall-manager
통화 연결음·벨소리, 얼굴을 대면 화면 끄기(근접센서), 통화 중 화면 꺼짐 방지.
LiveKit(WebRTC)
통화 본체. 마이크를 잡고 있는 주체다.

왜 확인이 필요한가

WebRTC가 오디오 세션을 playAndRecord로 점유한 상태에서 TTS를 돌리면, 한쪽이 무음이 되거나 통화 오디오가 끊기는 사례가 흔하다. 여기에 incall-manager도 같은 세션을 직접 만지므로 경합원이 하나 더 있다.

즉 iOS 통화는 LiveKit / InCallManager / TTS 3자의 오디오 세션 조율을 실기에서 맞춰야 한다. 코드 수정 없이 넘어갈 가능성은 낮다고 본다.

검증 방법iOS 실기에서 통화 연결 → 상담원 발화 → TTS 재생을 순서대로 시켜본다. 벨소리 단계·통화 중 단계에서 각각 소리가 나는지 따로 확인할 것.
P1-8 LINE 로그인이 끝나고 앱으로 되돌아오는 경로를 iOS에서 재검증해야 한다 이미 한 번 크게 헤맸던 부분이다. 앱이 꺼져 있다 켜지는 경우와 떠 있는 경우의 처리가 다르고, iOS는 그 타이밍이 Android와 다르다. # 확인필요

등장하는 것

딥링크 (bedar://)
웹에서 앱으로 되돌아오는 주소. LINE 로그인은 네이티브 SDK 없이 웹 OAuth + 이 딥링크로 처리한다.
warm / cold start
앱이 이미 떠 있을 때 돌아오는 경우(warm)와 꺼져 있다 새로 켜지며 돌아오는 경우(cold). 받는 코드가 서로 다르다.

왜 확인이 필요한가

이 앱은 딥링크 소비자를 반드시 하나만 둬야 한다는 제약이 있고(warm은 openAuthSessionAsync, cold는 _layoutgetInitialURL), 과거에 이걸 어겨 한참 헤맨 이력이 있다. iOS는 cold start 전달 타이밍이 Android와 달라 같은 코드로 같은 결과가 난다는 보장이 없다.

딸린 준비LINE Developers 콘솔에 iOS Bundle ID(kr.bedar.app) 등록이 안 돼 있으면 로그인 자체가 거부된다. 실기 테스트 전에 먼저 등록할 것.
P2-1 Apple 로그인이 없어서 심사에서 확정 리젝된다 구글·페이스북·LINE 로그인을 제공하는 앱은 Apple 로그인도 반드시 같이 제공해야 한다는 애플 규정(4.8). 지금 Apple 버튼은 "준비중"으로 숨겨져 있다. # 최우선확정

등장하는 것

심사지침 4.8
애플의 App Store 심사 규정. 제3자 소셜 로그인을 제공하면 Sign in with Apple도 동등하게 제공해야 한다.
expo-apple-authentication
Apple 로그인을 붙이는 공식 패키지. 현재 설치돼 있지 않다.

현재 상태

제공 중: Google · Facebook · LINE (+ 전화·이메일). Apple은 목록에 정의만 돼 있고 comingSoon 플래그로 화면에서 걸러진다.

근거

// app/login/index.tsx:276-281
{ key: 'apple', ..., comingSoon: true },
// :323
providers.filter((p) => !p.comingSoon)   ← 화면에서 아예 숨김
조치보류 중인 소셜 로그인 Phase 2(Apple)를 풀어야 한다. 서버(bedar-api)의 소셜 계정 매핑까지 손대야 하므로 가장 오래 걸린다 — 빌드 통과 작업과 병행 착수를 권한다.

회피책은 소셜 3종을 iOS에서 전부 숨기는 것뿐인데, 그러면 기존 소셜 가입자가 iOS에서 로그인할 방법이 없어진다.
P2-2 광고 식별자를 수집하면서 동의 팝업은 띄우지 않는 설정 iOS는 광고 추적에 동의 팝업이 의무다. 지금 설정은 수집은 켜고 팝업은 끈 상태라 심사에서 문제가 될 수 있다. # 확정

등장하는 것

react-native-fbsdk-next
이 앱에서 두 가지 일을 한다 — 페이스북 로그인(social-auth.ts)과 Meta 광고 전환 이벤트 전송(ads-events.ts, 주문 완료 시).
IDFA
iOS의 광고 식별자. 광고 성과를 사용자 단위로 추적하는 데 쓴다.
ATT (App Tracking Transparency)
"이 앱이 다른 앱·웹의 활동을 추적하도록 허용할까요?" 동의 팝업. iOS 14.5부터 추적 전에 반드시 띄워야 한다.

왜 문제인가

수집 스위치는 켜져 있는데 ATT 문구가 false팝업이 뜨지 않는다. 동의 없이 추적 데이터를 수집하는 구성으로 읽히고, 개인정보 신고 내용과 어긋나면 리젝 사유가 된다.

근거

// app.json:109-119
"advertiserIDCollectionEnabled": true,   ← IDFA 수집 ON
"autoLogAppEventsEnabled": true,
"iosUserTrackingPermission": false       ← ATT 문구 없음 = 팝업 없음
둘 중 하나로 정리(a) iosUserTrackingPermission에 안내 문구를 넣고 ATT 팝업을 띄운다 — 동의율만큼만 추적된다.
(b) advertiserIDCollectionEnabled: false로 내린다 — 팝업이 필요 없어지지만 Meta 광고 성과 추적이 줄어든다. 마케팅 판단이 필요하다.
P2-3 개인정보 신고 파일이 없는 서드파티 라이브러리가 7개 2024년부터 애플은 라이브러리마다 "무슨 정보를 쓰는지" 신고 파일을 요구한다. 없으면 업로드 후 경고 메일이 오고 항목에 따라 거절된다. # 확정

등장하는 것

privacy manifest
(PrivacyInfo.xcprivacy)
라이브러리가 어떤 개인정보와 시스템 API를 쓰는지 애플에 신고하는 파일. 라이브러리 제작자가 자기 패키지에 넣어야 한다.
ITMS-91061
이 파일이 빠졌을 때 App Store Connect가 보내는 경고 코드.
commonly used SDK
애플이 지정한 "특히 신고가 중요한 라이브러리" 목록. Facebook SDK가 여기 들어있어 서명된 신고 파일이 필수다.

근거 — 실제 node_modules 확인 결과

react-native-tts                        4.1.1     없음
react-native-incall-manager             4.2.1     없음
@react-native-ml-kit/text-recognition   2.0.0     없음
react-native-fbsdk-next                13.4.3     없음 (※)
@livekit/react-native                   2.11.1    없음
@livekit/react-native-webrtc          144.1.1     없음
expo-speech-recognition                 3.1.3     없음

※ 네이티브 FBSDK pod 자체에 들어있을 수 있어 pod 설치 후 재확인 필요
조치각 라이브러리의 최신 버전에 추가됐는지 먼저 확인하고, 없으면 앱 쪽에 PrivacyInfo.xcprivacy를 직접 넣는 config plugin을 만든다.
P2-4 iPad 지원이 켜져 있어 iPad 스크린샷과 동작이 의무가 된다 UI는 폰 전용으로 만들었는데 설정만 iPad 지원으로 돼 있다. 심사자가 iPad에서 직접 돌려본다. # 확정

왜 문제인가

app.json:12supportsTablet: true를 켜두면 App Store에 iPad 스크린샷 등록이 필수가 되고, 심사자가 iPad에서 실제로 실행해본다. 레이아웃이 폰 기준이라 깨져 보이면 그 자체가 리젝 사유다.

조치iPad를 지원할 계획이 없으면 false로 내리는 게 안전하다. 나중에 켜는 건 언제든 가능하다.
P2-5 암호화 수출 규정 답변이 설정에 없어 업로드마다 막힌다 한 줄이면 끝나는 설정인데, 없으면 업로드할 때마다 질문이 뜨고 답하기 전까지 TestFlight 배포가 멈춰 있다. # 확정

등장하는 것

ITSAppUsesNonExemptEncryption
"이 앱이 규제 대상 암호화를 쓰는가"를 미리 답해두는 항목. HTTPS만 쓰는 앱은 false가 정답이다.
조치app.json의 ios에 "config": { "usesNonExemptEncryption": false }를 추가한다.
P2-6 OCR 모델 5개 언어분이 전부 앱에 들어가 용량이 크게 는다 Android는 구글 서비스가 모델을 따로 내려받지만, iOS는 앱 파일 안에 통째로 들어간다. 한국어 하나만 쓰는데 중국어·일본어·데바나가리까지 강제로 딸려온다. # 확정

왜 문제인가

podspec이 5개 스크립트를 선택 불가로 전부 의존한다. 스크립트당 수십 MB 단위라 앱 용량이 크게 늘고, 셀룰러 다운로드 한도와 심사 대기시간에 영향을 준다. 실제 이 앱이 쓰는 건 한국어 하나다(src/tools/ocr.ts, TextRecognitionScript.KOREAN).

근거

# RNMLKitTextRecognition.podspec — 선택 불가, 5개 전부 의존
GoogleMLKit/TextRecognition           8.0.0   # Latin
GoogleMLKit/TextRecognitionChinese    8.0.0
GoogleMLKit/TextRecognitionDevanagari 8.0.0
GoogleMLKit/TextRecognitionJapanese   8.0.0
GoogleMLKit/TextRecognitionKorean     8.0.0   # ← 실제로 쓰는 건 이것뿐
선택지 3개(a) 필요한 스크립트만 남기도록 podspec을 패치(patch-package 또는 config plugin) — 가장 현실적.
(b) iOS 내장 Vision 프레임워크로 교체 — 용량 0MB, 무료, 한국어 인식도 지원하지만 네이티브 모듈을 따로 만들어야 한다. src/tools/ocr.ts가 이미 교체 지점을 격리해둬서 호출부는 안 건드려도 된다.
(c) iOS에선 OCR을 빼고 서버 비전 폴백만 쓴다 — 코드상 폴백 경로가 이미 있다(recognizeAddressViaServer).

정확한 증가분은 첫 아카이브의 App Thinning 리포트로 측정한다.
P3-1 ios.bitcode 설정이 이제 의미가 없다 Xcode 14부터 폐기된 기능이고, WebRTC 플러그인이 같은 처리를 이미 한다. #확정

app.json:19. 무해하지만 중복이라 지워도 된다. (bitcode는 앱을 애플 서버에서 재컴파일하게 하던 옛 기능이다.)

P3-2 안드로이드 권한 목록 13개가 통째로 두 번 적혀 있다 iOS와는 무관하지만 설정 파일을 읽을 때 눈에 걸린다. #확정

app.json:31–59에 같은 권한 13개가 정확히 두 번 나열돼 있다. 동작에는 영향이 없다(빌드 시 중복 제거됨).

P3-3 modules/digital-ink 가 빌드 찌꺼기만 남은 빈 폴더다 손글씨 인식을 시도했던 흔적으로 보인다. 소스도 없고 코드에서 참조하지도 않는다. #확정

android/build/만 남아 있고 소스·expo-module.config.json·JS 참조가 전부 0이다. Expo가 자동 연결하는 조건(설정 파일)이 없어 빌드엔 영향 없다. 삭제 대상.

P3-4 음성 읽어주기(TTS) 라이브러리가 두 벌 들어있다 단어장은 expo-speech, 통화는 react-native-tts를 쓴다. iOS에선 둘 다 같은 애플 엔진을 부른다. #확정

expo-speech단어장 발음 재생(WordbookCreatePane, wordbook/[folderId]), react-native-tts통화 중 번역 읽어주기(use-cs-call.ts)에 쓰인다. iOS에선 둘 다 AVSpeechSynthesizer를 부른다.

합칠 여지가 있지만, 통화 쪽은 오디오 세션 제어 때문에 지금 라이브러리가 필요할 수 있다 — P1-7 정리하면서 같이 판단할 것.

P3-5 KCP 결제 웹뷰가 외부 앱으로 넘어가는 처리를 안 한다 결제 중 PASS·은행 앱으로 넘어가야 하는 경우 iOS에선 그냥 멈춘다. 다만 이 경로가 아직 쓰이는지부터 확인이 필요하다. #확인필요

components/kcp-webview-modal.tsxonShouldStartLoadWithRequest(웹뷰가 앱 주소로 이동하려 할 때 가로채는 훅)도 Linking.openURL(외부 앱 실행)도 없다. iOS에서 인증 앱으로 넘어가야 하는 경로면 동작하지 않는다.

먼저 확인할 것: 현행 결제는 내부 예치금 방식이라 이 웹뷰가 실제로 열리는 경로가 남아 있는지부터 봐야 한다. 안 쓰이면 P3-5는 자동 소멸이다.

라이브러리별 판정

"앱에서 하는 일" 열은 이 패키지가 없어지면 무슨 기능이 사라지는지를 적은 것이다.

iOS 관점 판정 — 판정은 색과 함께 텍스트로도 표기된다
패키지버전앱에서 하는 일판정iOS에서 걸리는 것
@react-native-ml-kit/
text-recognition
2.0.0 사진 속 한글을 기기 안에서 읽는 OCR. 주소 검색에서 주소 사진을 글자로 바꾼다 차단 iOS 15.5 요구로 설치 거부(P0-2) · 언어모델 5종이 앱에 통째로 포함(P2-6) · 개인정보 신고 파일 없음
@react-native-firebase/
app · messaging
25.1.0 FCM 수신. 앱이 꺼져 있어도 통화 수신 푸시를 받아 코드를 깨운다 차단 iOS 설정 파일 없음(P0-1) · 플러그인이 Android만 설정 · Expo 푸시와 창구 충돌 가능(P1-5)
@react-native-google-signin/
google-signin
16.1.2 구글 로그인 네이티브 SDK 차단 돌아올 주소(URL scheme)를 iOS 설정 파일에서 읽는데 그 파일이 없음(P0-1)
expo-notifications0.32.17 푸시 권한 요청 · Expo Push 토큰 발급 · 안드로이드 알림 채널 설정 미비 백그라운드 수신 스위치 꺼짐(P1-1) · 푸시 환경이 개발용 기본값(P1-6)
plugins/
with-call-notification
(자체 제작)
로컬 통화 수신 알림 · 잠금화면 수신 화면 · 통화 중 오버레이 바 일체 iOS 대응물 없음 Kotlin 전용. CallKit + PushKit 신규 구현 필요, 서버 발송 경로도 변경(P1-2)
@config-plugins/
react-native-webrtc
13.0.0 통화 엔진(WebRTC)을 프로젝트에 설정해주는 플러그인 설정 미비 권한 문구만 넣음. 백그라운드 오디오 항목 없어 홈 누르면 소리 끊김(P1-3)
@livekit/react-native2.11.1 CS 상담 음성통화 본체. 방 접속·마이크·스피커 검증 필요 초기화 코드가 Android에만 주입됨(P1-4) · 개인정보 신고 파일 없음
@livekit/
react-native-webrtc
144.1.1 위 통화의 실제 전송 계층(WebRTC 네이티브) 검증 필요 대용량 네이티브. 개인정보 신고 파일 없음
react-native-tts4.1.1 통화 중 번역된 상담원 말을 음성으로 읽어줌 검증 필요 통화 오디오와 세션 경합(P1-7) · 구형 방식이라 New Architecture 호환 레이어로 동작
react-native-incall-manager4.2.1 통화 연결음·벨소리, 얼굴 대면 시 화면 끄기, 화면 꺼짐 방지 검증 필요 같은 오디오 세션을 직접 만져 경합원이 하나 더 늘어남(P1-7)
react-native-fbsdk-next13.4.3 페이스북 로그인 + 주문 완료 시 Meta 광고 전환 이벤트 전송 심사 리스크 광고 식별자 수집 ON인데 동의 팝업 OFF(P2-2) · 애플 지정 SDK라 신고 파일 필수(P2-3)
expo-speech-recognition3.1.3 단어장에서 음성으로 단어 입력(받아쓰기) 정상 iOS 지원. 마이크·음성인식 권한 문구도 이미 들어가 있음
expo-speechSDK 54 단어장에서 단어 발음 재생 정상 애플 기본 음성 엔진 사용. P3-4(중복) 참고
react-native-webview13.15.0 다음 우편번호 검색 · 지도 핀 · KCP 결제창을 앱 안에 띄움 정상 외부 앱 이동 처리만 확인 필요(P3-5)
react-native-svg15.12.1 아이콘·도형 렌더링 정상New Architecture 정식 지원
react-native-reanimated
+ react-native-worklets
4.1.7
0.5.1
화면 전환·제스처 애니메이션 정상 Reanimated 4는 New Architecture 필수 — newArchEnabled: true라 충족
expo-* (camera / location /
image-picker / secure-store …)
SDK 54 카메라·위치·사진첩·보안저장 등 기본 기기 기능 정상 권한 안내 문구가 플러그인 설정에 이미 들어가 있음
expo-build-properties미설치 app.json에서 iOS 최소 버전 등 네이티브 빌드 설정을 바꾸는 통로 설치 필요 이게 없어서 P0-2를 해결할 수단이 없다

조치 순서

  1. 일단 빌드가 통과하게

    Firebase 콘솔에서 iOS 앱을 만들어 설정 파일을 받고, iOS 최소 버전을 올릴 도구를 설치하고, 애플 개발자 계정을 준비해 eas.json에 iOS 프로필을 추가한다.

    1) Firebase 콘솔 → iOS 앱 추가 → GoogleService-Info.plist → bedar-RN/ 루트
    2) npx expo install expo-build-properties
       app.json plugins 에 ["expo-build-properties", { "ios": { "deploymentTarget": "15.5" } }]
    3) Apple Developer Program 가입 → eas.json 에 ios 프로필 추가

    P0-1 · P0-2 · P0-3 — 맥이 있으면 npx expo prebuild -p ios --clean을 먼저 돌려 pod install까지 눈으로 확인하면 클라우드 빌드 왕복이 크게 줄어든다

  2. iOS 설정표(Info.plist) 보강

    백그라운드 오디오·푸시 수신을 켜고, iPad 지원을 끄고, 수출 규정 답변을 미리 넣는다.

    "ios": {
      "supportsTablet": false,
      "config": { "usesNonExemptEncryption": false },
      "infoPlist": { "UIBackgroundModes": ["audio", "remote-notification"] }
    }
    plugins: ["expo-notifications", { ..., "enableBackgroundRemoteNotifications": true }]

    P1-1 · P1-3 · P2-4 · P2-5

  3. 실기 검증

    iPhone 1대로 순서대로: 푸시 토큰 발급 → 포그라운드 통화 → 백그라운드 통화 → 통화 중 TTS 재생 → LINE 로그인.

    검증을 시작하기 전에 push-token.ts:84의 빈 catch에 임시 로그를 먼저 넣을 것. 지금 구조면 푸시가 통째로 실패해도 아무 흔적이 남지 않는다.

    P1-4 · P1-5 · P1-6 · P1-7 · P1-8

  4. 심사 대비

    Apple 로그인 구현이 가장 오래 걸린다. 서버(bedar-api)의 소셜 계정 매핑까지 손대야 하므로 1단계와 병행 착수를 권한다. 광고 식별자 정리와 개인정보 신고 파일도 여기서 함께.

    P2-1 · P2-2 · P2-3

  5. 통화 수신

    CallKit + PushKit + 서버 VoIP push 발송 경로. iOS 1차 출시에 통화 수신을 넣을지부터 결정해야 한다. 넣지 않는다면 진입점을 숨기는 것보다 "앱을 켜 둔 상태에서만 통화 가능"으로 동작을 정의하고 안내를 넣는 편이 낫다.

    P1-2

용어 사전

이 문서에 나오는 iOS·Expo 용어를 한 줄로 정리했다.

CNG · prebuild
ios/·android/ 폴더를 레포에 두지 않고 빌드할 때마다 새로 생성하는 방식. 손으로 고친 네이티브 코드는 사라진다.
config plugin
prebuild가 만든 네이티브 프로젝트를 자동으로 고쳐주는 JS 스크립트. 이 레포엔 자체 제작이 5개 있고 전부 Android 전용이다.
Info.plist
iOS 앱의 설정표. 권한 안내 문구, 백그라운드 동작, URL 스킴 등. 안드로이드의 AndroidManifest.xml에 해당.
entitlements
애플이 승인해야 쓸 수 있는 기능 목록(푸시·CallKit 등). 서명 자격과 짝이 맞아야 한다.
CocoaPods · podspec
iOS 라이브러리 설치 도구와 그 명세 파일. "iOS 몇 버전부터"가 여기 적혀 있고, 앱 설정이 낮으면 설치를 거부한다.
deployment target
앱이 지원하는 iOS 최소 버전. Expo SDK 54 기본값은 15.1.
APNs
애플 푸시 서버. iOS 푸시는 어떤 방식이든 결국 여기를 거친다.
FCM
구글의 푸시 중계 서비스. iOS로 보낼 때도 최종적으로 APNs를 거친다.
Expo Push 토큰
Expo가 중계하는 푸시 주소. 서버는 이것 하나로 보내고 Expo가 APNs/FCM으로 나눠 보낸다. 이 앱의 기본 방식.
CallKit · PushKit
iOS에서 "진짜 전화처럼" 수신 화면을 띄우는 애플 프레임워크와 그 전용 푸시. 잠금화면 전화 UI를 쓰려면 이 조합뿐이다.
AVAudioSession
iOS의 앱 전체 오디오 설정 하나짜리 자원. 통화·벨소리·TTS가 이걸 서로 뺏는다.
privacy manifest
라이브러리가 어떤 개인정보·시스템 API를 쓰는지 애플에 신고하는 파일(PrivacyInfo.xcprivacy). 2024년부터 필수.
ATT · IDFA
광고 식별자(IDFA)와 그 사용을 묻는 동의 팝업(ATT). 동의 없이 추적하면 리젝.
New Architecture
React Native의 새 네이티브 연결 방식. 구형 라이브러리는 호환 레이어로 돌아가 실기 확인이 필요하다.
EAS Build
Expo의 클라우드 빌드 서비스. 맥 없이 iOS 앱 파일을 만들 수 있다.
TestFlight
애플의 베타 배포 채널. 스토어 정식 등록 전에 테스터에게 설치시킬 수 있다.

남은 결정 사항

iOS 1차 출시에 통화 수신을 넣는가?

넣으면 CallKit + PushKit + 서버 VoIP push 경로가 통째로 추가된다. iOS 작업량의 절반 이상이 여기다.

Apple 로그인 보류를 푸는가?

안 풀면 iOS 출시 자체가 불가능하다. 심사에서 확정 리젝.

OCR을 iOS에서도 유지하는가?

유지하면 최소 버전 15.5 + 앱 용량 증가. iOS 내장 Vision으로 바꾸면 용량 0이지만 네이티브 모듈을 만들어야 한다.

Meta 광고 이벤트를 iOS에서 유지하는가?

유지하면 ATT 동의 팝업을 넣어야 하고, 동의율만큼만 추적된다.