Published on

Safari에서만 CLS 튀는 이유 - 폰트 로딩·preload 진단

Authors

서버/클라이언트 모두에서 CLS를 줄였다고 생각했는데, 유독 Safari에서만 레이아웃이 한 번 크게 튀는 경우가 있습니다. 특히 첫 화면의 헤더, 타이포가 큰 히어로 섹션, 버튼 라벨처럼 폰트에 민감한 영역에서 두드러집니다. 이 글은 "Safari에서만 CLS가 튄다"를 폰트 로딩과 preload 관점으로 쪼개서 진단하는 방법을 정리합니다.

핵심은 두 가지입니다.

  • Safari는 폰트 로딩과 페인트 타이밍이 다른 브라우저와 다르게 보일 수 있고, 특히 preload가 기대대로 동작하지 않으면 FOUT/FOIT가 레이아웃 변화를 크게 만들 수 있습니다.
  • 같은 폰트라도 포맷(woff2/woff)과 서브셋, fallback 폰트의 메트릭 차이 때문에 Safari에서만 줄바꿈/높이가 달라져 CLS로 측정될 수 있습니다.

1) Safari에서만 CLS가 튀는 전형적인 시나리오

1-1. 폰트가 늦게 적용되며 줄바꿈이 바뀜

초기에는 시스템 폰트(또는 fallback)로 렌더링되다가, 웹폰트가 적용되는 순간 글자 폭이 변하면서 줄바꿈이 바뀌고 블록 높이가 변합니다. 이때 상단 컨텐츠(특히 above-the-fold)가 움직이면 CLS가 크게 잡힙니다.

Safari는 같은 네트워크 조건에서도 폰트 다운로드 시작 시점이 늦어지거나, preload가 무시/지연되는 것처럼 보이는 케이스가 있어 "Safari에서만"이 성립합니다.

1-2. preload를 해도 Safari가 실제로는 다른 리소스로 취급

다음 상황에서 preload가 실효성이 떨어집니다.

  • preload URL과 실제 CSS src URL이 미묘하게 다름(쿼리스트링, 상대/절대 경로, CDN 리라이트)
  • crossorigin 누락으로 preload 응답을 실제 폰트 요청이 재사용하지 못함
  • Cache-Control/Vary/Content-Type이 기대와 달라 캐시 키가 갈라짐

결과적으로 Safari는 "preload로 이미 받아뒀다"가 아니라 "또 요청한다"가 되어, 폰트 적용이 늦어지고 CLS가 커집니다.

1-3. woff2 우선 전략이 Safari 구버전/특정 환경에서 흔들림

현대 Safari는 woff2를 지원하지만, iOS 버전/프록시/기업망 환경에서 woff2 응답이 변형되거나, 서버가 잘못된 Content-Type을 주면 폰트 파싱 실패 후 fallback으로 돌아가며 레이아웃이 바뀔 수 있습니다.


2) 먼저 확인할 것: "측정"이 Safari에서만 다르게 잡히는가

Safari에서만 CLS가 튄다고 느끼는 것과, 실제 Web Vitals가 Safari에서만 높은 것은 다를 수 있습니다. 먼저 재현 환경을 고정하세요.

  • iOS Safari(실기기)에서 재현되는지
  • macOS Safari에서도 동일한지
  • 네트워크를 Slow 3G 수준으로 제한하면 더 잘 재현되는지

2-1. CLS 이벤트를 실제로 로그로 남기기

다음은 web-vitals로 CLS를 수집하면서, 어떤 요소가 흔들렸는지(가능한 범위에서) 같이 기록하는 예시입니다.

import { onCLS } from 'web-vitals';

onCLS((metric) => {
  // metric.value: CLS 점수
  // metric.entries: 레이아웃 이동 엔트리
  console.log('[CLS]', {
    value: metric.value,
    id: metric.id,
    entries: metric.entries?.map((e) => ({
      startTime: e.startTime,
      value: e.value,
      hadRecentInput: e.hadRecentInput,
      sources: e.sources?.map((s) => ({
        node: s.node ? s.node.tagName : null,
        previousRect: s.previousRect,
        currentRect: s.currentRect,
      })),
    })),
  });
});

Safari에서 sources.node가 항상 풍부하게 나오지 않을 수 있지만, 최소한 "언제" 튀는지(초기 로드 직후인지, 폰트 적용 시점인지)는 잡을 수 있습니다.


3) Safari에서 폰트 로딩을 의심할 때 보는 5가지 포인트

3-1. preload가 실제 폰트 요청과 매칭되는지

preload는 "미리 받기"가 아니라 "미리 받도록 힌트"이고, 같은 URL/같은 CORS 모드로 매칭되어야 재사용됩니다.

다음처럼 crossorigin을 명시하는 것이 안전합니다(특히 CDN/서브도메인에서 폰트를 제공할 때).

