React Native 바텀시트 컴포넌트의 props의 참조 안정성

eeennsu·2026년 6월 4일

React Native

목록 보기
48/95

개요

@gorhom/bottom-sheetonChange, backdropComponent, snapPoints 같은 props에 메모이제이션된 값/함수를 넘겨야 하는 이유와, 그렇지 않았을 경우 발생하는 일을 정리해보자.

결론 먼저

Prop처리인라인 전달 시 결과
snapPoints (배열)useMemo 또는 모듈 스코프 상수내부 worklet/effect 재실행, snap 위치 재계산
onChange / onAnimate / onClose (함수)useCallback내부 reaction 재구독, worklet 재등록, stale closure 위험
backdropComponent / handleComponent 등 (컴포넌트)useCallback언마운트 → 리마운트 → 시각적 깜박임
animationConfigs (객체)useMemo애니메이션 설정 재적용
initialSnapIndex 등 primitive불필요참조 비교 무관

공식 README의 모든 예제가 useMemouseCallback 패턴을 따른다.



근본적인 이슈 - React의 참조 비교

JavaScript에서 객체·배열·함수는 참조로 비교된다.

[1] === [1]                  // false
{ a: 1 } === { a: 1 }        // false
(() => {}) === (() => {})    // false

@gorhom/bottom-sheet는 받은 props를 내부에서 useMemo·useCallback·useDerivedValue·useAnimatedReactiondeps 배열로 재사용한다 (실제 소스 위치는 본문 "prop별 상세 분석" 섹션 참조). deps는 참조 비교(Object.is)로 변경을 감지하므로, 부모가 리렌더될 때마다 값은 동일해도, 새 참조가 들어오면 라이브러리 내부 로직이 매번 재실행된다.

부모 컴포넌트의 리렌더는 흔하다

  • 부모에서 useState가 바뀌면 리렌더
  • 상위 Context value가 바뀌면 리렌더
  • 부모의 부모가 리렌더되면 연쇄적으로 리렌더

이때 바텀시트는 그대로지만 내부적으로 불필요한 계산·재구독·재마운트가 반복된다.



prop별 상세 분석

1. snapPoints — 배열

// ❌ 인라인 배열
<BottomSheet snapPoints={['25%', '50%', '90%']} />;

// ✅ useMemo
const snapPoints = useMemo(() => ['25%', '50%', '90%'], []);
<BottomSheet snapPoints={snapPoints} />;

// ✅✅ 값이 고정이면 컴포넌트 외부의 모듈 스코프 상수가 더 효율적
const SNAP_POINTS = ['25%', '50%', '90%'];
<BottomSheet snapPoints={SNAP_POINTS} />;

라이브러리 실제 소스 (node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedDetents.ts):

// useAnimatedDetents.ts — 부모가 넘긴 snapPoints를 받아 정규화
export const useAnimatedDetents = (
  detents: BottomSheetProps['snapPoints'], // ← 부모의 snapPoints prop
  layoutState,
  enableDynamicSizing,
  maxDynamicContentSize,
  detached,
  $modal,
  bottomInset = 0,
) => {
  const state = useDerivedValue<DetentsState>(() => {
    const { containerHeight, handleHeight, contentHeight } = layoutState.get();
    if (containerHeight === INITIAL_LAYOUT_VALUE) return {};

    const _detents = detents ? ('value' in detents ? detents.value : detents) : [];

    // worklet 내부에서 직접 사용 — '25%' → 픽셀 변환
    let _normalizedDetents = _detents.map(snapPoint =>
      normalizeSnapPoint(snapPoint, containerHeight),
    ) as number[];
    // ... (dynamic sizing 계산 등 생략)
    return { detents: _normalizedDetents, highestDetentPosition, closedDetentPosition };
  }, [
    detents, // ⚠️ snapPoints가 useDerivedValue의 deps에 그대로 들어감
    layoutState,
    enableDynamicSizing,
    maxDynamicContentSize,
    detached,
    $modal,
    bottomInset,
  ]);
  return state;
};

그리고 BottomSheet.tsx에서 받은 prop을 그대로 이 훅에 전달한다.

// BottomSheet.tsx:197
const animatedDetentsState = useAnimatedDetents(
  _providedSnapPoints, // ← props.snapPoints
  animatedLayoutState,
  enableDynamicSizing,
  // ...
);

