React Native 태블릿 앱 전환기 (feat. Expo, Skia)
React Native2026.07.24

React Native 태블릿 앱 전환기 (feat. Expo, Skia)

#React Native#Expo#Skia#EAS#PWA

들어가며

최근 사내에서 운영중인 PWA 웹 앱을 React Native로 전면 전환하는 프로젝트를 마무리했습니다. 현재 개발중인 프로덕트는 펜차트라는 앱으로, 주로 병의원에서 환자를 진료하고 차트를 작성할 때 사용하는 태블릿 앱 입니다. 주기능인 차팅 에디터를 비롯해 예약 및 접수 관리 등 병원에 필요로 하는 다양한 CRM 기능들을 제공하고 있습니다.
이번 포스팅에서는 전환을 결정하게 된 배경부터 기술 스택 선정 이유, 그리고 실제 도입 후기까지 차례로 정리해보겠습니다.

React Native 전환 배경

기존의 펜차트 앱은 오랫동안 PWA 웹 앱으로 운영되어 왔습니다. React로 작성된 웹을 iOS와 Android 각각의 네이티브 웹뷰에서 띄우는 방식이었죠. 제가 팀에 합류하기 한참전부터 운영되어온 프로젝트이니 자세한 히스토리를 알 수는 없지만, 당시로서는 최선의 기술스택이라 생각됩니다.
하지만 웹뷰 기반이다 보니 피할 수 없는 한계들이 있었습니다. 특히 차트 에디터처럼 높은 그래픽 성능을 요구하는 기능에서는 터치 반응 지연과 드로잉, 지우개 작업 시 프레임 드랍이 간헐적으로 발생했습니다. 네이티브와의 사용성 차이도 존재했구요. 웹이라 배포가 용이하다는 장점은 있었지만, 근본적인 해결을 위해서는 네이티브 전환이 필요하다는 결론에 이르렀습니다.
간단한 에디터 POC를 통해 전환이 가능할 것으로 판단되었고, 그렇게 React Native 전환을 진행하게 되었습니다.
 
 
notion image
 

Why React Native?

크로스 플랫폼으로 Flutter라는 선택지도 있었지만, React Native를 선택한 이유는 크게 다음과 같습니다.

Skia + React Native Reanimated - 고성능 드로잉 구현

RN을 선택하면서 가장 먼저 고려한 부분중 하나는 ‘네이티브 수준의 드로잉이 가능한가’ 였습니다. 그리고 Skia는 그 고민을 해결해주었습니다. (@shopify/react-native-skia)
Skia는 구글에서 개발한 오픈 소스 2D 그래픽 라이브러리로 Chrome, Android 및 Flutter에서 렌더링을 담당하고 있으며 매우 빠른 속도로 화면에 픽셀을 그립니다. React Native 환경에 도입된 Skia는 고성능 2D 그래픽을 선언적으로 그릴 수 있는 렌더링 엔진 역할을 제공합니다.
기존 React Native의 Bridge 아키텍처는 UI 업데이트 시 JavaScript와 네이티브 간 비동기 통신 과정을 거쳐야 했기 때문에 지연이 발생할 수 있었습니다. Skia는 JSI(Javascript Interface)를 통해 JS와 네이티브가 직접 통신하고, GPU 가속을 이용해 네이티브에서 바로 렌더링하기 때문에 이 병목을 피하고 60fps의 부드러운 드로잉을 가능하게 합니다.
Skia가 고성능 렌더링을 담당한다면 React Native Reanimated는 렌더링에 필요한 실시간 값을 처리하는 역할을 합니다. 터치 좌표를 계산하는 로직이 JS Thread의 병목에 갇히면 아무리 렌더링이 빨라도 실시간 드로잉은 끊길수밖에 없습니다. Reanimated는 터치 좌표 같은 실시간 값을 SharedValue로 관리하고, Worklet을 통해 UI Thread에서 직접 계산합니다. Skia는 이 값을 구독해 즉시 렌더링하기 때문에, 두 라이브러리의 조합으로 JS Thread를 거치지 않는 드로잉 파이프라인을 구성할 수 있었습니다.
Skia 관련해서는 추후 별도 포스팅으로 자세히 정리해보겠습니다.
 
