LODY/기록

센디 iOS 앱에 React Native 얹기

박정환박정환

들어가며

React Native를 기존 iOS 앱에 통합하는 작업을 맡게 됐다는 얘기를 들었을 때, 머릿속에 제일 먼저 떠오른 장면이 있었다.

이걸요? 제가요? 왜요? 반응을 표현한 밈 이미지

왜였냐고 물으면 답은 간단하다. 팀에서 iOS를 보는 사람이 나 하나였으니까.

신규 기능과 실험은 iOS와 Android가 각각 네이티브로 따로 맞추고 있었다. 같은 스펙을 두 플랫폼이 같은 속도로 유지하기 어려웠고, 플랫폼마다 같은 화면을 다시 짜는 비용도 쌓이고 있었다. 생산성과 화면과 동작의 일관성을 같이 챙기려면, 화면과 로직을 한 코드베이스에서 짜고 기존 네이티브 앱에 부분적으로 얹을 수 있어야 했다.

팀에 React 개발자가 이미 있었고, 프론트엔드와 앱을 병행해야 하는 상황이라 React Native로 가기로 했다. UI와 시스템 API는 네이티브를 타는 방식이라, 이미 운영 중인 앱에 얹기에도 맞았다.

React Native 도입 방식

프로젝트에 React Native를 도입할 때는 크게 두 가지 방식이 있다.

  • 그린필드(Greenfield): React Native로 처음부터 새로 만드는 방식이다.
  • 브라운필드(Brownfield): 이미 운영 중인 네이티브 앱에 React Native를 얹는 방식이다.

그린필드는 현실적인 선택이 아니었다. 오랜 기간 쌓인 네이티브 코드와, 결제·인증·배포처럼 이미 구축된 핵심 로직과 리소스를 버리고 처음부터 다시 만드는 건 리스크가 너무 컸다. 새로운 기능을 빠르게 올리는 목적에도 맞지 않았다.

그래서 기존 프로젝트에 영향을 줄이고, 언제든 다시 되돌릴 수 있도록 브라운필드로 가기로 했다. 그 위에서 역할을 나눴다. 신규로 올리는 기능은 React Native로, 네이티브 플랫폼과 이미 공유하는 기능은 네이티브로 둔다.

브라운필드를 고른 순간부터 질문은 바뀌었다. 어떻게 얹을지가 아니라, 어디서 끊을지였다. 실제로 막혔던 지점은 아래와 같다.

  • Podfile 없이 완전히 갈 수 있는지, 아니면 CocoaPods를 다시 들여야 하는지
  • 네이티브와 React Native 중 어떤 프로젝트를 루트로 둘지
  • CocoaPods와 SPM처럼 서로 다른 의존성 관리 도구를 쓰면서 생긴 의존성 중복 문제
  • React Native와 네이티브 모듈을 잇는 브릿지를 어떻게 설계하고, Objective-C 기반 브릿지를 Swift 프로젝트에 어떻게 연결할 것인지
  • 타깃 분리와 컴파일 플래그로 React Native 기능 개발과 네이티브 기능 개발을 어떻게 나눌 것인지

Podfile 없이 가려던 첫 시도

이 무렵 iOS 진영과 React Native 진영 사이에는 묘한 엇박자가 있었다. iOS 쪽에서는 CocoaPods가 이미 archive 선언과 함께 지원 중단 수순에 들어갔고, 센디 iOS 네이티브 프로젝트도 SPM으로 의존성 관리를 전부 옮기고 CocoaPods 설정을 걷어낸 뒤였다. 그런데 React Native 공식 통합 가이드는 여전히 CocoaPods로 의존성을 연결하는 방식을 전제로 하고 있었다.

공식 가이드를 그대로 따르면, 애써 정리한 CocoaPods를 다시 들이고 관련 설정을 처음부터 다시 짜야 했다. React Native CLI로 pod install을 돌리면 곧 pod 지원이 끊긴다는 경고까지 뜨는 상황이었으니, 이 방향은 어차피 오래 갈 구조가 아니었다.

그래서 Podfile 자체를 아예 쓰지 않는 방향을 먼저 찔러봤다. 토스 오진성님의 CocoaPods 없이 React Native 개발하기에서 얻은 인사이트였다. 의존성 관리 도구를 하나로 모아두면 나중에 겪을 문제를 미리 피할 수 있어 보였다.

그러려면 New Architecture 전환 시점부터 짚어야 했다.

버전 구간아키텍처 상태
0.71 ~ 0.75opt-in 구조. 기본은 여전히 레거시
0.76 ~ 0.79새 아키텍처(Fabric, TurboModules)가 기본으로 켜짐
0.81 이상새 아키텍처만 남고 레거시 지원 종료

레거시가 언제 끊길지는 이미 정해져 있었으니, 방향은 새 아키텍처 쪽으로 잡는 게 맞았다. 그런데 그 방향으로 한 발짝 떼자마자 연달아 막혔다.

문제 1: 새 아키텍처가 요구하는 최소 iOS 버전

새 아키텍처 내부 구현이 쓰는 iOS API가 15.1 이상을 전제로 하고 있었다. 그런데 당시 유지 중이던 최소 지원 버전은 15.0이었다. 새 아키텍처를 쓰려면 최소 버전을 15.1로 올려야 했고, 그러면 15.0 이하 사용자는 앱을 더 이상 업데이트하지 못한다.

저울질할 것이 두 가지였다. 새 아키텍처와 레거시 아키텍처 중 어느 쪽으로 가도 나중에 마이그레이션 비용이 남는다는 점, 그리고 최소 버전을 올렸을 때 떨어져 나가는 사용자 규모가 감수할 만한 수준인지였다. 이 저울질의 결론은 뒤에서 다시 나온다. 최소 버전 문제와는 별개로, Podfile 없이 가는 방식 자체가 먼저 막혔다.

문제 2: Xcode 16.3에서 만난 Folly 에러

Podfile 없이 프리빌드 환경을 구성해 보는 과정에서, RCT-Folly 쪽에서 빌드 에러가 났다.

implicit instantiation of undefined template 'std::char_traits<unsigned char>'

