센디 iOS 앱에 React Native 얹기

안녕하세요. 센디 플랫폼 스쿼드에서 iOS 앱을 개발하고 있는 박정환(Lody)입니다.

센디는 iOS와 Android 앱을 각각 네이티브로 개발해 왔습니다. 새로운 기능이 추가되면 두 플랫폼에서 동일한 요구사항을 바탕으로 화면과 로직을 구현했습니다.

이후 정책이나 UI가 변경될 때도 각각 수정해야 했습니다. 기능이 늘어나면서 중복 개발과 유지보수에 드는 비용도 점차 커지고 있었습니다.

당시 스쿼드에는 네이티브 개발자보다 React 개발자가 더 많았습니다. 하지만 모바일 기능은 여전히 각 플랫폼의 네이티브 개발자가 담당하고 있어, 스쿼드의 개발 인력을 모바일 개발에 활용하기에는 한계가 있었습니다.

이러한 상황에서 React Native는 두 가지 문제를 함께 해결할 수 있는 선택지였습니다.

  • React 개발자도 모바일 기능 개발에 참여할 수 있음
  • iOS와 Android에서 화면과 로직을 공유해 중복 개발을 줄일 수 있음

다만 기존 앱 전체를 React Native로 전환할 계획은 없었습니다. 센디에는 결제와 인증을 비롯한 주요 기능이 이미 네이티브로 구현되어 있었고, 오랫동안 운영해 온 프로젝트 구조와 빌드, 배포 환경도 갖춰져 있었습니다.

일부 기능의 개발 효율을 높이기 위해 기존 앱을 전면적으로 재구성하는 것은 오히려 더 큰 비용과 위험을 수반할 수 있었습니다.

특히 iOS 개발을 혼자 담당하고 있었기 때문에 도입 이후의 유지보수도 고려해야 했습니다. React Native로 개발 속도를 높이더라도 기존 네이티브 앱의 개발이나 배포까지 복잡해진다면 기대했던 이점이 줄어들 수 있었습니다.

그래서 기존 네이티브 앱은 유지하면서 신규 기능에만 React Native를 적용하는 브라운필드(Brownfield) 방식을 선택했습니다.

React Native 화면에서도 기존 인증 상태와 내비게이션을 사용하되, React Native가 필요 없는 기능은 기존과 동일하게 개발하고 빌드할 수 있는 구조를 목표로 했습니다.

이 글에서는 센디 iOS에 React Native를 통합하면서 마주친 문제와 기술적 선택들을 공유합니다. 단순히 문제를 해결한 방법뿐 아니라, 각 선택지의 장단점과 장기적인 유지보수 비용을 비교하며 어떤 결정을 내렸는지 함께 정리했습니다.

1. 기존 의존성 관리 환경에서의 React Native 통합

React Native 도입을 결정한 뒤 가장 먼저 고민한 부분은 의존성 관리였습니다.

당시 센디 iOS는 CocoaPods에서 Swift Package Manager(SPM)로 의존성 관리를 옮긴 상태였습니다. 그런데 React Native의 공식 브라운필드 통합 방식은 여전히 CocoaPods를 사용하고 있었습니다.

이미 정리한 의존성 관리 환경에 CocoaPods를 다시 추가해야 하는 셈이었습니다.

물론 CocoaPods와 SPM을 함께 사용하는 것은 가능합니다. 다만 두 패키지 매니저는 서로의 의존성 그래프를 공유하지 않기 때문에 동일한 라이브러리를 중복으로 가져올 가능성이 있습니다.

일부 화면에 React Native를 도입하기 위해 기존 프로젝트의 의존성 관리까지 다시 복잡하게 만드는 것이 적절한지 고민됐습니다. 가능하다면 기존 SPM 중심의 환경을 유지하고 싶었습니다.

그래서 CocoaPods를 네이티브 프로젝트에 다시 추가하지 않고 React Native를 통합할 수 있는 방법부터 찾아봤습니다.

CocoaPods 없이 통합하려던 시도

토스의 Prebuild 방식

당시 참고했던 글은 토스의 CocoaPods 없이 React Native 개발하기였습니다.

토스에서 소개한 방식은 React Native 라이브러리들을 미리 XCFramework로 빌드한 뒤 기존 프로젝트에 포함하는 방법입니다. 미리 빌드하는 과정에서는 CocoaPods를 사용하지만, 적어도 운영 중인 iOS 프로젝트에서는 CocoaPods를 직접 관리하지 않아도 된다는 점이 매력적이었습니다.

이미 SPM으로 의존성 관리를 옮긴 상황이었기 때문에, 생성한 XCFramework를 SPM으로 기존 앱에 추가하는 방식으로 먼저 시도했습니다.

서드파티 네이티브 모듈이라는 변수

다만 실제 서비스 화면에는 React Native 코어만 필요한 것이 아니었습니다.

화면 전환을 위한 React Navigation과 이를 받치는 react-native-screens, 애니메이션을 위한 react-native-reanimated처럼 별도의 iOS 네이티브 구현을 포함하는 서드파티 라이브러리도 필요했습니다. 이 라이브러리들까지 포함하면서 예상하지 못한 문제를 만나게 됐습니다.

서드파티 네이티브 모듈의 빌드와 등록 문제

Prebuild로 XCFramework를 만드는 것과, 서드파티 네이티브 모듈이 런타임에서 정상적으로 로드되는 것은 별개의 문제였습니다.

Prebuild로 React Native와 서드파티 모듈을 XCFramework로 만들어도, Codegen 등록 정보가 연결되지 않으면 런타임에서 TurboModuleRegistry가 모듈을 찾지 못하는 구조

런타임에서 찾지 못한 TurboModule

서드파티 라이브러리를 Prebuild 대상에 포함해 XCFramework를 생성했지만, React Native에서 해당 모듈을 사용하는 시점에 런타임 오류가 발생했습니다.

TurboModuleRegistry.getEnforcing(...):
'ModuleName' could not be found.

당시 발생한 오류의 형태를 정리한 예시입니다.

JavaScript에서는 모듈을 요청했지만 React Native 런타임에서는 해당 네이티브 모듈을 찾지 못하는 상황이었습니다.

특히 해당 모듈을 실제로 사용하는 화면에 진입할 때 문제가 드러났습니다. 앱이 처음 실행될 때는 오류가 없다가 필요한 기능을 호출하는 순간 실패하기도 했습니다.

처음에는 모듈을 등록하는 설정이 빠졌다고 생각했습니다. 하지만 프리빌드 결과물을 확인하는 과정에서 일부 서드파티 모듈의 네이티브 심볼도 기대한 형태로 확인되지 않았습니다.

Codegen 산출물과 등록 정보

문제를 살펴보면서 React Native의 서드파티 모듈 통합이 단순히 바이너리를 링크하는 작업만은 아니라는 점을 확인했습니다.

React Native의 New Architecture에서는 Codegen이 네이티브 모듈에 필요한 타입과 연결 코드를 생성합니다. 서드파티 모듈의 구현뿐 아니라 생성된 코드와 등록 정보도 최종 앱에 올바르게 연결되어 있어야 합니다.

React Native 0.77부터는 RCTAppDependencyProvider를 통한 서드파티 의존성 등록 과정도 중요해졌습니다. 공식 릴리스 노트에서도 이 설정이 빠지면 런타임 문제가 발생할 수 있다고 설명합니다(React Native 0.77 릴리스 노트).

