RN 멀티 앱 아키텍처 01. RN 앱 두 개를 한 저장소에 두고, 배포는 따로 가져가기 글 표지
React Native모노레포아키텍처

RN 멀티 앱 아키텍처 01. RN 앱 두 개를 한 저장소에 두고, 배포는 따로 가져가기

웹 모노레포 경험을 바탕으로 처음 RN 모노레포를 구성하며, 익숙한 패키지 분리부터 Metro·네이티브 설정·배포 대상까지 살펴본 기록입니다.

연재 완료

RN 멀티 앱 아키텍처

읽는 중01

웹 모노레포 경험을 바탕으로 두 RN 앱을 구성하며 공통 코드, 네이티브 설정, 웹·모바일 UI의 경계를 정한 기록입니다.

전체 목차
  1. 01RN 앱 두 개를 한 저장소에 두고, 배포는 따로 가져가기현재 글
  2. 02앱 ID를 여러 파일에 복사하지 않기로 했다
  3. 03웹과 RN에서 같은 버튼을 쓰려면 어디까지 같아야 할까

삼성화재 컨시어지

임직원·배달원 앱의 RN 모노레포와 공통 패키지 구조를 설계한 과정을 정리했습니다.

프로젝트와 담당 역할 보기 →

웹에서는 모노레포를 여러 번 구성해 봤다. 앱과 공통 패키지를 나누고, 함께 사용하는 코드를 한곳에서 관리하는 방식은 익숙했다.

React Native 모노레포를 직접 구성하는 건 이번이 처음이었다. 회사 프로젝트에서 직원용 앱과 배달원용 앱의 구조를 설계하게 됐는데, 사용하는 사람과 업무 흐름은 달라도 버튼과 입력창, API 통신, 업데이트 확인 같은 코드는 함께 쓸 수 있었다.

그래서 두 앱을 한 저장소에 두기로 했다. 공통 버튼을 고칠 때 두 구현을 따로 찾아다니고 싶지는 않았다. 동시에 배달원 앱의 화면을 수정할 때 직원용 앱까지 같은 시점에 배포해야 하는 구조도 피하고 싶었다.

이번에는 각 앱에 iOS와 Android 프로젝트도 있었다. 공통 코드를 묶는 것과 함께 앱 식별자, 네이티브 설정, 배포 대상을 어떻게 나눌지도 정해야 했다. 웹에서 사용했던 방식을 출발점으로 삼고, RN에서 필요한 경계를 하나씩 정리해 봤다.

잔을 들고 미소 짓는 위대한 개츠비의 레오나르도 디카프리오
apps랑 packages까지는 익숙한 동네.

앱과 패키지를 나누는 건 익숙했다

앱의 화면은 각 앱 안에 두고, 함께 사용하는 코드를 공통 패키지로 옮기는 기준은 웹에서 해봤던 것과 같았다. 저장소의 큰 구조도 그 기준으로 잡았다. 설명에 필요한 폴더만 남기면 다음과 같다.

apps/
  employee/          # 직원용 앱
    src/
    ios/
    android/
  courier/           # 배달원용 앱
    src/
    ios/
    android/
packages/
  app-runtime/       # 앱 실행에 필요한 공통 환경
  backend/           # 프런트엔드 API 클라이언트
  design-system/     # 공통 UI
  shared-features/   # 두 앱이 실제로 함께 쓰는 기능
  test-kit/          # 공통 테스트 도구

각 앱의 화면은 apps에 두고, 함께 사용하는 코드는 packages로 나눴다. 여기서 backend는 서버 프로젝트가 아니라 API 통신을 맡는 프런트엔드 패키지다.

워크스페이스는 npm으로 구성했다. 루트 package.json에서 앱과 패키지의 위치를 지정한다.

{
  "private": true,
  "workspaces": ["apps/*", "packages/*"]
}

npm은 설치할 때 이 워크스페이스들을 연결해 준다. 앱에서는 공통 코드를 패키지 이름으로 가져올 수 있다. 이 동작은 npm workspaces 문서에 설명돼 있다.

버튼의 공통 동작을 바꾸면 같은 변경 안에서 두 앱의 사용처를 함께 수정할 수 있다. 다만 소스를 수정했다고 이미 설치된 두 앱이 바로 바뀌는 건 아니다. 변경한 코드를 어느 앱에 언제 배포할지는 별도로 정한다.

앱을 조립하는 코드는 각 앱에 남겼다

공유 범위를 정할 때도 익숙한 고민이 있었다. 두 앱의 진입점은 모두 Provider로 감싸고 내비게이터를 렌더링한다. 겉모양만 보면 하나로 합칠 수 있을 것 같다.

