스크롤이 안 되던 진짜 이유 - React Native의 제스처 시스템 분석과 레이아웃 개선기
문제
채팅방 화면을 FlatList로 구현했는데, 이때 채팅방 스크롤이 간헐적으로만 작동했습니다. 가끔씩만 화면이 조금 움직였고, 대부분 전혀 스크롤 되지 않았습니다.
레이아웃 v1
기존의 레이아웃 구조입니다. 이 구조를 레이아웃 v1이라 부르겠습니다.

모든 화면의 최상단에는 ScreenLayout이 있고, ScreenLayout은 화면 터치를 감지해서 키보드를 닫는 KeyboardDismissWrapper를 포함합니다. 그 안에는 ContentScrollView 또는 FlatList가 실제 화면의 내용물을 감싸고 있습니다.
대부분의 스크롤 가능한 화면에서는 ContentScrollView를 사용하고 있고, 채팅방에서는 가상화 기술이 적용된 FlatList를 사용했습니다.
그런데 ScrollView를 사용한 다른 화면들은 지금까지 문제가 없었습니다.
관련 내용을 찾아보다 React Native의 Gesture Responder System을 알게 되었고, 어떻게 동작하는지 직접 실험을 해보았습니다.
Gesture Responder System 살펴보기
Gesture Responder System은 앱에서 터치 이벤트에 어느 요소가 반응할지 결정합니다.
터치 이벤트가 발생하면, 시스템은 다음 순서대로 반응할 요소를 결정합니다.
- 터치 시작
- [Hit test]: 네이티브 View 트리에서 터치 좌표에 걸리는 가장 안쪽 View(leaf)를 재귀적으로 찾습니다.
- [Capture 단계]: 최상위 View부터 하위 View까지 내려가면서
onStartShouldSetResponderCapture(event)호출. true를 반환한 View가 자식이 결정되는 것을 막고 responder가 됩니다. - [Bubble 단계]: 3번에서 아무도 responder가 되지 않았다면, leaf부터 올라가면서
onStartShouldSetResponder()을 호출합니다. - 처음으로 true를 반환한 컴포넌트가 responder로 결정됩니다.
캡처 단계에서부터 responder가 되는 경우는 다음과 같습니다.
Modal의BackDrop은 화면 전체를 덮고 터치 이벤트를 하위 레이어로 전달하지 않습니다.PanResponder의 경우 캡처 단계에서 호출되는 핸들러에서 true를 반환해 전역적으로 이벤트를 선점합니다. 자세한 내용은 여기를 확인해보세요.
자식과 responder 경쟁을 하지 않고 무조건 먼저 가져가겠다는 의도를 가진 경우입니다.
반면 Touchable류나 Pressable은 버블 단계에서 responder가 됩니다.
가설 - TouchableWithoutFeedback이 responder를 선점한다?
처음에는 문제의 원인을 이렇게 추측했습니다.
부모인
TouchableWithoutFeedback이 터치 이벤트를 먼저 가져가서, 자식인 FlatList까지 이벤트가 전달되지 않은 게 아닐까?
하지만 버블 단계에서 responder가 된다면, 이 가설은 성립할 수 없게 됩니다. 보다 확실하게 검증하기 위해 직접 문제 상황을 재현했습니다. 그리고 로그를 찍어서 어느 요소가 responder가 되는지 확인했습니다.
실험 과정
실험 1. 정상 동작하던 구조
이전부터 문제가 없었던 구조입니다.
<TouchableWithoutFeedback>
<ScrollView ... />
</TouchableWithoutFeedback>
스크롤은 정상적으로 작동했습니다. 로그는 다음과 같습니다.
[ContentScrollView] onStartShouldSetResponder: true
[ContentScrollView] onMoveShouldSetResponder: true
[ContentScrollView] onResponderGrant - 🟢 Responder
[ContentScrollView] onScrollBeginDrag - 스크롤 시작
ScrollView가 responder가 되었습니다. 이 단계에서 TouchableWithoutFeedback은 문제가 아니라는 것을 알게 되었습니다.
실험 2. 문제가 발생한 구조
문제가 발생했던 채팅방의 구조입니다.
<TouchableWithoutFeedback>
<View>
<FlatList ... />
</View>
</TouchableWithoutFeedback>
차이점
ScrollView대신FlatList를 사용했습니다.FlatList내부에Pressable로 감싸진 사진 아이템이 있습니다.
이 구조에서는 다음 현상이 일어났습니다.
- 사진 위에서 터치할 때는 스크롤이 가능했습니다.
- 하지만 빈 영역에서는 전혀 작동하지 않았습니다.
- 사진 아이템의 터치 이벤트가 작동하지 않았습니다.
정리해보면, FlatList는 responder가 되지 못했고, 그 자식조차 제대로 처리되지 못했습니다.
여기서 간과하고 있던 하나의 차이점이 더 있었습니다. 바로, 중간에 View가 한 번 더 감싸고 있다는 것입니다.
실험 3. 문제 구조 + View 제거
이번엔 문제가 됐던 구조에서 View를 제거해보았습니다.
<TouchableWithoutFeedback>
<FlatList ... />
</TouchableWithoutFeedback>
스크롤이 정상적으로 작동했습니다.
실험 4. 정상 구조 + View 추가
채팅방 구조와 통일하기 위해 정상 동작하던 구조에 View를 추가해보았습니다.
<TouchableWithoutFeedback>
<View>
<ScrollView ... />
</View>
</TouchableWithoutFeedback>
이 구조에서는 스크롤이 전혀 작동하지 않았습니다. 로그는 다음과 같습니다.
[ScreenLayout Wrapper View] onStartShouldSetResponder: false
[ScreenLayout TouchableWithoutFeedback] onPressIn
[ScreenLayout TouchableWithoutFeedback] onPress - Keyboard.dismiss()
ScrollView까지 아예 이벤트가 도달하지 않았습니다.
실험 5. TouchableWithoutFeedback -> Pressable
이번에는 정상 구조에서 TouchableWithoutFeedback 대신 Pressable를 써보았습니다. 둘 다 터치를 감지하
<Pressable>
<ScrollView ... />
</Pressable>
이 구조에서도 스크롤이 전혀 작동하지 않았습니다. 왜 그럴까요?
TouchableWithoutFeedback과 Pressable의 내부 구현체를 직접 살펴보았습니다.
분석 6. Pressable vs TouchableWithoutFeedback 구현체 비교
소스 코드를 뜯어보면 두 코어 컴포넌트의 구조가 완전히 다릅니다.
- TouchableWithoutFeedback
return cloneElement(element, elementProps, ...children);
TouchableWithoutFeedback은 하나의 자식 컴포넌트를 복제(cloneElement)하여 props를 주입합니다. 별도의 네이티브 View를 만들지 않으므로 hit test에서 자식 요소가 leaf가 됩니다. 따라서 자식 요소가 responder가 됩니다.
- Pressable
// 핸들러 부분은 축약해서 표현
return <View {...eventHandlers}>{children}</View>;
반면 Pressable은 내부에 View를 렌더링하고, 그 View 자체가 responder 후보가 됩니다. hit test의 leaf가 자식(ScrollView/FlatList)이더라도, 자식은 버블 단계에서 false를 반환하고 Pressable이 true를 반환하여 선점하게 됩니다.
정리하면, TouchableWithoutFeedback은 자식이 leaf가 되기 때문에 가능한 것이고, Pressable은 탭 상호작용 처리를 위해 자식보다도 먼저 responder를 선점하게 됩니다.
원인 확정
실험과 내부 구현체 분석을 통해 문제의 원인을 요약했습니다.
| 구조 | 결과 | 이유 |
|---|---|---|
| TouchableWithoutFeedback > ScrollView/FlatList | 🟢스크롤 가능 | 자식이 leaf → 버블 단계에서 먼저 responder 경쟁 참여 |
| TouchableWithoutFeedback > View > ScrollView/FlatList | 🔴스크롤 불가 | 중간 View가 leaf → 버블 단계에서 TouchableWithoutFeedback이 responder |
| Pressable > ScrollView/FlatList | 🔴스크롤 불가 | Pressable이 responder 선점 |
| Pressable > View > ScrollView/FlatList | 🔴스크롤 불가 | Pressable이 responder 선점 |
간과하고 넘어갔던 중간 View의 유무와, TouchableWithoutFeedback과 Pressable의 동작 차이가 복합적으로 만들어 낸 문제였습니다.
개선 - 레이아웃 v2
위의 결과를 통해 TouchableWithoutFeedback로 감싸는 것은 좋은 설계가 아니라는 것을 알게 되었습니다. 중간의 View는 실수로라도 들어갈 수 있는데, 그럴 때마다 부모가 responder가 되어 스크롤이 동작하지 않는다면 어떨 때는 되고 어떨 때는 안 되는 레이아웃이 됩니다.
제가 낸 결론은 다음 세 가지입니다.
ScrollView/FlatList를TouchableWithoutFeedback으로 감싸지 않는다.ScrollView/FlatList의 키보드 관련 props만으로 dismiss 처리한다.- 자식 요소 내부에 필요한 경우에만 지역적으로 dismiss 처리한다.
이전에 키보드 관련 props의 존재를 모르고 전역으로 TouchableWithoutFeedback을 감쌌던 것이 문제가 되었습니다. 대부분의 경우에는 props만 지정해도 충분합니다.