<link
  rel="preload"
  href="/fonts/Pretendard-Subset.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

체크리스트:

  • href가 CSS @font-facesrc완전히 동일한가
  • crossorigin이 둘 다 동일한 모드로 요청되는가
  • type이 실제 응답과 일치하는가(서버 Content-Type 포함)

3-2. CSS @font-facefont-display가 적절한지

font-display는 FOIT/FOUT 전략을 결정합니다. Safari에서 "잠깐 숨겼다가 나타나며" 레이아웃이 변한다면 swap 또는 optional을 고려해야 합니다.

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard-Subset.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
  • swap: 즉시 fallback으로 그렸다가 폰트 도착 시 교체(레이아웃 변동 가능)
  • optional: 네트워크가 느리면 교체를 포기하는 경향(레이아웃 안정에 유리)

Safari에서 CLS가 치명적이면, **above-the-fold에 쓰는 폰트는 optional**을 검토할 가치가 있습니다. 대신 브랜딩 타이포 일관성이 약간 희생될 수 있습니다.

3-3. fallback 폰트의 메트릭 차이로 인한 줄바꿈 변화

swap을 쓰면 결국 "fallback으로 그린 텍스트"와 "웹폰트"의 폭/높이 차이가 CLS를 유발합니다. 이때 중요한 것은 fallback 스택입니다.

:root {
  --font-sans: 'Pretendard', -apple-system, BlinkMacSystemFont,
    'Apple SD Gothic Neo', 'Noto Sans KR', 'Segoe UI', Roboto,
    'Helvetica Neue', Arial, sans-serif;
}

body {
  font-family: var(--font-sans);
}

Safari에서는 -apple-system 계열이 강하게 작동하므로, 웹폰트와 가장 메트릭이 비슷한 시스템 폰트를 앞쪽에 두는 것이 효과적입니다.

추가로, 최신 브라우저는 size-adjust, ascent-override 같은 폰트 메트릭 조정 속성을 지원합니다. Safari 지원 범위가 환경에 따라 다를 수 있어 "전 브라우저 안정"을 기대하기는 어렵지만, 가능하면 아래처럼 시도할 수 있습니다.

@font-face {
  font-family: 'Pretendard-fallback';
  src: local('Apple SD Gothic Neo');
  size-adjust: 102%;
  ascent-override: 92%;
  descent-override: 20%;
  line-gap-override: 0%;
}

body {
  font-family: 'Pretendard', 'Pretendard-fallback', -apple-system, sans-serif;
}

3-4. 서브셋 폰트와 유니코드 범위가 Safari에서 다르게 매칭

unicode-range로 서브셋을 나누면, 특정 문자(예: 숫자, 특수문자, 한글 자모)가 다른 파일에서 로드됩니다. Safari에서만 특정 텍스트 구간이 늦게 로드되면, 그 구간이 포함된 서브셋이 늦게 도착하는 것입니다.

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard-KR.woff2') format('woff2');
  unicode-range: U+AC00-D7A3; /* 한글 */
  font-display: swap;
}

@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard-Latin.woff2') format('woff2');
  unicode-range: U+0000-00FF; /* 라틴 */
  font-display: swap;
}

진단 팁:

  • Safari 네트워크 탭에서 어떤 폰트 파일이 실제로 요청되는지 확인
  • CLS가 발생하는 텍스트에 포함된 문자가 어느 unicode-range에 해당하는지 확인

3-5. preconnect 누락으로 TLS 핸드셰이크가 병목

폰트를 CDN에서 가져오는데 preconnect가 없으면, Safari에서 첫 연결 비용이 더 크게 체감될 수 있습니다.

<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link
  rel="preload"
  href="https://cdn.example.com/fonts/Pretendard-Subset.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

4) Next.js에서 흔한 함정: preload는 했는데 CSS가 늦게 붙는 경우

Next.js에서 폰트를 @import로 불러오거나, 특정 페이지에서만 CSS가 로드되게 쪼개면, 폰트 선언 자체가 늦게 파싱되어 preload가 있어도 적용이 늦을 수 있습니다.

권장 방향:

  • 폰트 @font-face는 전역 CSS(초기 로드에 포함)로 올리기
  • Above-the-fold에서 쓰는 폰트는 초기 CSS 번들에 포함되도록 구성

4-1. next/font 사용 시 체크

next/font는 최적화를 많이 해주지만, 실제로 Safari에서만 튄다면 아래를 점검하세요.

  • 폰트가 로컬 파일인지, 외부 호스트인지
  • display 옵션이 무엇인지(swap, optional 등)
  • 특정 weight만 preload되는지

예시:

import localFont from 'next/font/local';

export const pretendard = localFont({
  src: [
    { path: './Pretendard-Regular.woff2', weight: '400', style: 'normal' },
    { path: './Pretendard-Bold.woff2', weight: '700', style: 'normal' },
  ],
  display: 'optional',
});