토스의 Prebuild 예제는 Archive에서 생성된 프레임워크를 xcodebuild -create-xcframework로 묶는 과정까지 보여 줍니다. 여기에 서드파티의 네이티브 구현과 Codegen 산출물, 모듈 등록 정보까지 모두 연결되는지는 별도로 확인해야 했습니다.

확인한 사실

물론 당시 오류의 직접적인 원인이 바이너리 생성 과정인지, 링크 과정인지, 런타임 등록 단계인지를 끝까지 특정하지는 못했습니다. 다만 확인한 사실은 분명했습니다.

React Native 코어를 미리 빌드해 가져오는 데 성공하더라도, 서드파티 네이티브 모듈까지 정상적으로 사용할 수 있는 것은 아니었습니다.

Prebuild 방식 유지 여부

여기서 선택지는 두 가지였습니다.

  1. 서드파티 네이티브 모듈까지 포함하도록 Prebuild 파이프라인을 보완
  2. React Native의 공식 CocoaPods 통합 방식을 사용

Prebuild를 유지할 때의 비용

Prebuild 방식을 유지한다면 기존 iOS 앱에 CocoaPods를 다시 추가하지 않아도 된다는 장점이 있었습니다.

하지만 서드파티가 추가되거나 업데이트될 때마다 네이티브 빌드 산출물과 Codegen 결과, 모듈 등록 과정까지 확인해야 했습니다. React Native 자체의 버전이 바뀌면 빌드 스크립트나 생성 코드에도 변경이 생길 수 있었습니다.

특히 iOS 개발자가 한 명인 상황에서 이 과정을 직접 관리하는 것이 부담스러웠습니다. React Native를 도입하려는 목적은 신규 기능을 빠르게 개발하는 것이었지, 네이티브 프레임워크의 빌드 파이프라인을 새로 운영하는 것이 아니었기 때문입니다.

공식 CocoaPods 통합으로의 복귀

처음에는 CocoaPods를 다시 도입하지 않는 것이 가장 깔끔한 방법이라고 생각했습니다. 그런데 프리빌드 방식으로 얻는 이점과 앞으로 관리해야 할 부분을 비교해 보니 판단이 달라졌습니다.

CocoaPods를 제거하기 위해 서드파티 네이티브 모듈의 빌드와 등록을 직접 관리해야 한다면, 오히려 유지보수 비용이 커질 수 있었습니다.

결국 당시에는 Prebuild 방식을 중단하고 공식 CocoaPods 통합 방식으로 돌아왔습니다.

토스에서 소개한 방법이 잘못됐다고 생각한 것은 아닙니다. 사용하는 서드파티 라이브러리의 종류와 통합 범위에 따라 충분히 활용할 수 있는 방식입니다.

다만 센디에서는 일부 화면에 React Native를 적용하는 것이 목적이었고, 이후에도 서드파티 라이브러리를 계속 사용할 예정이었습니다.

CocoaPods를 다시 도입하는 비용을 감수하더라도 React Native가 제공하는 공식 빌드 및 의존성 연결 과정을 사용하는 편이 낫다고 판단했습니다. 대신 CocoaPods의 영향을 기존 네이티브 앱에서 최대한 분리하는 방향으로 설계를 변경했습니다.

2. 공통 코드 공유와 기존 프로젝트 구조 유지

React Native 프로젝트를 둘 위치

CocoaPods를 사용하기로 결정한 뒤에는 React Native 프로젝트를 어디에 둘지 정해야 했습니다.

센디에는 이미 운영 중인 iOS와 Android 프로젝트가 각각 있었고, 두 플랫폼에서 공통으로 사용할 React Native 프로젝트 RNSendy가 별도로 만들어졌습니다. 검토한 구조는 크게 두 가지였습니다.

React Native 프로젝트를 루트로 두는 구조와 네이티브 프로젝트를 유지하고 RNSendy를 iOS와 Android에서 Git 서브모듈로 가져오는 구조의 장단점 비교

일반적인 React Native 프로젝트 구조

일반적인 React Native 프로젝트는 다음과 같은 구조를 사용합니다.

RNSendy/
├── package.json
├── node_modules/
├── ios/
└── android/

React Native 프로젝트가 루트에 있고, iOS와 Android가 그 아래에 위치합니다.

이 구조라면 공식 가이드를 그대로 따라가기 쉽습니다. React Native의 빌드 스크립트나 의존성 설치 과정도 이러한 디렉터리 구조를 전제로 작성되어 있기 때문입니다.

프로젝트 루트를 옮길 때의 영향

하지만 센디에서 같은 구조를 사용하려면 기존 iOS와 Android 프로젝트를 React Native 저장소 아래로 옮겨야 했습니다.

기존 네이티브 프로젝트에는 이미 빌드와 배포, 코드 서명 설정이 구성되어 있었습니다. React Native를 도입한다는 이유로 해당 설정들을 다시 정리하고 싶지는 않았습니다.

여기서 고민한 것은 단순히 프로젝트의 디렉터리 위치가 아니었습니다. React Native는 앞으로 일부 기능에서 사용할 기술인데, 프로젝트 전체의 관리 기준까지 React Native로 옮길 필요가 있는지 생각해야 했습니다.

  • React Native를 루트로 두는 경우: 공식 구조를 그대로 사용할 수 있지만, 기존 네이티브 앱이 React Native 프로젝트의 구성에 더 많이 의존함
  • 네이티브 프로젝트를 루트로 유지하는 경우: 기존 빌드와 배포 환경을 그대로 사용할 수 있지만, React Native가 기대하는 구조와 달라지는 부분은 직접 해결해야 함

둘 중 어느 쪽을 선택하더라도 추가 작업은 필요했습니다.

다만 React Native를 프로젝트 루트로 옮기는 작업은 기존 앱 전체에 영향을 줄 수 있는 반면, 네이티브 프로젝트를 유지하면서 발생하는 경로 문제는 비교적 제한된 범위에서 해결할 수 있을 것으로 봤습니다.

네이티브 프로젝트 유지와 Git 서브모듈

결국 기존 네이티브 프로젝트를 유지하기로 했습니다. React Native는 별도 저장소로 관리하고, iOS와 Android에서는 Git 서브모듈로 가져오는 방식입니다.

iOS 프로젝트에서는 다음과 같이 구성했습니다.

Sendy_iOS/
├── Sendy/
├── Sendy.xcodeproj
├── Podfile
└── RNSendy/               # Git Submodule
    ├── package.json
    ├── node_modules/
    └── src/

이렇게 하면 iOS와 Android가 같은 React Native 코드를 공유하면서도 각자의 네이티브 프로젝트를 유지할 수 있습니다.

Git 서브모듈은 상위 저장소에 참조하는 커밋을 기록하기 때문에 React Native에서 변경된 코드를 언제 네이티브 앱에 반영할지도 관리할 수 있습니다.

물론 React Native를 공식 구조와 다른 위치에 두기로 했으니, 이에 따른 문제도 감수해야 했습니다. 실제로 CocoaPods 의존성을 설치하는 과정에서 바로 오류가 발생했습니다.

프로젝트 경로 문제와 심볼릭 링크

네이티브 프로젝트를 루트로 유지하면서, React Native가 기대하는 디렉터리 구조와 실제 구조가 달라졌습니다. 이 차이 때문에 생긴 오류와 해결 과정은 다음과 같습니다.