Skia 드로링 예시
Skia 드로링 예시
 

Expo와 EAS(Expo Application Service)

Expo는 React Native의 대표적인 프레임워크입니다. React의 Next.js와 비슷한 포지션으로 볼 수 있죠. 최근에는 React Native 공식 문서에서도 새로운 프로젝트 시작 시 권장 프레임워크로 안내할 만큼 안정화되었습니다.
Expo의 라우팅 시스템은 Next.js처럼 파일 기반의 라우팅이기 때문에 기존에 웹 개발 경험이 있던 개발자도 쉽게 적응할 수 있었습니다. 또한 Expo SDK는 카메라, Image Picker, FileSystem 같은 네이티브 기능을 통일된 API로 제공해, 웬만한 네이티브 기능은 별도의 세팅 없이 사용할 수 있었습니다. Prebuild를 이용해 ios/, android/ 디렉토리를 자동으로 관리할 수 있어, Xcode나 Android Studio를 직접 만질 일도 크게 줄었습니다.
가장 큰 장점은 EAS Update를 통한 OTA(Over The Air) 업데이트입니다. 기존 AppCenter의 CodePush 서비스가 종료됨에 따라 OTA 업데이트의 대안이 필요했는데, EAS Update는 단순한 대체제를 넘어 단계적 롤아웃과 채널 기반 배포 관리 등 더 유연한 업데이트 전략을 제공했습니다. 특히나 초반 런칭 직후 빠른 이슈대응이 필요했는데, 스토어 심사 없이 JS 로직 변경사항을 즉시 배포할 수 있어 큰 도움이 되었습니다. 물론 MAU에 따라 과금이 달라지기는 하지만 웹처럼 재실행 시 업데이트 내역이 바로 반영되는점은 엄청난 매력으로 다가왔습니다.
Expo의 Dev Client는 편리한 무선 디버깅도 함께 제공
Expo의 Dev Client는 편리한 무선 디버깅도 함께 제공

React - 동일한 언어 생태계와 코드 재사용성

PWA와 React Native가 같은 React 언어를 공유한다는 점은 전환 과정에서 엄청난 이점이었습니다.
기존 PWA의 비즈니스 로직(API, 데이터 가공, 검증 등)이 이미 React 기반으로 잘 구축되어 있었기 때문에 앱 전환 시 기존 핵심 로직을 어렵지 않게 이전할 수 있었습니다. 물론 React Native 전용 컴포넌트로의 UI 전환이나 React-Query, Zustand, Zod, NativeWind 같은 최신 기술 스택의 도입이 필요하긴 했지만, 전반적인 로직의 흐름이 동일했기에 개발 진행 과정에서 큰 어려움은 없었습니다.
개발 생산성 관점에서도 큰 도움이 되었습니다. 앱과 웹을 동시에 관리하고 있는 상황에서 기본적인 비즈니스 로직 구조를 공유하다 보니, CRM WEB에 새로운 기능을 추가하거나 로직을 변경할 때에도 무리 없이 앱에 적용할 수 있었습니다.
 
펜차트의 핵심 기능인 차트 에디터
펜차트의 핵심 기능인 차트 에디터
 

개발 환경 구성

앞서 Skia, Expo 등 React Native를 선택한 핵심 이유에 대해 소개했는데, 이번에는 실제 프로젝트에서 사용중인 주요 기술 스택에 대해 간략히 정리해보겠습니다. React-Query, Zustand, React Hook Form + Zod 등도 사용하고 있지만 이미 많은곳에서 소개하고 있는 기술스택이라 이번 포스팅에서는 생략하겠습니다.
 

NativeWind - 스타일링

스타일링에는 NativeWind를 도입했습니다. React Native 버전의 Tailwind라고 생각하시면 편한데, className 기반으로 스타일을 작성할 수 있어 기존 Tailwind 개발경험을 그대로 재사용할 수 있었습니다. 특히 StyleSheet 대비 코드량이 줄고, 일관된 디자인 시스템을 유지하기에도 편리했으며 무엇보다 Claude Code, Codex등 AI 툴과 궁합이 좋아 개발 생산성을 크게 높일 수 있었습니다.
 
