크롬에서는 멀쩡한데 아이폰에서만 흰 화면이 뜨는 이유

개발하면서 가장 당황스러운 상황 중 하나가 내 브라우저에서는 되는데 다른 기기에서만 안 되는 경우입니다. 특히 화면이 아예 하얗게 나오면 어디서부터 봐야 할지 막막합니다. 콘솔도 못 보고 에러 메시지도 없습니다.

저는 얼마 전에 이걸 겪었습니다. 맥 크롬에서는 멀쩡한 페이지가 아이폰 사파리에서만 흰 화면으로 덮였습니다. 원인은 폰트 로딩 API였는데, 거기까지 가는 데 몇 번 헛짚었습니다.

흰 화면부터 분류하기

흰 화면은 한 가지가 아닙니다. 크게 셋으로 나뉩니다.

HTML 자체가 안 내려오는 경우는 서버 에러나 라우팅 문제입니다. 소스 보기를 하면 비어 있습니다. HTML은 왔는데 아무것도 안 그려진 경우는 하이드레이션 실패나 렌더 단계 예외입니다. 소스 보기에는 내용이 있는데 화면만 비어 있습니다. 마지막으로 뭔가가 화면을 덮고 있는 경우가 있습니다. 스플래시, 인트로, 로딩 오버레이가 안 걷힌 상태입니다.

이걸 먼저 가르지 않으면 엉뚱한 데를 팝니다. 저는 첫 번째부터 의심했다가 시간을 썼습니다.

헛짚은 것: dev 서버가 막는 줄 알았다

로컬 개발 서버를 다른 기기에서 여는 상황이었기 때문에 Next.js가 외부 origin을 차단하는 줄 알았습니다. Next에는 allowedDevOrigins 설정이 있고, 여기 등록되지 않은 origin에서 오는 개발용 요청을 막습니다. 설정이 없었으니 그럴듯했습니다.

curl 두 줄로 아니라는 게 나왔습니다.

curl -o /dev/null -w "%{http_code}\n" http://<개발 서버>/
# 200

curl -o /dev/null -w "%{http_code}\n" "http://<개발 서버>/_next/static/chunks/<...>.js"
# 200

HTML도 JS 청크도 다 내려옵니다. 첫 번째 유형이 아니었습니다.

진짜 원인은 폰트 API였다

저희 랜딩에는 인트로 오버레이가 있습니다. 로고가 떴다가 막이 위로 걷히는 연출인데, 라이트 테마에서 이 막이 흰색입니다. 그리고 이 막은 JS 타이머가 걷어냅니다.

즉 클라이언트 JS가 죽으면 막이 영원히 남습니다. 세 번째 유형이었습니다.

JS가 왜 죽었는지가 남았습니다. 인트로에 폰트 로딩을 기다리는 코드가 있었습니다.

const fontFamily = window.getComputedStyle(document.body).fontFamily;
document.fonts.load(`850 1em ${fontFamily}`, "DUOLABS");

폰트가 로드된 다음에 애니메이션을 시작해서 글자가 튀는 걸 막으려는 의도입니다. 문제는 getComputedStyle이 돌려주는 값입니다.

pretendard, "pretendard Fallback", -apple-system, "system-ui", "Segoe UI", sans-serif

WebKit은 font 단축 문법 파서에서 -apple-system 같은 시스템 폰트 키워드를 받지 않습니다. 그래서 사파리에서 document.fonts.load()는 거부된 프로미스를 돌려주는 대신 동기 예외를 던집니다. 스펙과 다른 동작입니다.

useEffect 안에서 던져진 예외가 밖으로 나가면 React 트리를 통째로 언마운트시킵니다. 남는 건 흰 인트로 막뿐입니다. 크롬은 같은 문자열을 그대로 받아주기 때문에 맥에서는 아무리 봐도 재현되지 않았습니다.

고친 방법

두 가지를 같이 했습니다.

let started = false;
const startOnce = () => { if (started) return; started = true; start(); };

// 폰트가 어떻게 되든 800ms 안에는 무조건 시작
setTimeout(startOnce, 800);

try {
const fontFamily = window.getComputedStyle(document.body).fontFamily;
document.fonts?.load(`850 1em ${fontFamily}`, BRAND)?.then(startOnce, startOnce);
} catch {
startOnce();
}

try/catch가 동기 예외를 잡습니다. 그런데 더 중요한 건 두 번째, 폰트와 무관한 타이머입니다. 폰트 API가 앞으로 또 무슨 짓을 하든 인트로는 800ms 뒤에 걷힙니다.

남는 것

화면 전체를 덮는 오버레이를 JS로 걷어내는 구조라면, JS가 죽었을 때 무슨 일이 생기는지 한 번은 따져봐야 합니다. 스플래시, 인트로, 로딩 막이 다 해당됩니다. 오버레이 해제는 JS 성공에 의존하지 않는 경로를 하나 갖고 있는 편이 안전합니다.

document.fonts.load()에 getComputedStyle로 뽑은 font-family를 그대로 넣는 것도 피하는 게 좋습니다. 시스템 폰트 키워드가 하나만 섞여 있어도 사파리가 던집니다. 확인하려는 폰트 이름만 직접 지정하면 됩니다.

그리고 모바일 문제를 크롬 재현 여부로 판단하면 안 됩니다. 이 문제는 크롬에서 절대 재현되지 않습니다. 실기기에서 실제 에러를 보려면 아이폰 설정에서 사파리 > 고급 > 웹 속성을 켜고 맥 사파리의 개발자용 메뉴로 연결하면 됩니다.

덧붙이면, 제가 원인을 특정한 건 코드 분석까지이고 실기기에서 예외 로그를 직접 확인한 건 아닙니다. 같은 증상이라면 웹 인스펙터로 첫 번째 에러를 보는 게 확실합니다.

같은 작업에서 겪은 다른 문제:

iOS 사파리에서 고정 헤더가 사라지거나 잘리는 이유

모바일에서만 가로 스크롤이 생기는 이유