React Native 칸반보드 드래그 앤 드롭 성능 최적화 (2) - PanResponder 안정화와 Native 애니메이션

Published2026.03.09
Read Time16 min read
Related Project: Fly:On

React Native 칸반보드 드래그 앤 드롭 성능 최적화 (2) - PanResponder 안정화와 Native 애니메이션

이 시리즈는 React Native에서 직접 구현한 칸반보드 드래그 앤 드롭의 성능을 측정하고 개선하는 과정을 다룹니다.

1편: 문제 인식과 측정에서 드래그 1회에 총 12번의 커밋, 최대 44.93ms, 최대 232개 컴포넌트 리렌더링이 발생한다는 것을 확인했습니다.


개선 우선순위 복습

1편에서 세운 계획입니다.

1순위: 즉시 효과가 있는 것

  1. useNativeDriver: true 전환 — 애니메이션을 Native 스레드로
  2. PanResponder useMemo 적용 — 불필요한 객체 생성 방지

2순위: 구조적 개선

  1. Zustand store로 props drilling 해결 — 중간 컴포넌트 리렌더링 방지
  2. React.memo 적용 — 불필요한 리렌더링 차단

이번 글에서는 1순위 개선을 적용하고 결과를 측정합니다.


문제 1: PanResponder가 매 렌더링마다 재생성

먼저, PanResponder가 뭔가요?

PanResponder는 손가락의 터치 이벤트(시작, 이동, 끝)를 감지하는 React Native의 API입니다. 드래그 앤 드롭은 PanResponder를 통해 "지금 어디를 누르고 있는지", "얼마나 움직였는지"를 계속 추적합니다.

개선 전 코드

// DraggablePlanCard.tsx - 개선 전
const DraggablePlanCard = ({ item, index, dayId, onDragStart, onDragMove, onDragEnd }) => {

  const panResponder = PanResponder.create({  // 🔴 렌더링마다 새 객체 생성
    onStartShouldSetPanResponder: () => isPanEnabled,
    onPanResponderGrant: (evt) => {
      onDragStart(item, dayId, index, ...);
    },
    onPanResponderMove: (evt, gestureState) => {
      onDragMove(...);
    },
    onPanResponderRelease: (evt) => {
      onDragEnd(evt.nativeEvent.pageY);
    },
  });

  return <Animated.View {...panResponder.panHandlers} />;
};

PanResponder.create()가 컴포넌트 함수 본문 안에 그냥 놓여 있습니다. React 컴포넌트는 상태가 바뀔 때마다 함수 전체를 다시 실행하기 때문에, 이 코드는 리렌더링이 일어날 때마다 PanResponder를 새로 만들고 버립니다. 드래그 중에 리렌더링이 발생하면 드래그 핸들러도 함께 새로 교체되는 셈입니다.

그냥 useMemo로 감싸면 안 되나?

useMemo로 감싸면 PanResponder를 한 번만 만들 수 있습니다. 그런데 새로운 문제가 생깁니다.

클로저 문제입니다. onPanResponderGrant 안에서 item, dayId, onDragStart 같은 값들을 사용합니다. 이 값들은 부모에서 props로 내려오는데, useMemo로 감싸는 순간 "최초에 PanResponder를 만들 때의 값"이 그대로 고정됩니다.

부모에서 item이 바뀌어도 PanResponder 내부는 여전히 처음 item을 바라보고 있는 것입니다.

결국 재생성 비용을 없애려면 값이 고정되고, 값을 최신으로 유지하려면 재생성 비용이 발생하는 딜레마입니다.

해결책: callbacksRef 패턴

이 딜레마를 끊는 방법이 useRef입니다.

useRef는 렌더링과 무관하게 항상 동일한 객체를 반환합니다. 그리고 그 안의 .current 값은 언제든지 바꿀 수 있습니다. 이 특성을 활용합니다.

