- Published on
Safari에서만 CLS 튀는 이유 - 폰트 로딩·preload 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 모두에서 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가 실효성이 떨어집니다.
preloadURL과 실제 CSSsrcURL이 미묘하게 다름(쿼리스트링, 상대/절대 경로, 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-face의src와 완전히 동일한가crossorigin이 둘 다 동일한 모드로 요청되는가type이 실제 응답과 일치하는가(서버Content-Type포함)
3-2. CSS @font-face의 font-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/woff2Cache-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-display를optional로 바꾸거나- 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과 웹폰트의 메트릭 차이가 레이아웃 변경으로 이어지는지를 먼저 확인하는 것이 가장 빠릅니다.
정리 체크리스트:
preload의href가@font-face src와 완전히 동일한가crossorigin이 preload와 실제 요청에서 일치하는가- 폰트 응답의
Content-Type,Cache-Control, CORS 헤더가 올바른가 font-display를swap에서optional로 바꿨을 때 CLS가 줄어드는가- fallback 폰트 스택이 Safari에서 과도하게 다른 메트릭을 만들지 않는가
관련해서 브라우저/프록시 환경 차이로 리다이렉트나 스킴이 바뀌는 문제도 종종 함께 나타납니다. 인프라 계층에서 URL/헤더가 바뀌는 이슈를 점검할 때는 Nginx 뒤 OAuth 콜백이 http로 바뀌는 원인·해결 같은 케이스도 참고가 됩니다.