React Native의 Modal (2. Android Window 구조와 React Native Modal)

eeennsu·2026년 10월 5일

React Native

목록 보기
98/100

개요

React Native 의 Modal 이 Android 에서 유독 무겁게 느껴지는 이유를 코드에서 찾다 보면 답이 안 나온다. 원인이 JS 쪽이 아니라 그 아래, 안드로이드가 화면을 그리는 방식에 있기 때문이다.

Android 에서 <Modal> 은 android.app.Dialog 로 구현된다. 그리고 Dialog 는 새로운 윈도우를 만든다. 이 한 문장에서 모달의 편리함과 성능 문제가 동시에 나온다.

이 글은 안드로이드의 Window 가 무엇인지, RN 이 Modal 을 어떻게 구현했는지, 윈도우가 하나 더 생기면 구체적으로 무슨 비용이 붙는지를 정리한다. 성능 문제를 실제로 우회하는 방법은 다음 글에서 다룬다.



Android 의 Window

Android 에서 Window 는 화면에 무언가를 그리기 위한 가장 기본 단위의 컨테이너다. 여기서 자주 오해하는 부분이 있다.

Activity 가 곧 Window 인 게 아니다. Activity 는 Window 를 하나 들고 있는 것이고, Window 는 Activity 없이도 존재할 수 있다.

┌─────────────────────────────────────────┐
│  SurfaceFlinger  (최종 화면 합성)         │
└─────────────────────────────────────────┘
         ▲                    ▲
    ┌────┴─────┐         ┌────┴─────┐
    │ Window A │         │ Window B │   각각 독립된 Surface
    │ (메인)   │         │ (Dialog) │
    └────┬─────┘         └────┬─────┘
         │                    │
    ┌────┴─────┐         ┌────┴─────┐
    │ DecorView│         │ DecorView│   각 윈도우의 루트 뷰
    └────┬─────┘         └────┬─────┘
         │                    │
    View tree A          View tree B

각 Window 는 다음 네 가지를 독립적으로 가진다.

  • Surface — GPU 가 실제로 픽셀을 그려 넣는 버퍼
  • View tree — 그 윈도우가 담고 있는 뷰 계층
  • ViewRootImpl — measure → layout → draw 사이클을 돌리는 관리자
  • InputChannel — 시스템이 터치 이벤트를 전달하는 통로

이 네 개가 세트로 따라온다는 점이 중요하다. 윈도우가 하나 늘어나면 이 네 가지가 통째로 하나씩 더 생긴다.



Dialog 와 Window 의 관계

Dialog 는 안드로이드에서 새 윈도우를 띄우는 가장 일반적인 방법이다. AlertDialog, BottomSheetDialog 같은 것들이 전부 내부적으로 Dialog 다.

Dialog 를 띄우면 시스템은 순서대로 이런 일을 한다.

  1. 새 PhoneWindow 객체를 만든다
  2. WindowManager.addView() 로 시스템에 등록한다
  3. SurfaceFlinger 가 새 Surface 를 할당한다
  4. 그 윈도우가 기존 Activity 윈도우 위에 배치된다

핵심은 Activity 윈도우와 Dialog 윈도우가 형제 관계라는 것이다. Dialog 가 Activity 안에 들어 있는 게 아니라, 둘 다 WindowManager 가 관리하는 동등한 윈도우다. z-order 로 순서만 정해져 있을 뿐이다.

WindowManager
├── Activity Window   (z-order 낮음)
└── Dialog Window     (z-order 높음)

이게 Modal 이 zIndex 와 무관하게 항상 위에 뜨는 이유이자, 앱의 세이프 에어리어나 windowSoftInputMode 설정을 그대로 물려받지 않는 이유다. 애초에 다른 윈도우니까.



React Native Modal 의 Android 구현

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 에 윈도우 등록 및 해제
  • Surface 할당과 반납
  • ViewRootImpl 초기화
  • 시스템 윈도우 애니메이션
  • 모달 자식 트리 전체의 마운트와 언마운트

마지막 항목이 특히 크다. Modal 은 visible 이 false 면 children 을 렌더하지 않으므로, 열 때마다 안의 내용이 처음부터 만들어진다. 모달 안에 FlatList 나 차트가 있으면 그 초기 렌더 비용을 여는 순간에 몰아서 치른다.

안드로이드에서 모달을 열고 닫을 때 느껴지는 한 박자의 끊김은 이 항목들이 겹쳐서 생긴다.



iOS 의 경우

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 한 조각이면 위의 네 가지가 전부 없어진다. 그 패턴은 다음 글에서 다룬다.

profile
이력서 https://resume.eunsu.pro

0개의 댓글