하지만 그 안에 어떤 화면을 넣고 어떤 흐름으로 연결할지는 앱마다 달랐다. 그래서 앱을 조립하는 코드와 내비게이션은 각 앱이 갖도록 했다.

공통 AppRuntime은 앱 설정과 children을 받는다. 업데이트 확인과 Query Provider 같은 실행 환경을 준비하고, 그 안에 들어갈 화면은 앱이 전달한다. 실제 진입점에서 주변 코드를 덜어내면 다음과 같은 모양이다.

// 직원용 앱
<AppRuntime config={employeeAppConfig}>
  <RootNavigator />
</AppRuntime>
 
// 배달원용 앱
<AppRuntime config={courierAppConfig}>
  <AuthProvider>
    <RootNavigator />
  </AuthProvider>
</AppRuntime>

여기서 이름이 같은 RootNavigator는 각 앱에 있는 서로 다른 컴포넌트다. 배달원 앱의 AuthProvider도 해당 앱에서 조립한다.

AppRuntime이 직원 화면과 배달원 화면을 직접 골라주지는 않는다. 화면을 추가하거나 로그인 이후의 이동 경로를 바꿀 때는 해당 앱 안에서 작업한다. 두 앱의 진입점에 비슷한 코드가 조금 남더라도 이 구분을 유지하기로 했다.

서로를 가리키는 두 스파이더맨
둘 다 RootNavigator지만 출근하는 앱이 다르다.

기능을 공통 패키지로 옮길 때도 같은 기준을 썼다. 두 화면이 비슷하게 생겼다는 이유만으로 먼저 합치지는 않았다. 함께 써야 하는 동작이 확인된 기능을 shared-features에 두고, 각 앱의 업무 흐름은 앱 안에 남겼다.

한 앱의 요구사항을 반영할 때마다 공통 컴포넌트에 앱 구분 조건을 추가해야 한다면, 그 기능을 같이 소유하는 게 맞는지 다시 볼 필요가 있다.

공통 패키지를 Metro가 읽을 수 있게

패키지를 나누는 방식은 익숙했지만, 이번 구성에서는 RN의 번들러인 Metro가 그 패키지를 읽는 범위를 맞춰야 했다. 앱 폴더를 기준으로 보면 공통 패키지가 src 바깥에 있기 때문이다.

npm이 워크스페이스를 연결하는 것과 Metro가 필요한 소스를 읽을 수 있는 것은 각각 확인할 부분이었다. 공유 패키지에서 가져온 코드가 각 앱의 번들에 들어갈 수 있도록 경로를 지정했다.

각 앱의 Metro 설정에서 저장소 루트를 watchFolders에 넣고, 앱과 루트의 node_modules 경로를 지정했다. NativeWind 설정 등을 제외한 관련 부분은 다음과 같다.

const appRoot = __dirname;
const repositoryRoot = path.resolve(appRoot, '../..');
 
const config = {
  projectRoot: appRoot,
  watchFolders: [repositoryRoot],
  resolver: {
    nodeModulesPaths: [
      path.resolve(appRoot, 'node_modules'),
      path.resolve(repositoryRoot, 'node_modules')
    ]
  }
};

watchFolders라는 이름 때문에 개발 중 파일 변경을 감시하는 설정으로만 읽기 쉽다. 실제로는 Metro가 프로젝트 루트 밖의 파일에 접근하는 범위에도 관여한다. CI에서 번들을 만들 때도 관련 파일이 projectRootwatchFolders 범위 안에 있어야 한다. Metro 설정 문서에서도 이 부분을 따로 설명한다.

NativeWind가 클래스를 찾는 범위도 앱마다 지정했다. 현재 앱과 공통 패키지를 읽도록 하고, 다른 앱의 전용 화면까지 스캔하지 않도록 했다. 패키지를 연결하는 설정, 번들러가 소스를 읽는 설정, 스타일을 찾는 설정을 각각 맞춘 셈이다.

ios와 android 폴더도 두 벌이다

RN 모노레포를 처음 구성하면서 새로 정해야 했던 부분은 네이티브 프로젝트를 어디에 둘지였다. 공통 JS 패키지를 두 앱이 사용하는 구조와, 최종적으로 설치되는 앱을 구분하는 구조를 함께 생각해야 했다.

이 프로젝트에서는 각 앱이 자신의 ios, android 디렉터리를 갖는다. iOS의 프로젝트와 scheme, Android의 namespace와 application ID를 앱별로 분리했다. 앱 버전 파일도 각각 둔다.

입을 벌리고 놀란 표정의 피카츄
저장소는 하나, 앱은 둘, 네이티브 프로젝트는 넷.

Xcode와 Gradle 설정에는 비슷한 부분이 생긴다. 파일 수를 줄이는 쪽만 생각하면 아쉬운 구조다. 그래도 직원용 앱과 배달원용 앱의 설치 대상과 버전을 따로 관리하는 편이 이번 프로젝트에 맞았다.