이제는 없어서는 안 될 React Native 버전의 Tailwind, NativeWind
이제는 없어서는 안 될 React Native 버전의 Tailwind, NativeWind
 

Feature Sliced Design (FSD) - 프로젝트 구조

프로젝트 구조는 제가 애용하고, 또 웹에서 많이 쓰이는 FSD(Feature-Sliced-Design)을 채택해서 사용하고 있습니다. 도메인 단위로 코드를 분리하고, 각 도메인 내부에서 타입, API, UI 등을 독립적으로 관리하고 있습니다.
FSD는 정해진 레이어를 모두 사용할 필요 없이 프로젝트 규모와 팀 사정에 따라 유동적으로 구성할 수 있는데, 현재 개발중인 프로젝트 구조를 간단히 표현하면 다음과 같습니다.
 
app/ # Expo Router 파일 기반 라우팅 │ src/ ├── entities/ # 비즈니스 엔티티 (도메인별 분리) │ └── {도메인}/ │ ├── types/ # 타입 정의 │ ├── apis/ # API 호출, React Query 훅, 쿼리 키 │ └── ui/ # 도메인 UI 컴포넌트 └── shared/ # 공통 모듈 ├── hooks/ # 공통 커스텀 훅 ├── providers/ # 앱 전역 Provider ├── stores/ # Zustand 스토어 ├── ui/ # 공통 UI 컴포넌트 └── utils/ # 유틸리티 함
 
도메인별로 타입, API, UI가 한 폴더에 모여 있어 유지보수 관점에서도 폴더 하나만 확인하면 되니 코드 파악과 수정이 빠르다는 점이 가장 큰 장점이며, 구조가 일관되게 정립되어 있어 Claude 및 AI 도구가 컨텍스트를 파악하기에도 유리합니다.
 

실전 트러블슈팅 이슈

앱 개발은 웹과는 사뭇 다른 느낌이었습니다. React Native가 크로스 플랫폼으로서 iOS와 AOS를 동시에 개발할 수 있다고는 하지만, 실제로는 각 플랫폼의 세세한 차이를 하나하나 커버해야 했습니다. 웹에서의 크로스 브라우징이 앱에서는 크로스 디바이스가 되는 셈이죠. 때로는 네이티브 속성을 직접 건드려야 하는 상황도 있었습니다. 개발과정에서 마주한 주요 이슈들과 해결 방법을 정리해보겠습니다.

안드로이드 글자 크기 고정

기획 단계에서 안드로이드의 모든 기기는 OS의 폰트 크기 설정과 무관하게 앱 내 글자 크기를 고정해야 하는 요구사항이 있었습니다. 폰이었다면 가변 사이즈를 사용할 수도 있었지만, 병원 펜차트 특성상 다양한 태블릿에서 일관된 UI를 보장해야 했기 때문입니다.
안드로이드에서 폰트 크기를 고정하는 방법은 크게 JS 레벨과 네이티브 레벨 두가지가 있습니다. JS 레벨에서는 Text.defaultProps.allowFontScaling=false로 처리할 수 있지만, defaultProps 자체가 deprecated 추세인 데다 서드파티 라이브러리 내부의 Text 컴포넌트까지는 커버할 수 없는 한계가 있었습니다. 결국 네이티브 레벨에서 fontScale을 1.0으로 강제 고정하는 방식을 선택했는데, 앱 전체에 일괄 적용되어 누락 없이 처리할 수 있기 때문입니다.
 
// MainApplication.kt (Config Plugin으로 자동 주입) override fun attachBaseContext(base: Context) { val config = Configuration(base.resources.configuration) config.fontScale = 1.0f super.attachBaseContext(base.createConfigurationContext(config)) } override fun onConfigurationChanged(newConfig: Configuration) { newConfig.fontScale = 1.0f super.onConfigurationChanged(newConfig) }
 
Expo managed 방식에서는 prebuild시 네이티브 코드가 매번 재생성되기 때문에, 네이티브 수정사항을 안전하게 유지하기 위해 Config Plugin을 작성해서 적용했습니다.
 
// app.config.js 플러그인 등록 plugins: [ "./plugins/with-font-scale-fix", // ... ]
 

안드로이드 모바일 가로모드시 TextInput이 뒤덮는 이슈

