앱의 3D 행성이 아이폰에서 안 돈다는 제보를 받았습니다. 가끔은 아예 안 뜨고, 표면 오브젝트도 안 보인다고 했습니다. 안드로이드는 멀쩡했습니다.
한참 엉뚱한 곳을 봤기 때문에, 증거가 실제로 뭐라고 했는지를 적어둡니다.
잰 것은 전부 정상이라고 말했습니다
react-three-fiber 캔버스 안에 계측을 넣고 네이티브 로그로 읽었습니다.
[PROBE] created bw=939 bh=940 size={"width":313.33,"height":313.33}
[PROBE] frame 1
[PROBE] frame 120
[PROBE] endFrame 120
[PROBE] frame 240
[PROBE] endFrame 240
GL 컨텍스트는 만들어졌습니다.
드로잉 버퍼가 939×940인데 313.33pt의 3배라서 크기도 맞습니다.
three.js는 2.0초에 120프레임을 그렸으니 60fps입니다.
endFrameEXP()도 매 프레임 정확히 맞물려 불렸습니다.
그 다음 배경을 불투명 마젠타로 강제했습니다.
st.gl.setClearColor('#ff00ff', 1);
GL이 할 수 있는 가장 단순한 일입니다. 지오메트리도, 텍스처도, 모델 파일도 개입하지 않습니다.
한 픽셀도 안 나왔습니다.
로그에 에러는 0건이었습니다.
이걸로 배제된 것
불투명 마젠타가 안 보인다면 씬 문제도, 재질 문제도, 모델 포맷 문제도 아닙니다. 버퍼 크기가 정확했으니 레이아웃 문제도 아닙니다.
즉 JS 층은 완전히 결백했습니다.
끊긴 곳은 endFrameEXP()와 화면 사이 어딘가였습니다.
기전
iOS의 expo-gl은 이렇게 되어 있습니다.
override func removeFromSuperview() {
glContext.destroy()
displayLink?.invalidate()
displayLink = nil
super.removeFromSuperview()
}
presentRenderbuffer를 부르는 것은 CADisplayLink 하나뿐입니다.
그리고 그건 init에서 딱 한 번 만들어집니다.
뷰가 다시 붙었을 때 되살리는 코드가 어디에도 없습니다.
그래서 이 뷰가 한 번이라도 떨어졌다 붙으면 영영 죽고, 조용히 죽습니다. JS 쪽은 계속 완벽하게 돌아갑니다.
무엇이 뷰를 떼어냈나
저는 Fabric의 뷰 재활용을 먼저 의심했습니다. 틀렸습니다.
public static func shouldBeRecycled() -> Bool {
// Turn off recycling for Expo views.
return false
}
Expo는 재활용을 꺼둡니다. 여기서 막혀 있다가 view flattening을 알려주는 분이 있어서 풀렸습니다.
리액트 네이티브의 New Architecture는 네이티브 노드가 필요 없는 View를 트리에서 지웁니다.
판정 규칙은 ViewShadowNode.cpp에 있습니다.
bool formsStackingContext = !collapsable
|| pointerEvents == BoxOnly || pointerEvents == None
|| opacity != 1.0 || transform != {} || events.bits.any() || ...;
opacity != 1.0을 보세요.
우리 행성은 이렇게 감싸여 있었습니다.
<Pressable style={({pressed}) => pressed && {opacity: 0.92}}>
<DreamPlanet />
</Pressable>
이 style은 함수입니다.
누르면 false와 {opacity: 0.92} 사이를 오갑니다.
그래서 누를 때마다 접힘 판정이 뒤집히고, 그 아래 subtree가 언마운트 후 재마운트되고, GL 컨텍스트가 죽습니다.
행성을 누르는 건 그 화면의 핵심 인터랙션입니다. 모든 사용자가 밟습니다.
고친 방법
<Pressable collapsable={false} style={({pressed}) => pressed && {opacity: 0.92}}>
짐작하고 있던 것 두 개도 확인했는데, 하나는 틀렸습니다.
pointerEvents="none"은 위 목록에 있습니다.
즉 그 prop이 붙은 View는 원래부터 네이티브 뷰를 만듭니다.
제가 범인으로 지목했던 래퍼는 애초에 안 접히고 있었습니다.
그렇게 적어둔 주석이 있어서 되돌아가 고쳤습니다.
backgroundColor: 'transparent'는 접힘을 못 막습니다.
우회법으로 제안되는 걸 봐서 적어둡니다.
inline bool hostPlatformColorIsColorMeaningful(Color color) {
return alphaFromHostPlatformColor(color) > 0;
}
알파 0은 “의미 없는 색”입니다.
collapsable={false}가 유일한 수단입니다.
확인하지 못한 것
이 수정이 실제로 먹었는지는 확인하지 못했습니다. 이 컴퓨터의 iOS 시뮬레이터는 행성을 한 프레임도 안 그리는데, 제보된 버그는 “그려지다가 멈춘다”입니다. 증상이 다르니 시뮬레이터로는 판정이 안 됩니다. 실기기로 넘겼습니다.
이 앱의 다른 컴포넌트가 같은 노출을 얼마나 가지고 있는지도 모릅니다. 읽어서 찾은 것만 고쳤지, 하나씩 눌러본 게 아닙니다.
남길 만한 부분
비싼 이유는 실패하는 방식에 있습니다. TypeScript가 통과했습니다. ESLint도 통과했습니다. 네이티브 로그도 깨끗했습니다. JS는 초당 60프레임으로 측정 가능하게 완벽했습니다.
네이티브 뷰의 수명주기를 라이브러리가 가져가는 경우(GL, 카메라, 비디오, 지도) 그 위의 View가 접힐 수 있는지부터 보세요.
토글되는 opacity 하나면 충분합니다.
2026년 8월 13일에 Expo SDK 56, React Native 0.85, expo-gl 56.0.5, @react-three/fiber 9.6.1에서 확인했습니다. 이전 버전은 안 봤습니다.