추가로 정규화된 detents 변경을 감시하는 useAnimatedReaction이 별도로 등록되어 있다.

// BottomSheet.tsx:1570 — snapPoints 변경 시 시트 위치 재평가
useAnimatedReaction(
  () => animatedDetentsState.get().detents,
  (result, previous) => {
    if (JSON.stringify(result) === JSON.stringify(previous) && isAnimatedOnMount.value) return;
    if (!isLayoutCalculated.value) return;
    evaluatePosition(ANIMATION_SOURCE.SNAP_POINT_CHANGE);
  },
  [isLayoutCalculated, isAnimatedOnMount, animatedDetentsState], // ⚠️ deps
);

인라인 전달 시 일어나는 일:

  1. 부모 리렌더 → ['25%', '50%', '90%'] 새 배열 인스턴스 생성
  2. useAnimatedDetents 호출 시 detents 인자가 새 참조
  3. useDerivedValue의 deps 배열 비교에서 변경 감지 → worklet 재구성
  4. 정규화 함수(normalizeSnapPoint)가 모든 원소에 대해 재실행 (퍼센트 → 픽셀 변환)
  5. derived value의 결과 객체가 새 참조 → useAnimatedReaction의 reaction worklet도 재평가

원소 개수나 값이 작다고 비용이 0이 되지는 않는다. snapPoints={[1]}처럼 원소 하나여도 재계산이 트리거되는 메커니즘은 동일하다.
시트가 열려있는 상태에서 부모가 자주 리렌더되면, 미세한 jank가 누적되거나 드물게 시트 위치가 살짝 튈 수 있다.


2. onChange, onAnimate, onClose — 콜백 함수

// ❌ 인라인 함수
<BottomSheet onChange={index => console.log(index)} />;

// ✅ useCallback
const handleChange = useCallback((index: number) => {
  console.log(index);
}, []);
<BottomSheet onChange={handleChange} />;

라이브러리 실제 소스 (node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx:440-468):

// BottomSheet.tsx — props.onChange를 _providedOnChange로 받아 내부에서 useCallback으로 래핑
const handleOnChange = useCallback(
  function handleOnChange(index: number, position: number) {
    if (__DEV__) {
      print({ component: 'BottomSheet', method: 'handleOnChange', /* ... */ });
    }
    if (!_providedOnChange) return;

    const { dynamicDetentIndex } = animatedDetentsState.get();
    _providedOnChange(
      index,
      position,
      index === dynamicDetentIndex ? SNAP_POINT_TYPE.DYNAMIC : SNAP_POINT_TYPE.PROVIDED,
    );
  },
  [_providedOnChange, animatedDetentsState], // ⚠️ 부모가 넘긴 onChange가 deps에 그대로
);

handleOnChange는 다시 시트 내부 컨텍스트(internalContextVariables)와 애니메이션 콜백 경로에서 참조된다. 즉, 부모가 매 렌더마다 새 함수 참조를 넘기면 handleOnChange 자체가 새로 만들어지고, 그것을 deps로 잡고 있는 후속 useMemo / useEffect / useAnimatedReaction이 줄줄이 재구성된다.

인라인 전달 시 일어나는 일:

  1. 부모 리렌더 → 새 화살표 함수 생성
  2. handleOnChangeuseCallback deps 변경 → 새 함수 참조
  3. 이 함수를 deps에 포함하는 내부 컨텍스트/effect들이 연쇄적으로 재계산
  4. worklet 경로의 reaction은 새 함수로 재등록되어 UI 스레드에 다시 직렬화 전달

stale closure 위험:

const [count, setCount] = useState(0);

// 인라인 함수가 매번 새로 생성되긴 하지만,
// 부모 리렌더가 발생하지 않는 시점에 worklet에 등록된 함수는
// 이전 count 값을 capture한 채로 남아있을 수 있음
<BottomSheet
  onChange={index => {
    console.log(count); // 항상 최신 count? 보장되지 않음
  }}
/>;

useCallback을 적절한 deps와 함께 사용하면 의도한 시점의 값을 안정적으로 캡처할 수 있다.


3. backdropComponent, handleComponent, backgroundComponent, footerComponent가장 치명적