펜차트는 태블릿을 기준으로 개발된 앱이기에 모바일 환경에서는 최소한의 기능만 제공하며, 가로모드를 기본으로 고정하고 있습니다. 그런데 안드로이드 모바일 기기에서 가로모드 상태로 TextInput에 포커스하면, 입력창이 전체 화면을 뒤덮어버리는 현상이 있었습니다. 로그인 화면에서 이메일이나 비밀번호를 입력할 때 화면 전체가 TextInput으로 덮여 다른 UI가 보이지 않는 문제가 발생했던거죠.
이는 Android의 기본 동작으로, 화면이 좁은 가로모드에서 키보드가 올라오면 시스템이 자동으로 TextInput을 전체화면 모드로 전환하기 때문입니다. React Native에서는 disableFullscreenUI 속성으로 간단히 해결할 수 있었습니다.
 
<TextInput disableFullscreenUI={true} // ... />
 
안드로이드 가로모드에서 Input이 화면 전체를 뒤덮음
안드로이드 가로모드에서 Input이 화면 전체를 뒤덮음
disableFullScreenUI 적용
disableFullScreenUI 적용

iOS 가로모드 Modal Crash 이슈

이번에도 동일하게 가로모드에서 발생한 이슈였습니다. Modal은 React Native에서 팝업 오버레이 UI를 구현할 때 가장 많이 사용되는 컴포넌트인데, iOS 기기에서 Modal을 열 때 앱이 크래시되는 문제가 발생했습니다. 원인은 React Native의 Modal이 기본적으로 portrait만 지원하는데, 앱이 가로모드로 고정된 상태에서 Modal을 띄우면 Orientation 충돌이 발생하기 때문이었습니다.
ModalsupportedOrientations 속성을 추가해 가로모드를 허용하면 해결됩니다. 다만 Modal을 공통 컴포넌트로 감싸서 사용한다면 한 곳만 수정하면 되지만, 개별 Modal을 사용하고 있다면 모든 Modal에 빠짐없이 추가해야 하므로 누락되진않았는지 확인이 필요합니다.
 
<Modal supportedOrientations={[ "portrait", "landscape", "landscape-left", "landscape-right", ]} // ... >
 

iOS 제스처 충돌 - Pull to Refresh 프리징 현상

iOS 아이패드에서 RefreshControl(pull to refresh)을 사용하는 화면에서, 새로고침 제스처 도중 다른 화면으로 이동하면 앱이 프리징되는 문제가 있었습니다. 아이패드의 멀티태스킹 제스처(손가락 오므리기 등)와도 충돌이 발생했습니다. 이 이슈는 React Native Issue에도 보고되어 있는 알려진 문제로, 아직 근본적인 수정은 이루어지지 않은 상태입니다.
네이티브 RefreshControl 자체의 문제이기 때문에, iOS에서만 Gesture Handler + Reanimated로 커스텀 pull to refresh를 직접 구현하는 방식으로 우회했습니다. Android에서는 네이티브 RefreshControl을 그대로 사용합니다.
커스텀 구현에서는 pullDistanceSharedValue로 관리하고, 임계값 초과 시 저항을 적용해 네이티브와 유사한 고무줄 효과를 재현했습니다. 근본 해결은 아니지만, 중요한 페이지들에서는 프리징 없이 안정적으로 동작하도록 수정하였습니다.
 
