제보는 이렇게 들어왔습니다.
처음 들어갔는데 “알겠어요” 버튼이 안 눌려요. 앱이 멈춘 것 같아요.
화면에는 첫 사용자를 위한 안내 카드가 떠 있습니다. 버튼도 잘 보입니다. 누르면 아무 일도 안 일어납니다. 뒤로 가기도 소용없습니다.
에러는 없습니다. 앱은 정상적으로 돌고 있습니다. 그냥 아무 터치도 안 먹습니다.
리액트 네이티브 모달은 화면 안에 있지 않습니다
원인은 리액트 네이티브 모달이었습니다. 안내 카드 위에 투명한 모달이 하나 떠 있었습니다.
여기서 중요한 건 “위” 라는 말의 의미입니다. 보통 화면 안에서 무엇이 위에 오는지는 우리가 정합니다. 쌓이는 순서를 바꾸거나 값을 조절하면 원하는 대로 됩니다.
리액트 네이티브 모달은 그 규칙 밖에 있습니다. 모달을 열면 운영체제가 창을 하나 더 만들고, 그 창이 앱 화면 전체를 덮습니다. 같은 화면 안의 요소가 아니라 화면 위에 얹힌 별개의 판입니다.
그래서 이렇게 됩니다.
- 화면 안에서 아무리 위로 올려도 모달보다 위로 갈 수 없습니다.
- 모달이 투명하면 아래가 그대로 다 보입니다.
- 그런데 터치는 위에 있는 모달이 전부 가져갑니다.
보이는 것과 눌리는 것이 어긋나는 상태입니다. 사용자 입장에서는 버튼이 멀쩡히 보이는데 안 눌리니까, 이건 “버튼이 고장났다” 가 아니라 “앱이 멈췄다” 로 읽힙니다.
제 경우 그 투명한 모달은 아침 계획을 묻는 시트였습니다. 같은 화면이 첫 진입에서 자동으로 여는 것이었고, 안내 카드와 정확히 같은 타이밍에 떴습니다.
// 이 시트는 안이 Modal 이다. 즉 별도의 창이다.
<Modal visible={visible} animationType="slide" transparent statusBarTranslucent>
transparent 가 붙어 있으니 화면은 그대로 보입니다.
그게 이 버그를 눈으로 못 잡게 만든 부분입니다.
코드만 봐서는 안 보입니다
이 버그가 오래 살아남은 이유가 있습니다.
두 요소가 서로를 모릅니다. 안내 카드는 화면 JSX 안에 있고, 시트는 다른 파일에 있는 별개의 컴포넌트입니다. 각각을 따로 읽으면 둘 다 정상입니다.
둘이 동시에 뜰 수 있다는 사실은 코드 어디에도 안 적혀 있습니다. “이 화면에 안내 카드가 떠 있을 때 시트를 열면 안 된다” 는 조건은 아무 파일에도 없었습니다. 그런 조건이 필요하다는 것 자체를 몰랐으니까요.
게다가 두 요소 다 실제로 화면에 있습니다. 하나가 다른 하나를 가린 것도 아니고, 렌더링이 실패한 것도 아닙니다. 둘 다 정상적으로 그려졌고, 터치를 한쪽이 독점했을 뿐입니다.
그래서 순서를 강제했습니다
고친 방식은 단순합니다. 안내 카드가 떠 있는 동안에는 자동으로 열리는 것들을 미룹니다.
// 가이드가 떠 있으면 미룬다. 이 시트는 RN Modal (별도 창) 이라
// 가이드(일반 View) 위에 떠서 터치를 전부 가져간다.
// 사용자에겐 "알겠어요가 안 눌리고 앱이 멈춘" 것으로 보인다.
if (showGuide) return;
여기서 놓치기 쉬운 게 하나 있습니다. 이 조건을 어디서 판단하느냐입니다.
처음에는 안내 카드를 띄울지 말지를 화면이 그려진 다음에 결정하고 있었습니다. 저장된 값을 읽어와서 “아직 안 봤네” 싶으면 그때 켜는 식입니다.
문제는 그 사이에 시트를 여는 쪽이 먼저 돈다는 것입니다. 순서가 이렇게 됩니다.
- 화면이 그려진다. 이때 안내 카드는 아직 꺼져 있다.
- 시트를 여는 코드가 돈다. 안내 카드가 꺼져 있으니 조건을 통과한다. 시트가 열린다.
- 그제서야 안내 카드가 켜진다.
가드를 넣었는데도 그 가드가 통과되는 상황입니다. “가이드가 떠 있으면 막는다” 를 검사하는 시점에 아직 안 떠 있었으니까요.
그래서 화면이 그려지기 전에 값을 정하도록 바꿨습니다.
// 값을 처음부터 결정한다. 나중에 켜면 그 사이에 시트가 먼저 열려버린다.
const [showGuide, setShowGuide] = useState(
() => !(DeviceStore.get<boolean>(CacheKeys.GUIDE_TIMELINE_V1_DONE) ?? false),
);
읽는 값이 기기에 저장된 것이라 기다릴 필요가 없어서 가능한 방법입니다. 서버에서 받아와야 하는 값이었다면 이 방법은 안 되고, “아직 모름” 상태를 하나 더 두고 그동안 아무것도 안 여는 쪽으로 가야 합니다.
그리고 보이지도 않는 화면이 시트를 열고 있었습니다
같은 자리에서 문제가 하나 더 나왔습니다.
이 앱은 아래 탭으로 화면을 오갑니다. 그런데 탭 설정 중에 안 보이는 화면도 메모리에 올려두는 옵션이 켜져 있습니다. 탭을 옮길 때마다 화면을 새로 만들면 느리고 상태도 날아가기 때문에 일부러 그렇게 둔 것입니다.
문제는 “안 보인다” 와 “안 돌아간다” 가 다르다는 점입니다. 안 보이는 화면도 코드는 정상적으로 돕니다.
그래서 앱을 켜자마자 다른 탭을 보고 있는데, 뒤에서 조용히 돌던 이 화면이 시트를 열어버립니다. 사용자에게는 엉뚱한 화면에서 갑자기 시트가 튀어나온 것으로 보입니다.
// 이 탭이 실제로 보이고 있을 때만 연다.
const isFocused = useIsFocused();
...
if (!isFocused) return;
여기서도 판단이 하나 필요했습니다. 안 보일 때는 그냥 건너뛸 것인가, 아니면 나중에 다시 시도할 것인가.
건너뛰면 그날 시트를 아예 못 봅니다. “오늘 한 번만 띄운다” 는 기록을 이미 남겼다면 더 그렇습니다. 그래서 버리지 않고 미루도록 했습니다. 탭이 보이는 시점에 다시 판단하고, 기록도 모든 조건을 통과한 뒤에 남깁니다.
왜 이게 하필 신규 사용자에게만 터졌나
이 버그의 조건은 딱 하나입니다. 안내 카드와 자동 시트가 같은 시점에 뜨는 것.
안내 카드는 처음 한 번만 뜹니다. 그러니까 기존 사용자는 이 조합을 만들 수가 없습니다. 저를 포함해서요.
반대로 신규 사용자는 100% 이 경로를 지납니다. 꿈을 만들면 이 화면으로 자동으로 넘어오게 되어 있고, 넘어오자마자 안내 카드가 뜨고, 같은 순간 아침 계획 시트가 열립니다.
즉 이 버그는 앱을 처음 켠 사람만 겪고, 이미 쓰고 있는 사람은 절대 못 만납니다. 가장 중요한 순간에, 가장 확인하기 어려운 사람들에게만 나타난 것입니다.
첫 할일을 완료하는 비율이 거의 0에 가까웠는데, 여기에 이게 얼마나 기여했는지는 정확히 모릅니다. 다만 앱을 처음 켠 사람이 첫 화면에서 아무것도 못 누른다면, 그 뒤 숫자를 해석하는 건 의미가 없습니다.
남는 규칙
첫째, 리액트 네이티브 모달은 화면 안의 다른 요소와 경쟁하지 않습니다. 항상 위입니다. 그러니 화면 안에 직접 올린 안내나 오버레이가 있고, 같은 화면이 모달을 자동으로 연다면, 그 둘은 반드시 순서를 정해줘야 합니다. 둘 다 “보이는” 상태로 공존할 수 있다는 게 이 문제의 핵심입니다.
둘째, 무언가를 막는 조건은 막으려는 대상보다 먼저 정해져 있어야 합니다. 나중에 켜지는 값으로 가드를 세우면, 그 가드는 정확히 필요한 첫 순간에 없습니다.
셋째, 화면이 안 보인다고 코드가 안 도는 게 아닙니다. 자동으로 무언가를 여는 코드에는 “지금 이 화면이 실제로 보이는가” 를 같이 확인하세요.
넷째, 처음 한 번만 나타나는 상태는 만드는 사람이 제일 못 봅니다. 그 상태를 되돌리는 스위치를 개발자 메뉴에 하나 만들어 두는 게 낫습니다. 저는 이 일 이후로 안내 카드 기록을 초기화하는 항목을 넣었습니다.
같은 앱에서 예약 알림이 계정을 넘어 살아남던 일도 성격이 비슷했습니다. 에러가 안 나는 대신 사람이 겪는 증상만 이상해지는 종류입니다.
2026년 8월 11일 기준이고, React Native 0.85 · Expo SDK 56 · React Navigation 7 · 안드로이드에서 확인했습니다. 모달이 별도 창이라는 점은 iOS 도 같지만, 화면을 안 보일 때도 유지하는 옵션은 탭 설정에 따라 다릅니다.