배포 코드까지 전부 복사할 필요는 없었다. Fastlane의 공통 구현은 루트에 두고, 앱별 파일은 어느 앱을 처리할지 선택하는 역할을 맡겼다. 절차는 함께 관리하고 대상은 앱별로 지정하는 방식이다.

OTA 업데이트도 앱을 구분한다. 업데이트 파일을 찾는 경로에 앱과 환경, 플랫폼, 바이너리 버전이 반영되도록 했다. 같은 저장 공간을 사용하더라도 서로 다른 앱의 업데이트 파일이 겹치지 않게 하기 위해서다.

물론 네이티브 프로젝트를 나눴다고 서로 완전히 무관해지는 건 아니다. 공통 패키지나 RN 의존성을 바꾸면 두 앱을 함께 확인해야 한다. 각 앱의 배포 시점을 선택할 수 있게 하면서도 공유 코드의 영향은 같이 살피는 구조다.

설정값은 한곳에서 고치도록 했다

앱을 각각 나누고 나니 설정을 읽는 곳도 함께 살펴봐야 했다. 개발용과 운영용 환경까지 구분하면 JS에서 읽는 값, Android 설정, iOS 설정에 같은 앱을 가리키는 정보가 들어간다. 이를 각각 손으로 맞추면 같은 의미의 값을 여러 군데에서 관리하게 된다.

이 값들은 config/applications.yml을 원본으로 삼았다. 스크립트가 내용을 검증하고 TypeScript 설정, Android의 Gradle 설정, iOS의 xcconfig 파일을 생성한다. CI에서는 원본으로 계산한 내용과 생성 파일이 일치하는지도 검사한다.

설정 파일은 여러 개 남지만 값을 수정하는 출발점은 한곳으로 모였다. 생성과 검사 과정은 2편에서 따로 살펴보려 한다.

공통 코드를 바꾸면 확인할 앱도 늘어난다

공통 버튼을 한 번에 고칠 수 있다는 건, 한 번의 수정이 두 앱에 영향을 줄 수도 있다는 뜻이다. 코드가 한곳에 있으니 확인할 곳도 하나로 줄어드는 건 아니었다.

CI에는 파일 경로로 Android JS 번들을 확인할 앱을 고르는 규칙을 넣었다. 앱 전용 소스가 바뀌면 해당 앱을 선택하고, 공통 패키지나 공통 설정이 바뀌면 두 앱을 선택한다. 문서만 바뀐 경우에는 이 번들 작업을 건너뛴다. 타입 검사와 테스트 같은 공통 품질 검사는 별도로 실행한다.

이 규칙은 패키지 의존 관계를 분석해서 영향 범위를 정확하게 계산하는 도구는 아니다. 공통 경로의 변경은 두 앱을 확인하는 쪽으로 잡은 단순한 규칙이다. Android JS 번들이 생성되는지 확인하는 작업과, 실제 네이티브 빌드·기기에서의 확인도 구분해서 보고 있다.

웹에서 가져온 기준에 네이티브 경계를 더했다

앱마다 저장소를 따로 만들고 공통 라이브러리를 배포하는 방식도 가능했다. 각 앱이 공통 라이브러리의 어느 버전을 사용할지 선택하기에는 그 방식이 편할 수 있다. 대신 공통 코드와 두 앱의 사용처를 함께 수정하려면 패키지 버전 반영 과정도 관리해야 한다.

이번에는 두 RN 앱과 공통 패키지를 같은 변경 안에서 다룰 수 있도록 한 저장소에 두었다. 관리자 웹은 실행 환경과 배포가 달라 별도 저장소로 유지했다.

웹에서 모노레포를 구성했던 경험은 공통 코드의 책임을 나누는 데 출발점이 됐다. 그 위에 이번에는 Metro의 소스 탐색 범위와 앱별 네이티브 프로젝트, 식별자와 배포 대상을 맞추는 작업이 더해졌다.

공통 UI와 실행 환경을 수정할 위치는 모였고, 앱별 화면과 네이티브 프로젝트는 각자 남았다. 진입점이나 네이티브 설정의 비슷한 코드를 모두 없애지는 않았다. 앱별 흐름을 바꿀 때 공통 진입점에 다른 앱의 분기를 계속 덧붙이는 구조를 피하고 싶었다.

이 구조에서 마음에 드는 부분도 거기에 있다. 직원용 앱을 수정할 때는 직원용 앱에서 시작하고, 둘이 같이 쓰는 버튼을 수정할 때는 공통 패키지로 간다. 코드를 어느 쪽에 두어야 할지 판단할 기준이 생겼다.