iOS Safari 모바일 버그 3종 해결기: 헤더 잘림 · 흰 화면 · 가로 넘침

핵심 요약

같은 날 확인된 세 증상은 원인이 서로 달랐으며, 모두 해당 테스트 환경에서 데스크톱 Chrome과 다르게 나타난 WebKit 관련 동작이었습니다.

# | 증상 | 진짜 원인 | 조치
1 | 모바일 헤더 상단이 조금 잘림 | position: sticky + margin-bottom: -72px (WebKit이 스티키 제약 사각형을 마진 박스로 계산) | 모바일 헤더를 fixed로 되돌림
2 | 실기기 접속 시 흰 화면이 덮음 | document.fonts.load()가 Safari에서 동기 예외 → React 트리 언마운트 → 흰 인트로 막만 잔존 | try/catch + 800ms 무조건 시작 타이머
3 | 화면 오른쪽에 빈 여백 · 가로 스크롤 | 3D 캐러셀 측면 카드의 투영 박스가 문서 폭을 37px 확장 | .c3d-viewport에 overflow: clip

공통 교훈은 body의 overflow-x: clip이 모든 넘침을 막아 줄 것이라는 가정에 기대면 안 된다는 점입니다. iOS Safari에서는 그 전파를 신뢰할 수 없으므로, 넘침은 발생 지점에서 잘라내야 합니다.

1. 모바일 헤더 상단 잘림

배경

원래 Safari에서 "주소창이 접혔다 펴질 때 고정 헤더가 사라지는" 문제가 있었고, 그 대응으로 모바일 헤더를 fixed → sticky로 바꿨습니다. 그런데 이번엔 헤더 상단이 잘려 보이는 새 증상이 생겼습니다.

문제의 코드

.landing-nav:not(.site-page-nav) {
position: sticky; top: 0;
height: 72px;
margin-bottom: -72px; /* 히어로 위에 겹치게 하려는 의도 */
}

원인

해당 WebKit 환경에서는 스티키 제약 사각형(sticky constraint rect)이 마진 박스의 영향을 받았습니다. margin-bottom: -72px를 주면 헤더의 마진 박스 높이가 0이 되면서, 스크롤 중 헤더가 위로 밀려 상단이 잘려 보입니다.

덤으로 height: 72px가 border-bottom: 1px를 포함해버려(box-sizing: border-box) 배경이 71px만 칠해지는 1px 오차도 있었습니다.

조치

sticky 블록을 제거하고 모바일 헤더를 fixed로 되돌렸습니다. 기존 Safari 헤더 소실 문제의 해결책은 sticky 전환이 아니라 다음 두 가지였으며, 해당 조치는 그대로 유지했습니다.

/* WebKit의 fixed + backdrop-filter 합성 레이어 소실 회피 */
@supports (-webkit-touch-callout: none) {
.landing-nav.scrolled { backdrop-filter: none; -webkit-backdrop-filter: none; }
[data-theme="light"] .landing-nav.scrolled { background: rgba(255,255,255,0.96); }
[data-theme="dark"] .landing-nav.scrolled { background: rgba(11,17,32,0.96); }
}

/* 스크롤 잠금 시 visibility:hidden 대신 뒤로만 보냄 (레이어를 파괴하지 않음) */
html[data-scroll-lock] .landing-nav { z-index: 90; pointer-events: none; }

만약 sticky가 다시 필요해지면, 음수 마진을 헤더가 아니라 다음 형제(.landing-hero { margin-top: -73px })에 주면 스티키 박스의 마진을 건드리지 않아 안전합니다.

2. 실기기 접속 시 흰 화면

증상

데스크톱 Chrome 개발 환경은 정상인데, iPhone Safari에서 같은 개발 페이지에 접속하면 흰 화면이 페이지를 덮었습니다.

흰 화면의 정체

인트로 오버레입니다. 라이트 테마에서 인트로 막은 흰색입니다.