Xcode 16.3의 libc++와 Folly 버전 사이의 호환성 문제였다. React Native 0.76.9부터 이 문제를 고친 패치가 들어갔다. 선택지는 두 가지, React Native를 0.76.9 이상으로 올리거나 Xcode를 16.2 이하로 유지하는 것이었다.

문제 3: RCTRootComponentView 런타임 크래시

Folly 문제를 우회해 프리빌드 패키지를 프로젝트에 물리고 실행했더니, React Native 화면을 부르는 시점에 크래시가 났다.

Exception NSException * "+[RCTRootComponentView supplementalComponentDescriptorProviders]: unrecognized selector sent to class ..."

nm으로 프리빌드 산출물을 뒤져보니 supplementalComponentDescriptorProviders 심볼 자체가 없었다. New Architecture가 기본으로 켜지면서 RCTRootComponentView 같은 클래스는 이 메서드를 갖고 있을 것을 전제로 코드가 짜여 있는데, 프리빌드 과정에서 Fabric 관련 모듈(React-Fabric, React-FabricComponents)이 온전히 빌드되지 않은 상태였다. 존재하지 않는 메서드를 호출하니 셀렉터를 못 찾는 크래시로 이어졌다.

문제 4: TurboModuleRegistry 등록 문제

React Navigation을 추가하고 나서는 Codegen이 만든 TurboModule이 TurboModuleRegistry에 등록되지 않아 런타임에 찾지 못하는 문제까지 겹쳤다. Codegen 산출물이 프리빌드 파이프라인에 온전히 반영되지 않고 있다는 신호였다.

판단: 완전한 Pod-less는 포기

네 가지 문제를 순서대로 만나고 나니 방향이 보였다. Podfile 없이 프리빌드만으로 New Architecture 기반 React Native를 통합하기엔 아직 일렀다. Fabric과 Codegen 산출물을 다루는 프리빌드 파이프라인 자체가 이 구성을 온전히 받쳐주지 못하고 있었다.

그런 거 하지 마라 반응을 표현한 밈 이미지

그래서 방향을 바꿨다. Podfile은 유지하되 그 영향 범위를 최대한 좁히는 쪽으로. 오진성님 아티클에서 가져온 원칙 자체는 버리지 않았다. 의존성 관리 도구를 하나로 모은다는 원칙은, 이후 CocoaPods와 SPM이 한 프로젝트에 같이 있어야 하는 상황에서 다른 형태로 다시 적용했다.

여기서부터는 서브모듈 구조를 어떻게 잡을지가 먼저였다.


어떤 프로젝트를 루트로 둘지

브라운필드로 가기로 한 뒤에도 바로 코드부터 붙일 수는 없었다. 이미 운영 중인 네이티브 앱이 iOS(Sendy_iOS)와 Android 양쪽에 있고, 새로 올릴 React Native 프로젝트(RNSendy)가 따로 있었다. 세 조각을 한 제품으로 돌리려면 어느 쪽을 루트로 둘지를 먼저 정해야 했다.

고민은 단순했다.

  • React Native를 master로 두면 공식 가이드를 그대로 따라가기 쉽지만, iOS·Android 각각의 배포·코드사인·네이티브 타깃 설정의 기준이 React Native 쪽으로 넘어간다.
  • 네이티브를 master로 두면 운영 중인 앱의 기준은 지키지만, React Native는 “붙는 쪽”이 되어 공식 구조와 어긋나고 경로·빌드 가정이 깨지기 쉽다.

iOS와 Android가 같은 RNSendy를 공유해야 한다는 점도 여기에 겹친다. 잘못된 쪽을 루트로 두면 되돌리기 비용이 커진다.

출발점: 세 조각

출발점은 세 개다.

  • 운영 중인 네이티브 iOS 레포(Sendy_iOS)
  • 네이티브 Android 프로젝트
  • 새로 올릴 React Native 프로젝트(RNSendy)
네이티브 iOS네이티브 AndroidReact NativeSendy_iOS/android/RNSendy/

셋을 어떻게 붙일지보다, 어느 쪽을 master로 둘지가 먼저였다.

  • React Native를 master로 두면 iOS·Android는 하위 폴더가 된다.
  • 네이티브를 master로 두면 React Native가 양쪽에 붙는 쪽이 된다.
RNSendy/Sendy_iOS/android/Sendy_iOS/android/RNSendy/React Native 프로젝트가 루트네이티브 프로젝트가 루트

React Native를 master로 두는 구조

React Native 브라운필드 가이드는 왼쪽, React Native를 master로 두는 쪽을 가정한다. React Native 프로젝트가 루트에 있고, ios/android/가 그 하위 폴더로 들어간다.

React Native 공식 가이드MyApp/ios/android/MyApp.xcworkspacePodfilepackage.jsonnode_modules/metro.config.js네이티브는 하위 폴더

Sendy에 그대로 대응하면 React Native 레포가 루트가 되고, 기존 Sendy_iOS는 그 아래로 들어간다.

공식 가이드를 Sendy에 그대로 대응하면SendyRN/Sendy_iOS/android/Sendy.xcworkspacePodfilepackage.jsonnode_modules/metro.config.js네이티브는 하위 폴더

React Native가 master라는 것은 React Native가 프로젝트의 메인 생명주기를 갖는다는 뜻이다. 루트에 이런 것들이 모인다.

  • package.json
  • node_modules
  • Metro 번들러 설정

React Native 의존성이 바뀌면 영향이 네이티브 쪽으로 번진다.

  • Pod 잠금 파일이 바뀐다
  • React Native 버전을 올리면 Podfile 스크립트도 바뀐다
  • package.json만 건드려도 네이티브 빌드 환경 전체가 영향을 받는다

네이티브를 master로 두고 React Native를 붙이기

Sendy는 위 구조로 뒤집을 수 없었다. React Native 루트 기준으로 다시 짜야 할 것들이 너무 많았고, 운영 중인 앱의 기준을 React Native 쪽으로 옮기는 리스크도 컸다. 네이티브를 루트로 유지하는 쪽이 맞았다.