display: 'optional'은 CLS에는 유리하지만, 폰트 교체가 덜 일어날 수 있습니다. 브랜드 타이포가 중요한 영역만 별도로 전략을 나누는 것도 방법입니다.


5) Safari 전용으로 의심해야 하는 preload/CORS/캐시 이슈

5-1. 폰트 응답 헤더가 올바른지

폰트는 정적 파일처럼 보이지만, 헤더가 틀리면 브라우저별로 캐시/재사용이 달라집니다.

권장 예:

  • Content-Type: font/woff2
  • Cache-Control: public, max-age=31536000, immutable
  • (CDN 사용 시) Access-Control-Allow-Origin: * 또는 적절한 오리진

서버 설정 예시(Nginx):

location ~* \.(woff2|woff)$ {
  add_header Access-Control-Allow-Origin "*";
  add_header Cache-Control "public, max-age=31536000, immutable";
  types {
    font/woff2 woff2;
    font/woff  woff;
  }
}

Safari에서만 "preload 했는데 또 받는" 느낌이면, 대개 CORS 또는 캐시 키가 갈라진 것입니다.

5-2. crossorigin이 빠진 preload

같은 오리진이면 괜찮을 때도 있지만, 폰트를 별도 도메인에서 제공하면 crossorigin 누락이 치명적입니다. preload로 받은 응답이 실제 폰트 요청에 재사용되지 않을 수 있습니다.


6) 재현을 쉽게 만드는 실전 진단 루틴

6-1. 네트워크를 느리게 하고 새로고침

  • 캐시 비우기 후 새로고침
  • 네트워크 제한을 걸고(개발자 도구), 첫 로드에서 CLS가 커지는지 확인

6-2. 폰트를 강제로 끄고 비교

웹폰트를 잠시 제거하고(또는 @font-face 주석 처리) CLS가 사라지면, 거의 확실히 폰트 문제입니다.

6-3. 폰트 파일 요청 타이밍 확인

  • HTML 파싱 직후 폰트 요청이 시작되는지
  • CSS가 늦게 로드되며 폰트 요청이 뒤늦게 시작되는지
  • preload가 실제 요청을 대체했는지(동일 URL, 동일 CORS)

7) 해결 패턴 4가지(우선순위 순)

7-1. Above-the-fold 폰트는 preload + optional 또는 더 강한 fallback 설계

  • preload는 정확히 매칭시키고
  • font-displayoptional로 바꾸거나
  • fallback 스택을 메트릭이 비슷한 폰트로 조정

7-2. 레이아웃이 민감한 영역은 높이/폭을 "고정"해 CLS 완충

버튼/헤더/카드 타이틀처럼 폰트 폭에 따라 줄바꿈이 바뀌는 영역은, 디자인이 허용하는 범위에서 레이아웃을 고정하세요.

.hero-title {
  line-height: 1.1;
  min-height: 2.2em; /* 2줄까지는 높이 고정 */
}

.cta-button {
  min-width: 10ch; /* 라벨 길이 변동 완충 */
}

7-3. 서브셋 전략 재검토

서브셋이 너무 잘게 쪼개져 있으면 Safari에서 특정 파일이 늦게 와서 부분적으로 레이아웃이 바뀔 수 있습니다. above-the-fold에 필요한 글리프는 한 파일로 묶는 편이 안정적일 때가 많습니다.

7-4. CDN/프록시에서 폰트 헤더와 캐시를 표준화

  • Content-Type 정확히
  • Cache-Control immutable
  • CORS 허용

이 단계는 CLS뿐 아니라 전체 로딩 성능에도 직접적인 영향을 줍니다.


8) 마무리: "Safari에서만"은 대개 힌트 매칭 실패다

Safari에서만 CLS가 튄다면, "Safari가 이상하다"로 끝내기보다 preload가 실제 폰트 요청과 재사용되는지, 그리고 fallback과 웹폰트의 메트릭 차이가 레이아웃 변경으로 이어지는지를 먼저 확인하는 것이 가장 빠릅니다.

정리 체크리스트:

  • preloadhref@font-face src와 완전히 동일한가
  • crossorigin이 preload와 실제 요청에서 일치하는가
  • 폰트 응답의 Content-Type, Cache-Control, CORS 헤더가 올바른가
  • font-displayswap에서 optional로 바꿨을 때 CLS가 줄어드는가
  • fallback 폰트 스택이 Safari에서 과도하게 다른 메트릭을 만들지 않는가

관련해서 브라우저/프록시 환경 차이로 리다이렉트나 스킴이 바뀌는 문제도 종종 함께 나타납니다. 인프라 계층에서 URL/헤더가 바뀌는 이슈를 점검할 때는 Nginx 뒤 OAuth 콜백이 http로 바뀌는 원인·해결 같은 케이스도 참고가 됩니다.