LODY/정리

애플 플랫폼 백과사전 / WKWebView와 Android WebView가 화면을 그리는 방식의 차이

WKWebView와 Android WebView가 화면을 그리는 방식의 차이

들어가며

웹뷰 위에 동일한 React 페이지를 올렸는데 iOS에서는 페이지 중간으로 점프한 뒤 위로 스크롤하면 위쪽이 잠깐 빈 화면으로 남고, Android에서는 같은 동작이 매끄럽게 됩니다. 같은 HTML, 같은 CSS, 같은 자바스크립트인데 결과가 다릅니다. 단순한 브라우저 호환성 문제가 아니라 두 웹뷰가 화면을 그리는 방식 자체가 다르기 때문에 생기는 차이입니다.

이 글의 목적은 그 차이를 한 단계씩 따라가는 것입니다. WKWebView의 TileController가 어떤 영역만 그리는지, Android WebView의 cc 컴포지터가 viewport 바깥을 어떻게 미리 그려 두는지, 그리고 fragment 앵커 점프(#review-0 같은 URL) 한 번에 두 엔진이 왜 다른 결과를 내는지 전체 흐름으로 정리합니다.

WKWebView 자체의 멀티프로세스 구조와 IPC, 쿠키·브리지 같은 기본기는 WKWebView의 내부 동작 원리에서 다뤘습니다. 이 글은 그 위에 렌더링 파이프라인Android WebView와의 비교를 더 얹는 위치에 있습니다.

용어 설명

  • WKWebView: iOS의 웹 콘텐츠 렌더링 뷰. WebKit2 기반
  • Android WebView: Android의 웹 콘텐츠 렌더링 뷰. Android 4.4부터 Chromium(Blink) 기반
  • fragment 앵커: URL 끝의 #id 형태. 페이지 내 특정 요소로 즉시 스크롤 점프
  • viewport: 화면에 실제로 보이는 영역
  • tile: 렌더링 엔진이 페이지를 잘게 쪼갠 사각 단위. raster·합성의 작업 단위

같은 코드, 다른 화면

문제 상황을 먼저 봅니다. 사용자가 알림에서 https://example.com/reviews?page=2#review-0 같은 URL로 진입한다고 가정합니다. #review-0는 페이지 중간쯤의 첫 리뷰 카드 ID입니다.

브라우저 기본 동작은 다음과 같습니다.

  1. HTML이 도착하고 파싱 끝
  2. CSS 적용·레이아웃 끝
  3. URL의 #review-0을 만족하는 위치로 즉시 스크롤 점프
  4. 사용자가 위로 스크롤하기 시작

이 4번 직후 두 플랫폼의 결과가 갈립니다. iOS WKWebView에서는 위쪽이 잠깐 흰 영역으로 남았다가 천천히 채워집니다. Android WebView에서는 그런 빈 영역이 거의 보이지 않습니다.

증상만 보면 "Safari 버그"로 흘려보내기 쉽지만, 실제로는 렌더링 정책이 정상적으로 작동한 결과입니다. 무엇을 미리 그릴지에 대한 두 엔진의 결정이 다르기 때문에 생긴 일입니다.

이 글은 그 결정 두 가지 — iOS의 lazy tile creation과 Android의 interest rect + skewport prepaint — 를 따라갑니다.


WKWebView의 렌더링 모델

멀티프로세스 위에 얹힌 렌더링

WKWebView는 WebKit2 위에 만들어졌고, 페이지 자체는 앱 프로세스가 아니라 별도의 WebContent Process에서 실행됩니다. 자세한 분리 구조는 별도 글에 있고 여기서는 렌더링과 직접 관련된 부분만 짚습니다.

핵심은 UI Process가 WebContent Process에 "지금 화면에 노출된 영역(view-exposed-rect)을 알려 주고", WebContent Process는 그 영역에 해당하는 tile만 만들어 합성한다는 점입니다. 이 통신이 IPC 한 번을 더 거치기 때문에, "지금 보이는 곳만 그리겠다"는 정책이 자연스럽게 따라옵니다.

용어 설명

  • WebContent Process: 웹 콘텐츠를 실제로 실행·렌더링하는 별도 프로세스. JavaScript의 JIT 실행도 여기서 일어남
  • GPU Process: 합성과 일부 가속 렌더링을 담당하는 별도 프로세스
  • TileController: WebKit iOS에서 tile의 생성·관리·재활용을 책임지는 객체
  • view-exposed-rect: UI Process가 WebContent Process에 알려 주는 "지금 노출된 영역"의 사각 좌표

TileController는 보이는 곳만 그린다

iOS의 TileController는 페이지 전체를 미리 tile로 쪼개 두지 않습니다. UI Process가 알려 주는 view-exposed-rect 안에 들어오는 tile만 raster 큐에 올립니다.

이 설계는 원래부터 그랬던 것은 아닙니다. WebKit Bugzilla #126457에는 "[iOS] [WK2] TileController creates all tiles on first paint, making it slow and consuming lots of memory"라는 제목의 이슈가 있습니다. 처음에는 첫 paint 때 모든 tile을 만들었고, 큰 페이지에서는 느리고 메모리를 많이 먹었습니다. 이 이슈를 해결하면서 lazy tile creation으로 바뀌었고, 그 시점부터 view-exposed-rect 기반 모델이 정착됐습니다.

didLiveScrollTo: 같은 콜백이 스크롤이 진행되는 동안 계속 호출되면서 새 노출 영역에 들어온 tile을 동적으로 만듭니다. 즉 사용자가 스크롤을 하지 않으면 새 tile이 만들어지지 않습니다.

실무에서 이 정책이 드러나는 순간은 두 가지입니다. 첫째, 페이지 전체를 캡처하려고 오프스크린으로 큰 컨테이너에 그리려 할 때 빈 영역이 나옵니다. Apple Developer Forums에서도 "WKWebView는 화면에 보일 때만 렌더링하며 오프스크린 렌더링을 위한 공개 API는 노출돼 있지 않다"고 명시하고 있습니다. 둘째, 페이지 중간으로 갑자기 점프했을 때 점프 직전까지 노출되지 않았던 영역의 tile은 존재하지 않는 상태가 됩니다. 이 두 번째 상황이 fragment 앵커 점프 지연의 핵심입니다.

위쪽 영역의 tile이 한 번도 만들어진 적이 없으니, 사용자가 스크롤로 그 영역을 노출시키는 순간에 비로소 raster가 시작됩니다. raster가 끝나기 전까지 그 자리는 비어 있습니다.

용어 설명

  • lazy tile creation: 필요한 시점에 필요한 tile만 만들어 두는 방식. 메모리·전력에 유리하지만 갑작스런 viewport 점프에는 약함
  • raster: 벡터 정보(텍스트·도형·이미지)를 실제 픽셀 비트맵으로 변환하는 작업
  • 합성(compositing): raster된 여러 layer/tile을 한 장의 화면 이미지로 결합하는 작업

Android WebView의 렌더링 모델

Chromium 위에 얹힌 WebView

Android 4.4부터 WebView는 시스템 WebKit이 아니라 Chromium의 Blink 엔진 위에서 동작합니다. Android R(API 30) 이후로는 렌더러가 항상 별도 프로세스(Out-of-Process Renderer, OOPR)에서 돌아갑니다. 그 이전에도 메모리가 충분한 64bit 디바이스에서는 OOPR이 기본이었습니다.

다만 한 가지 짚어 둘 점이 있습니다. 브라우저 코드 자체는 앱 프로세스 안에서 돕니다. Chrome의 멀티프로세스 모델과 약간 다른 부분으로, WebView는 GPU 서비스나 네트워크 서비스도 in-process로 돌려 메모리를 아낍니다. 즉 "프로세스 분리는 OOPR 한 곳에 집중되고, 합성은 앱 프로세스 안에서 마무리된다"는 구조입니다.

이 차이는 합성 모델로 이어집니다.

Synchronous compositor와 RenderThread

Android WebView는 synchronous compositor 모드로 동작합니다. 일반 Chrome처럼 별도 합성 스레드가 화면 갱신 사이클을 따로 돌리는 대신, 임베딩 앱의 OS 그리기 사이클(특히 RenderThread)에 직접 합성 결과를 끼워 넣습니다.

이 모델에서 중요한 점은 합성을 앱의 그리기 사이클에 동기로 묶는다는 것입니다. 그래서 WebView 안의 콘텐츠와 그 위에 얹힌 네이티브 뷰의 합성이 한 frame 안에서 일관되게 끝납니다.

cc 컴포지터의 4단계 tile 우선순위

Chromium의 cc(콘텐츠 컴포지터)는 페이지를 layer로 나누고, 각 layer를 다시 tile로 쪼갠 다음 raster합니다. iOS와 결정적으로 다른 부분이 여기 있습니다. cc는 viewport 바깥의 tile도 미리 raster합니다.

cc는 tile을 네 개의 우선순위 묶음(priority bin)으로 정렬합니다.

  1. NOW — 지금 viewport 안에 있는 tile (distance_to_visible == 0)
  2. SEEN (skewport) — viewport 바깥이지만 사용자의 스크롤 속도와 방향으로 곧 보일 가능성이 큰 tile
  3. soon border rect — skewport 바깥의 가까운 영역
  4. EVENTUALLY — 그보다 더 멀지만 메모리 여유가 있으면 미리 그려 둘 수 있는 tile

now는 즉시 raster, skewport는 사용자가 스크롤로 도달할 가능성이 높으므로 그 다음 우선순위로 raster, sooneventually는 메모리 예산이 허락하는 범위에서 미리 채워 둡니다. 이 정책 덕분에 사용자가 스크롤을 시작하면 이미 raster된 tile이 viewport에 들어올 가능성이 큽니다. tile이 준비되지 않아 화면에 빈 격자가 보이는 현상을 Chromium 문서는 checkerboarding이라고 부르고, cc의 우선순위 시스템 자체가 이 checkerboarding을 줄이려고 설계됐습니다.

용어 설명

  • OOPR (Out-of-Process Renderer): WebView의 렌더러 코드를 앱 프로세스 바깥의 sandbox 프로세스에서 실행하는 모델
  • cc: Chromium Compositor. layer/tile/raster/합성을 담당하는 라이브러리
  • Interest rect: SkPicture를 기록할 영역. viewport보다 충분히 큼
  • Skewport: viewport에 사용자의 스크롤 속도/방향을 반영해 확장한 사각 영역
  • Checkerboarding: tile이 미처 raster되지 않아 화면에 빈 격자가 보이는 현상

두 모델의 핵심 차이

항목WKWebView (iOS, WebKit)Android WebView (Chromium)
합성 모델UI Process ↔ WebContent Process ↔ GPU Process IPC 분리OOPR 한 줄기와 앱 프로세스의 sync compositor 결합
tile 생성 시점노출된 view-exposed-rect 범위만, 필요할 때 lazy 생성viewport뿐 아니라 skewport·soon·eventually까지 미리 prepaint
메모리·전력보수적. 보이지 않는 영역은 거의 그리지 않음적극적. 메모리 예산 안에서 가능한 한 미리 그려 둠
갑작스런 viewport 점프새 노출 영역만 raster. 점프 전 보이지 않던 영역은 미rasterinterest rect와 skewport가 점프 후의 주변까지 덮고 있어 빈 영역 거의 없음
checkerboarding 발생 가능성점프·빠른 스크롤·작은 메모리 디바이스에서 두드러짐정책상 적극적으로 회피. 못 따라가면 저해상도 tile을 먼저 깔기까지 함
오프스크린 렌더링공식적으로 지원되지 않음합성 모델 특성상 일부 가능

한 줄로 요약하면 Chromium은 "사용자가 곧 볼 것 같은 곳까지 미리 그려 둔다", WKWebView는 "지금 보이는 것만 그린다"입니다. 두 정책 모두 합리적이지만 다른 트레이드오프를 갖습니다. iOS의 정책은 메모리·전력에 유리하고, Android의 정책은 갑작스런 viewport 변화에 강합니다.


Fragment 앵커 점프가 일어나면 무슨 일이 벌어지는가

이제 두 모델을 머리에 넣고 처음의 문제 상황으로 돌아갑니다. URL …?page=2#review-0로 진입한 직후의 시간축을 따라가 봅니다.

iOS WKWebView 시간축

핵심은 3번과 4번입니다. UI Process가 알려 주는 새 view-exposed-rect는 review-0 주변이지 페이지 위쪽이 아니므로, 위쪽 tile은 존재하지 않는 상태로 남습니다. 사용자가 스크롤로 그 영역을 노출시키기 전까지 raster할 이유가 없기 때문입니다.

여기에 두 가지 부가 요인이 더해집니다. 첫째, 위쪽이 다시 그려질 때 위쪽에 있는 lazy 이미지들의 디코드도 함께 트리거돼 체감 지연이 더 길어집니다. 둘째, JavaScript가 실행 중이거나 main thread가 다른 작업으로 바쁜 경우 WebContent Process가 raster 작업을 미루게 되어 빈 시간이 더 길어집니다.

Android WebView 시간축

차이가 분명하게 드러나는 부분은 4번입니다. 점프가 일어나는 시점에 이미 위/아래 양쪽의 일정 범위 tile이 raster 큐에 있거나 완료된 상태이기 때문에, 위로 스크롤해도 viewport에 들어오는 tile이 대부분 준비돼 있습니다.

왜 iOS에서만 보이는가

같은 페이지·같은 자바스크립트인데 한쪽에서만 빈 영역이 보이는 이유가 이로써 정리됩니다. 두 엔진 모두 정상 동작했지만 "미리 그려 두는 정책"의 유무가 사용자 체감 결과를 갈랐습니다. Safari/WKWebView는 lazy tile creation 정책상 보이지 않던 영역을 그려 둘 이유가 없었고, Chromium은 그려 두는 게 정책의 핵심이라 그렇게 했습니다.

이 차이는 fragment 앵커뿐 아니라 다음 상황에서도 같은 형태로 드러납니다.

  • window.scrollTo(0, y)로 큰 거리를 한 번에 점프
  • scrollRestoration: 'auto'로 뒤로가기 직후 마지막 위치로 복원
  • 가상 스크롤(virtual scroll) 라이브러리에서 컨테이너 위치를 갑자기 바꾸는 경우

세 경우 모두 "갑작스러운 viewport 좌표 변화"라는 공통점이 있고, iOS WKWebView에서 위쪽 또는 점프 전 영역이 미raster인 채로 노출됩니다.


해결 패턴

원리를 알면 처방이 단순해집니다. 핵심은 "즉시 점프"를 끄고, raster 시간을 양보한 뒤 부드럽게 이동시키는 것"입니다. 그리고 중요한 영역은 합성 레이어로 분리해 둡니다.

프로그래매틱 스크롤로 fragment 점프 대체

브라우저의 기본 fragment 점프 대신 코드로 직접 스크롤을 제어합니다. 위쪽 tile이 raster될 시간을 양보하기 위해 (0, 0)에서 시작해 한 frame 정도 기다린 뒤 scrollIntoView로 부드럽게 이동시킵니다.

'use client';
 
import { useEffect } from 'react';
 
export function useSafariSafeHashScroll() {
  useEffect(() => {
    const hash = window.location.hash;
    if (!hash) return;
 
    const target = document.getElementById(hash.slice(1));
    if (!target) return;
 
    // 1. 우선 위쪽으로 강제 이동시켜 위쪽 tile을 노출 영역에 끌어옴
    window.scrollTo(0, 0);
 
    // 2. 한 frame 정도 기다려 raster 시간을 양보
    const timeoutId = setTimeout(() => {
      requestAnimationFrame(() => {
        // 3. smooth 스크롤로 이동하며 경로상의 tile도 순차 raster
        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
      });
    }, 50);
 
    return () => clearTimeout(timeoutId);
  }, []);
}

50ms와 requestAnimationFrame이 같이 들어 있는 이유는 두 가지를 동시에 보장하기 위함입니다. 50ms는 WebContent Process가 위쪽 tile raster를 시작·완료할 시간을 주고, requestAnimationFrame은 다음 paint 시점에 정확히 맞춰 스크롤을 시작해 합성 사이클과 어긋나지 않게 합니다.

페이지네이션처럼 페이지 전환 직후 특정 위치로 스크롤이 필요한 경우도 같은 패턴으로 처리합니다. URL hash를 쓰지 않고 컴포넌트가 직접 setTimeout(50) + rAF + scrollIntoView로 위치를 잡습니다.

GPU 합성 레이어로 분리

위쪽이 빈 영역으로 남는 빈도 자체를 줄이려면 그 영역을 별도 합성 레이어로 승격시킵니다. CSS로 transform, backface-visibility, will-change를 함께 걸어 주면 WebKit이 해당 요소를 독립 레이어로 합성합니다.

.force-gpu-render {
  -webkit-transform: translateZ(0);
  transform: translateZ(0);
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
  will-change: transform;
}

별도 레이어로 올라가면 그 영역의 backing store가 따로 잡히고, viewport 변화가 와도 같은 backing store를 재사용해 합성합니다. raster를 다시 안 해도 되는 만큼 빈 영역 노출 가능성이 줄어듭니다.

다만 이 패턴은 남발하면 안 됩니다. 합성 레이어가 늘어날수록 backing store 메모리 부담이 커지므로 페이지 안의 한두 개 핵심 영역(헤더·hero 섹션·이미지 갤러리 같은)에만 적용하는 것이 안전합니다.

위쪽 첫 이미지를 eager로

위쪽 tile이 다시 그려질 때 그 위에 있는 lazy 이미지들의 디코드까지 같이 트리거되면 빈 시간이 더 길어집니다. LCP 후보가 되는 첫 이미지(보통 첫 리뷰·첫 카드의 이미지)는 loading="eager"로 미리 디코드해 둡니다. 나머지는 그대로 loading="lazy"로 두어 동시 디코드 폭증은 막습니다.

{reviews.map((review, i) => (
  <ReviewCard
    key={review.id}
    review={review}
    imageLoading={i === 0 ? 'eager' : 'lazy'}
  />
))}

세 패턴은 서로 보완 관계입니다. 프로그래매틱 스크롤은 raster 시점을 통제하고, 레이어 분리는 raster 빈도 자체를 줄이며, eager 이미지는 raster 직후 디코드 비용을 분산시킵니다. 셋을 함께 적용하면 같은 페이지가 iOS에서도 Android와 비슷한 체감을 만듭니다.


정리

WKWebView (iOS)

  • 페이지는 별도의 WebContent Process에서 실행되고, UI Process가 알려 주는 view-exposed-rect 범위만큼만 tile을 만듭니다.
  • 처음에는 첫 paint 때 모든 tile을 만들었지만 메모리·성능 문제로 lazy tile creation으로 전환됐습니다(WebKit Bug 126457).
  • 갑작스러운 viewport 점프(fragment 앵커, scrollTo, scrollRestoration)에서 점프 전 보이지 않던 영역의 tile은 존재하지 않으며, 사용자가 그 영역을 노출시키는 순간에 raster가 시작됩니다.

Android WebView (Chromium)

  • Android 4.4부터 Blink 기반. Android R(API 30) 이후로는 OOPR이 강제됩니다.
  • WebView는 일반 Chrome과 달리 synchronous compositor 모드로 동작해 앱의 RenderThread에 합성 결과를 동기로 끼워 넣습니다.
  • cc 컴포지터는 tile을 4단계 우선순위(NOW / SEEN(skewport) / soon / EVENTUALLY)로 관리하며, viewport 바깥까지 적극적으로 prepaint합니다.

차이가 만드는 결과

  • 같은 fragment 앵커 점프에서 iOS는 위쪽이 미raster, Android는 prepaint된 tile이 노출됩니다.
  • 정책의 차이일 뿐 어느 쪽도 버그가 아니지만, 사용자 체감은 명확히 갈립니다.

해결 패턴

  • 브라우저의 즉시 점프 대신 setTimeout(50ms) + requestAnimationFrame + scrollIntoView('smooth') 조합의 프로그래매틱 스크롤로 대체합니다.
  • 핵심 영역은 transform: translateZ(0), backface-visibility: hidden, will-change: transform으로 합성 레이어로 분리합니다.
  • LCP 후보 이미지는 loading="eager", 나머지는 loading="lazy"로 분리해 디코드 비용을 분산시킵니다.

한 줄로

WKWebView 이슈는 대부분 "iOS는 지금 보이는 것만 그린다"는 정책 한 줄로 설명됩니다. 그리고 그 정책을 인정한 위에서 raster 시점을 양보하거나 합성 레이어로 분리하는 처방이 따라옵니다.


참고