100dvh로 잡은 화면에 세로 스크롤이 생긴 이유 — targetSdk 35+ edge-to-edge 함정
앱 15개에서 한꺼번에 "화면이 조금 위아래로 흔들린다"는 제보를 받았습니다. 원인은 targetSdk 35 이상에서 강제되는 edge-to-edge였습니다. body가 safe-area 인셋만큼 패딩을 먹는데 그 안의 컨테이너를 100dvh로 잡으면 인셋이 두 번 계산돼 문서가 넘칩니다. 진단 과정과 한 줄 처방
미니게임을 폰에서 확인하다가 이상한 걸 발견했습니다. 게임 화면을 손가락으로 문지르면 화면 전체가 위아래로 몇 픽셀씩 밀립니다. 스크롤이 생길 이유가 없는 화면입니다. 전체 화면을 꽉 채우는 게임이고, 컨테이너 높이를 100dvh로 잡아 뒀으니까요.
한 앱만 그런 게 아니었습니다. 앱 15개에서 전부 같은 증상이 나고 있었습니다.
증상
- 화면을 채우도록 만든 게임에서 손가락으로 문지르면 문서가 몇 픽셀 스크롤된다.
- 스크롤 양이 기기마다 다르다. 어떤 기기는 거의 없고, 어떤 기기는 눈에 띄게 밀린다.
- 브라우저(PC·모바일)에서는 재현되지 않는다. 앱으로 감싼 빌드에서만 난다.
- 게임 진행에 치명적이지는 않지만, 드래그로 조작하는 게임에서는 판이 흔들려 조작이 어긋난다.
기기마다 다르고 앱에서만 난다는 두 가지가 방향을 알려 줬습니다. 화면 크기나 노치 모양처럼 기기에 따라 달라지는 값이 계산에 끼어들고 있다는 뜻입니다.
원인: 인셋을 두 번 세고 있었다
안드로이드는 targetSdk 35부터 edge-to-edge가 강제됩니다. 앱 화면이 상태바와 내비게이션 바 아래까지 꽉 차게 그려지고, 대신 그 영역을 피해야 하는 책임이 앱으로 넘어옵니다. WebView도 여기에 맞춰 env(safe-area-inset-*) 값을 실제 크기로 보고합니다.
우리 공용 CSS는 이렇게 되어 있었습니다.
1
2
3
4
5
6
7
8
body {
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
#app {
height: 100dvh; /* ← 여기가 문제 */
}
두 규칙을 따로 보면 둘 다 맞습니다. body에 인셋만큼 패딩을 줘서 콘텐츠가 상태바 밑으로 숨지 않게 하고, 그 안의 #app을 화면 높이만큼 채웁니다.
문제는 함께 놓였을 때입니다. 100dvh는 인셋을 포함한 화면 전체 높이입니다. 그런데 #app은 이미 위아래로 인셋만큼 밀려 들어간 자리에서 시작합니다. 결과적으로 문서 높이는 이렇게 됩니다.
1
2
3
body 위 패딩 + 100dvh + body 아래 패딩
= 인셋(위) + 화면 높이 + 인셋(아래)
= 화면 높이 + 인셋 합
정확히 인셋 합만큼 문서가 넘칩니다. 그게 그 몇 픽셀의 스크롤이었습니다. 기기마다 스크롤 양이 다른 것도 설명됩니다. 인셋 크기가 기기마다 다르니까요. 브라우저에서 재현되지 않은 이유도 같습니다. 일반 브라우저 탭에서는 인셋이 0이라 두 번 세도 0입니다.
처방
100dvh에서 인셋을 다시 빼 주면 됩니다.
1
2
3
4
5
6
7
8
#app {
height: calc(100dvh
- var(--banner-h, 0px)
- env(safe-area-inset-top, 0px)
- env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
}
--banner-h는 별개의 사정입니다. 앱인토스처럼 광고 배너가 페이지 위에 겹쳐 뜨는 환경에서는 브릿지가 배너 높이를 이 변수에 넣어 주고, 화면을 그만큼 줄입니다. 안드로이드 래퍼는 네이티브 레이아웃이 이미 배너를 제외하고 WebView를 배치하므로 0으로 둡니다. 같은 CSS가 두 환경에서 다르게 동작해야 해서 변수로 뺐습니다.
env()에 기본값 0px을 꼭 넣어야 합니다. 인셋을 지원하지 않는 환경에서 env(safe-area-inset-top)은 값이 아니라 무효가 되고, 그러면 calc() 전체가 무효가 되어 높이 지정이 통째로 날아갑니다. 두 번째 인자를 주면 그 경우 0으로 계산됩니다.
플랫폼별 오버라이드가 있는 게임에서는 그쪽도 같이 고쳐야 했습니다. 앱인토스 환경에서는 배너 기본값이 다릅니다.
1
2
3
4
5
.platform-toss #app {
height: calc(100dvh - var(--banner-h, 56px)
- env(safe-area-inset-top, 0px)
- env(safe-area-inset-bottom, 0px));
}
공용 CSS 한 곳과, 이 오버라이드를 갖고 있던 게임 15개를 같이 수정했습니다.
왜 15개에서 동시에 났나
같은 버그가 15개 앱에 동시에 존재한 이유는 단순합니다. 공용 패키지의 CSS를 모든 게임이 같이 쓰기 때문입니다. 좋은 쪽으로도 작용합니다. 원인을 찾은 뒤 공용 CSS 한 줄을 고치자 대부분의 앱이 한 번에 해결됐고, 개별 오버라이드를 가진 것만 따로 손봤습니다.
공용 코드에 있는 버그는 앱 수만큼 곱해지지만, 수정도 앱 수만큼 곱해집니다. 15개를 하나씩 만들었다면 15번 다르게 틀렸을 테고, 각각을 15번 다르게 고쳐야 했을 겁니다.
정리
앞으로 같은 함정을 피하려면 이렇게 기억해 두면 됩니다.
100dvh는 인셋을 포함한 화면 높이다. safe-area 패딩이 걸린 요소 안에서 쓰면 반드시 이중 계산이 된다.- 인셋 처리는 한 층에서만 한다.
body에서 패딩으로 처리했다면 그 안쪽에서는100dvh를 그대로 쓰지 않는다. env()에는 항상 기본값을 준다. 없으면 미지원 환경에서calc()가 통째로 무효가 된다.- targetSdk 35 이상으로 올린 뒤에는 실기기에서 위아래로 문질러 본다. 몇 픽셀짜리 스크롤은 에뮬레이터나 브라우저에서 눈에 안 띈다.
마지막 항목이 이번에 제일 아쉬웠던 부분입니다. 기능이 동작하는지만 확인하고 넘어갔더니, 몇 주 동안 15개 앱에 남아 있었습니다.
만든 앱들은 Google Play와 앱인토스에 있고, 일부는 브라우저에서 바로 해볼 수 있습니다. 소식은 이 블로그와 인스타그램(@fadongkwon.soft) (새 창에서 열림)에서 전해드립니다.
