LODY/기록

달리는 센디 앱에 React Native 얹기

박정환박정환

들어가며

안녕하세요. 센디 플랫폼 스쿼드에서 iOS를 담당하는 로디입니다.

센디는 최근 운영 앱에 React Native를 부분적으로 도입했습니다. 신규 기능과 실험은 iOS·Android가 각각 네이티브로 맞추고 있었는데, 같은 스펙을 두 플랫폼이 같은 속도로 유지하기 어려웠다. 생산성과 화면·동작의 일관성을 함께 챙기려면 새로 붙는 영역부터 RN으로 묶는 편이 낫다고 보았다.

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

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

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

저희는 기존 프로젝트에 영향을 줄이고, 언제든 다시 되돌릴 수 있도록 브라운필드 방식으로 ReactNative를 도입했습니다.

이 글에서는 도입 과정에서 겪은 문제 상황들을 각각 어떻게 풀었는지 차례대로 적어둡니다.

  • 기존 프로젝트와 새로 붙이려는 프로젝트를 어떻게 함께 관리할 것인지. (모노레포, 멀티레포)
  • CocoaPod과 SPM 서로 다른 의존성 관리 도구를 사용하면서 발생한 의존성 중복 문제
  • React Native와 네이티브 모듈 간 상호작용을 위한 브릿지 모듈은 어떻게 설계하고, Objective C 기반의 브릿지 모듈을 Swift 기반의 프로젝트에 어떻게 연결할 것인지.

레포 구조: 두 프로젝트를 어떻게 공존시킬 것인가

RN이 루트에 있으면 생기는 일

RN 공식 문서가 권장하는 브라운필드 통합 구조는 RN 프로젝트가 루트에 있고 그 하위에 ios/ 폴더가 들어가는 형태입니다.

MyApp/                  ← RN 프로젝트가 루트
├── package.json
├── node_modules/
├── metro.config.js
├── ios/                ← 네이티브는 하위 폴더
│   ├── MyApp.xcworkspace
│   └── Podfile
└── android/

그런데 Sendy는 그 반대였습니다. App Store 배포 파이프라인, 코드사인 설정, 다수의 네이티브 타깃, AppDelegate — 모든 것이 이미 네이티브 Xcode 프로젝트를 중심으로 굴러가고 있었습니다.

Sendy_iOS/              ← 네이티브 Xcode 프로젝트가 루트
├── Sendy.xcworkspace
├── Sendy.xcodeproj
├── Podfile
├── fastlane/
└── scripts/

RN이 루트에 위치한다는 것은 RN이 프로젝트의 메인 생명주기를 갖는다는 뜻입니다. package.json이 루트에 있고, node_modules가 루트에 있고, Metro 번들러 설정이 루트에 있습니다. RN 의존성이 바뀌면 Pod 잠금 파일도 바뀌고, RN 버전을 올리면 Podfile 스크립트도 바뀝니다. RN 쪽에서 package.json만 건드려도 네이티브 빌드 환경 전체가 영향을 받습니다.

네이티브 레포가 Source of Truth를 유지하면서 RN을 올리려면, RN이 서브 프로젝트처럼 붙어야 했습니다.

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

RN 코드는 별도 레포(RNSendy)에서 개발되고 있었습니다. iOS·Android가 같은 RN 코드를 공유하는 구조였기 때문에 처음부터 독립 레포로 분리되어 있었고, 네이티브 레포에서는 이를 Git 서브모듈로 참조하는 방식을 택했습니다. 서브모듈은 외부 레포를 특정 커밋에 고정해서 가져오기 때문에, RN 쪽 변경이 네이티브 빌드에 즉시 영향을 미치지 않는다는 점도 이유였습니다.

서브모듈을 붙이면 네이티브 레포 루트 아래 RNSendy/ 디렉토리로 들어오는 구조가 됩니다.

