iOS 웹뷰에서 첫 화면이 10초 넘게 안 뜬 이유, WebKit 렌더링 분석

문제: iOS 환경에서 first paint가 10초 이상 걸린다

최근 우리 팀의 Next.js 기반 웹앱을 iOS 웹뷰에서 열었을 때 첫 화면이 유독 늦게 떴습니다. 같은 페이지를 Android 웹뷰에서 열면 0.4초 만에 화면이 나오는데, iOS에서는 7.7초가 걸렸습니다. 네트워크 환경이 불안정할 때는 최대 19초까지 지연되기도 했습니다.

처음에는 서버 응답 속도나 번들 크기를 의심했습니다. Network 탭을 보면 HTML, JS, CSS 모두 빠르게 내려오고 있었습니다. 리소스는 다 받았는데 화면에 아무것도 안 그려지는 구간이 문제였습니다.

로딩 중인 화면을 캡처해보니 완전한 백지였습니다. 데이터도, 글자도, 뼈대조차 없었습니다. 자바스크립트는 정상 실행되고 있었고, DOM 조작도 되고 있었습니다. 화면에는 아무것도 그려지지 않았습니다. first paint 자체가 일어나지 않고 있었습니다.

원인 추적: 어떻게 폰트를 의심하게 됐나

먼저 초기에 로드되는 리소스를 하나씩 점검했습니다. HTML, CSS, JS 모두 정상적으로 빠르게 내려오고 있었습니다.

그러다 Clarity 스크립트의 응답이 비정상적으로 느린 걸 발견했습니다. 응답이 끝나는 시점에 first paint가 발생하는 것도 확인했습니다. 그런데 Android에서는 같은 상황에서도 화면이 멀쩡히 떴습니다.

브라우저 차이에서 오는 이슈로 좁혀졌습니다. 그런데 iOS에서도 어떤 페이지는 되고 어떤 페이지는 안 됐습니다. 브라우저와 페이지, 두 조건이 맞물려야 문제가 생긴다는 걸 알게 됐습니다.

이 시점에서 AI의 도움을 받았습니다. WebKit 저장소와 Next.js 저장소 링크를 전달하고, first paint의 조건을 면밀히 살펴보라고 지시했습니다. 그 결과 WebKit의 레이어 트리 동결 조건과 @font-face 선언의 관계를 파악할 수 있었습니다.

원인 분석

Safari 개발자 도구의 Timeline을 열어보니 흥미로운 패턴이 보였습니다. 페이지의 load 이벤트가 발생하기 전까지 WebKit이 화면 갱신을 미루고 있었습니다. (load 이벤트는 HTML, CSS, JS, 이미지 등 페이지의 모든 리소스가 다 받아졌을 때 발생합니다.) load 이벤트를 늦추는 주범은 외부 분석 도구(Microsoft Clarity)의 스크립트였습니다.

Clarity는 사용자 행동 분석을 위해 세션 녹화와 히트맵 데이터를 수집하는 도구입니다. 스크립트 자체가 무겁지는 않지만, 외부 CDN에서 로드되기 때문에 네트워크 상황에 따라 2초에서 최대 19초까지 로딩 시간 편차가 컸습니다. 이 스크립트가 load 이벤트 발생을 그만큼 늦추고 있었습니다.

