작년 하루냥 팀에 합류하여 앱인토스 출시를 목표로 하루냥 웹뷰 버전을 개발하게 되었습니다. 첫 웹뷰 작업을 하면서 겪은 문제들을 기록하고자 합니다.
1. iOS Keyboard Viewport 처리
iOS Keyboard 이슈는 일기 작성 기능을 만들다가 처음 마주했습니다. 하루냥의 일기 작성 화면은 아래 구조로 되어있습니다.

사용자가 일기를 쭉 써 내려가다가 줄바꿈이 일어나는 순간, 새로 생긴 커서 라인이 하단의 고정 영역 뒤로 숨어버리는 문제가 있었습니다. 그래서 방금 쓴 글자가 화면에 보이지 않고, 사용자는 자신이 입력한 내용을 확인할 수 없었습니다.
첫번째 시도
처음에는 타이핑 중에 가시 영역에서 벗어난 높이를 구하고, 그만큼 스크롤하도록 작성했습니다. 안드로이드에서는 괜찮았지만, iOS에서는 여전히 커서가 키보드 뒤에 숨어있었습니다.
const rect = el.getBoundingClientRect();
// fixedAreaHeight: 화면 하단에 항상 떠 있는 suggestion 영역의 높이
const overflow = rect.bottom - window.innerHeight + fixedAreaHeight;
if (overflow > 0) window.scrollBy({ top: overflow });📱 모바일 뷰포트
이 문제를 이해하기 위해서는 먼저, 모바일 브라우저에서 뷰포트를 이해해야 했습니다.
모바일 브라우저는 Layout Viewport와 Visual Viewport, 2가지 뷰포트가 존재합니다.
Layout Viewport
CSS가 레이아웃을 계산할 때 기준으로 삼는 Viewport로 대표적으로 아래와 같은 역할을 합니다.
position: fixed의 기준vh단위의 기준 (최근에는 동적 주소창 대응을 위해svh/lvh/dvh가 추가됨)
JS에서는 window.innerHeight로 Layout Viewport의 높이를 읽을 수 있습니다. (iOS Safari에서는 주소창 상태 등에 따라 약간의 경계 케이스가 있긴 합니다.)
Visual Viewport
사용자가 실제로 보고 있는 영역으로 핀치 줌을 하거나 소프트 키보드가 올라왔을 때 크기가 변경됩니다. 키보드가 올라왔을 때를 예시로 들면:
- Layout Viewport:
100vh - Visual Viewport:
100vh-키보드 높이
Visual Viewport 값을 구하려면 window.visualViewport API를 사용합니다.
window.visualViewport.height // 실제 보이는 영역 높이 (CSS px)
window.visualViewport.width // 실제 보이는 영역 너비
window.visualViewport.offsetTop // layout viewport 대비 오프셋
window.visualViewport.scale // 핀치 줌 스케일Android와 iOS 차이점
Android Chrome은 한때 키보드가 올라올 때 Layout Viewport와 Visual Viewport를 키보드 높이만큼 둘 다 줄이는 동작이 기본이었습니다. 그래서 window.innerHeight으로 현재 커서가 가시 영역에서 벗어났는지 계산해도 의도대로 작동했습니다.
하지만 iOS Safari는 Visual Viewport만 높이를 줄입니다. Layout Viewport는 그대로 두고 키보드가 콘텐츠 위에 덮어씌워지는 구조입니다. 그래서 window.innerHeight 값으로 가시 영역을 판단하면 키보드 높이만큼의 오차가 생기고, 결과적으로 커서가 키보드 뒤에 숨어있는 케이스가 발생합니다.
최근에는 Chrome도 interactive-widget 메타 태그가 도입되면서 기본 동작이 점점 iOS 쪽(=Visual Viewport만 줄어드는 방식)으로 수렴하는 추세입니다. 그래서 "Android = 두 뷰포트 모두 축소"라는 전제는 언제든 깨질 수 있고, 동작을 명시적으로 지정하고 싶다면 아래 interactive-widget 메타 태그를 사용하는 쪽이 안전합니다.
해결방법 1: interactive-widget 메타 태그 추가하기
<meta name="viewport" content="width=device-width, interactive-widget=resizes-content">interactive-widget은 대화형 UI 위젯(가상 키보드)이 페이지 뷰포트에 어떻게 영향을 줄지 선택하는 속성으로 3가지 값이 있습니다.
resizes-visual: Visual Viewport만 크기 조정resizes-content: Layout Viewport와 Visual Viewport 모두 조정overlays-content: 뷰포트를 변경하지 않고 오버레이 처리
저의 경우에는 resizes-content로 처리하면 문제가 해결되지만, 글 작성일 기준으로는 iOS Safari에서 interactive-widget에 대한 지원이 제한적인 것으로 보였습니다. (정확한 지원 현황은 caniuse에서 확인)
해결방법 2: VisualViewport API 사용하기
위에서 말했듯이 window.visualViewport API를 사용하면 iOS에서 키보드가 열린 후 변경된 가시 영역의 높이를 구할 수 있습니다. 저의 경우에는 textarea에서 줄바꿈이 일어날 때, 가시 영역에서 벗어나면 그만큼만 스크롤해 주면 되는 문제였기에 아래와 같이 해결할 수 있었습니다.
// 포커스 영역이 보이도록 처리하는 함수
const handleVisible = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
const safeAreaBottom = SafeAreaInsets.get().bottom;
// 하단 suggestion 영역 높이 + safeAreaBottom
const fixedAreaHeight = FIXED_AREA_HEIGHT + safeAreaBottom;
// iOS는 visualViewport.height가 키보드를 제외한 실제 가시 영역
// (구버전 환경 대비로 innerHeight를 fallback으로 둠)
const viewportHeight =
window.visualViewport?.height ?? window.innerHeight;
const overflow = rect.bottom - viewportHeight + fixedAreaHeight;
if (overflow > 0) window.scrollBy({ top: overflow });
});
}, []);
// textarea의 onInput에서 호출하여, 줄바꿈으로 textarea 높이가 변할 때마다
// 커서 라인이 키보드 뒤로 숨지 않도록 처리
<textarea ref={textareaRef} onInput={handleVisible} />requestAnimationFrame을 쓴 이유는, textarea의 auto-resize로 바뀐 높이가 반영된 다음 프레임에서 getBoundingClientRect를 호출하기 위해서입니다. 줄바꿈이 일어나는 순간 textarea의 style.height가 먼저 바뀌는데, 이걸 같은 동기 블록에서 바로 측정하면 레이아웃이 안정되기 전의 값을 잡을 수 있습니다. raf를 통해 한 프레임 미뤄서 업데이트된 DOM 위에서 안전하게 측정하고자 했습니다.
⚠️ 한 가지 알아두면 좋은 점은, iOS에서 키보드가 막 올라오는 transition 구간(약 250~300ms) 동안
visualViewport.height가 점진적으로 줄어든다는 점입니다. 그래서 처음 포커스가 들어가는 순간에는 값이 아직 최종값이 아닐 수 있습니다. 만약 첫 포커스 시점에도 정확하게 맞춰야 한다면,visualViewport의resize이벤트를 같이 listen하는 방식으로 보완할 수 있습니다.
2. SPA FOUC 문제
앱인토스 하루냥은 React + Vite + React Router 7로 구성되어있습니다. 개발 버전에서는 별 문제가 없었으나, 배포된 버전에서는 FOUC가 발생하는 문제가 생겼습니다.


