RN 멀티 앱 아키텍처
웹 모노레포 경험을 바탕으로 두 RN 앱을 구성하며 공통 코드, 네이티브 설정, 웹·모바일 UI의 경계를 정한 기록입니다.
목차
삼성화재 컨시어지
관리자 웹과 RN 앱에 같은 디자인 기준을 적용하며 정한 구현 방식을 정리했습니다.
같은 프로젝트에 관리자 웹과 RN 앱이 있었다. 화면에서 사용하는 버튼과 입력창은 같은 디자인 기준을 따르지만, 관리자 웹은 DOM으로 그리고 모바일은 RN 컴포넌트로 그린다.
둘 다 React를 쓰고, 스타일에도 bg-brand 같은 클래스 이름을 사용한다. 여기까지만 보면 버튼 코드도 함께 쓰면 될 것 같다. 하지만 안으로 들어가면 클릭과 터치, 글자 스타일, 포커스를 처리하는 방식이 다르다.
이 프로젝트에서는 디자인 토큰과 기본 옵션을 맞추고, 화면을 그리는 컴포넌트는 플랫폼별로 구현했다. 버튼 하나를 따라가 보면 어디까지 같게 만들었는지 설명하기 좋다.
같은 저장소를 쓰는 범위부터 달랐다
먼저 공유 범위를 구분할 필요가 있다. 직원용 앱과 배달원용 앱은 하나의 RN 모노레포 안에 있고, 둘 다 packages/design-system을 사용한다. 이 두 앱은 같은 모바일 컴포넌트 코드를 가져온다.
관리자 웹은 별도 저장소다. 웹과 모바일이 같은 Button 패키지를 설치하는 구성은 아니다. 현재는 각 저장소에 있는 디자인 토큰과 옵션을 같은 기준으로 관리하고, 변경할 때 양쪽 구현을 함께 살피는 방식이다.
관리자 웹 저장소
CSS 토큰 → DOM Button
↕ 같은 역할의 이름과 기본 옵션을 맞춤
RN 모노레포
JSON 토큰 → NativeWind preset → RN Button
├─ 직원용 앱
└─ 배달원용 앱웹의 버튼을 고쳤다고 RN 버튼이 자동으로 바뀌지는 않는다. 같은 기준으로 맞춰야 할 항목을 정해둔 것이고, 두 저장소에 반영하는 과정은 여전히 필요하다.
색상값보다 쓰임새를 이름에 담았다
화면 코드에서는 bg-brand, text-text-muted, border-border-default처럼 역할을 나타내는 이름을 사용한다. 어떤 색인지뿐 아니라 어디에 쓰는 색인지 구분하려는 것이다.
웹은 CSS 변수로, 모바일은 design-tokens.json과 NativeWind preset으로 이 이름을 연결한다. 모바일 preset에서 일부만 가져오면 다음과 같다.
colors: {
brand: {
DEFAULT: tokens.colors.brand,
strong: tokens.colors.brandStrong,
surface: tokens.colors.brandSurface,
},
text: {
strong: tokens.colors.textStrong,
muted: tokens.colors.textMuted,
inverse: tokens.colors.textInverse,
},
}호출하는 화면에서는 브랜드 기본색을 쓸지, 약한 배경색을 쓸지 고른다. 실제 값을 연결하는 작업은 토큰과 preset 쪽에서 맡는다. 같은 색상값을 사용하더라도 역할이 다른 항목은 이름을 나눠서 다룰 수 있다.
간격과 모서리 반경도 같은 방식으로 관리한다. 화면마다 값을 다시 해석해서 입력하는 일을 줄이고 싶었다.
호출하는 모양은 비슷하게
버튼은 size와 variant를 기본 옵션으로 갖는다. 웹과 RN 모두 크기는 lg, md, sm, xs, 2xs를 사용하고, primary, secondary, neutral, outline, ghost, danger를 기본 변형으로 둔다.
사용하는 쪽에서는 이런 모양이다. 두 Button은 각 플랫폼에 있는 별도 구현이다.
// 관리자 웹
<Button size="md" variant="primary" onClick={handleConfirm}>
확인
</Button>
// RN 앱
<Button size="md" variant="primary" onPress={handleConfirm}>
확인
</Button>여기서 모든 props가 같아야 한다고 정하지는 않았다. 웹의 onClick과 RN의 onPress는 각 플랫폼의 이벤트를 그대로 사용한다. 현재 RN에는 danger-soft, selected 변형도 추가로 있어서 지원하는 옵션 전체가 완전히 같지는 않다.
공통으로 사용하는 옵션의 의미를 맞추면서 플랫폼에 필요한 기능은 해당 구현이 제공하도록 했다.