프로젝트 루트에서 package.json을 찾지 못해 pod install이 실패하던 상태와, 루트에 RNSendy/package.json과 RNSendy/node_modules를 가리키는 심볼릭 링크를 만들고 Podfile에 app_path를 지정해 설치가 성공한 상태

package.json 탐색 경로 오류

RNSendy를 서브모듈로 추가하고 pod install을 실행하자 다음 오류가 발생했습니다.

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

package.json은 존재했습니다. 다만 네이티브 프로젝트 루트가 아니라 RNSendy/ 안에 있었습니다.

Sendy_iOS/
├── package.json           ← 스크립트가 찾는 위치
└── RNSendy/
    ├── package.json       ← 실제 위치
    └── node_modules/

프로젝트 루트를 기준으로 동작하는 두 단계

React Native의 CocoaPods 통합은 Podfile이 있는 디렉터리를 React Native 프로젝트의 루트로 가정하고 동작합니다. 경로를 가정하는 단계는 두 곳이었습니다.

첫 번째는 Podfile의 첫 줄입니다. Podfile은 Node를 실행해 react_native_pods.rb의 위치를 찾습니다.

# Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
  'require.resolve(
    "react-native/scripts/react_native_pods.rb",
    {paths: [process.argv[1]]},
  )', __dir__]).strip

require.resolve는 paths로 넘긴 디렉터리(__dir__, 즉 Podfile 위치)부터 node_modules를 찾습니다. 그래서 Podfile이 있는 Sendy_iOS/에 node_modules가 있어야 스크립트를 불러올 수 있습니다.

두 번째는 react_native_pods.rb 내부입니다. 이 스크립트는 자기 위치에서 상위 디렉터리로 올라가 프로젝트 루트를 정하고, 그 자리의 package.json을 엽니다. 서브모듈 구조에서는 이 경로가 Sendy_iOS/package.json이 되어 앞의 오류가 발생했습니다.

정리하면 다음과 같습니다.

단계루트에서 찾는 것실제 위치
Podfile의 require.resolvenode_modules/react-nativeRNSendy/node_modules/
react_native_pods.rbpackage.jsonRNSendy/package.json

내부 스크립트 수정 대신 심볼릭 링크

해결 방법 자체는 어렵지 않았습니다. react_native_pods.rb의 경로 탐색 로직을 수정해 RNSendy/ 안의 package.json을 찾도록 만들면 됩니다. 실제로 수정해야 할 코드도 많지 않았습니다.

하지만 이 방법을 선택하면 React Native 내부 스크립트의 변경 사항을 앞으로 계속 관리해야 합니다.

해당 파일은 node_modules 안에 있습니다. 의존성을 다시 설치하면 수정 내용이 사라질 수 있고, React Native 버전을 업데이트할 때마다 패치가 유효한지도 확인해야 합니다.

앞서 Prebuild 방식을 중단한 이유와 비슷한 고민이 다시 생겼습니다. 지금 당장 해결하기 쉬운 방법이더라도, 앞으로 외부 라이브러리의 내부 구현까지 계속 관리해야 한다면 좋은 선택이라고 보기 어려웠습니다.

그래서 React Native 내부 스크립트는 수정하지 않고, 루트에서 찾는 두 경로를 심볼릭 링크로 맞추기로 했습니다.

define create_links
	ln -sfn RNSendy/node_modules node_modules
	ln -sfn RNSendy/package.json package.json
endef

ln -sfn의 각 옵션은 다음 역할을 합니다.

  • -s: 실제 파일 대신 경로를 가리키는 심볼릭 링크를 만듦
  • -f: 같은 이름의 파일이나 링크가 있으면 덮어씀
  • -n: 기존 링크가 디렉터리를 가리키고 있어도 그 안으로 들어가지 않고 링크 자체를 교체함

이제 네이티브 프로젝트 루트에서 node_modules와 package.json을 찾으면 실제로는 RNSendy/ 안의 파일을 바라봅니다. require.resolve와 react_native_pods.rb 모두 스크립트를 고치지 않고도 기대하는 위치에서 파일을 찾을 수 있게 됐습니다.

app_path를 따로 지정한 이유

심볼릭 링크와 별개로 Podfile에도 React Native 앱의 실제 위치를 지정했습니다.

use_react_native!(
  :hermes_enabled => true,
  :fabric_enabled => true,
  :path => config[:reactNativePath],
  :app_path => "#{Pod::Config.instance.installation_root}/RNSendy"
)

두 설정은 역할이 다릅니다.

  • 심볼릭 링크: 루트를 가정하고 파일을 찾는 스크립트가 실패하지 않도록 경로를 맞춤
  • app_path: React Native에 애플리케이션 루트의 절대 경로를 직접 알려 줌

installation_root는 Podfile이 있는 디렉터리이므로, app_path는 Sendy_iOS/RNSendy를 가리킵니다. 링크에만 기대지 않고 React Native 앱이 어디 있는지 설정으로 명시해 두면, 경로를 읽는 쪽이 링크를 따라가지 않아도 같은 위치를 보게 됩니다.

Makefile로 묶은 환경 준비

이 방법이라면 React Native 내부 스크립트를 수정하지 않아도 됐습니다. 대신 새로운 개발 환경에서 심볼릭 링크를 생성해야 한다는 조건이 생겼습니다.

루트의 node_modules와 package.json은 .gitignore에 들어 있어 저장소에 커밋되지 않습니다. 저장소를 새로 클론하거나 CI 러너에서 빌드할 때마다 링크를 다시 만들어야 했습니다.

개발자가 직접 실행하도록 두면 처음 설정할 때 빠뜨리기 쉬운 작업이었습니다. 그래서 Makefile에 서브모듈 초기화부터 의존성 설치까지 묶었습니다.

setup-staging:
	$(call init_submodules)
	$(call create_links)
	$(call install_rn_deps)
	$(call generate_env_staging)
	$(call install_ios_deps)

각 단계는 다음 명령을 실행합니다.

단계실행하는 명령
init_submodulesgit submodule init, git submodule update --remote --merge
create_linksln -sfn RNSendy/node_modules node_modules 외 1건
install_rn_depscd RNSendy && npm install
generate_env_stagingcd RNSendy && npm run gen:env:staging
install_ios_depspod install

순서도 중요했습니다. 서브모듈을 먼저 받아야 링크가 가리킬 RNSendy/가 생기고, npm install이 끝나야 RNSendy/node_modules에 react-native가 들어옵니다. 이 두 가지가 준비된 뒤에야 pod install에서 Podfile의 require.resolve가 성공합니다.

새로운 환경에서 프로젝트를 구성할 때도 make setup-staging 한 번으로 이 순서를 그대로 실행할 수 있게 됐습니다.

심볼릭 링크 방식의 한계

심볼릭 링크 방식이 React Native 공식 프로젝트 구조를 완전히 대체하는 것은 아닙니다. 루트 구조를 가정하는 다른 스크립트가 새로 생기면 같은 종류의 경로 문제가 다시 생길 수 있습니다.

다만 문제가 발생했을 때 우리가 관리해야 하는 부분이 심볼릭 링크와 프로젝트 설정에 한정된다는 점에서, React Native 내부 스크립트를 계속 수정하는 방식보다 부담이 적었습니다.

3. 패키지 매니저 간 중복 의존성과 심볼 충돌