// DraggablePlanCard.tsx - 개선 후
const DraggablePlanCard = memo(({ item, index, dayId, onDragStart, onDragMove, onDragEnd }) => {

  // ✅ ref는 렌더링마다 같은 객체. .current 값만 교체
  const callbacksRef = useRef({ onDragStart, onDragMove, onDragEnd });
  const propsRef = useRef({ item, dayId, index });
  const isPanEnabledRef = useRef(isPanEnabled);

  // 렌더링마다 최신 값으로 덮어씀 (.current 교체, ref 객체 자체는 그대로)
  callbacksRef.current = { onDragStart, onDragMove, onDragEnd };
  propsRef.current = { item, dayId, index };
  isPanEnabledRef.current = isPanEnabled;

  // ✅ useMemo로 PanResponder 한 번만 생성
  const panResponder = useMemo(() => PanResponder.create({
    onStartShouldSetPanResponder: () => isPanEnabledRef.current,  // ✅ ref 통해 최신 값
    onMoveShouldSetPanResponder: () => isPanEnabledRef.current,

    onPanResponderGrant: (evt) => {
      const { item, dayId, index } = propsRef.current;           // ✅ 항상 최신 값
      callbacksRef.current.onDragStart(item, dayId, index, ...);
    },
    onPanResponderMove: (evt, gestureState) => {
      callbacksRef.current.onDragMove(...);                       // ✅ 항상 최신 콜백
    },
    onPanResponderRelease: (evt) => {
      callbacksRef.current.onDragEnd(evt.nativeEvent.pageY);
    },
    onPanResponderTerminate: () => { ... },
  }), [cardOpacity]);  // 사실상 마운트 시 1회만 생성

  ...
});

왜 이 패턴이 동작하는가

핵심은 ref 객체와 .current 값을 분리해서 생각하는 것입니다.

PanResponder 내부의 핸들러는 ref 객체를 클로저로 캡처합니다. ref 객체 자체는 바뀌지 않으므로 useMemo는 재생성하지 않고, .current는 렌더링마다 최신 값으로 갱신되므로 터치 이벤트 시 항상 올바른 값을 읽습니다.

ref는 렌더링 사이클과 무관하게 항상 최신 값을 담고 있습니다. PanResponder는 한 번 생성된 후, 핸들러 내부에서 ref를 통해 최신 값을 읽어오기 때문에 의존성 배열 문제가 사라집니다.


문제 2: useNativeDriver: false로 인한 JS 스레드 애니메이션

개선 전 코드

// 개선 전 — 여러 곳에 useNativeDriver: false
Animated.timing(floatingOpacity, {
  toValue: 0.9,
  duration: 150,
  useNativeDriver: false,  // 🔴 JS 스레드에서 실행
}).start();

useNativeDriver: false일 때 애니메이션 계산은 JavaScript 스레드에서 실행됩니다.

드래그 중에 JS 스레드가 바쁘면 애니메이션도 버벅입니다.

왜 false를 쓰고 있었나

useNativeDriver: truetransform, opacity처럼 레이아웃에 영향을 주지 않는 속성만 지원합니다. width, height, backgroundColor 같은 속성은 지원하지 않아서, 이런 속성을 쓸 때는 어쩔 수 없이 false를 씁니다.

기존 코드를 추적해보니, 실제로는 opacitytransform만 사용하고 있었습니다. false를 쓸 이유가 없었던 것입니다.

개선 후 코드

// DraggablePlanCard.tsx — 드래그 시작 시 카드 투명하게
Animated.timing(cardOpacity, {
  toValue: 0.3,
  duration: 150,
  useNativeDriver: true,  // ✅ opacity → Native 스레드
}).start();

// FloatingPortal.tsx — 따라다니는 플로팅 카드
<Animated.View
  style={{
    opacity: floatingOpacity,           // ✅
    transform: [
      { translateX: floatingPan.x },   // ✅
      { translateY: floatingPan.y },   // ✅
    ],
  }}
/>

opacitytransformuseNativeDriver: true가 지원되므로 모두 Native 스레드로 전환했습니다.


두 가지 개선의 관계

사실 이 두 문제는 서로 얽혀 있습니다.

두 가지를 같이 적용해야 온전히 동작합니다.


실제 측정 결과

테스트 환경 (1편과 동일)

  • Day 컬럼: 4개 (Day1 ~ Day4)
  • 카드: 총 8개
  • 테스트: 카드 1개를 드래그 + 자동 스크롤 1회

React DevTools Profiler 결과

개선 전 (성능개선전.json)

