지난 글이 "앱이 안 켜지는" 문제였다면, 이번엔 "켜지긴 하는데 하단이 무너지는" 문제입니다. 링크 북마킹 앱을 만들면서 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으로 시트 자체를 들어올리는 방식입니다.
레이아웃을 재계산하는 게 아니라 위치만 결정적으로 이동시키기 때문에 충돌할 내부 동작이 없습니다. 안드로이드는 키보드 높이만큼 시트가 떠오르고, iOS는 gorhom의 keyboardBehavior="interactive"가 원래대로 처리합니다.
4. 세 번째 범인: 시트가 스스로 계산한 키가 44px 모자랐다
키보드를 잡고 나서도 시트 하단 버튼이 살짝 잘리는 문제가 남았습니다. 처음엔 Modal의 navigationBarTranslucent 때문이라 의심해 제거했지만 — 효과가 없었습니다. (이 "고쳤는데 그대로"가 오히려 단서였습니다. 원인이 다른 곳에 있다는 뜻이니까요.)
진짜 원인은 snapPoint 계산식이었습니다. 시트 높이를 콘텐츠 높이 기반으로 동적 계산하고 있었는데:
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. 같은 늪에 빠진 분들을 위한 진단 순서
1단계 — useSafeAreaInsets()의 insets.bottom을 로그로 찍는다. 0이라면 그 어떤 레이아웃 수정도 무의미합니다. react-native-edge-to-edge를 npx expo install로 설치하고 재빌드하세요.
2단계 — 바텀시트가 RN Modal 안에 있다면 adjustResize류 옵션은 잊는다. Modal은 별도 창입니다. Keyboard 이벤트로 높이를 받아 bottomInset으로 시트를 직접 들어올리세요. KeyboardAvoidingView로 감싸는 것은 gorhom과 충돌할 수 있습니다.
3단계 — 동적 높이 시트의 하단이 잘리면 snapPoint 계산식을 의심한다.onLayout 측정값은 콘텐츠 높이일 뿐, 그랩 핸들 등 시트 크롬의 높이를 더해야 합니다.
4단계 — "여전히 잘린다"가 반복되면 스크린샷의 픽셀을 잰다. 실제 절단인지, 간격이 좁아 그렇게 보이는 것인지부터 갈라야 다음 수정이 결정됩니다.
이번 트러블슈팅의 최대 교훈은 코드가 아니라 진단 태도였습니다. 초반에는 "그럴듯한 원인"을 상상하고 코드를 먼저 고쳤고, 그 결과가 콘텐츠가 통째로 사라지는 회귀였습니다. 흐름이 바뀐 건 순서를 뒤집은 다음부터입니다 — inset 값을 로그로 찍고, 스크린샷의 픽셀을 재고, "고쳤는데 그대로"를 원인 배제의 증거로 쓰기 시작하자 네 겹의 원인이 하나씩 분리됐습니다.
"증상은 하나로 보여도 원인은 층층이 쌓여 있다. 추측으로 고치지 말고, 측정으로 가려내라."
iOS에서 완벽했던 하단 UI가 안드로이드에서 무너졌다면, 그것은 대개 안드로이드가 이상해서가 아니라 edge-to-edge라는 다른 규칙 위에서 우리 코드의 숨은 가정들이 드러난 것입니다. 이 글이 그 가정들을 하나씩 걷어내는 지름길이 되었기를 바랍니다.