배경
서비스 내 웹뷰 지면에서 댓글 입력창이 화면 가장 하단에 딱 붙어있었다. 어색하게 느껴져 이걸 개선하자고 제안한 게 시작이었다.
문제
위 이미지만 봤을 때는 크게 이질감이 안 느껴진다고 생각할 수 있다. 하지만 아래 스크린샷을 보면 얘기가 달라진다.



동일한 기종인 아이폰 13 Pro로 캡처한 이미지이다. 처음에 보여준 이미지와 차이점이 있지 않은가? 바로, 최하단에 약간의 여백이 있다는 점이다.
상황 파악
위에서 본 여백이 바로 Safe Area이다.
Safe Area란 기기 화면 중에서 노치, 다이나믹 아일랜드, 상태바, 홈 인디케이터, 둥근 모서리에 가려지지 않는 영역을 말한다.
내가 주로 사용하는 앱들은 Safe Area를 고려하여 화면 최하단에 여백이 생겼던 것이다.
그렇다면, 처음 소개한 배경의 페이지에서 댓글 입력창은 왜 최하단에 여백이 없던 것일까? 해당 지면이 웹뷰이기 때문이다. 앱이 Safe Area 영역 없이 웹뷰를 화면 전체에 차지하도록 했고, 웹뷰 또한 Safe Area를 고려하지 않아서 여백이 생기지 않은 것이다.
해결 과정
그럼 여기서 해결 방법은 크게 두 가지이다.
- 네이티브에서 Safe Area를 추가한다.
- 웹뷰에서 Safe Area를 추가한다.
1번의 방식에는 한계가 있다. 구버전의 앱에서는 대응이 안 된다는 점, 그리고 배포까지 시간이 걸린다는 점. 그래서 웹뷰에서 대응하는 방향으로 진행하기로 결정했다.
웹에서 Safe Area를 어떻게 대응할 수 있을까? viewport 메타 태그에는 viewport-fit="cover"라는 옵션이 있다. 값은 auto/contain/cover 세 가지이고, 미설정했을 때의 기본값은 auto다.
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
위 옵션을 설정하면 웹뷰가 디바이스의 Safe Area 영역까지 포함하여 화면 전체를 차지하게 된다. 웹뷰가 이미 화면 전체를 차지하고 있었다면 보이는 건 크게 달라지지 않는다. 반대로 콘텐츠가 Safe Area를 피해 그려지고 있었다면, 이 설정으로 콘텐츠가 Safe Area 영역까지 확장되어 보인다.
또한, env(safe-area-inset-*)에 Safe Area 영역만큼 값이 부여된다. 앞선 환경 변수의 *에는 bottom/top/left/right가 들어가며 각각 사용자 기기의 하단/상단/좌측/우측 Safe Area 영역만큼의 값이 부여된다.
해결책은 상당히 간단하다. 위 env(safe-area-inset-*)을 기존 요소의 여백에 추가하면 된다. 여백을 추가해야 하는 요소는 다음과 같다.
- 레이아웃 요소의 상/하단 여백 : padding-top/bottom으로 레이아웃 요소의 상/하단에 여백을 추가한다.
- fixed/sticky 요소의 상/하단 여백 : fixed/sticky 요소는 일반적인 문서 흐름의 영향을 받지 않는다. 따라서 기존 top/bottom 오프셋이나 padding에 Safe Area만큼을 더해줘야 한다. 이때 top/bottom을 밀어내면 요소와 화면 끝 사이가 비어 뒤 배경이 보이므로, bottom은 0으로 두고 padding-bottom으로 여백을 주는 편이 낫다.
- fixed/sticky 요소에 의해 가려지는 요소의 상/하단 여백 : fixed/sticky 요소는 일반적인 문서 흐름의 영향을 받지 않기 때문에, 일반적인 문서 흐름에 놓여있는 요소에 여백 추가가 필요하다.
- 예를 들어, 댓글 리스트의 아랫 부분은 fixed 요소인 댓글 입력창에 의해 가려질 수 있다. 따라서 가려지는 쪽인 댓글 리스트에 여백을 추가해야 한다. 이때 댓글 입력창 높이만큼도 함께 계산해야 한다. 보통 Safe Area가 없어도 여백을 추가했을 텐데, 여기에 Safe Area 여백도 함께 추가해줘야 한다.
/** 예시 */
.comments_list{
/** .. */
margin-bottom:calc(20px + env(safe-area-inset-bottom));
}
.bottom_fixed_input{
/** .. */
position:fixed;
bottom:0;
padding-bottom:env(safe-area-inset-bottom)
}
문제 2: AOS의 이중 여백
위와 같이 적용해서 테스트 서버에 배포를 했다. 그런데 문제가 생겼다. iOS에서는 정상적으로 보이는데, AOS 일부 기기에서는 이중 여백이 생긴 거다.
원인을 확인하기 위해서 Inspector에서 iOS와 AOS의 웹뷰 영역을 각각 확인했다.


