- Published on
Safari 렌더링 깨짐 - contain·will-change 버그 대응
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 API가 멀쩡한데 특정 iOS Safari에서만 카드가 잘리거나, 스크롤 중 텍스트가 사라졌다가 나타나고, sticky 헤더가 순간이동하는 현상을 겪어본 적이 있다면 대개 CSS 최적화 옵션이 트리거가 됩니다. 특히 contain과 will-change는 성능 최적화에 자주 쓰이지만, Safari에서는 이 둘이 결합될 때 레이어 승격과 페인팅 경계 계산이 어긋나 렌더링이 깨지는 케이스가 꾸준히 보고됩니다.
이 글에서는
- 어떤 조합이 위험한지
- 최소 재현 코드로 어떻게 확인하는지
- 실무에서 안전하게 우회하는 패턴
을 중심으로 정리합니다.
증상 패턴: “성능 최적화”가 UI를 깨뜨릴 때
Safari에서 자주 보이는 증상은 다음처럼 묘사됩니다.
- 스크롤 중 요소가 클리핑되어 일부만 보이거나 완전히 사라짐
position: sticky가 있는 컨테이너에서 sticky가 튕김 또는 순간적으로 고정 해제- transform 애니메이션 중 텍스트가 블러되거나 깜빡임
- overflow 영역에서 자식 요소가 페인트 경계 밖으로 잘림
공통점은 “브라우저가 요소를 별도 레이어로 올리고(compositing), 그 레이어 경계를 최적화하려다가” 경계 계산이 틀어지는 경우가 많다는 점입니다.
왜 contain과 will-change가 위험 조합이 되나
contain의 의도
contain은 레이아웃/스타일/페인트/사이즈 계산을 외부로 전파하지 않도록 제한해 렌더링 비용을 줄입니다.
contain: layout레이아웃 영향 차단contain: paint페인트가 경계를 넘지 않도록 클리핑contain: content는 대개layout과paint등을 묶어 적용
문제는 contain: paint 성격이 들어가면 브라우저는 해당 박스를 “강한 페인트 경계”로 취급하고, 내부를 별도 합성 레이어로 다루는 최적화를 수행할 수 있습니다.
will-change의 의도
will-change: transform 같은 힌트는 “곧 transform이 바뀔 테니 레이어 승격을 미리 해두라”는 신호입니다. Chrome 계열에서는 대체로 이 힌트가 예측 가능한 성능 개선으로 이어지지만, Safari에서는 다음과 같은 부작용이 상대적으로 자주 발생합니다.
- 불필요한 레이어 승격 증가
- 레이어 경계가 실제 레이아웃 경계와 불일치
- 스크롤과 합성 타이밍이 맞지 않아 깜빡임
결합될 때의 함정
contain이 “경계를 강하게 만들고”, will-change가 “레이어 승격을 강제”하면 Safari는 내부 합성 레이어를 잘못 클리핑하거나, 스크롤 중 업데이트 순서를 꼬이게 만들 수 있습니다. 특히 다음 조합이 위험합니다.
- 부모에
contain: paint또는contain: content - 자식 또는 부모에
will-change: transform또는transform: translateZ(0) - 동시에
overflow: hidden이나-webkit-overflow-scrolling: touch같은 스크롤 최적화가 있는 경우
최소 재현 예제: 깨짐을 “눈으로” 확인하기
아래 예제는 의도적으로 위험 조합을 만든 것입니다. iOS Safari에서 스크롤하거나 transform을 토글하면, 카드가 잘리거나 깜빡이는 형태로 재현될 수 있습니다.
<div class="feed">
<div class="card">
<h3>Title</h3>
<p>Safari에서만 잘리거나 깜빡일 수 있는 텍스트 영역</p>
</div>
<div class="card">
<h3>Title</h3>
<p>스크롤 중 페인팅 경계가 틀어지는 케이스를 관찰</p>
</div>
</div>
.feed {
height: 60vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
/* 성능 최적화 목적으로 넣었다가 Safari에서 문제를 만드는 경우가 있음 */
contain: content;
}
.card {
margin: 16px;
padding: 16px;
border-radius: 12px;
background: white;
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
/* 레이어 승격 힌트 */
will-change: transform;
}
// transform 변화를 줘서 합성 레이어 업데이트를 유도
document.querySelectorAll('.card').forEach((el, i) => {
el.addEventListener('click', () => {
el.style.transform = el.style.transform ? '' : 'translate3d(0, -1px, 0)';
});
});
재현이 “항상” 되지 않을 수 있습니다. Safari 렌더링 이슈는 기기 메모리, 스크롤 속도, 요소 수, 폰트 렌더링 상태에 따라 확률적으로 나타나는 경우가 많습니다. 그래서 실무에서는 의심되는 최적화 옵션을 하나씩 제거하면서 원인을 좁히는 방식이 가장 빠릅니다.
진단 체크리스트: 원인 좁히는 순서
다음 순서로 토글해보면 범인이 빨리 드러납니다.
will-change제거contain제거 또는contain: layout처럼 약한 옵션으로 변경overflow: hidden제거 또는 스크롤 컨테이너 구조 변경transform: translateZ(0)같은 강제 레이어 승격 제거- sticky가 있다면 sticky 요소의 조상에
transform또는contain이 있는지 확인
Safari에서 position: sticky는 조상에 transform이 있으면 동작이 달라지거나 깨질 수 있습니다. contain도 “조상 컨텍스트를 바꾸는” 효과가 있어 sticky와 충돌하는 경우가 있습니다.
실전 우회 전략 7가지
1) will-change를 상시 적용하지 말고 “짧게” 쓰기
will-change는 원래도 상시 사용을 권장하지 않습니다. Safari에서는 더더욱 그렇습니다. 애니메이션 직전에만 켰다가 끝나면 끄는 패턴이 안전합니다.
function withWillChange(el, prop, fn) {
const prev = el.style.willChange;
el.style.willChange = prop;
// 다음 프레임에 실행해 레이어 준비 시간을 줌
requestAnimationFrame(() => {
fn();
// 애니메이션 종료 후 정리
setTimeout(() => {
el.style.willChange = prev;
}, 300);
});
}
// 사용 예
const card = document.querySelector('.card');
withWillChange(card, 'transform', () => {
card.style.transform = 'translate3d(0, -2px, 0)';
});
2) contain: content 대신 더 약한 범위로 쪼개기
contain: content는 강합니다. 필요한 것만 적용하세요.
- 레이아웃 격리를 원하면
contain: layout - 페인트 격리가 꼭 필요할 때만
contain: paint
.feed {
/* contain: content; */
contain: layout;
}
페인트 격리(paint)가 들어가는 순간 클리핑/합성 경계 문제가 발생할 확률이 올라갑니다.
3) “contain 대상”과 “transform 대상”을 분리하기
부모에 contain을 걸고 자식에 will-change를 걸면 충돌이 날 수 있습니다. 구조를 분리해서 contain 영역 내부에서 transform이 일어나지 않게 만들면 안정적입니다.
<div class="feed">
<div class="card-shell">
<div class="card">...</div>
</div>
</div>
.feed { contain: layout; }
.card-shell { /* contain을 적용할 거면 여기 */ }
.card { will-change: transform; }
핵심은 “경계 계산을 강제하는 요소”와 “레이어 승격/transform을 강제하는 요소”를 같은 박스 트리에서 겹치지 않게 하는 것입니다.
4) Safari에서만 contain을 끄는 폴백
최적화가 크지 않거나, Safari에서만 문제가 난다면 조건부 폴백이 현실적입니다.
.container {
contain: content;
}
@supports (-webkit-touch-callout: none) {
/* iOS Safari 계열에서만 완화 */
.container {
contain: layout;
}
}
위 @supports는 iOS Safari를 100퍼센트 정확히 식별하는 용도라기보다, “웹킷 계열에서만 완화”하는 실무적 타협에 가깝습니다. 더 보수적으로는 기능 플래그로 Safari에서만 최적화를 끄는 것도 방법입니다.
5) 강제 레이어 승격 트릭을 남발하지 않기
transform: translateZ(0)나 backface-visibility: hidden 같은 트릭은 과거부터 합성 레이어를 유도하는 데 쓰였지만, Safari에서 부작용이 생길 수 있습니다.
- 정말 필요한 애니메이션 요소에만 적용
- 적용 범위를 최소화
- 스크롤 컨테이너나 sticky 조상에는 피하기
6) 스크롤 컨테이너 내부 최적화는 content-visibility와 역할 분담
스크롤 성능을 위해 contain을 넣었다면, 목적이 “오프스크린 렌더링 비용 절감”인지 점검하세요. 그 목적에는 content-visibility가 더 직접적일 수 있습니다.
관련해서 CLS와 렌더링 최적화 관점은 아래 글과 함께 보면 설계가 쉬워집니다.
다만 content-visibility도 브라우저별 지원과 부작용이 있을 수 있으니, Safari 타깃이 크면 폴백을 설계해야 합니다.
7) “깨져도 안전한” UI로 만들기: 클리핑 의존 줄이기
Safari 버그는 종종 “클리핑 경계”에서 터집니다. 그래서 다음을 고려하면 피해를 줄일 수 있습니다.
overflow: hidden을 꼭 필요한 곳에만 사용- 그림자나 blur를 가진 요소를 스크롤 컨테이너 내부에서 과도하게 쓰지 않기
- 카드 내부에
position: relative와 복잡한 스태킹 컨텍스트가 겹치지 않게 단순화
디버깅 팁: 재현이 어려울 때의 관찰 포인트
- iOS Safari 원격 디버깅(Web Inspector)에서 Layers, Rendering 관련 정보를 확인
- 문제 요소에 임시로
outline: 1px solid red;를 주고 “클리핑 경계가 어디로 잡혔는지” 눈으로 확인 - 문제 발생 시점에
transform이 적용된 조상 요소가 있는지 DOM 트리 상단까지 추적
또한 이런 류의 이슈는 “원인을 하나씩 제거하면서 좁히는” 디버깅이 중요합니다. 인프라에서 병목을 단계적으로 좁히는 방식과 유사한데, 접근법 자체는 다른 분야에도 그대로 적용됩니다. 예를 들어 대형 저장소에서 원인을 분해해 진단하는 글을 참고하면 트러블슈팅 루틴을 잡는 데 도움이 됩니다.
권장 결론: 성능 최적화는 “안정성 예산”과 함께
contain과 will-change는 분명히 유용합니다. 하지만 Safari에서는
contain: content같은 강한 격리- 상시
will-change - 스크롤 컨테이너, sticky, overflow 클리핑
이 한 화면에 겹치면 렌더링이 깨질 확률이 올라갑니다.
실무적으로는 다음 원칙이 가장 안전합니다.
will-change는 짧게, 필요한 순간에만contain은 목적을 분리해 최소 범위로- contain 박스와 transform 박스를 분리
- Safari에서만 완화하는 폴백을 준비
이렇게 설계하면 “성능 최적화로 UX를 깨뜨리는” 상황을 피하면서도, 대부분의 브라우저에서 성능 이점을 유지할 수 있습니다.