두 패키지 매니저가 가져온 같은 라이브러리

링크 단계의 중복 심볼

프로젝트 구조를 정리한 뒤에는 Xcode 빌드를 진행했습니다. 그런데 링크 단계에서 다음 오류가 발생했습니다.

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 관련 심볼이 중복으로 정의됐다는 오류였습니다. 원인은 두 패키지 매니저가 서로 독립적으로 의존성을 관리한다는 점에 있었습니다.

센디 iOS에서는 기존부터 Lottie를 사용하고 있었고, 네이티브 화면의 Lottie는 SPM으로 관리했습니다. 그런데 React Native 프로젝트에서 사용하는 lottie-react-native도 CocoaPods를 통해 별도의 Lottie 의존성을 가져왔습니다.

SPM과 CocoaPods는 서로의 의존성 그래프를 공유하지 않습니다. 예를 들어 SPM으로 설치한 라이브러리 A와 CocoaPods로 설치한 라이브러리 B가 동일한 라이브러리 C에 의존하더라도, 두 도구는 C를 자동으로 하나의 의존성으로 합쳐 주지 않습니다.

Archive 단계의 산출물 충돌

같은 라이브러리가 두 경로에서 빌드되고 링크될 수 있는 것입니다. Archive 단계에서는 다음과 같은 오류도 발생했습니다.

Multiple commands produce
'.../Lottie/Metadata.appintents'
 
Target 'Lottie'     (project 'Lottie')
Target 'lottie-ios' (project 'Pods')
  • 첫 번째 오류: 동일한 심볼이 중복으로 링크되는 문제
  • 두 번째 오류: 서로 다른 빌드 작업이 같은 경로에 산출물을 생성하려는 문제

발생한 단계는 달랐지만, 하나의 앱 타깃에 동일한 의존성이 중복으로 포함되어 있다는 공통점이 있었습니다. 이를 해결하려면 두 경로 중 하나를 변경해야 했습니다.

외부 라이브러리의 의존성을 수정하는 방법

Podspec 포크

먼저 검토한 방법은 lottie-react-native의 Podspec을 수정하는 것이었습니다.

라이브러리를 포크한 뒤 Lottie 의존성을 제거하면 CocoaPods가 중복된 Lottie를 가져오지 않도록 만들 수 있습니다. 당장의 빌드 오류만 생각하면 비교적 간단한 방법이었습니다.

하지만 나중에 문제가 될 수 있는 부분이 있었습니다. React Native 라이브러리가 원래 선언한 의존성을 우리가 임의로 변경하는 구조가 되기 때문입니다.

  • lottie-react-native를 업데이트할 때마다 포크를 유지해야 함
  • 라이브러리 내부에서 Lottie를 사용하는 방식이 달라지면 호환성도 확인해야 함
  • 다른 개발자가 원본 라이브러리로 되돌리면 동일한 문제가 다시 발생할 수 있음

겉으로 드러나지 않는 의존 관계

무엇보다 이런 변경은 겉으로 잘 드러나지 않습니다. 왜 포크를 사용하고 있는지, 어떤 의존성을 제거했는지까지 맥락을 알아야 합니다.

기존 앱에서 사용하는 Lottie를 제거하는 경우에도 문제가 될 수 있습니다. CocoaPods 쪽 의존성을 임의로 제거한 상태라면, 원래 SPM을 통해 제공하던 Lottie가 사라지면서 React Native 쪽 빌드까지 실패할 수 있기 때문입니다.

지금은 제가 그 이유를 알고 있지만, 시간이 지나면 다시 확인해야 할 내용입니다. iOS를 혼자 담당하고 있는 상황에서는 이러한 암묵적인 의존 관계를 가능한 한 만들지 않는 편이 낫다고 판단했습니다.

타깃을 나누는 것만으로 해결되지 않았던 이유

다음으로 검토한 방법은 네이티브 앱과 React Native 앱의 타깃을 분리하는 것이었습니다.

기존 네이티브 앱은 SPM 의존성만 사용하고, React Native를 포함하는 앱에서는 CocoaPods 의존성을 추가하는 방식입니다. 이렇게 하면 React Native를 사용하지 않는 타깃에는 CocoaPods 의존성을 포함하지 않을 수 있습니다.

하지만 Lottie 충돌 자체는 해결되지 않았습니다. Sendy와 SendyRN은 같은 Swift 소스를 공유하고 있었기 때문입니다.

React Native 통합 타깃에서도 기존 네이티브 화면을 사용해야 했습니다. 따라서 SPM으로 설치한 Lottie가 필요했고, 동시에 lottie-react-native가 가져온 Lottie도 사용해야 했습니다.

타깃을 분리하더라도 SendyRN 안에서는 두 라이브러리가 함께 존재하는 것입니다. 여기서 문제를 구분할 필요가 있었습니다.

React Native 의존성을 기존 네이티브 타깃에서 제외하는 것과, React Native 타깃 내부에 존재하는 중복 의존성을 해결하는 것은 다른 문제였습니다.

타깃 분리는 이후에도 필요한 작업이었지만, 이번 충돌을 해결하려면 별도의 방법이 필요했습니다.

모듈 경계를 분리하는 방법

포크는 유지보수 부담이 컸고, 타깃 분리만으로는 SendyRN 안의 충돌이 남았습니다. 남은 방법은 두 Lottie의 모듈 이름 자체를 다르게 만드는 것이었습니다.

SPM Lottie와 CocoaPods Lottie가 SendyRN 타깃에서 중복 링크되던 구조와, 네이티브 화면은 LottieSPM.xcframework를, React Native 화면은 CocoaPods Lottie를 쓰도록 나눈 구조

LottieSPM 바이너리 타깃

결국 기존 네이티브에서 사용하는 Lottie를 별도 모듈로 만들었습니다.

Lottie 4.6.0을 LottieSPM이라는 모듈 이름으로 다시 빌드한 XCFramework를 만들고, 로컬 SPM 패키지의 binaryTarget으로 앱에 연결했습니다.

// Package.swift
.binaryTarget(
    name: "LottieSPM",
    path: "Vendor/LottieSPM.xcframework"
)

Swift 코드에서도 기존 Lottie 대신 LottieSPM을 사용하도록 변경했습니다.

// 기존
import Lottie
 
// 변경
import LottieSPM

React Native가 CocoaPods를 통해 가져오는 Lottie는 그대로 유지했습니다.

두 타깃의 Lottie 링크 구성

이제 네이티브 화면에서는 LottieSPM을 사용하고, React Native에서는 CocoaPods의 Lottie를 사용합니다.

타깃링크되는 Lottie
SendyLottieSPM
SendyRNLottieSPM + Pod Lottie (lottie-react-native)

두 앱 타깃은 같은 Swift 소스를 공유하므로 Sendy와 SendyRN 모두 LottieSPM을 링크하고, SendyRN에만 lottie-react-native가 가져오는 Pod Lottie가 추가됩니다.

미리 빌드한 바이너리로 넣으면 Xcode가 소스 빌드 때 만드는 App Intents 메타데이터 추출 작업도 생기지 않습니다. 그래서 Metadata.appintents 산출물 충돌도 함께 사라졌습니다.

모듈 분리의 한계와 비용

