Skip to main content
← 블로그

사라진 시트, 가둬진 화면 — iOS Modal 스태킹 버그를 좁히기

VauDium ·

Template task를 완료하면 만족도 시트가 사라진 직후 화면 전체가 동결되는 증상. 애니메이션은 계속 도는데 터치는 다 먹힘. 가설 여러 개를 거치고 마지막에 진짜 원인을 찾은 디버깅 기록.

사라진 시트, 가둬진 화면

증상은 단순했어요. Template으로부터 만든 task를 완료하면 만족도 시트가 잠깐 떴다가 사라진 직후, 화면 전체가 터치에 반응하지 않게 됨. 별 반짝임이나 다른 애니메이션은 계속 도는데 어디를 눌러도 아무 일도 안 일어남.

게다가 Template이 없는 task를 완료할 때는 이런 동결이 발생하지 않았어요. 신호는 분명했지만, template만 그러는지 한참을 헤맸습니다.

처음 든 가설들 — 다 빗나감

오늘 아침에 깐 코드가 의심됐어요. 어제 “진행 중 표지자” 같은 시각 변경들이 들어갔고, 그 자리에 만족도 시트 닫힘 lifecycle을 건드린 게 있을 수도 있었으니까.

처음엔 BottomSheetModal의 mounted 상태가 안 풀리는 게 아닐까 의심했습니다. 시트 닫힘 애니메이션의 callback이 어떤 이유로 도착하지 않으면 setMounted(false)가 못 불려서 native Modal이 살아 남고, 그게 터치를 가둘 수 있죠.

안전망을 박았습니다 — setTimeout(closeDuration + 200)을 걸어 callback이 늦어도 강제로 unmount되도록. 한 번은 풀리는 것 같았는데, 다음 번 시도엔 다시 동결. 안전망만으론 안 잡혔어요.

두 번째 가설: BottomSheetModal lifecycle을 더 결정적으로. animation callback 의존을 빼고 setTimeout 기반으로 mount 결정. 역시 효과 없었습니다. 사용자도 “여전히 막혔어” 라고 했고요.

이 시점에 깨달은 게 있었어요. 잘못된 곳을 보고 있다. BottomSheetModal은 깨끗하게 닫히고 있는데, 그 밑에 뭔가가 남아 있는 거였습니다.

결정적 질문

좁히기 위해 로그를 박았습니다. 만족도 시트 닫힘 시점, AchievementOverlay 렌더 시점, ResultReviewCard 렌더 시점을 다 찍어보니:

[AchievementOverlay] render, queue.length=2 head=achievement
[BottomSheetModal] effect run, isVisible=true mounted=false
[BottomSheetModal] anim cb finished=true isVisible=true
[BottomSheetModal] effect run, isVisible=false mounted=true
[BottomSheetModal] anim cb finished=true isVisible=false
[BottomSheetModal] cb setMounted(false)
[BottomSheetModal] effect run, isVisible=false mounted=false
[AchievementOverlay] render, queue.length=1 head=result_review
[ResultReviewCard] render, taskId=... contentReady=true

BottomSheetModal은 깨끗하게 mounted=false까지 갔어요. 그런데 화면은 여전히 동결. 사용자는 “빨간색 안 보임” 이라고 했고요 (디버그용으로 ResultReviewCard 안에 빨간 반투명 backdrop을 박아 둔 상태였습니다).

여기서 결정타가 나왔습니다 — 만족도 시트가 사라진 것처럼 보였는데 AchievementOverlay의 콘텐츠가 그 자리에 안 그려졌다는 뜻이었어요.

진짜 원인

AchievementOverlay는 <Modal transparent visible animationType="none"> 으로 떠 있는 별도의 RN Modal이었습니다. 그 안에 ResultReviewCard 또는 AchievementCard가 들어가요. Template task 가 완료되면 큐에 result_review 이벤트가 들어가서 AchievementOverlay Modal이 열립니다. Non-template task 는 이 이벤트가 안 들어가서 AchievementOverlay Modal 자체가 열리지 않고요.