글자색은 Text까지 내려가야 했다
웹 Button은 <button> 하나에 배경색과 글자색 클래스를 함께 적용한다. 모바일 Button은 누르는 영역과 글자를 나눠서 스타일을 준다. 실제 구현에서 스타일을 나누는 부분만 남기면 다음과 같다.
<MotionPressable
accessibilityRole="button"
className={buttonVariantClasses[variant]}
>
<Text className={labelVariantClasses[variant]}>{children}</Text>
</MotionPressable>RN의 텍스트 스타일 상속은 텍스트 하위 트리 안에서 제한적으로 동작한다. 웹의 일반적인 CSS 상속을 기대해서 부모 누름 영역에 글자색만 지정하는 식으로 옮기지는 않았다. 글자색과 크기는 Text에 직접 전달한다. RN Text 문서에서도 이 차이를 설명한다.
그래서 모바일에는 버튼 영역의 variant 클래스와 라벨의 variant 클래스가 따로 있다. primary를 선택하면 버튼은 브랜드 배경을, 라벨은 그 위에 놓을 글자색을 받는다.
호출하는 쪽이 매번 이 둘을 맞추게 할 필요는 없었다. 화면은 variant="primary"를 고르고, 내부 조합은 Button이 맡도록 했다.
hover와 햅틱은 각 플랫폼에서
웹 Button에는 마우스를 올렸을 때의 배경 변화와 키보드 포커스 표시가 있다. 비활성 상태에서의 커서와 클릭 가능 여부도 웹 구현이 처리한다.
RN Button은 MotionPressable과 Text를 사용하고, 누를 때의 햅틱 피드백을 연결한다. accessibilityRole을 버튼으로 지정하고, disabled 값도 접근성 상태에 반영한다. 위험 동작을 나타내는 variant에는 다른 햅틱 의도를 선택하도록 했다.
| 맞추는 기준 | 웹에서 처리하는 부분 | RN에서 처리하는 부분 |
|---|---|---|
| 기본 크기·variant의 이름과 의미 | DOM 버튼과 CSS 클래스 조합 | 누름 영역과 Text의 클래스 조합 |
| 누를 수 있는 상태 | disabled, 커서, 포커스 표시 | disabled, 접근성 상태, 터치 처리 |
| 상호작용 피드백 | hover, focus, active 스타일 | 누름 모션과 햅틱 |
이 동작들을 하나의 컴포넌트로 묶으면 플랫폼별로 갈라지는 코드도 함께 들어온다. 이번에는 렌더러를 나눠서 각 플랫폼에서 필요한 동작을 읽고 고칠 수 있게 했다.
같은 클래스 이름이어도 크기 계산은 확인했다
크기에서도 맞춰야 할 기준이 있었다. h-11처럼 rem을 사용하는 유틸리티는 기준값이 달라지면 계산 결과도 달라진다.
사용 중인 NativeWind 설정에서 네이티브 rem 기준의 기본값은 14다. 웹에서 기준으로 삼은 16에 맞추기 위해 두 앱의 Metro 설정에 inlineRem: 16을 지정했다. 이 옵션은 NativeWind 공식 문서에 설명돼 있다.
withNativeWind(config, {
input: path.resolve(appRoot, 'global.css'),
inlineRem: 16
});이 변경은 8월 11일 커밋에도 남아 있다. 두 앱의 설정을 함께 바꾸고, 같은 기준값이 들어가는지 확인하는 테스트를 추가했다.

rem 기준을 맞췄다는 이유로 웹과 RN의 화면이 픽셀 단위로 같다고 볼 수는 없다. 글꼴과 렌더링 방식, 화면 밀도도 다르다. 여기서는 같은 유틸리티 이름을 사용할 때 크기를 계산하는 기준부터 맞춘 것이다.
함께 고칠 부분이 남는다
웹과 모바일의 토큰을 하나의 패키지로 배포하면 값을 전달하는 과정은 자동화할 수 있다. 현재 구조는 그 단계까지 묶여 있지 않아서, 공통 기준을 바꿀 때 두 저장소에 반영하는 수고가 남는다.
반면 렌더러를 나눈 덕분에 웹의 포커스 표시나 RN의 햅틱을 고칠 때 상대 플랫폼의 이벤트까지 같은 형태로 만들 필요는 없었다. 토큰을 묶어 배포하게 되더라도 이런 동작은 각 구현에 남겨둘 수 있다.
두 RN 앱 사이에서는 같은 컴포넌트를 사용하고, 웹과 RN 사이에서는 같은 역할의 이름과 기본 옵션을 맞췄다. 플랫폼마다 공유할 수 있는 범위가 달랐다.
앞선 글에서 앱의 구조와 설정을 나눴던 것처럼, UI에서도 함께 관리할 기준을 정하는 데 시간을 썼다. 화면에서는 버튼의 크기와 역할을 고르고, 그 버튼을 실제로 어떻게 그리고 반응시킬지는 플랫폼별 컴포넌트가 맡는다. 이번 프로젝트의 디자인 시스템은 그 경계를 기준으로 구성했다.