💡 FOUC란? Flash Of Unstyled Content의 약자로 외부 CSS가 불러오기 전에 잠시 스타일이 적용되지 않은 웹페이지가 나타나는 현상 위키피디아-FOUC
확인해보니 Vite는 개발 모드일 때 CSS 파일을 각각 별도의 <style> 태그로 만들어 <head>에 즉시 주입하지만, 빌드 후에는 CSS Module이 라우트별 .css 파일로 분리되고 페이지 이동 시점에 로드됩니다.
React Router는 페이지 이동 시 해당 라우트의 CSS를 <link rel="stylesheet">로 동적 삽입하는데, 이때 CSS 파일 로드가 완료되기 전에 컨텐츠가 먼저 렌더링되면서 FOUC가 발생했습니다.
💡 확인 방법 (framework mode 기준) 빌드 후
window.__reactRouterManifest를 브라우저 콘솔에서 확인하면 라우트별로 연결된 CSS 파일 목록을 볼 수 있습니다. (manifest*.js파일을 확인하면 됩니다.)
현재 앱인토스 하루냥은 CSS 파일이 크지 않아 cssCodeSplit: false로 변경하여 하나의 css 파일로 제공하기로 했습니다.
// cssCodeSplit: true (기본값) 일때, manifest 예시
root → [root.css, Button.css, Snackbar.css]
routes/home → [home.css, Button.css, Confirm.css]
routes/about → [about.css, Button.css]
routes/detail → [detail.css, Input.css, Button.css, BottomSheet.css]
// cssCodeSplit: false 일때, manifest 예시
root → [style.css] ← 모든 CSS가 하나로 통합
routes/home → []
routes/about → []
routes/detail → []다만 cssCodeSplit: false는 장단점이 분명한 옵션입니다.
라우트별 분리의 장점은 사용자가 방문하지 않은 라우트의 CSS는 받지 않는다는 것인데, false로 두면 첫 페이지 로드에서 모든 CSS를 한 번에 받게 되어 초기 번들이 커집니다. 그래서 CSS가 작은 SPA에서는 FOUC를 깔끔하게 없애주는 좋은 선택이지만, CSS가 큰 앱에서는 오히려 초기 로딩을 느리게 만들 수 있습니다.
만약 CSS 파일이 큰 경우라면, cssCodeSplit: false 옵션보다는 rollup-plugin-critical을 통해 Critical CSS만 <style> 태그에 인라인으로 주입하는 방법을 고려해볼 수 있습니다.
또는 라우트별 분리를 유지하면서 FOUC만 없애고 싶다면, 라우트 모듈의 links export로 해당 라우트의 스타일시트를 선언하는 방법도 있습니다. 이렇게 하면 React Router가 콘텐츠를 렌더링하기 전에 <link rel="stylesheet">를 미리 삽입해 주기 때문에, 라우트별 CSS 분리의 이점을 살리면서도 깜빡임을 막을 수 있습니다. (바로 뒤에서 이미지 preload에 쓰는 links와 같은 API입니다.)
FOUC를 처리하는 김에 자주 사용되는 이미지 파일도 preload 처리해서 깜빡임 없이 보일 수 있도록 개선했습니다.
// react-router v7 이미지 프리로드 처리 방법
export const links = () => [
{ rel: "preload", href: 이미지, as: "image" },
];후기
첫 웹뷰 프로젝트라 새로운 것들이 많았습니다. 그동안 SSR 기반 웹 작업만 해왔던 터라, 브라우저가 알아서 해주던 것들(뷰포트, 스타일 로드 타이밍)을 직접 신경 써야 하는 모바일 웹뷰 환경이 낯설면서도 재밌었습니다.
"안드로이드는 되는데 iOS는 왜 안 되지?" 같은 걸로 한참 헤맸는데, 돌아보면 결국은 플랫폼마다 다르게 동작하는 부분을 하나씩 확인해 가는 과정이었던 것 같습니다. 익숙한 웹이라고 생각했던 것도 환경이 바뀌니 처음 보는 문제가 계속 나와서, 오랜만에 이것저것 뜯어보는 재미가 있었습니다.