물론 모듈 이름을 변경한다고 모든 중복 심볼 문제가 해결되는 것은 아닙니다. Objective-C 런타임 이름이나 C 심볼처럼 모듈 이름과 별개로 충돌하는 요소도 있습니다. 이번에는 실제 사용하던 라이브러리 구성에서 모듈 분리가 가능했습니다.

대신 새로운 유지보수 비용이 생겼습니다. Lottie를 업데이트할 때마다 XCFramework를 다시 생성해야 하고, 빌드한 프레임워크가 앱에 올바르게 포함되는지도 확인해야 합니다.

외부 라이브러리의 의존성을 수정하는 방법과 비교하면, 어느 쪽이든 별도의 작업이 필요했습니다.

  • XCFramework 방식: 빌드된 바이너리와 재생성 방법, 별도 모듈로 유지하는 이유를 같은 프로젝트에 남길 수 있음
  • 포크 방식: 외부 라이브러리의 변경 사항을 지속적으로 추적해야 함

유지보수 비용을 완전히 없앨 수 없다면, 적어도 그 비용이 어디서 발생하는지 알 수 있는 구조를 선택하는 편이 낫다고 생각했습니다.

이렇게 의존성 충돌을 해결한 뒤에는 React Native가 필요한 빌드와 기존 네이티브 빌드를 구분하는 작업을 진행했습니다.

4. 네이티브와 React Native의 빌드 환경 격리

같은 소스를 유지한 빌드 타깃 분리

중복 의존성 문제를 해결한 뒤에는 React Native가 기존 네이티브 빌드에 미치는 영향을 줄여야 했습니다.

센디의 모든 화면에서 React Native를 사용하는 것은 아니었습니다. React Native를 포함하지 않는 기능을 개발할 때까지 CocoaPods와 React Native 의존성을 함께 빌드할 필요는 없었습니다.

특히 기존 네이티브 앱은 이전과 같은 방식으로 계속 개발하고 싶었습니다. React Native에 문제가 생기더라도 네이티브 앱을 빌드할 수 있어야 한다는 점은 처음부터 고려했던 조건이었습니다.

이를 위해 기존 Sendy 타깃과 React Native를 포함하는 SendyRN 타깃을 분리했습니다.

소스를 나누지 않은 이유

여기서도 소스 코드를 어떻게 관리할지 고민해야 했습니다. 두 타깃의 소스를 완전히 나누면 의존성을 구분하기에는 편합니다.

하지만 기존 네이티브 기능이 변경될 때마다 양쪽 소스에 수정 사항을 반영해야 합니다. 애초에 중복 개발을 줄이기 위해 React Native를 도입했는데, 기존 Swift 코드까지 두 벌로 관리하는 것은 피하고 싶었습니다.

그래서 Swift 소스는 공유하고, React Native 의존성을 포함하는 빌드 타깃만 분리하기로 했습니다.

타깃별 Xcode 스킴

두 타깃은 같은 네이티브 기능을 사용하지만, React Native 관련 라이브러리와 빌드 설정은 SendyRN에만 포함합니다. Xcode 스킴도 구분했습니다.

네이티브 전용React Native 포함
Sendy-StagingSendy-Staging-RN
Sendy-ProductionSendy-Production-RN

기존 네이티브 기능을 개발할 때는 네이티브 스킴을 사용하고, React Native 화면을 확인할 때는 RN 스킴을 사용합니다.

단순히 타깃을 하나 더 만드는 작업은 아니었습니다. 같은 소스를 사용하더라도 각 타깃이 참조하는 의존성과 컴파일 설정은 구분해야 했습니다.

의존성 설정까지 분리해야 했던 이유

RN 전용 xcconfig

CocoaPods는 pod install을 실행하면 Pod 의존성에 필요한 xcconfig 파일을 생성합니다. React Native 통합 타깃에서는 해당 설정이 필요하지만, 네이티브 전용 타깃에는 React Native 관련 헤더 경로나 컴파일 설정이 필요하지 않습니다.

그런데 처음에는 타깃을 분리했더라도 xcconfig 참조까지 완전히 구분하지 못한 부분이 있었습니다. CocoaPods가 생성한 설정이 어떤 타깃에 적용되는지 다시 확인해야 했습니다.

기존 네이티브 설정은 유지하고, React Native에 필요한 설정만 별도로 추가하는 방식으로 xcconfig를 정리했습니다.

Sendy/ConfigurationFiles/
├── Sendy-Staging.xcconfig
├── Sendy-Staging-RN.xcconfig
├── Sendy-Release.xcconfig
└── Sendy-Release-RN.xcconfig

React Native 전용 xcconfig에서는 기존 네이티브 설정을 상속한 뒤 CocoaPods 설정을 추가했습니다.

#include "Sendy-Staging.xcconfig"
 
#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?는 해당 파일이 존재할 때만 설정을 포함합니다. CocoaPods가 생성하는 xcconfig 파일명은 빌드 구성에 따라 달라질 수 있어 여러 경로를 지정했습니다.

pod install 이후에도 유지되는 xcconfig 참조

여기서 한 가지 더 처리해야 했습니다. xcconfig를 수동으로 설정하더라도 pod install을 다시 실행하면 CocoaPods가 Xcode 프로젝트의 설정을 변경할 수 있습니다.

개발 환경을 다시 구성할 때마다 Xcode에서 xcconfig 참조를 수정하는 방식은 피하고 싶었습니다. 그래서 Podfile의 post_install에서 SendyRN 타깃의 base_configuration_reference를 명시적으로 지정했습니다.

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 |target|
      next unless APP_TARGETS.include?(target.name)
 
      target.build_configurations.each do |config|
        mapped = config.name == "Debug" ? "Staging" : config.name
        path = "Sendy/ConfigurationFiles/Sendy-#{mapped}-RN.xcconfig"
 
        file_ref = project.files.find { |file| file.path == path } ||
                   project.new_file(path)
 
        config.base_configuration_reference = file_ref
      end
    end
 
    project.save
  end
end

실제 Podfile에서 xcconfig 참조를 지정하는 부분을 중심으로 정리했습니다.

이제 pod install을 다시 실행하더라도 React Native 타깃에서 사용할 xcconfig를 지정할 수 있습니다. 네이티브 전용 타깃에서는 CocoaPods 설정을 포함하지 않도록 구성했습니다.

공통 설정 상속 구조

이 작업에서 중요했던 것은 빌드 설정을 최대한 많이 분리하는 것이 아니었습니다. 공통으로 사용할 설정은 유지하되, React Native 의존성 때문에 필요한 설정만 별도로 관리하는 것이었습니다.

  • 설정을 모두 복제한 경우: 네이티브 설정이 변경될 때마다 두 곳을 수정해야 함
  • 설정을 모두 공유한 경우: React Native가 없는 타깃까지 불필요한 의존성을 갖게 됨

공통 설정을 상속하고 RN 전용 설정만 추가하는 방식은 그 사이에서 선택한 구조였습니다.

공유 소스에서의 React Native 코드 제외

타깃과 xcconfig를 분리한 뒤에도 같은 소스를 공유한다는 점은 남아 있었습니다. 소스 안에서도 React Native 코드를 타깃에 따라 켜고 끌 방법이 필요했습니다.

같은 공유 소스에서 Sendy 타깃(RN_ENABLED=0)은 React Native 브릿지 코드를 컴파일에서 제외해 안내 화면을 띄우고, SendyRN 타깃(RN_ENABLED=1)은 브릿지 코드와 RNBaseViewController로 React Native 화면을 띄우는 구조