개선한 레이아웃 구조입니다. ScreenLayout에 있던 TouchableWithoutFeedback을 필요한 경우에 사용할 수 있도록 분리했습니다.
ScrollView, FlatList에서 탭 이벤트를 처리하기 위해 keyboardShouldPersistTaps을 적용했고, 드래그와 스크롤 이벤트를 처리하기 위해 keyboardDismissMode을 적용했습니다.
콘텐츠가 적어 스크롤 되지 않는 화면에서는 flexGrow 스타일을 지정해 빈 공간 전체가 터치 영역이 되도록 했습니다. 그럼에도 children의 상·하단에 dismiss 보조 영역을 두어 호환되지 않는 디바이스/OS 조합에 방어적으로 설계했습니다.
결과
터치 이벤트를 누가 소비할 것인가는 생각보다 복잡한 문제였습니다. 직접 실험하며 React Native의 제스처 시스템이 hit test와 responder 협상으로 나뉜다는 사실을 이해할 수 있었습니다.
레이아웃 구조를 개선한 뒤로, 모든 화면에서 안정적이고 일관된 키보드 dismiss 처리와 스크롤이 가능해졌습니다.
또한, 레이아웃별 책임이 명확하게 구분되었습니다. ScrollView, FlatList는 props를 최대한 활용합니다. ScreenLayout은 레이아웃만 담당하게 되었고, 키보드 dismiss는 콘텐츠 내부에서만 관리하게 되었습니다.
결론:
TouchableWithoutFeedback의 문제가 아니라, 중간에View가 들어간 구조가 responder 체인을 끊어내고 있었습니다.