위 화면에서, 기기 화면 인디케이터까지 웹뷰 영역을 차지하고 있는 iOS와 달리, AOS에서는 인디케이터 영역을 제외하고 웹뷰 영역이 차지하고 있었다. 즉 AOS는 iOS와 달리 네이티브 단에서 웹뷰를 감싼 뷰에 인셋만큼 padding을 넣어두고 있었다. 웹뷰 입장에서는 이미 여백이 확보되어 있던 셈이다.
그렇다면 왜 특정 AOS 기기에서만 위 이슈가 발생한 것일까? 이는 AOS 버전의 차이에 있었다. 실제로 위 이슈가 재현되는 기기는 AOS 16이며, AOS 11에서는 문제가 발생하지 않았다. 이는 AOS 15부터 강제된 엣지투엣지와 관련이 있었다. 엣지투엣지는 시스템 바 영역까지 앱 창이 확장되어 그려지는 것을 말한다. 정확히는 targetSdk 35 이상 + AOS 15 이상 조합에서 강제되는데, AOS 15에서는 windowOptOutEdgeToEdgeEnforcement로 빠져나올 수 있었고 targetSdk 36부터는 이 옵트아웃마저 무력화된다.
Android 15 이상을 실행하는 기기에서 SDK 35 이상을 타겟팅하면 앱이 더 넓은 화면으로 표시됩니다. 창은 시스템 표시줄 뒤에 그려져 디스플레이의 전체 너비와 높이에 걸쳐 있습니다. 시스템 표시줄에는 상태 표시줄, 캡션 표시줄, 탐색 메뉴가 포함됩니다.
즉, 정리하면 AOS에서 이중 여백이 생긴 현상의 원인은 이렇다. 엣지투엣지로 앱 창 자체는 시스템 바 뒤까지 확장되지만, 네이티브가 웹뷰를 감싼 뷰에 인셋을 padding으로 넣어 웹뷰는 인디케이터를 침범하지 않는다. 그래서 웹뷰에는 이미 여백이 있는데 env(safe-area-inset-*)에는 창 기준의 실제 값이 들어와 여백이 두 번 생긴 것이다. 이 문제를 해결하는 방법은 다음과 같다.
- AOS도 iOS와 동일하게 Safe Area를 적용하지 않는다.
- iOS에서 AOS와 동일하게 Safe Area를 적용한다.
- 웹에서 AOS와 iOS를 분기하여 처리한다.
1번과 2번 해결 방법은 위와 동일하게 네이티브 배포가 필요하고, 구버전 앱에 대응이 되지 않는다는 단점이 있었다. 따라서 3번으로 진행하기로 결정했다.
AOS와 iOS를 분기하기 위해서는, 요청 헤더의 user-agent로 OS를 판단한 후 AOS에서는 viewport-fit을 auto로, iOS에서는 cover로 설정하면 된다. 단, 같은 URL이 OS별로 다른 HTML을 내려주게 되므로 CDN/풀 라우트 캐시가 걸려 있다면 캐시 키에 user-agent를 포함시키거나 해당 라우트를 동적으로 돌려야 한다.
문제 3: auto인데도 들어오는 inset
분기까지 넣었는데 또 걸렸다. AOS에서는 viewport-fit을 분명히 auto로 설정했는데 env(safe-area-inset-*) 값이 적용되는 것이다. 실제로 Inspector로 viewport 메타 태그의viewport-fit 옵션이 auto로 설정된 것까지 확인을 했음에도 말이다.
찾아보니 viewport-fit과 무관하게, 엣지투엣지로 확장된 창을 기준으로 inset이 계산되어 값이 그대로 들어올 수 있다는 내용이었다.
이를 해결하려면 viewport-fit을 분기하는 것만으로는 부족하고, env(safe-area-inset-*) 값 자체를 직접 제어해야 한다. 따라서 CSS 변수 선언을 통해 이 문제까지 해결할 수 있었다.
// src/app/layout.tsx
export const generateViewport = (): Viewport => {
return {
// ..
viewportFit: isIOS ? 'cover' : 'auto',
}
}
// ..
const Layout = () => {
return(
<html className={isIOS ? 'os-ios' : 'os-android'}>
)
}
viewport-fit을 iOS에서는 cover로, AOS에서는 auto로 설정한다.
/** src/app/(renewal)/_core/css/global.css */
.os-ios {
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-left: env(safe-area-inset-left);
--safe-area-inset-right: env(safe-area-inset-right);
}
.os-android {
--safe-area-inset-top: 0px;
--safe-area-inset-bottom: 0px;
--safe-area-inset-left: 0px;
--safe-area-inset-right: 0px;
}
iOS에서 var(--safe-area-inset-*)은 실제 env(safe-area-inset-*) 값을 가지며, AOS에서는 값을 가지지 않는다(0px). 다만 UA 판별이 실패해 두 클래스 중 아무것도 붙지 않으면 변수가 미정의 상태가 되고, calc(20px + var(--safe-area-inset-bottom)) 같은 선언이 통째로 무효가 되어 여백이 아예 사라진다. 그래서 사용처에서는 var(--safe-area-inset-bottom, 0px)처럼 fallback을 두거나, :root에 0px 기본값을 먼저 선언해두는 편이 안전하다.
해결 후
결론
단순히 safe-area-inset-*을 추가하면 해결이 되는 문제라 생각했지만, 생각보다 복잡하게 얽혀있는 문제들이 잠재되어 있었다.
정리하면 웹뷰에서 Safe Area를 다뤄야 할 땐 볼 건 세 가지다. 네이티브가 인셋을 이미 처리하고 있는지 OS별로 Inspector로 직접 확인할 것(네이티브에 물어봐 크로스 체크하는 것도 중요하다), OS마다 다르다면viewport-fit은 OS별로 분기할 것, env()를 컴포넌트에서 바로 쓰지 말고 CSS 변수로 한 겹 감싸서 제어권을 쥘 것.
이 문제를 해결하며 하나하나 미션을 깨는 기분이었다. 처음에는 ‘이건 어떻게 하지..’하는 막연한 두려움이었지만 해결하고 나면 정말 별거 아니기도 했다.
이 문제를 해결하고 나서, AI가 내 역할을 완전히 대체하지는 않겠구나 또 한 번 느꼈다. AI가 처음에 작성한 코드는 viewport-fit을 cover로 설정하고 필요한 요소에 safe-area-inset-* 여백을 추가한 것이었다. 이렇게 배포했을 경우 AOS에서는 이중 여백이 생기며 난리가 났을 것이다. 하지만 원인이 AOS/iOS의 Safe Area 처리 차이라는 걸 짚어냈기 때문에 추가 문제 없이 넘어갈 수 있었다. AI가 틀린 이유는 능력이 아니라 컨텍스트였다. 우리 앱 네이티브가 인셋을 어떻게 처리하는지, 사용자 AOS 버전이 어디에 몰려 있는지를 AI는 알 수 없었다. 앞으로도 AI는 도구로 쓰고, 판단은 내가 하자고 한 번 더 생각했다.