여기서 의문이 생겼습니다. 분석 스크립트가 늦게 로드되는 건 Android에서도 마찬가지인데, 왜 iOS만 화면이 안 뜨는 걸까요? 원인은 아래와 같았습니다.

  1. WebKit은 폰트가 준비될 때까지 화면 그리기를 미룬다
  2. 폰트 사용을 선언했지만 본문에서 실제 사용하지 않아 '폰트 준비 완료' 신호를 받지 못해 load 이벤트를 기다린다
  3. 리소스 로드 지연(JS, CSS, 이미지 등)되면 load 이벤트 완료 시점도 함께 지연된다
  4. first paint 시점도 그 만큼 지연된다
  5. '지금' 문제가 된 이유

    폰트 선언은 2024년 12월에 Pretendard를 도입하면서 추가된 것이었습니다. 당시 적용을 시도했다가 쓰지 않기로 결정됐는데, 선언만 남아 있었습니다. 그 후 1년 7개월 동안 아무 문제 없이 운영됐습니다. load를 크게 늦출 리소스가 없었기 때문입니다.

    2026년 4월에 Clarity를 적용했지만, 당시에는 다운로드 속도가 빨라서 문제가 드러나지 않았습니다. 하지만 7월에 Clarity 스크립트를 서빙하는 서버의 응답이 현저히 느려졌고 잠복해 있던 조건과 외부 서버 장애가 만나 문제가 수면 위로 올라왔습니다.

    지연이 발생하는 흐름

    flowchart TD
        A["CSS에 @font-face 선언
    (미사용 폰트 포함)"] --> B["WebKit: 폰트 완료 신호 대기
    (미사용이라 신호가 안 옴)"] B --> C["레이어 트리 동결
    (화면 그리기 중단)"] D["Clarity 스크립트 로딩 지연"] --> E["load 이벤트 지연"] C --> F["동결 해제 대기"] E --> F F --> G["첫 화면 렌더링 지연
    (최대 19초)"]

    레이어 트리 동결이란

    "레이어 트리 동결"이라는 용어가 생소할 수 있습니다. 이해하려면 브라우저가 화면을 그리는 과정을 간략히 알아야 합니다.

    브라우저는 HTML을 받아서 화면에 그리기까지 여러 단계를 거칩니다.

    여기서 "레이어"는 포토샵의 레이어와 비슷합니다. 브라우저는 페이지를 여러 레이어로 나눠서 따로 그린 뒤, 마지막에 합칩니다. 스크롤이나 애니메이션이 있을 때 전체를 다시 그리지 않고 해당 레이어만 움직이면 되니까 효율적입니다.

    "레이어 트리 동결"은 이 합성 단계를 멈추는 것입니다. 레이어를 합치는 작업을 안 하니, 사용자 눈에는 아무것도 바뀌지 않습니다.

    WebKit이 "CSS에 폰트가 선언되어 있으니 폰트가 준비될 때까지 기다리자"라고 판단해서 동결 상태를 유지했습니다. 문제는 그 폰트가 어느 곳도 사용되지 않아 load 이벤트가 발생할 때까지 동결 상태를 유지하게 되었습니다.

    WebKit과 Blink의 최적화 전략 차이

    문제를 이해하려면 브라우저 엔진의 최적화 전략 차이를 알아야 합니다. WebKit(Safari, iOS 웹뷰)과 Blink(Chrome, Android 웹뷰)는 같은 웹 표준을 구현하지만, 내부 동작 방식이 다릅니다.

    영역 WebKit (Safari, iOS) Blink (Chrome, Android)
    렌더링 타이밍 리소스 준비될 때까지 대기 준비된 것부터 먼저 표시
    폰트 대기 무기한 (load 이벤트까지) ~3초 후 fallback 표시
    철학 "완성된 상태만 보여주자" "일단 보여주고 완성하자"

    Apple의 철학: 불완전한 화면은 보여주지 않는다

    차이는 단순한 구현 방식의 차이가 아니라, 브라우저 벤더의 철학 차이에서 비롯됩니다. 2020년 Wikimedia 재단이 WebKit에 Paint Timing API를 기여할 때, Apple은 이런 입장을 밝혔습니다[1].

    "Apple's main argument was that web developers might optimize for first-paint if it's available, and it's less desirable because pages would appear quickly but with missing content."

    (Apple의 주요 주장은, first-paint 지표가 제공되면 웹 개발자들이 이를 최적화하려 할 것이고, 그 결과 콘텐츠가 빠진 불완전한 페이지가 빠르게 나타나는 바람직하지 않은 상황이 생길 수 있다는 것이었다.)

    WebKit은 철학을 "Visually Non-Empty" 휴리스틱으로 구현했습니다. 의미 있는 콘텐츠가 준비될 때까지 화면 그리기를 지연시키는 것입니다.

    "The WebKit implementation delays the first paint if there are pending styles and fonts unless a meaningful amount of content is ready to be rendered (Equivalent to 1024 pixels of images, or 200 characters of text)."

    (WebKit 구현은 스타일과 폰트가 아직 로딩 중이면 first paint를 지연시킨다. 단, 의미 있는 양의 콘텐츠가 렌더링될 준비가 되면 예외다. 여기서 "의미 있는 양"이란 1024픽셀(32×32) 면적 이상의 이미지, 또는 200자 이상의 텍스트를 말한다.)

    Chrome(Blink)은 다른 접근을 취합니다. 뭐라도 그릴 수 있으면 일단 그리고, 나머지는 나중에 채웁니다. 사용자가 빈 화면을 보는 시간을 줄이는 게 더 중요하다고 보는 것입니다. 어느 쪽이 맞다고 할 수는 없지만, 철학 차이가 같은 코드에서 다른 결과를 만들어냈습니다.

    렌더링 타이밍 차이

    이번 문제는 렌더링 타이밍 차이에서 비롯됐습니다.

    WebKit의 LocalFrameView.cpp에는 qualifiesAsVisuallyNonEmpty()라는 함수가 있습니다. "화면에 뭔가 보여줄 준비가 됐나?"를 판단합니다. 준비가 안 됐다고 판단되면 레이어 트리를 동결해서 화면을 그리지 않습니다.

    함수가 @font-face 선언을 어떻게 해석하느냐가 핵심입니다. CSS에 @font-face가 선언되어 있으면, WebKit은 "이 폰트가 로딩 완료되어야 준비된 상태"라고 판단합니다. 문제는 WebKit이 해당 폰트가 실제로 사용되는지까지는 확인하지 않는다는 점입니다. 선언만 있어도 "폰트 로딩 중"으로 간주합니다.

    사용하는 폰트와 미사용 폰트의 렌더링 차이는 다음과 같습니다.

    • 사용하는 폰트: 브라우저가 폰트 파일을 다운로드 → 완료 → "준비됨"으로 전환 → 동결 해제
    • 미사용 폰트: 브라우저가 다운로드를 시작하지 않음 → 완료가 영원히 오지 않음 → "준비 안 됨" 상태 유지

    미사용 폰트는 완료 신호가 없으니, WebKit은 load 이벤트가 발생할 때까지 "아직 준비 중"이라고 판단합니다. 그 사이 화면은 계속 동결된 상태로 빈 화면을 보여줍니다.

    Blink는 이런 동작이 없습니다. 폰트 로딩과 관계없이 준비된 콘텐츠부터 화면에 그립니다. 같은 조건에서 Android가 빠르게 화면을 보여주는 이유입니다.

    WebKit에서는 초기 로딩 최적화가 더 중요하다

    렌더링 타이밍이 다르니, WebKit 기반 브라우저에서는 초기 로딩 최적화에 더 신경 써야 합니다. Blink에서는 문제없던 코드가 WebKit에서는 화면 멈춤으로 이어질 수 있습니다.

    아래는 WebKit을 고려한 초기 로딩 체크리스트입니다.

    • 사용하지 않는 @font-face 선언 제거
    • 분석 도구, 광고 스크립트 등 비필수 리소스는 load 이벤트 이후로 지연
    • SSR HTML에 충분한 텍스트 콘텐츠 포함 (200자 임계값 고려)

    왜 iOS에서만 체감이 심했나

    정확히 말하면 iOS만의 문제가 아닙니다. WebKit 엔진을 사용하는 모든 브라우저에서 발생합니다. iOS 웹뷰뿐 아니라 macOS의 데스크탑 Safari에서도 같은 현상이 재현됐습니다. 다만 모바일 환경에서 네트워크 지연이 더 크기 때문에 iOS 웹뷰에서 체감이 심했습니다.

    Next.js 하이드레이션과의 연결

    여기에 Next.js의 특성이 더해집니다. Next.js 클라이언트 코드에는 displayContent()라는 함수가 있습니다. 이 함수는 FOUC(Flash of Unstyled Content)를 방지하려고 내부적으로 requestAnimationFrame 콜백 안에서만 완료 처리됩니다. 프레임워크는 화면 그리기(하이드레이션)를 시작하기 전에 이 함수의 완료를 기다립니다.

    WebKit이 레이어 트리를 동결하면 requestAnimationFrame 콜백이 실행되지 않습니다. requestAnimationFrame은 "다음 화면을 그리기 직전에 실행해줘"라는 요청인데, 화면 그리기 자체가 멈춰있으니 "다음 화면"이 오지 않는 것입니다. Next.js의 하이드레이션도 시작조차 못 하고, 사용자는 빈 화면을 보게 됩니다.

    같은 앱인데 왜 어떤 페이지는 괜찮을까

    흥미로운 점이 있었습니다. 같은 CSS를 사용하는데 어떤 페이지는 멈추고 어떤 페이지는 멀쩡했습니다. 예를 들어 피드 페이지는 멈췄지만 프로모션 페이지는 괜찮았습니다.

    원인은 WebKit의 200자 임계값이었습니다. WebKit은 "Visually Non-Empty" 휴리스틱을 사용해서 의미 있는 콘텐츠가 준비될 때까지 페인팅을 지연시킵니다[1]. WebKit 소스 코드(LocalFrameView.h#L1089)를 보면 visualCharacterThreshold라는 상수가 200으로 설정되어 있습니다. SSR로 내려오는 HTML에 보이는 텍스트가 200자를 넘으면 "이미 보여줄 콘텐츠가 충분하다"고 판단해서 동결을 풀어버립니다.

    피드 페이지는 로그인 사용자 화면이라 SSR HTML에 텍스트가 거의 없었습니다. 반면 프로모션 페이지는 정적 콘텐츠가 많아서 임계값을 넘겼습니다. 같은 폰트 CSS를 쓰는데 결과가 달랐던 이유가 여기 있었습니다.

    정말 폰트 선언이 원인인지 검증하기

    실제 앱의 CSS 응답을 가로채서 @font-face 블록만 제거하고 나머지는 그대로 둔 상태로 측정했습니다.

    조건 requestAnimationFrame load 판정
    원본 CSS 5,263ms 5,263ms 화면 멈춤
    @font-face 제거 119ms 5,122ms 정상

    load 이벤트는 여전히 5초 넘게 걸렸지만, 폰트 선언을 빼니 requestAnimationFrame이 119ms에 실행됐습니다. 화면 그리기가 load와 분리됐습니다.

    Clarity를 완전히 제거하고 대신 5초짜리 느린 이미지를 넣어봤습니다. 결과는 같았습니다. requestAnimationFrameload(5,160ms)까지 밀렸습니다. Clarity가 아니라 "load를 늦추는 무엇이든"이 조건이었습니다.

    직접 테스트해보기

    이 현상을 직접 체험해볼 수 있도록 테스트 페이지를 만들었습니다. Safari(macOS 또는 iOS)에서 각 케이스를 열어보면 레이어 트리 동결 여부를 눈으로 확인할 수 있습니다.

    WebKit 레이어 트리 동결 테스트 페이지
    테스트 케이스 목록

    → 테스트 페이지 열기

    테스트 케이스

    케이스 조건 예상 결과
    1 미사용 @font-face 🔴 동결
    2 사용 폰트 (성공) 🟢 정상
    3 사용 폰트 (404) 🟢 정상
    4 미사용 + font-display 🔴 동결
    5 이미지 로딩 지연 조절 🔴 동결
    6 200자/32×32 임계값 🟠 가변
    Case 1(동결)과 Case 2(정상)의 차이
    Case 1(동결)과 Case 2(정상)의 차이

    우측 상단 패널에서 RAF와 Load 타이밍을 확인할 수 있습니다.

    • Case 1 (동결): RAF 2001ms, Load 2000ms → 차이 1ms
    • Case 2 (정상): RAF 48ms, Load 2005ms → 차이 1957ms

    Case 1은 RAF와 Load가 거의 동시에 발생해 화면이 2초간 멈춰 있었습니다. Case 2는 RAF가 48ms에 실행되어 화면이 바로 그려졌고, 이미지 로딩은 백그라운드에서 진행됐습니다.

    해결: 미사용 폰트 제거와 스크립트 지연 로딩

    원인을 알았으니 해결책은 명확했습니다.

    1. 미사용 폰트 선언 제거

    더 이상 쓰지 않는 폰트 선언들을 모두 제거했습니다.

    /* 제거 전: 실제로 쓰지 않는 폰트가 선언되어 있음 */
    @font-face {
      font-family: 'Pretendard';
      src: url('/fonts/pretendard.woff2') format('woff2');
    }
    
    /* 제거 후: 미사용 폰트 선언 삭제 */
    /* (선언 없음) */

    만약 폰트가 실제 사용 중이었다면?

    만약 폰트가 실제로 쓰이고 있었다면 브라우저가 폰트 파일을 다운로드했을 것이고, 캐싱되지 않았어도 네트워크 상황이 좋으면 수백 밀리초 내에 로딩이 끝났을 것입니다. 폰트 로딩이 완료되면 WebKit은 "시각적으로 준비됐다"고 판단해 동결을 해제합니다. 실제로 쓰이는 폰트는 로딩 완료라는 출구가 있지만, 미사용 폰트는 출구 없이 load까지 갇혀버린 셈입니다.

    이 가설을 검증하려고 폰트 선언 상태별로 측정했습니다.

    raf: requestAnimationFrame
    폰트 선언 본문 사용 폰트 응답 raf 판정
    없음 31ms 정상
    있음 안 씀 요청 안 됨 5,036ms 멈춤
    있음 즉시 성공 34ms 정상
    있음 즉시 실패(404) 16ms 정상

    성공이든 실패든 결론이 나면 동결이 풀립니다. 미사용 폰트는 요청 자체가 시작되지 않아 결론이 없고, 그래서 load가 유일한 탈출구가 됩니다.

    참고로 font-display: swap 같은 속성을 추가해도 소용없었습니다. swap, optional, fallback, block 모두 5,021~5,048ms로 차이가 없었습니다. font-display는 폰트가 실제로 사용될 때 텍스트를 어떻게 보여줄지 제어하는 속성이기 때문입니다. 폰트가 사용되지 않으면 속성 자체가 적용될 일이 없습니다.

    여러 폰트를 사용하는 프로젝트라면

    "폰트를 여러 개 쓰는데, 페이지마다 다른 폰트를 쓴다"는 상황도 있습니다. 기본 본문에는 Pretendard를, 프로모션 타이틀에는 브랜드 폰트를, 이벤트 페이지에는 또 다른 폰트를 쓰는 식입니다.

    전역 CSS에 세 폰트를 모두 선언해두면, 폰트 하나만 쓰는 페이지에서 나머지 두 폰트가 미사용 상태가 됩니다. 앞서 설명한 문제가 그대로 발생합니다.

    페이지 Pretendard 브랜드 폰트 이벤트 폰트 결과
    피드 사용 미사용 미사용 동결 위험
    프로모션 사용 사용 미사용 동결 위험
    이벤트 사용 사용 사용 정상

    페이지별로 필요한 폰트만 선언하면 됩니다.

    styles/
    ├── base.css      → Pretendard만 선언
    ├── promo.css     → 브랜드 폰트 선언
    └── event.css     → 이벤트 폰트 선언

    Next.js의 next/font를 쓴다면

    next/font도 @font-face를 생성합니다. layout.tsx에서 여러 폰트를 import하면 모든 페이지에 해당 폰트 선언이 들어갑니다.

    // app/layout.tsx
    import { Noto_Sans_KR } from 'next/font/google';
    import localFont from 'next/font/local';
    
    const notoSans = Noto_Sans_KR({ subsets: ['latin'] });
    const brandFont = localFont({ src: './brand.woff2' });  // 프로모션용
    const eventFont = localFont({ src: './event.woff2' });  // 이벤트용
    
    // 세 폰트 모두 @font-face로 선언됨 → 미사용 폰트 문제 발생

    페이지별로 필요한 폰트만 import하면 해결됩니다.

    // app/page.tsx - 기본 페이지
    import { notoSans } from '@/fonts';
    
    // app/promo/page.tsx - 프로모션 페이지
    import { notoSans, brandFont } from '@/fonts';
    
    // app/event/page.tsx - 이벤트 페이지
    import { notoSans, brandFont, eventFont } from '@/fonts';

    컴포넌트에서 폰트를 import하면 해당 컴포넌트가 렌더링될 때만 @font-face가 추가됩니다. 전역 layout 대신 실제로 폰트를 쓰는 컴포넌트에서 import하는 게 안전합니다.

    2. 분석 도구 지연 초기화

    Clarity 같은 분석 스크립트는 사용자 경험에 직접적인 영향을 주지 않습니다. 이런 스크립트는 load 이벤트 이후에 로드하도록 바꿨습니다.

    async 속성을 붙이면 해결될 것 같지만, 이 경우에는 소용없었습니다. async는 화면을 막지 않는 방식으로 스크립트를 불러오지만, "전부 다 받았다"는 신호(load 이벤트)는 이 파일까지 기다립니다. 스크립트 파일이 다 받아져야 load가 발생하므로, 느린 외부 스크립트가 있으면 여전히 load가 지연됩니다.

    // 변경 전: Next.js Script로 즉시 로딩
    <Script src="https://www.clarity.ms/tag/xxx" strategy="afterInteractive" />
    
    // 변경 후: 첫 페인트 이후 로딩 (이벤트 큐잉은 별도 구현)
    useEffect(() => {
      requestAnimationFrame(() => {
        const script = document.createElement('script');
        script.src = 'https://www.clarity.ms/tag/xxx';
        script.onload = () => flushQueuedEvents();
        document.head.appendChild(script);
      });
    }, []);

    SDK 초기화 전에 들어온 이벤트가 유실되지 않도록, 큐에 저장했다가 로드 완료 시 일괄 처리하는 로직을 추가했습니다.

    개선 결과

    p50은 중앙값, p95는 하위 5% 느린 케이스를 의미합니다.

    네트워크가 빠른 상황(Clarity 서버 응답 약 2.3초)에서도 개선 전에는 첫 화면이 3.3초나 걸렸습니다. 서버가 느려지면서 p50, p95 수치는 더 악화됐습니다.

    지표 개선 전 개선 후
    iOS 웹뷰 첫 화면 (p50) 6.5초 1.7초
    iOS 웹뷰 첫 화면 (p95) 19초 3.2초
    iOS 웹뷰 첫 화면 (빠른 네트워크) 3.3초 0.3초
    Android 웹뷰 첫 화면 0.4초 0.4초 (변화 없음)

    배운 점

    이번 경험에서 몇 가지 교훈을 얻었습니다.

    선언만 하고 쓰지 않는 코드도 부작용을 일으킵니다. @font-face는 CSS에 적어두기만 해도 브라우저 동작에 영향을 줍니다. "언젠가 쓸 것 같아서" 남겨둔 코드가 실제로 문제를 일으킬 수 있습니다.

    플랫폼별 렌더링 차이를 인지해야 합니다. WebKit과 Blink는 같은 웹 표준을 구현하지만, 최적화 전략은 다릅니다. iOS에서 잘 돌아간다고 Android에서도 괜찮을 거라는 보장은 없습니다. 반대도 마찬가지입니다. 이번 문제는 WebKit의 '완성된 페이지를 보여준다'라는 최적화 전략 때문에 생겼습니다. 이 전략을 미리 알았다면 원인을 더 빨리 찾았을 것입니다. 브라우저가 "왜" 그렇게 동작하는지 이해하면, 문제가 생겼을 때 어디를 봐야 할지 감이 옵니다.

    측정 조건을 명확히 해야 합니다. "웹페이지가 느리다"는 피드백만으로는 원인을 찾기 어렵습니다. 어떤 기기에서, 어떤 네트워크에서, 어떤 페이지가 느린지 구체적으로 측정해야 진짜 원인에 접근할 수 있습니다.

    초기 로딩 성능을 지속적으로 모니터링해야 합니다. 이번 문제는 서서히 악화됐습니다. Clarity 서버가 점점 느려지면서 로딩 시간도 조금씩 길어졌는데, 팀은 "원래 이 정도였나?"라며 넘긴 점도 있는 것 같습니다. Sentry에서 Web Vitals 대시보드를 만들어 적극적으로 활용해볼 예정입니다.

    루트에 모든 폰트를 선언하지 말고, 각 페이지에서 필요한 폰트만 선언해야 합니다. 전역 CSS에 여러 폰트를 선언해두면, 특정 페이지에서 쓰지 않는 폰트가 미사용 상태로 남아 동일한 문제를 일으킬 수 있습니다.

    참고자료

    1. Wikimedia Foundation, "How We Contributed Paint Timing API to WebKit," 2020. diff.wikimedia.org