Sendy_iOS/              ← 네이티브 레포 루트
├── Sendy.xcworkspace
├── Podfile             ← use_react_native! 탐색 시작점
├── RNSendy/            ← Git 서브모듈
│   ├── package.json   ← 실제 위치: 루트에서 한 단계 아래
│   └── node_modules/
└── (package.json 없음) ← 탐색 실패

문제는 CocoaPods와 RN의 빌드 스크립트였습니다. Podfile에서 RN을 참조할 때 사용하는 use_react_native! 헬퍼는 내부적으로 find_config 헬퍼를 호출해 package.json을 탐색합니다.

# node_modules/react-native/scripts/react_native_pods.rb
 
def find_config()
  # __dir__ 는 이 스크립트 파일의 위치 (node_modules/react-native/scripts/)
  # 거기서 상위로 올라가며 package.json 을 탐색
  prefix = File.join(__dir__, "..")          # → node_modules/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

심볼릭 링크를 만들기 전, node_modules 자체가 RNSendy/ 안에 있습니다. __dir__에서 세 단계를 올라가면 Sendy_iOS/에 도달하고, 거기서 package.json을 찾습니다. 파일이 없으니 탐색이 실패합니다.

[!] Invalid `Podfile` file: No such file or directory @ rb_sysopen - /path/to/Sendy_iOS/package.json

두 선택지와 판단

선택지는 두 가지였습니다.

선택지 1: 벤더 스크립트의 경로 탐색 로직을 수정한다.

use_react_native! 헬퍼는 Ruby로 작성된 CocoaPods 플러그인 스크립트입니다. 탐색 경로를 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이나 yarn install을 실행할 때마다 덮어씌워집니다. 패치를 살아남게 하려면 patch-package 같은 도구로 패치 파일을 별도 관리하거나, 스크립트를 레포에 복사해두어야 합니다.

RN 0.82 → 0.83 업데이트 시
 
1. react_native_pods.rb 변경 여부 확인
2. 패치 파일과 diff 비교
3. 충돌 있으면 수동 병합
4. pod install 후 빌드 검증
5. 문제 발생 → 패치가 원인인지 RN 자체 문제인지 먼저 의심

이 선택지를 포기한 이유는 유지보수 비용입니다. RN 버전을 올릴 때마다 해당 스크립트가 바뀌었는지 확인하고 패치를 재검증해야 합니다. 어떤 빌드 문제가 생기든 "커스텀된 스크립트가 영향을 미친 건 아닌가"가 첫 번째 질문이 되는 구조를 만들고 싶지 않았습니다.

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

루트에서 RNSendy/node_modulesRNSendy/package.json을 가리키는 심볼릭 링크를 만들면, CocoaPods 스크립트 입장에서는 루트에 두 파일이 있는 것처럼 보입니다. 스크립트를 전혀 건드리지 않습니다. 수정 표면이 최소화되고, RN 버전을 올려도 재검증할 커스텀 코드가 없습니다.

# 심볼릭 링크 생성 후
 
Sendy_iOS/
├── package.json    → RNSendy/package.json     (심볼릭 링크)
├── node_modules/   → RNSendy/node_modules/    (심볼릭 링크)
├── Podfile         ← use_react_native! 탐색 성공
├── RNSendy/
│   ├── package.json
│   └── node_modules/
└── Sendy.xcworkspace

CocoaPods 스크립트는 루트에서 package.json을 찾고 성공합니다. 실제 파일은 RNSendy/ 안에 있고, 링크만 루트에 노출돼 있습니다.

링크 생성과 서브모듈 초기화는 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 타깃의 흐름은 서브모듈 동기화 → 심볼릭 링크 생성 → RN 의존성 설치 → 환경 변수 생성 → pod install 순서입니다. 이 순서가 중요합니다. 서브모듈이 없으면 링크가 죽은 링크가 되고, 링크가 없으면 pod install이 실패합니다.

