지난 글이 "앱이 안 켜지는" 문제였다면 이번엔 "켜지긴 하는데 하단이 무너지는" 문제입니다. 링크 북마킹 앱을 만들면서 iOS는 손댈 게 없을 만큼 깔끔했는데, 같은 코드가 안드로이드에서는 이랬습니다.
탭바가 시스템 내비게이션 바에 잘리고, 바텀시트는 키보드에 깔리고, 시트를 띄우면 탭바가 그 위로 뚫고 올라온다.
세 증상이 동시에 터지니 원인도 하나일 거라 생각했는데, 파고들어 보니 각각 완전히 다른 층위의 문제였습니다. 수정만 8차에 걸쳐 진행됐고, 그 과정에서 "고쳤다고 믿었지만 사실 아무 효과가 없던 수정"과 "문제라고 믿었지만 사실 문제가 아니었던 증상"까지 나왔습니다. 같은 늪에 빠질 분들을 위해 전 과정을 남깁니다.
1. 배경: Android Edge-to-Edge
Android 15부터 edge-to-edge가 강제됩니다. 콘텐츠가 상태 바와 내비게이션 바 밑까지 그려지는 방식이죠. 시스템 바가 더 이상 앱 영역을 밀어내지 않고 앱 위에 반투명하게 얹히는 구조입니다. 그래서 하단 UI는 시스템 바가 차지하는 높이(inset)만큼 스스로 피해야 합니다.
결과는 0. 3버튼 내비게이션 기기에서 하단 inset이 0이라는 건 말이 안 됩니다. safe-area 라이브러리가 값을 계산하지 못하고 있다는 뜻이죠.
원인은 edge-to-edge가 반만 설정된 상태였습니다. gradle.properties의 edgeToEdgeEnabled=true는 "콘텐츠를 시스템 바 밑까지 그려라"라는 선언일 뿐이고, inset 값을 React Native 쪽으로 전달해주는 배선은 별개입니다. 그 배선을 담당하는 게 react-native-edge-to-edge 패키지입니다.
bash
npx expo install react-native-edge-to-edge # bun add 금지, 지난 글 참고!npx expo run:android
재빌드 후 로그는 insets.bottom = 48. 첫 번째 층이 뚫렸습니다.
여기서 얻은 원칙이 하나 있습니다. 레이아웃 버그는 코드를 고치기 전에 레이아웃의 근거가 되는 숫자부터 로그로 확인해야 합니다. inset이 0인 상태에서 어떤 패딩 코드를 넣어도 0 + 무언가일 뿐이니, 이 확인 없이 했던 수정들은 전부 헛발질이었습니다.
3. 두 번째 원인: RN Modal은 다른 창이다
바텀시트는 @gorhom/bottom-sheet를 RN Modal로 감싸 쓰고 있었습니다. 탭바보다 위 레이어를 확보하기 위해서요. gorhom에는 친절하게도 안드로이드 키보드 옵션이 있습니다.
그런데 아무 효과가 없었습니다. 이유를 알고 나면 당연한데, adjustResize는 액티비티 창(window) 기준의 동작입니다. RN의 Modal은 액티비티와 별도의 네이티브 창을 만들기 때문에, 액티비티 창이 리사이즈되든 말든 Modal 창 속의 시트는 꿈쩍도 하지 않습니다.
3-1. 잘못된 수선: KeyboardAvoidingView
여기서 반사적으로 KeyboardAvoidingView로 gorhom을 감쌌다가 더 큰 사고가 났습니다. KAV의 레이아웃 재계산이 gorhom 내부의 애니메이션 레이아웃과 충돌하면서 시트 콘텐츠가 통째로 하얗게 사라졌습니다. 그럴듯한 컴포넌트를 덧대는 수정은 두 라이브러리의 내부 동작이 겹칠 때 서로를 파괴할 수 있습니다.
3-2. 제대로 된 수선: 키보드 높이를 직접 받아 시트를 들어올리기
결국 채택한 방법은 우회가 아니라 정공법이었습니다. 키보드 이벤트로 높이를 직접 받아서, gorhom이 제공하는 bottomInset prop으로 시트 자체를 들어올리는 방식입니다.
onLayout으로 측정한 것은 콘텐츠의 높이일 뿐이고, 시트에는 콘텐츠 말고도 그랩 핸들과 상단 패딩이라는 크롬이 있습니다. 그 44px만큼 시트가 짧아지니 정확히 하단 버튼이 그만큼 잘렸던 거죠. 동적 높이 바텀시트를 만든다면 여기를 꼭 확인하세요.
5. 네 번째 원인: 잘린 게 아니라 잘려 보였다
시트가 해결되고 마지막으로 탭바가 남았습니다. inset도 48로 정상이고, bottom-tabs v7은 safe-area 패딩을 자동 처리한다기에 수동 height와 paddingBottom을 전부 걷어내고 순정 상태로 뒀는데도 "여전히 잘린다"는 피드백이 계속됐습니다.
여기서 마지막 수단을 썼습니다. 스크린샷을 열어 픽셀 좌표를 직접 측정한 겁니다.
code
라벨("홈"/"설정") 하단: y ≈ 1880
시스템 버튼(III O <) 상단: y ≈ 1920
→ 글자 절단 없음. 40px+ 여백 존재.
잘림은 이미 없었습니다. 그런데 왜 잘려 보였을까요? 디자인 시안의 탭바는 높이 78에 여유로운 상하 패딩이었는데, v7 기본 탭바는 콘텐츠 영역이 50 정도로 훨씬 촘촘합니다. 아이콘과 라벨이 시스템 버튼에 바짝 붙은 그 모습이 "잘렸다"로 인지된 겁니다. 버그 리포트의 언어와 실제 픽셀은 다를 수 있습니다.
실측 정보를 하나 남기자면, v7은 paddingBottom을 명시하면 자체 inset 패딩을 중복 적용하지 않았습니다. 수동 구성과 자동 구성의 렌더 결과가 픽셀 단위로 같았습니다. 참고로 v6의 safeAreaInsets prop은 v7에서 조용히 무시되니, v6 시절 코드를 이관할 때 주의하세요.
6. 같은 늪에 빠진 분들을 위한 진단 순서
useSafeAreaInsets()의 insets.bottom을 로그로 찍습니다. 0이라면 그 어떤 레이아웃 수정도 무의미합니다. react-native-edge-to-edge를 npx expo install로 설치하고 재빌드하세요.
바텀시트가 RN Modal 안에 있다면 adjustResize류 옵션은 잊으세요. Modal은 별도 창입니다. Keyboard 이벤트로 높이를 받아 bottomInset으로 시트를 직접 들어올리는 편이 낫습니다. KeyboardAvoidingView로 감싸면 gorhom과 충돌할 수 있습니다.
동적 높이 시트의 하단이 잘리면 snapPoint 계산식을 의심하세요. onLayout 측정값은 콘텐츠 높이일 뿐이라 그랩 핸들 같은 시트 크롬의 높이를 더해야 합니다.
"여전히 잘린다"가 반복되면 스크린샷의 픽셀을 재세요. 실제 절단인지, 간격이 좁아 그렇게 보이는 것인지부터 갈라야 다음 수정이 결정됩니다.
이번 트러블슈팅에서 제일 많이 배운 건 코드가 아니라 진단 순서였습니다. 초반에는 그럴듯한 원인을 상상하고 코드를 먼저 고쳤고, 그 결과가 시트 콘텐츠가 통째로 사라지는 회귀였습니다. 흐름이 바뀐 건 순서를 뒤집은 다음부터입니다. inset 값을 로그로 찍고, 스크린샷의 픽셀을 재고, "고쳤는데 그대로"를 원인 배제의 증거로 쓰기 시작하자 네 겹의 원인이 하나씩 분리됐습니다.
iOS에서 완벽했던 하단 UI가 안드로이드에서 무너졌다면, 대개 안드로이드가 이상해서가 아니라 edge-to-edge라는 다른 규칙 위에서 우리 코드의 숨은 가정이 드러난 것입니다. 위 진단 순서대로 짚어보면 그 가정들을 하나씩 걷어낼 수 있습니다.