RN_ENABLED 컴파일 플래그

React Native 브릿지에는 Codegen으로 생성된 헤더를 사용하는 부분이 있습니다. 하지만 네이티브 전용 타깃에는 해당 헤더가 존재하지 않습니다. 공통 소스에 있는 브릿지 코드를 그대로 컴파일하면 React Native 관련 헤더를 찾지 못해 빌드에 실패할 수 있었습니다.

이를 해결하기 위해 RN_ENABLED 컴파일 플래그를 사용했습니다.

타깃RN_ENABLED
SendyRN (React Native 통합)1
Sendy (네이티브 전용)0

Objective-C 브릿지 코드는 다음과 같이 조건부로 컴파일했습니다.

#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>
#else
#error "NativeSendy Codegen header not found"
#endif
 
@interface RCTNativeSendy : NSObject <NativeSendySpec>
@end
 
#endif

실제 코드에서 헤더 탐색 경로 일부를 생략했습니다.

Swift에서도 React Native 관련 코드를 구분할 수 있도록 해당 타깃의 SWIFT_ACTIVE_COMPILATION_CONDITIONS에 RN_ENABLED를 추가했습니다.

RNBaseViewController의 화면 분기

React Native 화면을 띄우는 컨테이너도 같은 플래그로 나눴습니다. React Native 화면은 모두 RNBaseViewController를 상속하고, 서브클래스는 JavaScript에 등록된 모듈 이름과 전달할 Props만 정의합니다.

#if RN_ENABLED
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
#endif
 
class RNBaseViewController<Props: Encodable>: UIViewController {
 
    class var moduleName: String { fatalError("Override moduleName in subclass") }
    let props: Props
 
    #if RN_ENABLED
    private var reactNativeFactory: RCTReactNativeFactory?
    private var reactNativeFactoryDelegate: RCTReactNativeFactoryDelegate?
    #endif
 
    override func viewDidLoad() {
        super.viewDidLoad()
 
        #if RN_ENABLED
        let delegate = ReactNativeDelegate()
        delegate.dependencyProvider = RCTAppDependencyProvider()
        reactNativeFactoryDelegate = delegate
 
        let factory = RCTReactNativeFactory(delegate: delegate)
        reactNativeFactory = factory
 
        view = factory.rootViewFactory.view(
            withModuleName: type(of: self).moduleName,
            initialProperties: props.asParams()
        )
        #else
        // "React Native is disabled" 안내 레이블 표시 (생략)
        #endif
    }
}

RN_ENABLED가 켜진 SendyRN에서는 RCTReactNativeFactory로 React Native 루트 뷰를 만들어 화면에 붙입니다. 이때 dependencyProvider에 지정하는 RCTAppDependencyProvider가 앞서 말한 0.77의 서드파티 의존성 등록 설정입니다.

네이티브 전용 Sendy에서는 React 모듈을 import하지 않고, 같은 자리에 안내 문구만 표시합니다.

React Native 화면 클래스와 이를 여는 코드는 두 타깃에서 그대로 컴파일되고, 네이티브 전용 빌드에서 해당 화면에 진입해도 크래시 대신 안내 화면이 나타납니다.

별도 모듈로 분리하지 않은 이유

처음에는 React Native 관련 소스를 완전히 별도 모듈로 분리하는 것도 생각할 수 있었습니다. 하지만 React Native 화면과 기존 네이티브 화면은 인증 상태와 내비게이션을 공유해야 했습니다.

결국 두 영역을 연결하는 코드는 필요했습니다. 그 코드를 어디에 둘지는 선택할 수 있지만, 반드시 별도의 소스 트리로 분리해야 하는 것은 아니었습니다.

현재 필요한 것은 React Native가 없는 타깃에서 관련 코드를 컴파일하지 않도록 하는 것이었고, 타깃 분리와 컴파일 플래그를 통해 이 조건을 충족할 수 있었습니다.

별도로 살펴본 빌드 성능

단일 파일 수정 빌드 시간

React Native를 통합한 뒤에는 Xcode 빌드 시간도 신경 쓰이기 시작했습니다. 초기에는 Swift 파일 하나를 수정해도 빌드에 2분 이상 걸리는 경우가 있었습니다. 개발 환경을 정리한 이후에는 비슷한 단일 파일 수정에서 20초 이내로 완료되는 경우도 확인했습니다.

다만 이 과정에서 여러 설정과 빌드 환경이 함께 변경됐기 때문에 특정 작업이 얼마나 기여했는지 구분하기는 어려웠습니다. 앞서 진행한 xcconfig 분리는 의존성을 격리하기 위한 작업이지, 증분 빌드 성능을 개선하기 위한 조치는 아니었습니다.

번들 생성 스크립트

별도로 확인한 부분 중 하나는 React Native의 번들 생성 스크립트였습니다. Xcode에는 JavaScript 번들을 생성하는 Bundle React Native code and images Run Script가 있습니다.

당시 프로젝트에서는 이 스크립트의 입력과 출력 파일이 선언되어 있지 않았습니다. 입력과 출력 파일이 지정되지 않은 Run Script는 Xcode에서 빌드할 때마다 실행될 수 있습니다(Running custom scripts during a build).

하지만 이 스크립트가 반복 실행되는 것과 Swift 파일이 다시 컴파일되는 것은 다른 문제입니다. 어떤 작업 때문에 빌드 시간이 늘어나는지 확인하려면 스크립트 실행 시간과 실제 Swift 컴파일 로그를 구분해야 했습니다.

따라서 당시 관찰한 빌드 시간 변화는 특정 설정 변경의 성과로 연결하지 않았습니다.

5. React Native와 Swift 사이의 인터페이스 설계

빌드 환경을 정리한 뒤에는 React Native 화면에서 기존 네이티브 기능을 사용해야 했습니다. 센디 iOS에는 이미 다음 기능이 Swift로 구현되어 있었습니다.

  • 인증 토큰 갱신
  • 화면 전환
  • 딥링크 실행
  • 이벤트 로깅

React Native에서 같은 기능을 다시 구현하는 것은 피하고 싶었습니다. 특히 인증이나 내비게이션은 앱 전반에서 사용되는 기능입니다.

React Native에 별도의 구현을 두면 두 코드가 항상 같은 동작을 하도록 관리해야 합니다. 공통 화면을 개발하면서 기존 네이티브 기능까지 중복으로 구현하는 것은 처음 도입 목적과도 맞지 않았습니다.

그래서 React Native에서는 필요한 작업을 요청하고, 실제 동작은 기존 Swift 코드에서 담당하도록 만들기로 했습니다.

기존 Swift 기능의 노출 범위

처음에는 React Native에서 Swift 메서드를 호출할 수 있도록 연결하는 것만 생각했습니다. 그런데 작업을 진행하면서 어떤 기능을 노출할지도 중요하다고 느꼈습니다.

React Native에서 기존 네이티브 기능을 자유롭게 호출하도록 만들면 개발할 때는 편할 수 있습니다. 하지만 인증 상태를 변경하거나 네이티브 화면을 닫는 기능은 단순한 유틸리티 메서드가 아닙니다. 현재 앱의 상태와 내비게이션 구조를 알고 있어야 올바르게 동작합니다.