// ❌ 인라인 함수형 컴포넌트
<BottomSheet backdropComponent={props => <BottomSheetBackdrop {...props} />} />;

// ✅ useCallback으로 안정화
const renderBackdrop = useCallback(
  (props: BottomSheetBackdropProps) => (
    <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
  ),
  [],
);
<BottomSheet backdropComponent={renderBackdrop} />;

왜 이게 가장 큰 문제인가?

React Reconciliation의 핵심 규칙:

컴포넌트 타입이 다르면(prevType !== nextType), React는 이전 트리 전체를 버리고 새로 마운트한다.

함수 컴포넌트는 함수 참조 자체가 컴포넌트 타입이다. 인라인으로 정의하면 매 렌더마다 새 함수가 생성되어, React는 매번 다른 컴포넌트 타입으로 인식한다.

인라인 전달 시 일어나는 일:

부모 리렌더
  → backdropComponent prop이 새 함수 참조
    → React: "이전 컴포넌트 타입과 다르네"
      → 이전 backdrop 인스턴스 언마운트
        → backdrop 내부 state 초기화
        → 진행 중인 페이드 애니메이션 중단
      → 새 backdrop 인스턴스 마운트
        → useAnimatedStyle 처음부터 다시 평가
        → appearsOnIndex 페이드 인 애니메이션 처음부터 재생

시각적 증상 (눈에 명확히 보임):

  • 시트가 열려있는 상태에서 부모 state가 바뀌면 backdrop이 깜박이는 현상
  • appearsOnIndex={0} 같은 설정이 있으면 페이드 인이 처음부터 재생되어 결함이 두드러짐
  • handle, background도 동일한 원리로 깜박일 수 있음

이는 라이브러리 GitHub 이슈에서도 자주 보고되는 패턴이며, 원인은 항상 동일하다.

참고: 컴포넌트 prop을 모듈 스코프로 빼는 것도 유효하다.

const renderBackdrop = (props: BottomSheetBackdropProps) => (
  <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
);

const MyScreen = () => {
  return <BottomSheet backdropComponent={renderBackdrop} />;
};

다만 props·closure 의존성이 있으면 모듈 스코프로 빼기 어려우므로, 일반적으로 useCallback 패턴이 사용된다.


4. animationConfigs — 객체

// ❌ 인라인 객체
<BottomSheet animationConfigs={{ duration: 250 }} />;

// ✅ useMemo
const animationConfigs = useMemo(() => ({ duration: 250 }), []);
<BottomSheet animationConfigs={animationConfigs} />;

인라인 객체는 매 렌더마다 새 참조라서, 라이브러리 내부에서 애니메이션 설정 변경으로 감지하고 재적용한다. snap 도중에 부모가 리렌더되면 애니메이션이 불안정해질 수 있다.


5. primitive 값 — 메모이제이션 불필요

// ✅ 숫자, 문자열, boolean은 값 비교
<BottomSheet initialSnapIndex={1} enablePanDownToClose={true} index={0} />

JavaScript에서 primitive는 값으로 비교되므로 1 === 1true. 새 참조 문제가 없다.


왜 라이브러리는 props 변경에 반응해야 하는가?

@gorhom/bottom-sheetprops 변경에 따라 동적으로 시트 동작을 조정해야 하는 라이브러리다. 예를 들어

  • snapPoints가 바뀌면 → 실제로 snap 위치를 재계산해야 함
  • onChange가 바뀌면 → 새 콜백을 사용해야 함
  • backdropComponent가 바뀌면 → 다른 백드롭을 렌더링해야 함

따라서 라이브러리 내부에서 이 props를 deps로 감시하는 것은 올바른 설계다. 문제는 사용자 측에서 의도하지 않게 매번 새 참조를 넘기는 것이다.

라이브러리 관점: "props가 바뀌었으니 반응해야지" (정상 동작)

사용자 관점: "값은 그대로인데 참조만 새로 만들었을 뿐인데 왜 매번 반응하지?" (실수)

이 간극을 메우는 것이 useMemo / useCallback이다.


단발성 UI(모달·시트)라도 예외 없음

"모달이나 시트는 수명이 짧으니까 최적화 불필요"라는 생각은 위험하다.

