이전 글에서 Reanimated가 어떻게 JS 스레드를 거치지 않고 UI 스레드에서 애니메이션을 실행하는지 살펴봤다. 이번 글에서는 그 마지막 퍼즐 조각인 사용자 상호작용(Interaction) — 즉, 손가락의 움직임을 UI 스레드에서 어떻게 처리하는지 다룬다.
모바일 앱의 사용성을 결정짓는 가장 큰 요소 중 하나는 '제스처에 화면이 얼마나 즉각적으로 반응하는가'다. 60fps의 부드러운 애니메이션도, 손가락을 따라오지 못한다면 의미가 없다.
PanResponder는 안티패턴인가React(웹)에서는 onMouseMove나 onTouchMove 같은 이벤트를 DOM에 직접 달아서 처리한다. 하지만 React Native에서 이 방식을 그대로 사용하면 성능이 크게 저하된다.
React Native에 기본 내장된 PanResponder API는 사용자의 터치 좌표(x, y)가 바뀔 때마다 네이티브(OS) → JS 스레드로 이벤트를 계속 전송한다. JS 스레드에서 좌표를 계산하고, 그 결과를 다시 네이티브 렌더러로 돌려보내는 동안 병목이 발생한다. 그 결과 손가락을 따라오는 UI가 버벅거리는(Frame Drop) 현상이 나타난다. 예시를 잠깐 보자.
import React, { useRef } from 'react';
import { View, Animated, PanResponder, StyleSheet } from 'react-native';
export default function DraggableCardLegacy() {
// 1. Animated.ValueXY: RN 내장 Animated의 좌표 값
// (Reanimated의 SharedValue와는 다른 객체!)
const pan = useRef(new Animated.ValueXY()).current;
// 2. PanResponder 인스턴스 생성
const panResponder = useRef(
PanResponder.create({
// 이 뷰가 제스처를 받을지 결정
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
// 제스처 시작 시: 현재 값을 offset으로 옮기고 value는 0으로 리셋
// (이어서 드래그해도 위치가 튀지 않게 하는 트릭)
onPanResponderGrant: () => {
pan.extractOffset();
},
// 손가락이 움직일 때마다 호출 (JS 스레드에서!)
onPanResponderMove: Animated.event(
[
null, // 첫 인자(nativeEvent)는 무시
{ dx: pan.x, dy: pan.y }, // gestureState의 dx/dy를 pan에 매핑
],
{ useNativeDriver: false }, // ⚠️ transform 변경이라 false만 가능
),
// 손가락을 뗐을 때: 원래 위치로 스프링 애니메이션
onPanResponderRelease: () => {
Animated.spring(pan, {
toValue: { x: 0, y: 0 },
useNativeDriver: false,
}).start();
},
}),
).current;
return (
<View style={styles.container}>
<Animated.View
style={[
styles.card,
{
transform: [
{ translateX: pan.x },
{ translateY: pan.y },
],
},
]}
{...panResponder.panHandlers} // 3. 핸들러를 prop으로 펼쳐서 전달
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
card: {
width: 150,
height: 150,
backgroundColor: '#FF3B30',
borderRadius: 20,
},
});
PanResponder도 무조건 나쁜 건 아니다. 다음 경우라면 충분히 쓸 만하다.
그러나 드래그나 스와이프 등 좌표를 따라가는 인터렉션이 필요하면 Reanimated 를 쓰는게 정석이자 표준이다. 굳이 더 성능이 좋은 기능을 냅두고 좋지 않은 걸 쓰는건 권장하진 않는다. 특히 60fps가 중요한 화면에서는 RNGH가 필수다.
이 문제를 해결하기 위해 react-native-gesture-handler (이하 RNGH) 라이브러리와 Reanimated를 결합해서 사용한다.
RNGH를 사용하면 iOS의 UIPanGestureRecognizer나 Android의 MotionEvent 같은 순수 네이티브 제스처 이벤트가 JS 스레드를 완전히 건너뛰고, JSI를 통해 UI 스레드의 워클릿(Worklet)으로 직행한다.
[전통 방식 - PanResponder]
손가락 터치 → 네이티브 → Bridge → JS 스레드 → Bridge → UI 갱신
(직렬화/역직렬화 비용 발생)
[현대 방식 - RNGH + Reanimated]
손가락 터치 → 네이티브 → JSI → UI 스레드 워클릿 → Shadow Tree 갱신
(JS 스레드 미경유)
손가락이 움직이는 순간 Reanimated의 SharedValue가 UI 스레드 내에서 즉각 업데이트되고, Fabric 렌더러가 화면을 다시 그리는 모든 과정이 JS 스레드 왕복 없이 UI 스레드 내에서 완결된다.
RNGH v2부터는 객체 지향적인 빌더 패턴(Gesture.Pan(), Gesture.Tap() 등)을 사용해서 제스처의 생명주기를 워클릿으로 관리한다.
| 콜백 | 호출 시점 |
|---|---|
onBegin | 터치가 감지된 순간(아직 제스처로 확정 안 됨) |
onStart | 제스처가 정식 인식된 순간 |
onUpdate | 터치 이벤트가 발생할 때마다 실행 (가장 중요) |
onEnd | 손가락을 뗐을 때 (관성, 스냅백 처리) |
onFinalize | 제스처가 어떤 식으로든 종료되었을 때(취소 포함) |
⚠️ 흔한 오해:
onUpdate는 "매 프레임" 실행되지 않는다. 터치 이벤트 기반으로 호출되므로, 손가락이 멈춰 있으면 호출되지 않는다.
바텀 시트(Bottom Sheet)나 스와이프 가능한 리스트를 만들 때 기초가 되는 패턴이다.
import React from 'react';
import { View, StyleSheet } from 'react-native';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
export default function DraggableCard() {
// 1. 현재 카드의 위치 (이동 거리)
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
// 2. 제스처 정의 (Pan: 드래그)
const panGesture = Gesture.Pan()
// RNGH v2.10+: 누적 이동을 자동으로 처리하는 권장 방식
// event.translationX는 onStart 시점부터의 상대 좌표
.onChange((event) => {
// 워클릿 — UI 스레드에서 실행됨
// changeX/Y는 이전 onChange 호출 이후의 변화량
translateX.value += event.changeX;
translateY.value += event.changeY;
})
.onEnd(() => {
// 손가락을 떼면 원래 위치(0, 0)로 탄성 있게 복귀
translateX.value = withSpring(0, { damping: 15, stiffness: 90 });
translateY.value = withSpring(0, { damping: 15, stiffness: 90 });
});
// 3. 애니메이션 스타일 바인딩
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureHandlerRootView style={styles.container}>
{/* 4. GestureDetector로 Animated.View를 감싸서 제스처 주입 */}
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, animatedStyle]} />
</GestureDetector>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
card: {
width: 150,
height: 150,
backgroundColor: '#007AFF',
borderRadius: 20,
},
});
RNGH의 모든 제스처가 동작하기 위해 반드시 필요한 최상위 래퍼 컴포넌트이다. 앱의 가장 바깥쪽(App.tsx)에 한 번만 감싸주면 그 안의 모든 GestureDetector와 Gesture.* API가 정상 동작한다. (모달에선 모달 안에 한
RNGH는 일반적인 React Native의 터치 시스템(onTouchStart 같은 이벤트)을 사용하지 않는다. 대번 더 선언해주어야하긴 하다.)
신 iOS/Android의 네이티브 제스처 시스템에 직접 연결해서 동작한다.
이걸 가능하게 하려면 네이티브 쪽에 "여기서부터는 RNGH가 터치 이벤트를 가로채서 처리할 거야" 라고 알려주는 진입점이 필요한데, 그 역할을 하는 게 GestureHandlerRootView이다.
GestureHandlerRootView가 없으면 네이티브 측에서 터치 이벤트를 RNGH에게 넘겨주지 않기 때문에, 앱이 크래시되지는 않지만 제스처가 그냥 동작하지 않는 황당한 상황이 벌어진다.
scheduleOnRN 연동)제스처는 UI 스레드에서 끝나지만, 그 결과를 React 상태나 비즈니스 로직에 반영해야 할 때가 많다. 2편에서 다룬 scheduleOnRN(구 runOnJS)이 여기서 쓰인다.
예를 들어, 카드를 화면 절반 이상 드래그하면 "좋아요"를 토글하는 경우다.
const isLiked = useSharedValue(false);
const [likedState, setLikedState] = useState(false);
const updateLikeInJS = (liked: boolean) => {
setLikedState(liked);
// analytics.track('card_liked', { liked });
};
const panGesture = Gesture.Pan()
.onChange((event) => {
translateX.value += event.changeX;
})
.onEnd(() => {
const threshold = 100;
const shouldLike = Math.abs(translateX.value) > threshold;
if (shouldLike !== isLiked.value) {
isLiked.value = shouldLike;
// UI 스레드 → JS 스레드로 상태 변경 예약
scheduleOnRN(updateLikeInJS, shouldLike);
}
translateX.value = withSpring(0);
});
세로로 스크롤되는 ScrollView 안에 위에서 만든 '가로/세로 드래그 카드'를 넣으면 어떻게 될까? 손가락을 대각선으로 움직이면 OS는 스크롤을 해야 할지, 카드를 드래그해야 할지 판단하지 못해서 어색한 동작이 나타난다.
RNGH는 이런 충돌을 위한 정식 해법으로 Gesture Composition API를 제공한다.
| API | 동작 |
|---|---|
Gesture.Simultaneous(a, b) | 두 제스처를 동시에 인식 |
Gesture.Race(a, b) | 먼저 인식된 하나만 실행 (나머지는 취소) |
Gesture.Exclusive(a, b) | 우선순위 기반으로 한 번에 하나만 |
그리고 개별 제스처에는 다음 메서드로 관계를 정의할 수 있다.
.simultaneousWithExternalGesture(ref) — 외부 제스처와 동시 인식 허용.requireExternalGestureToFail(ref) — 외부 제스처가 실패해야만 자신이 동작.activeOffsetX([-10, 10]) — 일정 거리(예: 10px) 이상 움직여야 활성화 (스크롤뷰 안에서 유용)const panGesture = Gesture.Pan()
// X축으로 10px 이상 움직여야 제스처 활성화
.activeOffsetX([-10, 10])
// Y축은 활성화하지 않음 → 세로 움직임은 ScrollView가 가져감
.failOffsetY([-5, 5])
.onChange((event) => {
translateX.value += event.changeX;
})
.onEnd(() => {
translateX.value = withSpring(0);
});
이 패턴은 카카오톡의 대화 목록을 좌우 스와이프해서 '읽음 표시'하는 UX, 인스타그램 스토리에서 좌우로 넘기는 UX 등에서 흔히 쓰인다.
지금까지 세 편에 걸쳐 React Native의 애니메이션을 다뤘다.
이 세 가지를 조합하면, 손가락이 화면에 닿는 순간부터 애니메이션이 끝나고 React 상태가 업데이트되기까지의 전체 흐름을 UI 스레드 친화적으로 설계할 수 있다. 다음 시리즈에서는 이를 응용해 실제 프로덕션에서 자주 만들게 되는 컴포넌트(바텀 시트, 스와이프 가능한 리스트, 카드 스택 등)를 직접 구현해볼 예정이다.