그다음 문제는 붙이는 방식이다. iOS와 Android가 같은 React Native 코드를 써야 해서 RNSendy는 별도 레포로 두는 편이 자연스러웠고, 네이티브 레포에서는 이를 서브 프로젝트처럼 가져와야 했다. Git 서브모듈로 RNSendy를 붙였다. 서브모듈은 외부 레포를 특정 커밋에 고정해서 가져오기 때문에, React Native 쪽 변경이 네이티브 빌드에 즉시 영향을 미치지 않는다는 점이 이유였다.

서브모듈 구조가 만든 새 문제

네이티브를 master로 둔 채 RNSendy를 Git 서브모듈로 붙이면, RNSendy/가 네이티브 레포 트리 아래로 들어온다. package.jsonnode_modules는 그 안에만 있고, 루트에는 없다.

[submodule "RNSendy"]
	path = RNSendy
	url = https://github.com/Venditz/RNSendy.git
서브모듈만 붙인 직후Sendy_iOS/PodfileRNSendy/package.jsonnode_modules/Git 서브모듈

위 트리에서 node_modulesRNSendy/ 안에만 있다. 세 단계를 올라가면 Sendy_iOS/에 도달하고, 루트 package.json은 없다. require.resolve도 같은 이유로 루트 node_modules를 못 찾는다.

# Podfile (Sendy_iOS 루트)
 