{Platform.OS === "ios" ? ( // Gesture Handler + Reanimated 기반 커스텀 pull to refresh <GestureDetector gesture={panGesture}> <Animated.FlatList style={pullListStyle} // ... /> </GestureDetector> ) : ( // Android: 네이티브 RefreshControl 사용 <FlatList refreshControl={ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> } // ... /> )}
 
이 외에도 Form 관리, HTML 렌더링, RichTextEditor, 키보드 처리 등 다양한 트러블슈팅들이 있었지만, 추후 포스팅을 통해 상세히 다뤄보겠습니다.
 

AI, AI 그리고 AI

AI의 시대인 만큼, AI를 뺴놓고는 이번 React Native 개편 프로젝트를 설명할 순 없었습니다. 수년동안 운영되어 온 PWA를 몇 달 만에 React Native로 전환할 수 있었던 건 AI가 단순한 코드 완성도구를 넘어 수많은 동료 개발자가 되어주었기 때문입니다.

히스토리 파악

이번 프로젝트의 스펙은 기존 PWA기능 호환 + 고성능 에디터 추가가 목적이었습니다. 기존 사용자들이 이질감 없이 앱을 사용할 수 있도록 기존 기능들은 동일하게 작동해야했죠. 특히나 오랫동안 운영되어왔던 PWA였기에 기존 기능을 React Native로 마이그레이션하는데 큰 도움을 받았습니다.

스펙 기반 개발

요즘 핫한 SDD(Spec Driven Development)와 일치할지는 모르겠지만, 나름 스펙 기반의 개발을 하려고 노력했습니다. 개발 시작 전, AGENTS.md에 프로젝트의 구조화된 틀을 먼저 정의했습니다. FSD 폴더 구조, NativeWind 스타일링 규칙, 코딩 컨벤션은 물론 에디터의 경우 Skia 좌표 시스템, SharedValue 흐름, 제스처 처리 로직까지 상세한 스펙을 문서로 작성한 뒤 개발에 들어갔습니다. 이렇게 정의된 스펙을 AI에게 컨텍스트로 제공한 덕분에, 프로젝트 컨벤션에 맞는 일관된 코드를 생성할 수 있었고, 복잡한 에디터 로직도 정의된 스펙 위에서 빠르게 구현할 수 있었습니다.
 

Maestro를 활용한 E2E 테스트

프론트엔드 분야에서 AI가 빛나는 영역 중 하나가 바로 E2E 테스트라고 생각하는데요, 테스트 시나리오 작성, TC(Test Case) 정리 등 반복적이면서도 꼼꼼해야 하는 작업을 AI에게 맡긴덕분에 빠르고 누락 없이 처리할 수 있기 때문입니다.
Expo 환경에서는 E2E 테스팅 도구로 Maestro를 사용했습니다. YAML 기반으로 테스트 시나리오를 작성하는데, 요즘은 AI가 문서까지 직접 작성해주기 때문에 개발자가 테스트 코드를 일일이 작성할 필요도 없죠. 전체적인 맥락과 테스트 시나리오만 전달하면 AI가 YAML로 변환해주고, 개발자는 테스트 결과만 검수하면 됩니다. 덕분에 기능 개발과 동시에 E2E 테스트를 병렬로 돌릴 수 있어, 개발 속도를 유지하면서도 품질을 챙길 수 있었습니다.
 
notion image
 

마치며

기존에도 앱 개발 경험이 없진 않았지만, 수백 곳의 병원에서 사용하는 엔터프라이즈급 프로덕트 개발은 처음이었습니다. 부담이 없다면 거짓말이겠지만, 훌륭한 동료들이 있었기에 성공적으로 런칭하고 안정적으로 운영할 수 있었습니다.
특히나 AI를 활용해 개발 기간을 크게 단축할 수 있었습니다. 오랫동안 운영되어온 앱인 만큼 생각보다 방대한 기능들이 존재했는데, 히스토리 파악부터 개발 생산성, 품질관리까지 다양한 측면에서 AI의 도움을 받을 수 있었습니다.
또한 Skia 기반 차트 에디터 개발과 태블릿 최적화는 흔히 접할 수 없는 경험이었습니다. 고성능 드로잉을 위해 ReanimatedSharedValueWorklet을 깊이 다뤄볼 수 있었고, 다양한 태블릿 기기에서의 회전, 제스처, 핀치 줌까지 모바일과는 또 다른 챌린지를 경험할 수 있었던 값진 프로젝트였습니다.
이번 펜차트 앱 전환 덕분에 개인적으로 React Native라는 무기가 하나 더 생긴 것 같습니다. 이전까지는 사이드 프로젝트 수준으로 다뤄본 게 전부였지만, 실제 현장에서 수백 곳의 병원이 사용하는 앱을 만들고 운영하는건 완전히 다른 경험이었으니까요. 생각지도 못한 이슈들, 무수히 많은 경우의 수를 마주하며 같은 React이지만 또 다른 영역이라는 것을 느낄 수 있었고, 앞으로도 이 경험을 바탕으로 더 나은 프로덕트를 만들어 나가고 싶습니다.