const MyScreen = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');

  return (
    <>
      <TextInput
        value={searchQuery}
        onChangeText={setSearchQuery} // 타이핑마다 MyScreen 리렌더
      />
      <BottomSheetModal
        onChange={idx => console.log(idx)} // ❌ 타이핑마다 새 참조
        backdropComponent={p => <Backdrop {...p} />} // ❌ 타이핑마다 새 참조
      />
    </>
  );
};

TextInput 타이핑 한 번마다 MyScreen이 리렌더되고, 그때마다 시트의 backdropComponent가 언마운트/리마운트된다. 시트가 열려있는 상태라면 백드롭이 매 타이핑마다 깜박인다.

수명이 짧은 컴포넌트라도 콜백이 어디로 흘러가는지 먼저 확인해야 한다.



검증 방법

직접 console.log로 확인할 수 있다.

// ❌ 인라인일 때 — 매 렌더마다 출력됨
<BottomSheet
  backdropComponent={props => {
    console.log('backdrop function created');
    return <BottomSheetBackdrop {...props} />;
  }}
/>;

// ✅ useCallback일 때 — 최초 1회만 출력
const renderBackdrop = useCallback(props => {
  console.log('backdrop function created');
  return <BottomSheetBackdrop {...props} />;
}, []);

BottomSheetBackdrop 내부에 useEffect(() => { console.log('mounted'); return () => console.log('unmounted'); }, [])를 추가하면, 인라인일 때 부모 리렌더마다 mount/unmount 로그가 찍히는 것을 확인할 수 있다.


정리

@gorhom/bottom-sheet의 권장 메모이제이션 패턴은 단순한 "최적화"가 아니라 올바른 동작을 위한 필수 조건이다. 특히:

  • snapPoints : 인라인 시 내부 worklet 재실행 (성능 저하)
  • 콜백 props : 인라인 시 worklet 재등록 + stale closure 위험
  • 컴포넌트 props : 인라인 시 언마운트/리마운트로 명확한 시각적 버그 발생

공식 README의 모든 예제가 useMemo / useCallback을 사용하는 이유다. 단발성 UI나 래퍼 컴포넌트라도 예외 없이 적용한다.

최종 권장 패턴

import BottomSheet, { BottomSheetBackdrop, BottomSheetBackdropProps } from '@gorhom/bottom-sheet';
import { useCallback, useMemo, useRef } from 'react';

// 값이 정적이면 모듈 스코프 상수
const SNAP_POINTS = ['25%', '50%', '90%'];

const MyScreen = () => {
  const bottomSheetRef = useRef<BottomSheet>(null);

  const handleSheetChange = useCallback((index: number) => {
    // ...
  }, []);

  const renderBackdrop = useCallback(
    (props: BottomSheetBackdropProps) => (
      <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
    ),
    [],
  );

  return (
    <BottomSheet
      ref={bottomSheetRef}
      snapPoints={SNAP_POINTS}
      onChange={handleSheetChange}
      backdropComponent={renderBackdrop}
    />
  );
};

부록 — 실제 소스 위치 인덱스

@gorhom/bottom-sheet@5.x 기준. 위 본문의 내용은 모두 아래 위치에서 직접 확인 가능하다.

이론파일라인
snapPointsuseDerivedValue의 deps에 들어감src/hooks/useAnimatedDetents.tsdetents가 deps 배열에 존재 (useDerivedValue 호출의 두 번째 인자)
부모의 snapPoints가 그대로 훅에 전달됨src/components/bottomSheet/BottomSheet.tsxuseAnimatedDetents(_providedSnapPoints, ...) 호출부 (≈ L197)
부모의 onChange가 내부 useCallback의 deps에 들어감src/components/bottomSheet/BottomSheet.tsxhandleOnChange = useCallback(..., [_providedOnChange, animatedDetentsState]) (≈ L440–L468)
snapPoints 변경 반응 useAnimatedReactionsrc/components/bottomSheet/BottomSheet.tsxOnSnapPointChange 리액션 (≈ L1570–L1605)

라인 번호는 라이브러리 버전·patch에 따라 약간 달라질 수 있다. 심볼명(_providedSnapPoints, _providedOnChange, useAnimatedDetents, evaluatePosition, SNAP_POINT_CHANGE)으로 grep하면 변경된 위치에서도 즉시 찾을 수 있다.

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

0개의 댓글