이 구조로 네이티브 레포가 여전히 Source of Truth입니다. 새로운 팀원이 레포를 클론하면 make setup 한 번으로 서브모듈 초기화, 심볼릭 링크, 의존성 설치, 환경 변수 생성, pod install까지 끝납니다.

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


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

Sendy iOS는 CocoaPods deprecation 이후 의존성을 SPM으로 전환한 상태였습니다. Lottie, Kingfisher 등 대부분의 서드파티가 Package.resolved에 고정되어 있었고, CocoaPods는 프로젝트에서 걷어낸 상태였습니다.

RN을 붙이면서 CocoaPods가 다시 들어왔습니다. RN 생태계의 서드파티는 여전히 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가 두 번 링크되고 있었습니다.

왜 이런 일이 생기는가

문제는 SPM과 CocoaPods가 서로의 의존성 그래프를 전혀 모른다는 점입니다. SPM은 Xcode의 패키지 해석 레이어에서 동작하며 Package.resolved를 기준으로 각 패키지를 빌드합니다. CocoaPods는 별도의 Pods/ 디렉터리에 소스를 복사한 뒤 libPods-*.a 정적 라이브러리를 생성합니다. 공유하는 레지스트리나 잠금 파일이 없으므로, SPM이 lottie-ios를 한 번 빌드하고 CocoaPods가 같은 Lottie 소스를 또 한 번 컴파일합니다. 링커는 두 오브젝트 파일에서 동일한 심볼을 발견하고 실패합니다.

해결 선택지 두 가지

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

react-native-lottie의 Podspec을 수정해서 SPM이 이미 제공하는 Lottie를 다시 가져오지 않도록 막는 방법입니다. 기술적으로는 가능합니다. 그러나 이 방법을 포기한 이유는 가시성입니다. Podspec을 수정하려면 해당 레포를 포크해서 커스텀 버전을 직접 유지해야 합니다. 이후 모든 버전 업데이트는 포크에서 수동으로 병합해야 하고, 팀에 새 인원이 합류했을 때 커스텀 Podspec의 존재와 이유를 알지 못하면 같은 에러가 재발합니다. 포크 히스토리는 코드 리뷰에서 드러나지 않는 운영 부채가 됩니다.

선택지 2: 타깃 분리 + RN_ENABLED 플래그

채택한 방법입니다. 의존성 경계를 빌드 레벨에서 강제합니다. RN 통합 코드가 필요한 타깃과 순수 네이티브 타깃을 분리하고, 네이티브 전용 타깃은 CocoaPods가 생성하는 Pods-*.xcconfig를 링크하지 않습니다. CocoaPods가 가져온 Lottie를 볼 수 없고, SPM의 Lottie만 남습니다. 심볼 중복이 구조적으로 발생할 수 없습니다.

이 방식의 트레이드오프도 있습니다. Xcode scheme이 두 개로 늘어나고, CI 빌드 행렬이 타깃에 따라 분기됩니다. 같은 소스 파일을 두 타깃이 컴파일하기 때문에 풀 빌드 시간도 일부 늘어납니다. 다만 이 비용은 포크 유지 비용보다 훨씬 예측 가능합니다.

실제 구현

타깃을 나누는 것만으로는 충분하지 않습니다. RN 관련 코드가 소스 레벨에서도 네이티브 타깃으로 컴파일되지 않아야 합니다.

RCTNativeSendy.h는 RN 브리지 레이어의 핵심 헤더입니다. 파일 전체가 #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에 정의합니다. RN 통합 타깃에는 RN_ENABLED=1, 네이티브 전용 타깃에는 해당 항목을 두지 않습니다. 같은 .h.m 파일을 두 타깃이 공유하면서 컴파일 시점에 플래그 하나로 RN 관련 코드 전체를 켜고 끕니다.