커밋Duration리렌더 컴포넌트 수Updater
11.36ms2View (Animated)
25.29ms23DraggablePlanCard
340.90ms232DraggablePlanCard, TravelPlanKanban, FloatingPortalProvider
4~80.65~1.48ms2View (애니메이션 프레임)
944.93ms211TravelPlanKanban
1039.77ms209DraggablePlanCard × 4, TravelPlanKanban
1138.89ms209DraggablePlanCard × 2, TravelPlanKanban
1235.16ms212FloatingPortalProvider
합계총 12 커밋최대 232개

개선 후 (성능개선후(2).json)

커밋Duration리렌더 컴포넌트 수변화
11.61ms2
25.42ms23
346.10ms232
4~50.65~1.24ms2~4
645.06ms211
731.28ms209
836.74ms212
합계총 8 커밋최대 232개커밋 수 12 → 8

분석

항목개선 전개선 후
총 커밋 수12회8회
최대 duration44.93ms46.10ms
평균 duration17.63ms21.01ms
최대 리렌더 컴포넌트 수232개232개

커밋 수는 4개 줄었지만, duration과 리렌더 컴포넌트 수는 거의 그대로입니다.


왜 duration이 줄지 않았나?

줄어든 4개의 커밋은 개선 전 4~8번 커밋 (0.65~1.48ms짜리들)입니다.

useNativeDriver: true로 바꾼 효과가 바로 여기 나타납니다. 애니메이션 커밋들이 프로파일러에서 보이지 않는 것은, Native 스레드로 넘어가서 JS 스레드와 무관하게 실행되기 때문입니다.

반면 큰 커밋들 (30~46ms, 200개 이상 리렌더)은 여전히 남아있습니다. 이건 props drilling과 React.memo 부재로 인한 전체 트리 리렌더링이 원인이고, 이번 개선으로는 해결되지 않습니다.


정리

이번에 적용한 개선들의 실제 효과를 정리하면:

개선 항목실제 효과한계
useNativeDriver: true애니메이션 커밋 4개 → Native 스레드로 이동, JS 스레드 부하 감소큰 리렌더링 커밋은 그대로
useMemo PanResponder렌더마다 PanResponder 재생성 없음, isPanEnabled 버그 해결리렌더링 횟수 자체는 변화 없음
callbacksRef 패턴클로저 문제 없이 항상 최신 값 참조

드래그 중 JS 스레드는 가벼워졌고, 애니메이션은 더 부드러워졌습니다. 하지만 큰 리렌더링 커밋 (30~46ms, 200개 이상)은 여전히 남아 있습니다.


배운 것

1. useMemo와 클로저는 같이 생각해야 한다

useMemo로 값비싼 객체를 한 번만 만드는 것은 좋지만, 내부에서 외부 변수를 참조한다면 클로저 문제가 생깁니다. callbacksRef 패턴은 이 둘을 동시에 해결합니다.

// 핵심 패턴 요약
const latestRef = useRef(value);
latestRef.current = value;  // 렌더마다 동기화

const memoized = useMemo(() => {
  // latestRef.current으로 항상 최신 값 참조
}, []);

2. 프로파일러에서 "사라진 커밋"의 의미

useNativeDriver: true 후 애니메이션 커밋이 프로파일러에서 보이지 않는 것은 버그가 아니라 성공입니다. Native 스레드에서 실행되므로 JS 프로파일러에 기록되지 않습니다.

3. 즉각적인 개선과 구조적 개선을 구분할 것

이번 개선들은 코드 몇 줄로 적용 가능하지만, 큰 리렌더링 문제는 구조를 바꿔야 합니다. 효과의 크기도 다릅니다 — 측정 결과가 그것을 증명합니다.


다음 글 예고

이번 개선으로 애니메이션 품질은 개선됐지만, 30~46ms짜리 전체 트리 리렌더링은 여전히 남아있습니다.

다음 글에서는 draggingItem 상태를 Zustand store로 분리하고, React.memo와 세밀한 selector로 리렌더링을 최소화합니다. 실제로 이 개선을 적용한 후 평균 커밋 duration이 17.63ms → 6.63ms로 떨어지는 것을 확인했습니다.


참고 자료