순서는:

  1. AchievementOverlay Modal 열림 (queue 갱신으로)
  2. RetrospectSatisfactionModal(BottomSheetModal)이 그 위에 열림
  3. 사용자가 만족도 시트 dismiss → BottomSheetModal 닫힘
  4. 이론상 그 자리에 AchievementOverlay가 visible로 복귀
  5. 현실: AchievementOverlay native layer가 invisible-but-touch-capturing 상태로 남음

iOS에서 transparent <Modal> 두 개를 동시에 native presentation으로 띄우고 위의 것을 닫을 때, 아래 Modal의 visual은 복귀하지 않고 touch capture만 살아 있는 경우가 있어요. 컨텐츠가 분명 React 트리에는 있는데 화면에는 안 그려지고, 그 native frame의 좌표에 들어오는 모든 터치는 native level에서 흡수됨.

증상이 정확히 들어맞았습니다:

  • 화면에 콘텐츠가 안 보임 → invisible
  • 모든 터치가 가둠 → touch-capturing
  • 애니메이션은 계속 → 다른 native-driver 애니메이션은 영향 X (Reanimated, CALayer)
  • Template task만 → result_review가 template-only라서 AchievementOverlay가 그때만 열림

Portal로 우회

해결은 native Modal을 안 쓰는 것. @gorhom/portal<Portal>은 React 트리 안에서 PortalHost로 콘텐츠를 hoist합니다. native Modal stack에 안 올라가니 iOS의 스태킹 버그 자체가 발생하지 않아요.

AchievementOverlay를 <Modal><Portal>로 바꾸고 _layout.tsx에서 PortalProvider 안쪽으로 옮겼습니다. BottomSheetModal은 그대로 RN <Modal> 사용 (메인 인터랙션 sheet이라 native layer가 자연스럽습니다). Portal로 옮긴 AchievementOverlay는 그 위에 떠도 stacking 영향을 받지 않고, BottomSheetModal이 닫혀도 깔끔하게 visible로 남아 있어요.

다만 한 가지 더 — BottomSheetModal(아래)과 AchievementOverlay Portal(위)이 동시에 보이는 잠깐의 겹침이 시각적으로 어색했습니다. 그래서 satisfactionSheetActiveAtom 이라는 atom 하나를 추가하고, AchievementOverlay가 atom이 true인 동안엔 렌더 보류하도록 게이팅. 사용자는 만족도 시트 → 닫기 → 결과 카드 의 순차 노출을 보게 됩니다.

회고

처음에 잘못 들여다본 데서 시간이 꽤 갔습니다. BottomSheetModal의 mount lifecycle을 두세 번 만지고 안전망까지 박았는데, 정작 진짜 원인은 그 아래에 또 다른 Modal이 있다는 사실 자체 였어요.

방어적으로 다시 짠 코드(BottomSheetModal 안전망, ResultReviewCard 빈 콘텐츠 fallback)는 그 자체로는 옳은 방어 였지만 진짜 원인을 가리는 패치 이기도 했어요. 결국 다 들어내고 Portal 한 줄 차이로 해결됐죠.

교훈은 단순합니다 — 증상에 가장 가까운 컴포넌트가 항상 원인은 아니다. 만족도 시트가 동결의 위치 였지만 원인 은 그 아래 보이지 않는 다른 Modal이었습니다. 보이는 것 너머에서 같이 떠 있는 게 뭐가 있나, 그게 native level에서 어떤 상태인가를 묻는 게 더 빠른 길이었어요.

그리고 iOS에서 transparent RN Modal 두 개 동시 스태킹은 일반적으로 피해야 한다는 룰을 메모리에 박아 뒀습니다. 보조 overlay는 Portal로. 다음에 비슷한 자리에서 시간 덜 쓰려고요.