타깃 분리 후 네이티브 타깃은 CocoaPods의 RN 의존성 전체에서 격리됐습니다. 심볼 충돌은 재발하지 않았습니다. 그런데 빌드가 통과되고 앱이 뜨기 시작했는데도 한 가지가 이상했습니다.


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

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

증분 빌드 무효화란

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

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

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

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

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

먼저 Bundle React Native code and images 스크립트입니다. RN JS 번들을 생성하는 이 스크립트는 기본 설정으로 입력/출력 파일이 선언되어 있지 않습니다. Xcode는 입력/출력이 없는 스크립트를 항상 실행이 필요한 것으로 취급하고, 스크립트가 실행될 때마다 후속 빌드 단계들이 재실행됩니다.

다음으로 RN의 xcconfig에 포함된 컴파일 플래그입니다. CLANG_ENABLE_MODULES, SWIFT_INCLUDE_PATHS, MODULEMAP_FILE 등의 설정이 xcconfig를 타고 전파됩니다. 이 플래그들이 네이티브 전용 Swift 타깃에 적용되면 컴파일 조건이 바뀌어 Xcode가 이전 빌드 캐시를 유효하지 않다고 판단합니다. xcconfig 상속 구조 자체가 문제가 아니라, 그 xcconfig에 실린 플래그가 네이티브 타깃의 컴파일 조건을 바꾼다는 점이 핵심입니다.

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

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

해결 방향은 명확했습니다. RN 빌드 설정이 네이티브 전용 타깃에 닿지 않도록 경계를 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 타깃만 -RN 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 파일 구조는 이렇습니다. 네이티브 타깃과 RN 타깃이 서로 다른 파일을 바라봅니다.

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

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

// RN Pods는 RN 타깃 전용 xcconfig에서만 include 합니다.

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

#include "Sendy-Staging.xcconfig"
 
// pod install 시점에 생성되는 파일명이 RN 버전에 따라 달라질 수 있어 옵셔널 포함
#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?를 쓰는 이유는 RN 버전에 따라 CocoaPods가 생성하는 xcconfig 파일명이 다를 수 있기 때문입니다. 옵셔널 포함으로 파일이 없어도 에러가 나지 않고, 있는 파일만 적용됩니다.

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

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

Makefile의 build 타깃은 RN 스킴을 명시적으로 지정합니다.

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

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

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


TurboModule을 Swift로 연결하기

React Native 화면에서 기존 네이티브 코드를 호출해야 했습니다. 인증 토큰 갱신, 화면 닫기와 네비게이션, 딥링크 실행, 이벤트 로깅 — 이 모든 것이 이미 Swift로 구현되어 있었습니다. 연결만 하면 됐습니다.