require Pod::Executable.execute_command('node', ['-p',
  'require.resolve(
    "react-native/scripts/react_native_pods.rb",
    {paths: [process.argv[1]]},
  )', __dir__]).strip
 
target 'SendyRN' do
  config = use_native_modules!
 
  use_react_native!(
    :hermes_enabled => true,
    :fabric_enabled => true,
    :path => config[:reactNativePath],
    :app_path => "#{Pod::Config.instance.installation_root}/RNSendy"
  )
end
# node_modules/react-native/scripts/react_native_pods.rb
 
def find_config()
  prefix = File.join(__dir__, "..")   # → react-native/
  prefix = File.join(prefix, "..")   # → node_modules/
  prefix = File.join(prefix, "..")   # → Sendy_iOS/  ← 여기서 멈춤
 
  package_json = File.join(File.expand_path(prefix), "package.json")
  ...
end
[!] Invalid `Podfile` file: No such file or directory @ rb_sysopen - /path/to/Sendy_iOS/package.json
루트에는 package.json·node_modules가 없다Sendy_iOS/Podfilepackage.json 없음node_modules/ 없음RNSendy/package.jsonnode_modules/Git 서브모듈

문제는 CocoaPods·React Native 빌드 스크립트가 레포 루트를 기준점으로 삼는다는 점이다.

  • 루트 Podfile은 Node로 react_native_pods.rb를 resolve한다
  • use_react_native!는 내부에서 find_configpackage.json을 찾는다
  • find_config는 스크립트 위치에서 ..을 세 번 타고 올라간 뒤, 그 자리에서 package.json을 연다
find_config 탐색 경로.../react-native/scripts/.../react-native/.../node_modules/Sendy_iOS/package.json 없음......

두 선택지와 판단

선택지는 두 가지였다.

선택지 1: react_native_pods.rb의 경로 탐색을 고친다.

탐색 경로를 RNSendy/ 기준으로 바꾸면 당장의 오류는 해결된다.

# node_modules/react-native/scripts/react_native_pods.rb (패치 예시)
 
- config = ReactNative::Utils.find_config()
+ config = ReactNative::Utils.find_config("RNSendy")

한 줄만 고치면 되니 마음이 흔들렸다. 하지만 이 파일은 node_modules 안에 있다. npm install마다 덮어씌워지고, React Native 버전을 올릴 때마다 패치를 재검증해야 한다. 빌드 문제가 생길 때마다 "커스텀 스크립트 탓인가"가 첫 질문이 되는 구조를 만들고 싶지 않았다.

선택지 2: 심볼릭 링크로 경로를 맞춘다.

루트에서 RNSendy/node_modulesRNSendy/package.json을 가리키는 심볼릭 링크를 만들면, CocoaPods 스크립트 입장에서는 루트에 두 파일이 있는 것처럼 보인다. 스크립트는 건드리지 않는다.

심볼릭 링크 후Sendy_iOS/Podfilepackage.json(심볼릭 링크)node_modules/(심볼릭 링크)RNSendy/package.jsonnode_modules/Git 서브모듈

(추가) 링크 생성과 서브모듈 초기화는 매번 반복되므로 Makefile로 자동화했다.

define create_links
	@echo "Creating symbolic links..."
	ln -sfn RNSendy/node_modules node_modules
	ln -sfn RNSendy/package.json package.json
	@echo "Symbolic links created."
endef
 
define init_submodules
	@echo "Initializing submodules..."
	git submodule init
	git submodule update --remote --merge
	@echo "Submodules initialized."
endef
 
setup-staging:
	$(call init_submodules)
	$(call create_links)
	$(call install_rn_deps)
	$(call generate_env_staging)
	$(call install_ios_deps)
	@echo "Setup complete for staging."

setup-staging 타깃의 흐름은 아래 순서다. 이 순서가 중요하다. 서브모듈이 없으면 링크가 죽은 링크가 되고, 링크가 없으면 pod install이 실패한다.

  1. 서브모듈 동기화
  2. 심볼릭 링크 생성
  3. React Native 의존성 설치
  4. 환경 변수 생성
  5. pod install

이 구조로 네이티브 레포가 여전히 루트다. 새로운 팀원이 레포를 클론하면 make setup 한 번으로 위 준비까지 끝난다.

그런데 pod install이 성공했다고 빌드가 되는 것은 아니었다. Xcode가 React Native 소스를 컴파일하는 단계에서 다음 문제가 기다리고 있었다.


패키지 매니저가 두 개일 때: 심볼 충돌

이미 지난 상반기에 센디 iOS 네이티브 프로젝트는 CocoaPods의 deprecated 선언으로 의존성 관리를 SPM으로 전부 전환한 상태였다.

React Native를 붙이면서 CocoaPods가 다시 들어왔다. React Native 생태계의 서드파티는 여전히 Podspec 기반으로 배포된다. react-native-reanimated, react-native-lottie 같은 패키지는 네이티브 Pod을 포함하고 있고, use_react_native!가 이를 CocoaPods로 끌어온다. SPM을 유지하면서 CocoaPods가 다시 들어온 셈이다.

처음 빌드를 돌렸을 때 링크 단계에서 에러가 났다.

duplicate symbol '_OBJC_CLASS_$_LOTAnimationView' in:
  .../DerivedData/.../Lottie.o
  .../DerivedData/.../libLottie.a(Lottie.o)
ld: 2 duplicate symbols
clang: error: linker command failed with exit code 1

심볼 이름만 봐도 원인이 명확했다. Lottie가 두 번 링크되고 있었다.

왜 의존성 관리 도구를 하나만 써야 하는가

앞서 Podfile을 없애려다 참고했던 오진성님의 글에서 정리한 구조와 같다. 의존성 관리 도구를 두 개 이상 쓰면, 각 도구는 자기가 관리하는 의존성만 resolve한다. 상대가 무엇을 가져왔는지 모른다. 그래서 라이브러리 A를 CocoaPods로도 넣고 SPM으로도 넣으면, 프로젝트 안에 A가 두 번 들어온다.

AACocoaPodsSwift Package Manager

라이브러리 B가 A에 의존하고, 라이브러리 C도 A에 의존한다고 생각해 보자.

ABC

이 상태에서 B는 CocoaPods로, C는 SPM으로 관리하면 아래처럼 된다. A가 양쪽 그래프에 각각 한 번씩 들어온다.

ABACCocoaPodsSwift Package Manager

이렇게 A가 중복되면 빌드·아카이브가 실패한다. SPM은 Xcode 패키지 레이어에서 Package.resolved 기준으로 빌드하고, CocoaPods는 Pods/에 소스를 따로 복사한 뒤 libPods-*.a를 만든다. 공유 레지스트리나 잠금 파일이 없으므로 같은 심볼이 두 번 링크된다.

센디에서는 A가 Lottie였다. B는 CocoaPods로 들어온 react-native-lottie, C는 SPM으로 이미 쓰던 네이티브 Lottie 의존이다.

해결 선택지

선택지 1: Podspec에서 중복 의존성 제거 + 커스텀 레포 포크

B가 A에 의존하지 않도록 끊는 방법이다. B 레포를 포크하고, podspec에서 A 의존 항목을 지운 뒤, Podfile이 그 포크를 보게 한다. 그러면 CocoaPods 쪽 A는 사라지고, SPM이 가져온 A만 남는다.

ACBSwift Package ManagerCocoaPods

기술적으로는 가능하다. 다만 이 구조는 지뢰가 된다.

  • 나중에 C가 필요 없어져 SPM에서 빼면 A도 같이 빠질 수 있다. 그때 B는 점선으로만 A에 기대고 있었으므로 빌드가 깨진다.
  • 히스토리를 모르는 사람이 원본 B 레포로 되돌리면 A 중복이 다시 살아난다.
  • 포크 유지, 버전 병합, 온보딩 비용이 코드 리뷰에 잘 안 보이는 운영 부채가 된다.

그래서 이 선택지는 버렸다.

선택지 2: 타깃 분리

React Native 경계를 빌드 레벨에서 나누는 방법이다. React Native 통합 타깃(SendyRN)과 순수 네이티브 타깃(Sendy)을 분리하고, 네이티브 전용 타깃은 CocoaPods가 생성하는 Pods-*.xcconfig를 링크하지 않는다. 네이티브 타깃 입장에서는 CocoaPods Lottie가 보이지 않는다.

이 방식은 React Native 의존성 전체 격리에는 맞았다. 다만 Lottie 충돌은 여기서 끝나지 않았다. 타깃 분리와 브리지 소스 게이팅(RN_ENABLED)은 별도 이슈로 뒤에서 다룬다.

왜 타깃 분리만으로는 Lottie가 안 풀렸나

문제는 SendyRN 한 타깃 안에 두 경로가 동시에 필요하다는 점이다.

lottie-react-native Pod은 네이티브 쪽에서 모듈명 Lottie를 강제 링크한다. 동시에 앱 Swift UI(LottieView 등)와 SendyOrderForm 패키지도 Lottie를 쓴다. SendySendyRN은 같은 Sendy/ 소스 폴더를 fileSystemSynchronizedGroup으로 공유 컴파일하므로, 공유 소스의 import가 양쪽 타깃에서 모두 해석돼야 한다.

여기서 SPM의 한계가 걸린다. SPM은 소비자 타깃별 조건부 의존을 지원하지 않는다. "네이티브 타깃만 SPM lottie-ios를 쓰고, React Native 타깃은 Pod Lottie만 쓴다"처럼 한쪽만 끌 수 없다. 공유 소스가 import Lottie를 하는 한, 워크스페이스에 모듈명 Lottie가 SPM과 Pod 두 벌로 남는다.

아카이브 단계에서 충돌이 터졌다.

error: Multiple commands produce '…/Lottie/Metadata.appintents'
    note: Target 'Lottie'     (project 'Lottie')  ← SPM
    note: Target 'lottie-ios' (project 'Pods')    ← CocoaPods

포크도 싫고, 타깃 분리만으로는 아래를 동시에 못 푼다.

  • 공유 소스의 import Lottie
  • React Native Pod의 Lottie 강제 링크
  • SPM의 타깃별 조건부 의존 부재

남은 방법은 모듈명 자체를 갈라 놓는 것이었다.

채택: 모듈명을 갈라 놓은 Lottie

Airbnb Lottie를 그대로 쓰지 않고, 모듈 이름만 LottieSPM으로 바꿔 미리 빌드한 xcframework로 넣었다. 앱 Swift 코드는 import Lottie 대신 import LottieSPM을 쓴다. React Native 쪽 Pod이 끌어오는 모듈명은 그대로 Lottie다. 이름이 다르니 한 타깃 안에 두 경로가 있어도 심볼이 겹치지 않는다.

Lottielottie-react-nativeLottieSPM앱 Swift UICocoaPods 경로미리 빌드한 프레임워크

이 방식을 고른 이유는 아래와 같다.

  • 모듈 이름이 다르면 링크 단계에서 같은 심볼로 만나지 않는다. Lottie 4.x는 순수 Swift라, 예전에 ObjC 라이브러리처럼 고정된 런타임 이름에 묶여 있지도 않다. 소스 포크 없이 모듈명만 갈라도 격리가 된다.
  • 미리 빌드해 두면 Xcode가 그 바이너리를 다시 컴파일하지 않는다. SPM 소스로 두 번 빌드될 때 나던 메타데이터 산출물 충돌도 같이 사라졌다.
  • 결정은 레포 안에 이유를 남겨 두었다. 나중에 “공식 Lottie SPM으로 되돌리면 되지 않나” 하고 손대면 충돌이 다시 살아난다. 포크를 외부에 숨기지 않고, 아래를 같은 프로젝트 트리에 두었다.
    • 바이너리
    • 다시 만드는 방법
    • 되돌리면 안 되는 이유

대가는 있다.

  • Lottie 버전을 올릴 때마다 xcframework를 다시 만들어야 한다.
  • dynamic framework라 앱에 제대로 넣지 않으면 런타임에 죽는다.

다만 그 비용은 예측 가능하고, 왜 이렇게 뒀는지도 코드 옆에서 바로 보인다.

이걸로 React Native 통합 타깃의 모듈명 충돌은 사라졌다. 그런데 빌드가 통과되고 앱이 뜨기 시작했는데도 한 가지가 이상했다.


빌드를 망가뜨리지 않으려면

네이티브 코드를 전혀 건드리지 않았는데도 빌드가 느렸다. .swift 파일 하나를 수정하면 해당 파일만 다시 컴파일되어야 한다. 그런데 매번 수십 개의 파일이 함께 컴파일됐다. 작은 변경 후 빌드 완료까지 2분이 넘게 걸리는 상황이 반복됐다. 증분 빌드(incremental build)가 무효화되고 있었다.

증분 빌드 무효화란

Xcode의 증분 빌드는 마지막 빌드 이후 변경된 파일만 다시 컴파일하는 메커니즘이다. Xcode는 이를 판단하도록 각 컴파일 단위의 의존 관계와 타임스탬프를 DerivedData에 기록한다. 무효화는 이 기록이 깨지는 상황이다.

가능한 원인은 두 가지다.

  • 컴파일러 플래그가 빌드마다 달라지는 경우. 동일한 소스라도 플래그가 다르면 별개의 컴파일 단위로 취급된다.
  • 빌드 스크립트가 항상 실행되도록 설정된 경우. Xcode의 Run Script 빌드 페이즈는 입력/출력 파일을 선언하지 않으면 매번 실행한다.

DerivedData 오염은 이보다 더 심각한 증상이다. 결국 rm -rf ~/Library/Developer/Xcode/DerivedData가 의례처럼 자리잡는다. 이것이 정기적인 유지보수 명령으로 정착했다면 그만큼 캐시를 신뢰할 수 없는 상태였다는 뜻이다.

원인: React Native 컴파일 플래그가 네이티브 타깃의 빌드 조건을 바꿈

CocoaPods는 pod install 시점에 각 타깃에 대한 xcconfig 파일을 생성하고, 타깃은 이 xcconfig를 빌드 설정으로 참조한다. React Native CocoaPods 통합은 여기에 두 가지 레이어를 추가한다.

  • Bundle React Native code and images 스크립트. React Native JS 번들을 생성하는 이 스크립트는 기본 설정으로 입력/출력 파일이 선언되어 있지 않다. Xcode는 입력/출력이 없는 스크립트를 항상 실행이 필요한 것으로 취급하고, 스크립트가 실행될 때마다 후속 빌드 단계들이 재실행된다.
  • React Native xcconfig에 실린 컴파일 플래그. CLANG_ENABLE_MODULES, SWIFT_INCLUDE_PATHS, MODULEMAP_FILE 같은 설정이 xcconfig를 타고 전파된다. 이 플래그들이 네이티브 전용 Swift 타깃에 적용되면 컴파일 조건이 바뀌어 Xcode가 이전 빌드 캐시를 유효하지 않다고 판단한다.

xcconfig 상속 구조 자체가 문제가 아니라, 그 xcconfig에 실린 플래그가 네이티브 타깃의 컴파일 조건을 바꾼다는 점이 핵심이다.

타깃을 분리했지만, xcconfig 상속 관계까지 완전히 끊지는 않았다. Sendy-Staging-RN 타깃과 네이티브 전용 타깃이 같은 Pods-Sendy.xcconfig를 공유하고 있었다.

해결: xcconfig 분리와 스크립트 입출력 명시

해결 방향은 명확했다. React Native 빌드 설정이 네이티브 전용 타깃에 닿지 않도록 경계를 xcconfig 레벨까지 내려야 했다.

pod install은 기본적으로 모든 사용자 타깃의 base_configuration_reference를 Pods xcconfig로 덮어쓴다. 이걸 막으려면 post_install 훅에서 명시적으로 다시 할당해야 한다.

# Podfile
APP_TARGETS = %w[SendyRN]
 
post_install do |installer|
  react_native_post_install(installer, config[:reactNativePath], ...)
 
  installer.aggregate_targets.map(&:user_project).uniq.each do |project|
    project.targets.each do |t|
      next unless APP_TARGETS.include?(t.name)
 
      t.build_configurations.each do |bc|
        mapped = bc.name == 'Debug' ? 'Staging' : bc.name
        # SendyRN 타깃만 -React Native xcconfig, 나머지는 베이스 xcconfig
        suffix = t.name == 'SendyRN' ? '-RN' : ''
        path = "Sendy/ConfigurationFiles/Sendy-#{mapped}#{suffix}.xcconfig"
 
        file_ref = project.files.find { |f| f.path == path } || project.new_file(path)
        bc.base_configuration_reference = file_ref
      end
    end
    project.save
  end
end

xcconfig 파일 구조는 이렇다. 네이티브 타깃과 React Native 타깃이 서로 다른 파일을 바라본다.

Sendy/ConfigurationFiles/
├── Sendy-Staging.xcconfig      ← 네이티브 타깃 (Pods xcconfig 미포함)
├── Sendy-Staging-RN.xcconfig   ← React Native 타깃 전용
├── Sendy-Release.xcconfig
└── Sendy-Release-RN.xcconfig

Sendy-Staging.xcconfig에는 명시적으로 주석을 달았다.

// React Native Pods는 React Native 타깃 전용 xcconfig에서만 include한다.

Sendy-Staging-RN.xcconfig는 베이스를 상속한 뒤 Pods xcconfig를 추가로 include한다.

#include "Sendy-Staging.xcconfig"
 
// pod install 시점에 생성되는 파일명이 React Native 버전에 따라 달라질 수 있어 옵셔널 포함
#include? "../../Pods/Target Support Files/Pods-SendyRN/Pods-SendyRN.staging-rn.xcconfig"
#include? "../../Pods/Target Support Files/Pods-SendyRN/Pods-SendyRN.staging.xcconfig"
#include? "../../Pods/Target Support Files/Pods-SendyRN/Pods-SendyRN.debug.xcconfig"

#include?를 쓰는 이유는 React Native 버전에 따라 CocoaPods가 생성하는 xcconfig 파일명이 다를 수 있기 때문이다. 옵셔널 포함으로 파일이 없어도 에러가 나지 않고, 있는 파일만 적용된다.

이 구조로 네이티브 타깃은 CocoaPods가 생성한 React Native 플래그를 전혀 보지 못한다. pod install이 실행돼도 post_install 훅이 xcconfig 참조를 원래대로 되돌린다.

React Native 번들 빌드 스크립트에는 입력/출력 파일을 명시적으로 선언했다. $(DERIVED_FILE_DIR)/main.jsbundle을 출력으로 선언하면 Xcode는 해당 파일이 존재하고 최신 상태일 때 스크립트를 건너뛴다.

Makefile의 build 타깃은 React Native 스킴을 명시적으로 지정한다.

build:
	@echo "Building project..."
	xcodebuild -workspace Sendy.xcworkspace -scheme Sendy-Staging-RN -configuration Debug

-scheme Sendy-Staging-RN으로 React Native 통합 타깃을 명시한다. CI에서 네이티브 전용 스킴을 빌드할 때는 별도 타깃을 지정한다. 스킴 레벨에서도 빌드 경계가 명확하다.

이 시점부터 .swift 파일 하나를 수정하면 해당 파일만 컴파일된다. React Native 번들 스크립트는 JS가 변경됐을 때만 실행된다. 2분 이상 걸리던 증분 빌드가 단일 파일 수정 기준으로 20초 이내로 줄었고, DerivedData 전체 삭제는 clean 루틴에서 빠져서 이례적인 상황에서만 쓰이는 도구가 됐다. 빌드가 예측 가능해지자 다음 단계로 넘어갔다. 기능을 실제로 연결하는 일이었다.


TurboModule을 Swift로 연결하기

React Native 화면에서 기존 네이티브 코드를 호출해야 했다. 이미 Swift로 구현된 것들이었다.

  • 인증 토큰 갱신
  • 화면 닫기와 네비게이션
  • 딥링크 실행
  • 이벤트 로깅

연결만 하면 됐다.

여기서 앞서 미뤄뒀던 저울질의 답이 나온다. 최소 지원 버전을 15.1로 올리고 New Architecture 쪽으로 갔다. TurboModule을 선택한 이유가 그것이다. New Architecture(React Native 0.68 이후 도입된 새 렌더링·모듈 시스템으로, 현재 기본값)의 공식 네이티브 모듈 방식이고, JSI 위에서 JS와 네이티브가 직접 통신한다. 그런데 구현을 시작하자마자 구조적인 문제가 생겼다.

TurboModule은 왜 ObjC++로 써야 하는가

TurboModule의 핵심은 JSI(JavaScript Interface)다. JSI는 C++ 레이어다. Hermes나 V8 같은 JS 엔진과 네이티브 코드가 직접 메모리를 공유하며 통신하는 구조인데, 이 경계면이 C++로 정의된다.

Codegen은 TypeScript 스펙 파일(NativeSendy.ts)을 읽어서 C++ 타입이 포함된 헤더를 생성한다. 구체적으로 NativeSendySpecJSI라는 C++ 클래스와 facebook::react 네임스페이스의 구조체들이다. 이 헤더를 #include할 수 있는 파일은 .mm, 즉 ObjC++ 파일뿐이다.

NativeSendy.tsCodegenNativeSendySpec.h(C++ 타입)RCTNativeSendy.mmObjC++Swift 파일#include 가능#include 불가

Swift는 Bridging Header로 ObjC 타입을 import할 수 있지만, C++ 타입을 직접 참조할 수 없다. Swift/C++ Interop(Xcode 15+)이 있긴 하지만, React Native Codegen 헤더가 사용하는 std::function, 가변 템플릿, 매크로 조합이 Swift/C++ Interop의 현재 지원 범위를 벗어나기 때문에 실용적으로 사용할 수 없다. 결국 TurboModule 구현의 진입점은 반드시 .mm 파일이어야 한다.

ObjC++에서 Swift 구현체를 호출하면 충돌한다

Swift와 ObjC 사이의 상호운용은 두 방향 모두 단방향이다. ObjC에서 Swift를 사용할 때는 Xcode가 자동으로 [ModuleName]-Swift.h를 생성하고, Swift 파일에서 @objc로 표시한 클래스와 메서드가 이 헤더에 노출된다.

RCTNativeSendy.mm은 ObjC++ 파일이다. 여기서 Swift 구현체를 호출하려면 자동 생성된 Sendy-Swift.h를 import해야 한다. 그런데 이 .mm 파일에는 이미 Codegen이 생성한 C++ 헤더들이 include되어 있다. Sendy-Swift.h는 순수 ObjC 헤더로, Swift 컴파일러가 모든 public Swift 심볼을 ObjC 형식으로 표현한 결과다. 여기에 std::optional, std::shared_ptr, facebook::react 네임스페이스가 가득한 Codegen 스펙 헤더를 같은 번역 단위(translation unit)에서 함께 include하면, C++ 키워드와 순수 ObjC 헤더가 충돌한다. 헤더 순서를 바꿔봐도 해결되지 않는다. 구조적인 문제다.

NativeSendySpec.hC++ (Codegen)Sendy-Swift.h순수 ObjC (자동 생성)RCTNativeSendy.mm같은 번역 단위#include#include헤더 충돌

해결: 수동 ObjC 프로토콜로 계약을 정의한다

자동 생성 헤더에 의존하지 않으면 된다. 순수 ObjC로 인터페이스를 수동 정의했다.

NativeSendySwiftBridge.h가 그 계약이다.

@protocol NativeSendySwiftBridge <NSObject>
 
+ (instancetype)shared;
 
- (void)closeCurrentComponentWithParams:(NSDictionary * _Nullable)params
                                resolve:(RCTPromiseResolveBlock)resolve
                                 reject:(RCTPromiseRejectBlock)reject;
 
- (void)closeAndNavigateWithParams:(NSDictionary *)params
                           resolve:(RCTPromiseResolveBlock)resolve
                            reject:(RCTPromiseRejectBlock)reject;
 
- (void)reportJsErrorWithPayload:(NSDictionary *)payload
                         resolve:(RCTPromiseResolveBlock)resolve
                          reject:(RCTPromiseRejectBlock)reject;
 
- (void)executeDeepLinkWithUrl:(NSString *)url
                       resolve:(RCTPromiseResolveBlock)resolve
                        reject:(RCTPromiseRejectBlock)reject;
 
- (void)refreshTokenWithResolve:(RCTPromiseResolveBlock)resolve
                         reject:(RCTPromiseRejectBlock)reject;
 
- (void)logEventWithEvent:(NSString *)event
                   params:(NSDictionary * _Nullable)params
                  resolve:(RCTPromiseResolveBlock)resolve
                   reject:(RCTPromiseRejectBlock)reject;
 
@end

FoundationRCTBridgeModule만 import한다. C++ 헤더는 없다. 이 헤더는 어디서든 안전하게 include한다.

RCTNativeSendy.mm에서는 자동 생성 헤더 대신 전방 선언을 쓴다.

#import "NativeSendySwiftBridge.h"
 
@interface NativeSendySwiftImpl : NSObject <NativeSendySwiftBridge>
+ (instancetype)shared;
@end
 
@implementation RCTNativeSendy {
  NativeSendySwiftImpl *_swiftImpl;
}
 
- (instancetype)init {
  self = [super init];
  if (self) {
    _swiftImpl = [NativeSendySwiftImpl shared];
  }
  return self;
}

런타임에 NativeSendySwiftImpl 클래스가 존재할 것이라고 컴파일러에게 알린다. 자동 생성 헤더를 import하지 않으므로 C++ 충돌이 없다. 모든 메서드는 C++ 파라미터를 NSDictionary로 변환한 뒤 _swiftImpl로 위임한다. RCTNativeSendy.mm은 JSI 바인딩과 파라미터 변환만 담당한다.

Swift 쪽에서는 프로토콜을 conform한다.

@objc(NativeSendySwiftImpl)
@objcMembers
final class NativeSendySwiftImpl: NSObject, NativeSendySwiftBridge {
    static let shared = NativeSendySwiftImpl()
    // ...
}

@objc(NativeSendySwiftImpl)로 ObjC 런타임 이름을 명시한다. 이름이 일치해야 RCTNativeSendy.mm의 전방 선언과 연결된다.

NativeSendy.tsCodegenNativeSendySpec.h(C++)RCTNativeSendy.mmNativeSendySwiftBridge.h순수 ObjC 프로토콜NativeSendySwiftImpl.swift기존 네이티브 코드전방 선언NSDictionaryconformC++ / JSISwift

핵심은 NativeSendySwiftBridge.h가 C++ 세계와 Swift 세계 사이에 놓인 순수 ObjC 경계라는 점이다. C++는 이 경계를 넘지 못한다. Swift도 C++을 볼 필요가 없다.

이 구조로 ObjC++ 레이어를 얇게 유지했다. RCTNativeSendy.mm이 하는 일은 두 가지다.

  • JSI 바인딩
  • C++ 파라미터를 NSDictionary로 변환

비즈니스 로직이 없으므로 이 파일은 거의 변경될 일이 없다. 아래는 전부 Swift 안에 있다.

  • 인증 토큰 갱신 시 Combine과 RxSwift를 iOS 버전에 따라 분기
  • 네비게이션 스택을 탐색해 React Native 화면을 pop
  • AppDelegate의 로깅 셀렉터 호출

NativeSendySwiftBridge.h에 없는 메서드는 RCTNativeSendy.mm에서 호출할 수 없다. 새 기능을 추가할 때는 프로토콜을 먼저 수정해야 하고, Swift 구현체가 프로토콜을 conform하지 않으면 컴파일이 실패한다. 무엇을 노출할지에 대한 결정이 코드 구조에 반영된다.

단점도 있다.

  • ObjC 셀렉터 이름과 Swift 메서드의 @objc 어노테이션이 수동으로 맞아야 한다.
  • 컴파일러가 이것을 검증하지 않는다. 시그니처와 셀렉터 이름이 다르면 런타임 크래시다.

이 부분은 새 메서드를 추가할 때 컨벤션을 문서화하고 코드 리뷰에서 확인하는 것으로 커버하고 있다.

브리지를 같은 소스 트리에서 SendySendyRN이 공유하려면, 컴파일 시점에 React Native 쪽 코드를 켜고 끌 장치가 필요했다.


타깃마다 React Native 코드를 켜고 끄기

SendySendyRN은 같은 Sendy/ 소스를 공유 컴파일한다. React Native 헤더·브리지·Codegen 의존이 네이티브 전용 타깃에도 그대로 들어가면, CocoaPods Codegen 헤더가 없는 환경에서 컴파일이 깨진다. React Native 관련 코드는 소스 레벨에서도 네이티브 타깃으로 컴파일되지 않아야 한다.

같은 소스, 타깃마다 RN 코드 on/offSendy/ 공유 소스Sendy 타깃SendyRN 타깃RN_ENABLED 없음RN_ENABLED=1브리지·Codegen컴파일 안 됨브리지·Codegen컴파일됨네이티브 전용React Native 통합

RCTNativeSendy.h가 그 예시다. 파일 전체가 #if RN_ENABLED / #endif로 감싸여 있다. RN_ENABLED가 정의되지 않은 타깃에서는 이 헤더가 빈 파일처럼 동작한다.

#if RN_ENABLED
 
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
 
#if __has_include(<NativeSendySpec/NativeSendySpec.h>)
#import <NativeSendySpec/NativeSendySpec.h>
#elif __has_include(<NativeSendySpec/NativeSendy.h>)
#import <NativeSendySpec/NativeSendy.h>
#elif __has_include(<NativeSendy/NativeSendy.h>)
#import <NativeSendy/NativeSendy.h>
#elif __has_include("NativeSendySpec.h")
#import "NativeSendySpec.h"
#elif __has_include("NativeSendy.h")
#import "NativeSendy.h"
#else
#error "Codegen header for NativeSendy not found. Check codegenConfig.name / Pods."
#endif
 
NS_ASSUME_NONNULL_BEGIN
 
@interface RCTNativeSendy : NSObject <NativeSendySpec>
@end
 
NS_ASSUME_NONNULL_END
 
#endif

RN_ENABLED 플래그는 Xcode 타깃 빌드 설정의 GCC_PREPROCESSOR_DEFINITIONS에 정의한다. React Native 통합 타깃에는 RN_ENABLED=1, 네이티브 전용 타깃에는 해당 항목을 두지 않는다. 같은 .h.m 파일을 두 타깃이 공유하면서, 컴파일 시점에 플래그 하나로 React Native 관련 코드 전체를 켜고 끈다.

구조가 안정되자 다음 문제가 수면 위로 올라왔다. React Native 자체를 어떻게 유지보수할 것인가였다.


React Native 버전 관리 전략

구조가 잡히고 얼마 지나지 않아 예상치 못한 문제가 터졌다. React Native 마이너 버전 업데이트를 따라갔더니 서드파티 라이브러리가 호환되지 않았다.

React Native 마이너 업데이트의 실제 무게

RNSendy 서브모듈은 2025년 9월에 처음 도입됐다. 당시 package.json에 명시된 React Native 버전은 0.79.x 계열이었다. 현재 RNSendy/package.json에는 "react-native": "^0.83.0"이 명시되어 있다. 약 반 년 사이에 네 개의 마이너 버전이 올라갔다.

React Native는 semver를 따르지만 마이너 업데이트 사이에도 실질적인 브레이킹 체인지가 있는 경우가 많다.

  • Metro 설정 변경

  • Codegen 스펙 타입 조정

  • New Architecture 관련 빌드 플래그 변화

    0.82에서 0.83으로 올릴 때 react-native-reanimated가 React Native 내부 API 변경으로 인해 빌드 자체가 깨졌고, 해당 라이브러리의 호환 버전이 나오기까지 수 일간 React Native 빌드를 묶어두어야 했다. 라이브러리가 새 React Native 버전에 대응하는 타이밍의 공백이 실제로 비용이 된다.

서드파티 라이브러리의 호환성이 React Native 버전에 묶여 있다는 사실이 브라운필드 구조에서는 더 무겁게 작용한다. 네이티브 iOS 앱의 릴리즈 주기와 RNSendy 서브모듈의 업데이트 주기가 따로 있는데, React Native 버전을 올리는 순간 이 두 주기가 강제로 동기화된다. react-native-reanimated처럼 네이티브 Pod을 포함한 라이브러리가 있으면 연쇄가 이어진다.

  1. Podfile.lock이 바뀐다
  2. 네이티브 빌드 캐시가 무효화된다
  3. CI 빌드 시간이 늘어난다
  4. 예상치 못한 네이티브 빌드 오류가 따라온다

버전 정책 설계

판단은 하나로 수렴했다. React Native를 라이브러리처럼 취급한다. 앱 릴리즈와 분리된 버전 정책을 갖고, 업데이트는 의도적으로 결정된 시점에만 진행한다.

구체적으로 세 가지 룰을 세웠다.

  • React Native 버전 업데이트는 별도 태스크로 관리한다. 기능 개발 도중에 따라가지 않는다. 적용 전에 아래를 거친다.
    1. 서드파티 호환성 확인
    2. 네이티브 빌드 검증
    3. QA
  • git submodule 브랜치를 릴리즈 브랜치로 고정한다. git submodule은 특정 커밋 해시를 가리킨다. .gitmodulesbranch 필드는 git submodule update --remote 시 어느 브랜치를 추적할지를 나타낼 뿐, 커밋 해시가 자동으로 따라가지는 않는다. 선택한 방법은 태그 지점 브랜치다. release/1.1.0 같은 브랜치를 만들고, 서브모듈이 이 브랜치를 추적하도록 한다. 해당 브랜치에 커밋이 없는 한 서브모듈 참조가 움직이지 않는다.
  • 패치 버전은 즉시 적용하고, 마이너 버전은 검증 후 적용한다.

현재 .gitmodules 설정이다.

[submodule "RNSendy"]
	path = RNSendy
	url = https://github.com/Venditz/RNSendy.git
	branch = release/1.1.0
 32ff6c8a6093e81478f0fd12835bd28e06392d7e RNSendy (1.1.0)

이 정책을 세우고 나서 달라진 것이 하나 있다. React Native 버전 업데이트가 갑자기 "해야 하는 일"이 아니라 "할지 말지 팀이 결정하는 일"이 됐다. 정책이 없을 때는 새 React Native 버전이 나오면 반사적으로 따라가는 분위기가 있었다. 정책이 생기자 업데이트 타이밍과 리스크를 팀이 통제하게 됐다.