바텀시트가 안 열리는데 에러는 하나도 안 뜹니다. 콘솔은 깨끗하고, present()도 분명히 호출됩니다. 그런데 화면엔 아무 일도 일어나지 않죠. 이런 "조용한 실패"가 제일 잡기 어렵습니다. 이번에 만난 @gorhom/bottom-sheet 문제가 딱 그랬고, 알고 보니 원인이 하나가 아니라 둘이었습니다. 같은 최신 스택에서 헤맬 분들을 위해 원인을 좁혀간 과정을 남깁니다.
1. 개요
앱의 모든 바텀시트를 @gorhom/bottom-sheet로 통일하려다 벽에 부딪혔습니다. 시트가 열려야 할 버튼을 눌러도 아무 반응이 없었습니다. 크래시도, 빨간 에러 화면도 없이 그냥 조용했죠.
문제의 스택은 이랬습니다.
영역
버전
React Native
0.83.2
아키텍처
New Architecture (Fabric)
Reanimated
4.2.1
react-native-worklets
0.7.4
@gorhom/bottom-sheet
5.2.14
Expo SDK
55
@gorhom/bottom-sheet 5.2.14는 peerDependencies에서 이미 Reanimated 4를 허용합니다. 즉 "지원하지 않는 버전"이 아니었어요. 그런데도 안 열렸습니다. 여기서부터 추적이 시작됩니다.
2. 증상 요약
가장 중요한 단서는 로그가 어디까지 찍히느냐였습니다. 시트 컴포넌트 곳곳에 로그를 심고 버튼을 눌러보니 이렇게 나왔습니다.
딱 여기까지였습니다. present()는 호출됐는데, 그 뒤에 와야 할 onAnimate(열림 애니메이션 시작), onChange(스냅 인덱스 변경), 심지어 콘텐츠의 onLayout조차 하나도 찍히지 않았습니다.
이게 뜻하는 바는 분명했습니다. "시트를 열라"는 명령은 전달됐는데, 시트 콘텐츠가 렌더링도, 애니메이션도 시작하지 못한 것. 문제는 내 코드가 아니라 라이브러리 내부의 어느 지점에서 흐름이 끊긴다는 얘기였습니다.
3. 첫 번째 범인: babel worklets 플러그인 중복
Reanimated 4는 worklet 변환 로직을 별도 패키지 react-native-worklets로 분리했습니다. 그래서 babel 설정도 react-native-reanimated/plugin이 아니라 react-native-worklets/plugin을 써야 하죠. 저는 이걸 알고 babel.config.js에 직접 추가해 뒀습니다.
즉 제가 수동으로 추가한 플러그인은 preset이 이미 넣어준 것과 중복됐고, worklet 변환이 두 번 적용되면서 깨졌습니다. worklet이 깨지면 Reanimated의 클럭 기반 애니메이션(withTiming·withSpring)이 UI 스레드에서 돌지 못하고, gorhom의 열림 애니메이션이 바로 이 방식이라 시트가 안 열렸던 겁니다.
해결은 수동 플러그인을 제거하는 것이었습니다.
js
// babel.config.js (해결)module.exports = function (api) { api.cache(true); return { presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }]], // react-native-worklets/plugin 을 여기에 직접 추가하지 말 것! // babel-preset-expo(SDK 50+)가 자동 주입한다. 중복 추가 시 worklet 변환이 깨진다. plugins: [], };};
한 가지 더. babel 설정을 바꿔도 Metro 변환 캐시가 남아 있으면 반영되지 않습니다. 반드시 캐시를 비우고 재시작해야 합니다.
bash
npx expo start -c
-c 플래그가 이 문제의 마지막 퍼즐이었습니다. 앱 재설치는 소용없어요. 문제는 폰이 아니라 PC의 번들 캐시에 있었으니까요.
4. worklet이 진짜 도는지부터 증명하기
여기서 바로 "됐겠지" 하고 넘어가면 안 됩니다. 추측 대신 worklet이 실제로 이 기기에서 도는지를 눈으로 확인하는 자가진단을 넣었습니다.
tsx
useEffect(() => { // 1) UI 스레드 worklet → JS 콜백 왕복 runOnUI(() => { 'worklet'; runOnJS(setWorkletOk)(true); })(); // 2) 클럭 구동 애니메이션 (gorhom 열림 애니메이션과 같은 방식) sv.value = withTiming(100, { duration: 300 });}, []);useAnimatedReaction( () => sv.value, (v) => { if (v >= 99) runOnJS(setTimingOk)(true); },);
화면 한구석에 작은 배지로 결과를 띄우고 앱을 다시 켰더니 — 둘 다 OK. worklet 인프라도, withTiming 클럭 애니메이션도 정상 작동했습니다. babel 캐시 문제가 해결됐다는 확증이었죠.
이 단계가 중요한 이유는, 만약 여기서 FAIL이 떴다면 원인이 "gorhom"이 아니라 "worklet 네이티브 설정"으로 완전히 갈렸을 것이기 때문입니다. 원인을 갈라주는 리트머스 시험지를 하나 만들어 둔 셈입니다.
5. 두 번째 범인: BottomSheetModal의 Portal
worklet이 도는데도 BottomSheetModal은 여전히 안 열렸습니다. 로그는 정확히 똑같았습니다. present()까지 찍히고 onAnimate·onChange·onLayout은 침묵.
BottomSheetModal은 내부적으로 @gorhom/portal을 씁니다. present()를 부르면 시트 콘텐츠를 BottomSheetModalProvider가 만든 Portal 호스트로 "텔레포트"시키는 구조죠. 그런데 콘텐츠의 onLayout조차 안 뜬다는 건, Portal이 콘텐츠를 아예 마운트하지 못하고 있다는 뜻이었습니다.
Provider 설정도, Portal 중복 설치 여부도 모두 정상이었습니다. 그런데도 이 스택에서는 Portal 경로가 조용히 실패했습니다. 라이브러리 내부의 Portal/Provider 경로를 계속 파고들 수도 있었지만, 디바이스 테스트 왕복 비용이 컸습니다. 그래서 방향을 틀었습니다.
Portal을 쓰지 않으면 된다.
@gorhom/bottom-sheet의 기본 export인 인라인 <BottomSheet> 컴포넌트는 Provider도, Portal도 필요 없습니다. 컴포넌트 트리에 그대로 렌더링되고 index 프롭으로 열림/닫힘을 제어하죠. worklet은 이미 정상이니, Portal이라는 실패 지점만 걷어내면 되는 상황이었습니다.
6. 최종 해결: 인라인 컴포넌트로 전환
핵심 아이디어는 세 가지입니다. (1)visible일 때만 조건부로 마운트해 index={0}으로 즉시 열고, (2) 화면 전체를 덮는 absoluteFill 래퍼 안에 두어 백드롭이 화면을 덮게 하고, (3) 콘텐츠 높이를 직접 측정해 명시적 snapPoint로 넘겨 동적 사이징 이슈를 피하는 것입니다.
tsx
import BottomSheetInline, { BottomSheetBackdrop, BottomSheetScrollView,} from '@gorhom/bottom-sheet';export default function BottomSheet({ visible, onClose, title, children }) { const ref = useRef<BottomSheetInline>(null); const [rendered, setRendered] = useState(visible); const [contentHeight, setContentHeight] = useState(0); // visible=true 일 때만 마운트(그래야 index=0 으로 열림). 닫힘은 애니메이션 후 언마운트. useEffect(() => { if (visible) setRendered(true); else ref.current?.close(); }, [visible]); // 콘텐츠 높이를 측정해 명시적 snapPoint 로 사용 const snapPoints = useMemo(() => { const measured = contentHeight > 0 ? contentHeight : SCREEN_HEIGHT * 0.45; return [Math.min(Math.max(measured, 120), MAX_HEIGHT)]; }, [contentHeight]); const handleClose = useCallback(() => { setRendered(false); if (visible) onClose(); }, [visible, onClose]); if (!rendered) return null; return ( <View style={StyleSheet.absoluteFill} pointerEvents="box-none"> <BottomSheetInline ref={ref} index={0} snapPoints={snapPoints} enableDynamicSizing={false} enablePanDownToClose onClose={handleClose} backdropComponent={(props) => ( <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} pressBehavior="close" /> )} > <BottomSheetScrollView> <View onLayout={(e) => { const h = e.nativeEvent.layout.height; if (h > 0) setContentHeight(h); }}> {children} </View> </BottomSheetScrollView> </BottomSheetInline> </View> );}
버튼을 눌렀더니 — 드디어 시트가 올라왔습니다. 팬다운으로 닫기, 백드롭 탭으로 닫기, 키보드 대응까지 전부 매끄럽게 동작했죠. 프롭 API(visible/onClose/title/children)를 그대로 유지했기 때문에, 이 공용 컴포넌트를 쓰던 화면들은 한 줄도 고칠 필요가 없었습니다.
7. 디버깅 순서 요약
이번 삽질에서 건진 순서는 이렇습니다.
로그로 "어디까지 실행되는지" 먼저 확정하세요.present()가 불리는지, 그 뒤 onAnimate/onChange/onLayout이 뜨는지에 따라 원인의 층위가 완전히 달라집니다. 그다음 worklet 자가진단으로 애니메이션 엔진 자체가 도는지 갈라내고, babel 쪽이 의심되면 babel-preset-expo가 이미 자동 주입하는 플러그인을 내가 또 넣지 않았는지 확인하세요. 설정을 고쳤다면 expo start -c로 캐시를 반드시 비우고, 그래도 라이브러리의 특정 경로(여기선 Portal)가 조용히 실패한다면 **그 경로를 우회하는 대안(인라인 컴포넌트)**을 고려하는 겁니다.
8. 결론
이번 문제의 교훈은 두 가지입니다. 첫째, "조용한 실패"일수록 로그로 실행 지점을 좁혀야 합니다. 에러가 없다고 정상인 게 아니라, 어디서 멈췄는지 눈으로 봐야 원인의 층위가 보입니다. 둘째, 최신 스택에서는 도구가 알아서 해주는 일을 내가 또 하지 않는지 의심하세요. babel 플러그인 중복이 딱 그 사례였습니다.
peerDependencies가 허용한다고 모든 경로가 동작하는 것은 아닙니다. 라이브러리의 한 경로가 막히면, 같은 라이브러리의 다른 경로로 우회하는 것도 훌륭한 해법입니다. Portal이 막혔을 때 인라인 컴포넌트가 그랬듯이요.