TurboModule을 선택했습니다. New Architecture(RN 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++ 파일뿐입니다.

Swift는 Bridging Header로 ObjC 타입을 import할 수 있지만, C++ 타입을 직접 참조할 수 없습니다. Swift/C++ Interop(Xcode 15+)이 있긴 하지만, RN 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 헤더가 충돌합니다. 헤더 순서를 바꿔봐도 해결되지 않습니다. 구조적인 문제입니다.

해결: 수동 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의 전방 선언과 연결됩니다.

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

이 구조로 ObjC++ 레이어를 얇게 유지했습니다. RCTNativeSendy.mm이 하는 일은 두 가지입니다. JSI 바인딩과 C++ 파라미터를 NSDictionary로 변환하는 것. 비즈니스 로직이 없으므로 이 파일은 거의 변경될 일이 없습니다. 인증 토큰 갱신 시 Combine과 RxSwift를 iOS 버전에 따라 분기하거나, 네비게이션 스택을 탐색해 RN 화면을 pop하거나, AppDelegate의 로깅 셀렉터를 호출하는 모든 로직이 Swift 안에 있습니다.

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

단점도 있습니다. ObjC 셀렉터 이름과 Swift 메서드의 @objc 어노테이션이 수동으로 맞아야 합니다. 컴파일러가 이것을 검증하지 않습니다. NativeSendySwiftBridge.h의 메서드 시그니처와 Swift의 @objc 셀렉터 이름이 다르면 런타임 크래시입니다. 이 부분은 새 메서드를 추가할 때 컨벤션을 문서화하고 코드 리뷰에서 확인하는 것으로 커버하고 있습니다.

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


RN 버전 관리 전략

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

RN 마이너 업데이트의 실제 무게

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

RN은 semver를 따르지만 마이너 업데이트 사이에도 실질적인 브레이킹 체인지가 있는 경우가 많습니다. Metro 설정 변경, Codegen 스펙 타입 조정, New Architecture 관련 빌드 플래그 변화가 각 마이너 버전에 포함됩니다. 0.82에서 0.83으로 올릴 때 react-native-reanimated가 RN 내부 API 변경으로 인해 빌드 자체가 깨졌고, 해당 라이브러리의 호환 버전이 나오기까지 수 일간 RN 빌드를 묶어두어야 했습니다. 라이브러리가 새 RN 버전에 대응하는 타이밍의 공백이 실제로 비용이 됩니다.

서드파티 라이브러리의 호환성이 RN 버전에 묶여 있다는 사실이 브라운필드 구조에서는 더 무겁게 작용합니다. 네이티브 iOS 앱의 릴리즈 주기가 있고, RNSendy 서브모듈의 업데이트 주기가 따로 있습니다. RN 버전을 올리는 순간 이 두 주기가 강제로 동기화됩니다. react-native-reanimated 같은 라이브러리는 네이티브 Pod을 포함하기 때문에, RN 버전이 올라가면 Podfile.lock이 바뀌고 네이티브 빌드 캐시가 무효화됩니다. CI 빌드 시간이 늘어나고 예상치 못한 네이티브 빌드 오류가 따라옵니다.

버전 정책 설계

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

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

우선 RN 버전 업데이트는 별도 태스크로 관리합니다. 기능 개발 도중에 따라가지 않습니다. 서드파티 호환성 확인, 네이티브 빌드 검증, 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)

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


지금까지 얻은 것, 그리고 남은 것

이 작업의 핵심 질문은 하나였습니다. 네이티브와 JS가 한 앱 안에서 충돌 없이 공존하려면 경계를 어디에 그어야 하는가.

서브모듈은 레포 레벨의 경계입니다. 타깃 분리와 xcconfig 격리는 빌드 시스템 레벨의 경계입니다. ObjC 프로토콜 계약 레이어는 언어 레벨의 경계입니다. 릴리즈 브랜치 버전 정책은 운영 레벨의 경계입니다. 각 레이어마다 경계를 명확히 그었을 때, 두 런타임이 서로에게 예상치 않은 영향을 미치는 상황이 눈에 띄게 줄었습니다.

현재 이 구조 위에서 RN 화면이 실제 배포되어 운영되고 있습니다. 네이티브 엔지니어가 RN 화면을 추가할 때 별도 환경 설정 없이 make setup 한 번으로 작업 환경이 구성됩니다.

이 구조 위에서 실제 기능 개발

RN 런타임이 안정적으로 공존하기 시작한 뒤, 실제 프로덕트 기능에 RN을 적용하기 시작했습니다. 첫 소비자는 운송 플랜 온보딩, 다음은 운송 리뷰였습니다. 두 사례는 프로덕트 임팩트 서사가 주라 별도 글에서 다룹니다.

운송 플랜 온보딩과 스탠다드→비즈니스 전환


남은 과제

남은 과제는 네비게이션 충돌입니다. 기존 네이티브 네비게이션 스택과 RN 네비게이션이 한 앱 안에서 어떻게 공존하는지, 화면 전환 시 어떤 충돌이 발생하는지는 이 글에서 다루지 않았습니다. 현재 운영 중에 발생하는 문제들이 있고, 그 내용은 별도로 정리할 예정입니다.