[data-theme="light"] .intro { background: #fff; }

이 막은 JS 타이머가 걷어내므로, 클라이언트 JS가 죽으면 영원히 걷히지 않습니다.

원인

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

이 프로젝트의 계산된 fontFamily는 이렇다:

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

해당 Safari 환경에서는 font 단축 문법에 -apple-system 같은 시스템 폰트 키워드가 포함되자 파싱에 실패했습니다. 그래서 그 결과 document.fonts.load()가 동기 예외(SyntaxError) 를 발생시켰습니다. 그 예외가 useEffect 밖으로 나가면 React 트리 전체가 언마운트되고, 남는 건 흰 인트로 막뿐입니다. Chrome에서는 같은 문자열이 처리되어 동일한 문제가 재현되지 않았습니다.

조치 (Intro.tsx)

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

// 폰트 결과와 무관하게 800ms 안에는 무조건 시작
fontWaitId = window.setTimeout(startOnce, 800);
try {
const introFontFamily = window.getComputedStyle(document.body).fontFamily;
const introFontReady = document.fonts?.load(`850 1em ${introFontFamily}`, BRAND);
if (introFontReady) void introFontReady.then(startOnce, startOnce);
else startId = window.requestAnimationFrame(startOnce);
} catch {
startId = window.requestAnimationFrame(startOnce);
}

핵심 두 가지:

try/catch 로 동기 예외가 React 트리를 죽이지 못하게 막습니다.

폰트와 독립된 타이머로 무슨 일이 있어도 인트로가 걷히게 합니다.

짚고 넘어간 오해

개발 서버가 실기기 접근을 막는 것인지 먼저 확인했지만, 원인은 아니었습니다. HTML과 정적 JavaScript 파일이 모두 정상 응답하는 것을 확인했습니다.

3. 화면 오른쪽 빈 여백 (가로 넘침)

증상

실기기에서 화면 오른쪽에 빈 띠가 생기고 가로로 스크롤되었습니다. 처음엔 카카오톡 플로팅 아이콘을 의심했으나 무관했습니다.

진단 방법 (재사용 가치 있음)

Chrome을 모바일 폭으로 띄우고 다음을 실행하면 클리핑 조상이 없는 넘침 요소만 추려낼 수 있습니다.

const cw = document.documentElement.clientWidth;
const clipped = el => {
let p = el.parentElement;
while (p && p !== document.body) {
if (/hidden|clip|auto|scroll/.test(getComputedStyle(p).overflowX)) return true;
p = p.parentElement;
}
return false;
};
const out = [];
document.querySelectorAll('body *').forEach(el => {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.right > cw + 1 && !clipped(el))
out.push({ c: el.className, right: Math.round(r.right) });
});
out.sort((a, b) => b.right - a.right);
console.log(document.documentElement.scrollWidth, cw, out.slice(0, 10));

결과: clientWidth 374 vs scrollWidth 411 → 37px 넘침, 범인은 .c3d-card / .handover-card.

원인

Carousel3D의 .c3d-ring이 카드를 rotateY(...) translateZ(radius)로 원형 배치합니다. 링 측면의 카드는 거의 옆으로 서 있어도 투영 박스는 좌우로 크게 뻗습니다. .c3d-viewport에 클립이 없어서 그게 그대로 문서 폭이 되었습니다.

데스크톱 Chrome에서 보이지 않은 이유는 body { overflow-x: clip }이 뷰포트로 전파돼 가로 스크롤을 막아주기 때문입니다. iOS Safari는 이 전파를 신뢰할 수 없습니다.

조치

.c3d-viewport {
perspective: 1100px; width: 100%; height: 13rem; position: relative;
overflow: clip; /* hidden 아님 */
}

hidden이 아니라 clip을 쓴 이유: overflow가 visible/clip 외의 값이면 해당 요소의 transform-style: preserve-3d가 flat으로 강제되어 3D 연출이 깨질 수 있습니다. clip은 스크롤 컨테이너를 만들지 않아 안전합니다. (Safari 16+ 지원)

검증

scrollWidth 411 → 382

정면 카드 기하 75~299 (클립 박스 20~354) → 정면 카드는 전혀 안 잘림. 옆면 카드(opacity 0.4)만 좌우 23px씩 덜 보이는데, 원래 화면 밖으로 나가 있던 부분입니다.

남은 382는 .aurora { width: 100vw }가 데스크톱 스크롤바 15px을 포함해서 생기는 값. 아이폰은 스크롤바가 없어 100vw == clientWidth이므로 해당되지 않습니다.

주의할 점 (재발 방지 체크리스트)

100vw는 스크롤바를 포함합니다. 풀블리드 배경(width: 100vw; margin-left: -50vw)은 데스크톱에서 항상 스크롤바 폭만큼 넘친다. 모바일에는 영향이 없지만 진단 과정에서 혼동을 줄 수 있으므로 기억해 두어야 합니다.

가로 넘침은 발생 지점에서 잘라냅니다. body의 overflow-x: clip은 크롬용 안전망일 뿐, iOS의 최종 방어선이 아닙니다.

3D 변환 컨테이너를 클립할 땐 clip을 사용합니다. hidden은 preserve-3d를 flat으로 만듭니다.

sticky 요소에 음수 마진을 주지 않습니다. 필요하면 인접 형제에 줍니다.

document.fonts.load()는 try/catch로 감쌉니다. 시스템 폰트 키워드가 들어간 font-family를 그대로 단축 문법에 넣으면 Safari가 예외를 발생시킵니다.

전체 화면을 덮는 오버레이는 JS 실패에 독립적인 해제 경로를 갖습니다. 인트로/스플래시처럼 "JS가 걷어내는 막"은 JS가 죽으면 그대로 백지 화면이 됩니다.

모바일 문제는 Chrome 재현 여부로 판단하지 않습니다. 이번 3건 중 3번만 Chrome에서 재현됐고, 1·2번은 WebKit 전용이었습니다.

다음 액션

실기기(아이폰 Safari)에서 3건 모두 해소됐는지 최종 확인

여전히 이상하면 macOS Safari 웹 인스펙터로 실기기 연결해 첫 번째 에러 확인

iPhone: 설정 → Safari → 고급에서 웹 속성을 켭니다.

macOS Safari: 개발자용 메뉴에서 iPhone과 점검할 페이지를 선택합니다.