이런 기능의 실제 처리 방식까지 React Native에서 결정하도록 만들고 싶지는 않았습니다. React Native에서는 필요한 동작만 요청하고, 네이티브 앱이 현재 상태에 맞게 처리하는 구조가 더 적합했습니다.

이를 위해 New Architecture의 TurboModule을 사용했습니다.

TurboModule과 Swift 연결

React Native의 호출은 Codegen이 만든 Objective-C++ 코드를 거쳐 기존 Swift 구현체에 도달합니다. 전체 경로는 다음과 같습니다.

NativeSendy.ts에서 Codegen을 거쳐 RCTNativeSendy.mm(Objective-C++)이 타입을 변환하고, NativeSendySwiftBridge.h 프로토콜을 통해 NativeSendySwiftImpl.swift의 기존 기능을 호출하는 구조

Objective-C++ 연결 계층

React Native의 TurboModule은 TypeScript로 네이티브 모듈의 인터페이스를 정의하면 Codegen이 필요한 네이티브 코드를 생성합니다. 센디에서는 NativeSendy.ts에 React Native에서 호출할 메서드를 정의했습니다.

Codegen으로 생성되는 네이티브 코드에는 C++ 기반의 JSI 타입이 포함됩니다. Swift도 C++ 상호운용을 지원하지만, 당시 프로젝트에서 React Native의 생성 코드와 Swift 구현을 직접 연결하기에는 제약이 있었습니다.

React Native 공식 문서에서도 Swift 기반의 TurboModule을 구현할 때 Objective-C++ 연결 계층을 사용하는 방식을 안내합니다(Turbo Modules with Swift).

그래서 Objective-C++에서는 React Native의 호출을 처리하고, 실제 네이티브 기능은 Swift에서 구현하도록 역할을 나눴습니다.

자동 생성 Swift 헤더의 컴파일 문제

처음에는 Objective-C++에서 Xcode가 생성하는 Sendy-Swift.h를 가져와 Swift 구현체를 호출하려고 했습니다. 일반적인 Objective-C와 Swift 상호운용에서는 자주 사용하는 방법입니다.

하지만 당시 프로젝트에서는 Codegen으로 생성한 헤더와 자동 생성된 Swift 헤더를 함께 사용하는 과정에서 컴파일 문제가 발생했습니다. 헤더를 가져오는 순서를 변경하는 방법도 시도했지만 안정적으로 연결하기 어려웠습니다.

그렇다고 기존 Swift 로직을 Objective-C++로 옮기고 싶지는 않았습니다. 그러면 React Native를 연결하기 위해 이미 만들어 둔 기능을 다시 구현해야 했습니다.

Objective-C++에 비즈니스 로직이 들어가기 시작하면 새로운 기능을 추가할 때마다 어떤 코드를 어느 언어에서 관리할지 고민해야 합니다.

Objective-C++는 React Native와 Swift를 연결하는 역할만 맡고, 실제 기능은 Swift에 남기는 편이 낫다고 판단했습니다.

자동 생성 헤더 대신 명시적 인터페이스

Objective-C 프로토콜 계약

선택한 방법은 Objective-C 프로토콜로 인터페이스를 정의하는 것이었습니다. NativeSendySwiftBridge.h에 React Native에서 사용할 네이티브 기능을 선언했습니다.

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
 
@protocol NativeSendySwiftBridge <NSObject>
 
+ (instancetype)shared;
 
- (void)closeAndNavigateWithParams:(NSDictionary *)params
                           resolve:(RCTPromiseResolveBlock)resolve
                            reject:(RCTPromiseRejectBlock)reject;
 
- (void)refreshTokenWithResolve:(RCTPromiseResolveBlock)resolve
                         reject:(RCTPromiseRejectBlock)reject;
 
@end

실제 브릿지 인터페이스 중 일부입니다.

이 헤더에는 Codegen에서 생성한 C++ 타입을 노출하지 않았습니다. NSDictionary, NSString처럼 Objective-C에서 사용할 수 있는 타입으로 메서드 계약을 정의했습니다.

RCTNativeSendy.mm에서는 Swift 구현체의 인터페이스를 별도로 선언해 사용했습니다.

#import "NativeSendySwiftBridge.h"
 
@interface NativeSendySwiftImpl :
    NSObject <NativeSendySwiftBridge>
 
+ (instancetype)shared;
 
@end

Swift 구현체

React Native에서 전달한 파라미터는 Objective-C++에서 변환한 뒤 Swift 구현체에 넘깁니다. Swift에서는 같은 프로토콜을 구현했습니다.

@objc(NativeSendySwiftImpl)
@objcMembers
final class NativeSendySwiftImpl:
    NSObject,
    NativeSendySwiftBridge {
 
    static let shared = NativeSendySwiftImpl()
 
    // 기존 Swift 기능 호출
}

이제 RCTNativeSendy.mm에는 React Native와의 연결에 필요한 코드만 남습니다. Codegen에서 생성한 타입을 처리하고, Swift에서 사용할 수 있는 형태로 변환한 뒤 해당 메서드를 호출하는 역할입니다.

화면 전환이나 인증 토큰 갱신 같은 실제 동작은 기존 Swift 코드에서 처리합니다.

예를 들어 React Native에서 현재 화면을 닫고 다른 화면으로 이동하도록 요청하면, Swift에서는 현재 내비게이션 상태를 확인한 뒤 적절한 화면 전환을 수행합니다. 이렇게 구성하면서 React Native가 기존 네이티브 앱의 내비게이션 구현을 직접 알 필요가 없어졌습니다.

인터페이스를 하나 더 관리하는 비용

수동 선언과 런타임 연결

물론 이 방법도 단점은 있습니다. 자동 생성된 Swift 헤더를 직접 사용하는 대신 수동으로 인터페이스를 작성했기 때문에, Objective-C 선언과 Swift 구현 사이의 일관성을 관리해야 합니다.

특히 Objective-C 셀렉터 이름과 Swift의 @objc 메서드 이름은 정확하게 일치해야 합니다. 잘못 선언하면 컴파일 단계에서 발견하지 못하는 런타임 문제가 발생할 수도 있습니다.

프로토콜로 드러나는 노출 범위

처음에는 인터페이스 파일을 하나 더 관리해야 한다는 점이 번거롭게 느껴졌습니다. 하지만 실제로 사용하다 보니 다른 장점도 있었습니다. React Native에서 호출할 수 있는 네이티브 기능이 프로토콜에 명시되어 있다는 점입니다.

새로운 기능을 노출하려면 먼저 인터페이스에 메서드를 추가해야 합니다. Swift 구현체는 해당 프로토콜을 준수해야 하므로 구현이 누락된 경우 컴파일 단계에서 확인할 수 있습니다.

기존 Swift 기능을 React Native에서 자유롭게 호출하도록 만드는 것보다, 필요한 기능을 명시적으로 노출하는 편이 책임을 구분하기 쉬웠습니다.

수동 인터페이스를 관리하는 비용은 남지만, 그 비용을 감수하더라도 기존 네이티브 기능과 React Native 사이의 경계를 명확히 유지하는 것이 낫다고 판단했습니다.

6. 도입 이후의 유지보수 비용

React Native 업데이트 주기

0.83 업데이트와 서드파티 호환성

React Native 통합 구조를 정리한 뒤에는 버전 관리도 고민해야 했습니다. 2025년 9월 처음 도입할 당시에는 React Native 0.79 계열을 사용했고, 이후 버전을 올리면서 0.81을 거쳐 0.83까지 업데이트하는 작업도 진행했습니다.

