React Native 의 Modal 이 Android 에서 유독 무겁게 느껴지는 이유를 코드에서 찾다 보면 답이 안 나온다. 원인이 JS 쪽이 아니라 그 아래, 안드로이드가 화면을 그리는 방식에 있기 때문이다.
Android 에서 <Modal> 은 android.app.Dialog 로 구현된다. 그리고 Dialog 는 새로운 윈도우를 만든다. 이 한 문장에서 모달의 편리함과 성능 문제가 동시에 나온다.
이 글은 안드로이드의 Window 가 무엇인지, RN 이 Modal 을 어떻게 구현했는지, 윈도우가 하나 더 생기면 구체적으로 무슨 비용이 붙는지를 정리한다. 성능 문제를 실제로 우회하는 방법은 다음 글에서 다룬다.
Android 에서 Window 는 화면에 무언가를 그리기 위한 가장 기본 단위의 컨테이너다. 여기서 자주 오해하는 부분이 있다.
Activity 가 곧 Window 인 게 아니다. Activity 는 Window 를 하나 들고 있는 것이고, Window 는 Activity 없이도 존재할 수 있다.
┌─────────────────────────────────────────┐
│ SurfaceFlinger (최종 화면 합성) │
└─────────────────────────────────────────┘
▲ ▲
┌────┴─────┐ ┌────┴─────┐
│ Window A │ │ Window B │ 각각 독립된 Surface
│ (메인) │ │ (Dialog) │
└────┬─────┘ └────┬─────┘
│ │
┌────┴─────┐ ┌────┴─────┐
│ DecorView│ │ DecorView│ 각 윈도우의 루트 뷰
└────┬─────┘ └────┬─────┘
│ │
View tree A View tree B
각 Window 는 다음 네 가지를 독립적으로 가진다.
이 네 개가 세트로 따라온다는 점이 중요하다. 윈도우가 하나 늘어나면 이 네 가지가 통째로 하나씩 더 생긴다.
Dialog 는 안드로이드에서 새 윈도우를 띄우는 가장 일반적인 방법이다. AlertDialog, BottomSheetDialog 같은 것들이 전부 내부적으로 Dialog 다.
Dialog 를 띄우면 시스템은 순서대로 이런 일을 한다.
PhoneWindow 객체를 만든다WindowManager.addView() 로 시스템에 등록한다핵심은 Activity 윈도우와 Dialog 윈도우가 형제 관계라는 것이다. Dialog 가 Activity 안에 들어 있는 게 아니라, 둘 다 WindowManager 가 관리하는 동등한 윈도우다. z-order 로 순서만 정해져 있을 뿐이다.
WindowManager
├── Activity Window (z-order 낮음)
└── Dialog Window (z-order 높음)
이게 Modal 이 zIndex 와 무관하게 항상 위에 뜨는 이유이자, 앱의 세이프 에어리어나 windowSoftInputMode 설정을 그대로 물려받지 않는 이유다. 애초에 다른 윈도우니까.
RN 의 Android 소스에서 Modal 을 담당하는 클래스는 ReactModalHostView 다. 이름은 View 지만 자기 자식을 직접 그리지 않는다. 하는 일은 Dialog 를 만들고 거기에 내용을 얹는 것이다.
// ReactModalHostView.kt (개념적 요약)
class ReactModalHostView(context: Context) : ViewGroup(context) {
private var dialog: ComponentDialog? = null
private val hostView = DialogRootViewGroup(context)
private fun showOrUpdate() {
dialog = ComponentDialog(context, theme).apply {
setContentView(hostView) // 모달 내용은 여기로 들어간다
show() // 이 시점에 새 Window 가 생성된다
}
}
}
여기서 두 가지를 짚어두면 나중이 편하다.
React 트리는 하나다. Modal 의 children 은 부모 화면과 같은 React 트리, 같은 surface 안에 있다. Context 도 그대로 내려가고 state 도 공유된다. 분리되는 건 React 트리가 아니라 네이티브 뷰 계층이다. "모달이 다른 트리에 있어서 느리다"는 설명은 정확하지 않다.
모달 내용의 루트는 DialogRootViewGroup 이다. 이 클래스는 RN 의 RootView 인터페이스를 구현하고, 자체 JSTouchDispatcher 와 JSPointerDispatcher 를 들고 있다.
jsTouchDispatcher.handleTouchEvent(event, eventDispatcher, reactContext)
jsPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
즉 모달 안의 터치는 앱 화면의 루트 뷰가 아니라 이 별도의 디스패처를 통해 JS 로 올라간다. 앱 화면과 모달은 서로의 터치를 전혀 모른다.
여기서부터가 실제 비용이다. 흔히 "렌더링 비용이 두 배가 된다"고 말하는데, 그건 과장이다. 정확히 무엇이 늘어나는지 항목별로 보는 편이 낫다.
윈도우마다 ViewRootImpl 이 독립적으로 measure → layout → draw 를 돌리고, SurfaceFlinger 가 두 Surface 를 합성해 최종 화면을 만든다.
부모 화면이 변하지 않으면 부모의 draw 는 다시 돌지 않으므로 매 프레임 비용이 두 배가 되지는 않는다. 실제로 늘어나는 건 합성해야 할 레이어 하나와, 두 렌더링 사이클의 타이밍이 어긋날 수 있다는 가능성이다. 모달이 열리는 도중에 부모 화면에서도 setState 가 일어나면 두 윈도우가 같은 프레임을 두고 경쟁하면서 끊김이 눈에 띈다.
그림자, blur, 반투명 backdrop 처럼 합성 부담이 큰 요소가 모달 안에 있으면 이 비용이 커진다.
모달 안의 버튼을 눌렀을 때 이벤트는 이런 경로를 거친다.
터치
→ InputDispatcher (시스템)
→ Dialog Window 의 InputChannel
→ DialogRootViewGroup
→ JSTouchDispatcher
→ EventDispatcher → JS
일반 View 였다면 앞의 두 단계가 앱 화면의 것으로 대체될 뿐, JS 로 올라가는 뒷부분의 비용은 사실상 같다. 그러니 "이벤트 전달 비용이 두 배"라고 말하기는 어렵다.
실제로 체감되는 건 다른 쪽이다. 모달이 열려 있는 동안 입력 포커스는 Dialog 윈도우가 독점한다. 그래서 BackHandler 가 동작하지 않고, 앱 화면 쪽에 걸어둔 제스처 핸들러도 이벤트를 받지 못한다. 그리고 윈도우 애니메이션이 끝나기 전에는 포커스 전환이 마무리되지 않아서, 모달이 막 뜬 직후의 첫 탭이 무시되는 현상이 나타난다. "두 번 눌러야 동작한다"는 증상의 정체가 대체로 이것이다.
New Architecture 에서 특히 눈여겨볼 지점이다.
일반 View 는 부모의 레이아웃 계산 안에서 자기 크기가 바로 정해진다. 그런데 Modal 은 다르다. Dialog 윈도우의 실제 크기는 안드로이드가 윈도우를 띄워봐야 알 수 있고, ReactModalHostView 는 그 값을 StateWrapper.updateState() 로 shadow tree 에 되돌려준다. 그 상태가 반영된 다음에야 모달 안의 자식들이 레이아웃된다.
즉 모달 내용은 네이티브에서 JS 쪽 shadow tree 로 한 번 왕복한 뒤에 자리를 잡는다. 모달이 열리는 첫 프레임에 내용이 엉뚱한 위치에 잠깐 보이는 종류의 버그가 이 왕복 구간에서 나온다. 그리고 모달 안에서 크기에 영향을 주는 state 변경이 잦으면 이 왕복이 반복된다.
visible 을 토글할 때마다 다음이 실행된다.
WindowManager 에 윈도우 등록 및 해제마지막 항목이 특히 크다. Modal 은 visible 이 false 면 children 을 렌더하지 않으므로, 열 때마다 안의 내용이 처음부터 만들어진다. 모달 안에 FlatList 나 차트가 있으면 그 초기 렌더 비용을 여는 순간에 몰아서 치른다.
안드로이드에서 모달을 열고 닫을 때 느껴지는 한 박자의 끊김은 이 항목들이 겹쳐서 생긴다.
iOS 의 RN Modal 은 UIViewController 를 present 하는 방식이다. 뷰 컨트롤러 계층이 하나 더 생기긴 하지만, UIWindow 자체를 새로 만들지는 않는다.
윈도우는 그대로 하나이므로 Surface 가 늘어나거나 입력 채널이 분리되는 일이 없다. UIKit 의 프리젠테이션 전환은 시스템이 오래 다듬어온 경로이기도 하다. 안드로이드에서만 유독 모달이 무겁게 느껴지는 이유가 여기에 있다.
다만 iOS 라고 공짜는 아니다. 뷰 컨트롤러 전환이 끝나기 전에 다음 모달을 띄우면 표시가 실패하고, pageSheet 계열은 프리젠테이션 스타일이 레이아웃에 직접 영향을 준다. 모달을 연속으로 다루는 UX 라면 양쪽 다 순서를 관리해야 하는 건 같다.
Android 의 RN <Modal> 은 android.app.Dialog 이고, Dialog 는 별도의 Window 다. 윈도우가 하나 늘어난다는 건 Surface, ViewRootImpl, InputChannel 이 통째로 하나씩 더 생긴다는 뜻이다.
여기서 나오는 비용은 네 가지다. 합성 레이어가 하나 늘어나고, 입력 포커스가 분리되고, New Architecture 에서는 윈도우 크기가 shadow tree 로 한 번 왕복하며, 열고 닫을 때마다 윈도우 생성과 자식 트리 마운트를 처음부터 다시 한다.
반대로 분리되지 않는 것도 분명히 해두면 좋다. React 트리와 state 는 부모 화면과 그대로 공유된다. 문제는 JS 쪽이 아니라 네이티브 뷰 계층이 둘로 나뉘는 데서 온다.
그래서 "Modal 처럼 보이는 View" 로 바꾸는 접근이 단순한 꼼수가 아니다. 같은 윈도우 안의 View 한 조각이면 위의 네 가지가 전부 없어진다. 그 패턴은 다음 글에서 다룬다.