그 과정에서 서드파티 라이브러리 호환성 문제가 발생했습니다. 0.82에서 0.83으로 업데이트할 때 react-native-reanimated가 React Native 내부 API 변경에 대응하지 못해 빌드가 실패했습니다.

React Native 버전만 변경하면 끝나는 작업이 아니었습니다. 서드파티 라이브러리가 새로운 버전과 호환되어야 했고, Codegen이나 CocoaPods 의존성이 변경되는지도 확인해야 했습니다.

React Native 프로젝트는 별도 저장소로 관리하고 있었지만, 실제 iOS 앱에 포함되는 네이티브 의존성까지 독립적인 것은 아니었습니다. React Native 버전이 변경되면 Podfile.lock이 달라질 수 있고, 이는 네이티브 빌드 환경에도 영향을 줍니다.

업데이트 기준

처음에는 새로운 버전이 나오면 가능한 한 따라가는 방향으로 생각했습니다. 하지만 실제로 호환성 문제를 겪으면서 업데이트 기준이 필요하다고 느꼈습니다.

새 버전이 나왔다는 이유만으로 운영 중인 앱의 의존성을 변경할 필요는 없었습니다.

특정 기능이 필요하거나 해결해야 할 문제가 있다면 업데이트할 수 있습니다. 반대로 현재 사용하는 버전이 안정적으로 동작하고 있다면 무조건 최신 버전을 적용하는 것이 좋은 선택은 아닐 수 있습니다.

그래서 React Native 버전 업데이트를 일반 기능 개발과 분리해 관리하기로 했습니다.

  • 패치 버전: 호환성을 확인한 뒤 비교적 빠르게 반영
  • 마이너 버전: 서드파티 라이브러리와 네이티브 빌드를 검증한 뒤 적용

업데이트를 진행할 때도 React Native 자체뿐 아니라 iOS와 Android의 주요 기능까지 확인하도록 했습니다.

서브모듈 버전의 명시적 관리

--remote 옵션과 빌드 재현성

앞서 RNSendy를 Git 서브모듈로 관리하기로 했습니다. 서브모듈은 상위 저장소에 참조하는 커밋을 기록하기 때문에 React Native 코드가 변경되더라도 네이티브 프로젝트에서 사용할 버전을 관리할 수 있습니다.

하지만 초기 환경 설정에서는 다음 명령을 사용하고 있었습니다.

git submodule update --remote --merge

--remote 옵션을 사용하면 상위 저장소에 기록된 커밋 대신 설정된 원격 브랜치의 최신 커밋을 가져옵니다. 개발 중에는 React Native의 변경 사항을 쉽게 가져올 수 있어 편리했습니다.

반면 빌드할 때마다 어떤 커밋을 가져오는지 명확히 관리하지 않으면 예상하지 못한 변경이 포함될 수 있습니다.

릴리즈 브랜치 기준 관리

React Native를 업데이트하면서 네이티브 빌드가 실패하는 경험을 하고 나니, 언제 어떤 커밋을 반영할지 결정하는 과정이 필요하다고 생각했습니다. 이후에는 릴리즈 브랜치를 기준으로 React Native 버전을 관리하는 방향으로 정리했습니다.

[submodule "RNSendy"]
    path = RNSendy
    url = <RNSendy 저장소 URL>
    branch = release/1.1.0

.gitmodules의 브랜치 설정은 --remote를 사용할 때 어느 브랜치를 추적할지 지정합니다. 다만 브랜치를 지정했다고 상위 저장소에 기록된 커밋이 자동으로 고정되는 것은 아닙니다.

빌드 재현성을 유지하려면 일반적인 빌드에서는 기록된 서브모듈 커밋을 사용하고, 업데이트가 필요할 때 의도적으로 변경하는 편이 적합합니다.

중요한 것은 Git 서브모듈을 사용한다는 사실 자체가 아니었습니다. React Native의 변경 사항을 네이티브 앱에 언제 반영할지 결정할 수 있어야 한다는 것이었습니다.

React Native의 릴리즈 주기와 센디 앱의 릴리즈 주기를 반드시 동일하게 가져갈 필요는 없었습니다. 새로운 버전이 나왔다고 바로 따라가기보다는 실제로 필요한 변경인지 검토하고, 적용 시점을 선택할 수 있도록 하는 편이 운영 중인 앱에는 더 적합했습니다.

마치며

선택한 구조

처음 React Native를 도입하려던 이유는 iOS와 Android에서 공통으로 사용할 화면을 개발하기 위해서였습니다. 하지만 작업하면서 가장 많은 시간을 사용한 부분은 React Native 화면 구현보다 기존 네이티브 프로젝트와의 통합이었습니다.

  • 의존성 관리: SPM 중심 환경을 유지하려 Prebuild 방식을 검토했지만, 서드파티 네이티브 모듈까지 직접 빌드하고 등록하는 비용 때문에 공식 CocoaPods 통합 방식으로 복귀
  • 프로젝트 구조: 기존 네이티브 프로젝트를 유지하고 React Native를 서브모듈로 가져옴. 경로 문제는 내부 스크립트 수정 대신 심볼릭 링크로 해결
  • 중복 의존성: 외부 라이브러리를 포크하는 대신 네이티브에서 사용하는 Lottie 모듈을 분리
  • 빌드 환경: 같은 Swift 소스를 공유하되, 의존성과 빌드 설정은 별도 타깃과 컴파일 플래그로 관리
  • 네이티브 인터페이스: Objective-C++에는 연결 코드만 두고, 실제 동작은 Swift에 유지

남은 비용과 판단 기준

각각의 선택이 가장 간단한 구현 방법이었던 것은 아닙니다. CocoaPods를 다시 도입해야 했고, 별도로 빌드한 XCFramework를 관리해야 하는 비용도 생겼습니다. 수동으로 정의한 네이티브 인터페이스를 유지해야 하고, React Native 버전 업데이트에 따른 호환성 문제도 확인해야 합니다.

다만 작업을 진행하면서 계속 고려했던 부분은 같았습니다.

React Native를 도입하면서 얻는 이점이 기존 네이티브 앱을 유지하는 비용보다 커야 한다는 점이었습니다.

새로운 화면을 공통으로 개발하는 것은 분명한 이점이지만, 이를 위해 기존 앱의 빌드와 배포가 복잡해진다면 도입 목적과 맞지 않습니다. 그래서 당장 구현하기 쉬운 방법보다는 이후에 무엇을 직접 관리해야 하는지 비교하면서 구조를 선택했습니다.

모든 의존성을 하나의 패키지 매니저로 관리하는 목표는 지키지 못했지만, 적어도 React Native가 필요한 타깃과 기존 네이티브 타깃은 분리할 수 있었습니다.

현재는 기존 네이티브 앱을 React Native 없이도 빌드할 수 있고, React Native를 변경할 때 어떤 코드와 의존성을 확인해야 하는지도 구분할 수 있습니다.

브라운필드 도입에서 어려웠던 것은 React Native를 실행하는 방법 자체가 아니었습니다. 이미 운영 중인 앱에서 무엇을 그대로 유지하고, 새로운 기술이 책임질 범위를 어디까지로 정할 것인지 판단하는 과정에 더 많은 고민이 필요했습니다.

다음 글차주 위치를 실시간으로 공유하는 운송 추적