CLS 0.5를 0으로 — TypeWriter 애니메이션의 함정과 해결
이미지를 36MB에서 0.8MB로 줄였는데, Mobile 점수는 38에서 45로 7점밖에 안 올랐습니다. 이미지가 문제의 전부가 아니었던 겁니다. 남은 감점의 핵심은 CLS 0.481이었습니다.
CLS를 0으로 만들자 Mobile 점수가 45에서 70으로 25점 올랐습니다. 디자인은 하나도 바꾸지 않았습니다. 이 글은 CLS의 원인을 추적하고, 시각적 변화 없이 제거한 과정을 기록한 것입니다.
0.481→0
CLS (Mobile)
+25점
Mobile 상승
60~70%
타이핑 애니메이션 비중
0개
디자인 변경
1CLS란 무엇인가
CLS(Cumulative Layout Shift)는 페이지 로딩 중 요소가 예상치 못하게 이동하는 정도를 측정하는 지표입니다. 뉴스 사이트에서 기사를 읽으려는데 갑자기 광고가 삽입되면서 텍스트가 밀려난 경험이 있을 겁니다. 그게 CLS입니다.
| CLS 점수 | 등급 | PageSpeed 영향 |
|---|---|---|
| 0 ~ 0.1 | Good | 감점 없음 |
| 0.1 ~ 0.25 | Needs Improvement | 부분 감점 |
| 0.25 이상 | Poor | 심각한 감점 (25% 가중치) |
Lighthouse는 전체 Performance 점수의 25%를 CLS에 배정합니다. CLS가 0.481이면 이 25%에서 거의 만점 감점을 받는 것입니다. 이미지를 아무리 최적화해도 CLS가 높으면 점수가 오르지 않는 이유입니다.
2범인 찾기 — 타이핑 애니메이션
메인 페이지에는 글자가 한 자씩 타이핑되듯 나타나는 TypeWriter 컴포넌트가 있었습니다. Hero 영역의 제목, 서비스 섹션의 소제목, 프로세스 섹션의 제목, CTA 섹션의 제목 — 총 4곳에서 사용되고 있었습니다.
왜 CLS가 발생하나?
기존 TypeWriter 컴포넌트의 동작 방식은 이랬습니다:
// 기존 방식 (CLS 발생)
// 상태: count가 0에서 시작하여 전체 글자 수까지 증가
const [count, setCount] = useState(0);
// 렌더링: count만큼의 글자만 표시
<span>{text.slice(0, count)}</span>
// "D" → "Di" → "Dig" → "Digi" → ...
// 글자가 추가될 때마다 높이/너비가 변함 → CLS!한 글자가 추가될 때마다 텍스트의 너비가 변합니다. 줄바꿈이 발생하면 높이도 변합니다. 이 변화가 아래에 있는 모든 요소를 밀어냅니다. 4개 섹션에서 동시에 이런 일이 벌어지니 CLS가 0.481까지 올라간 겁니다.
기존: 글자가 하나씩 추가
너비가 계속 변함 → CLS 발생
수정: 전체 공간 확보 후 노출
너비 고정 → CLS 0
3해결: Invisible Placeholder 패턴
아이디어는 단순합니다. 전체 텍스트를 처음부터 렌더링하되, 아직 타이핑되지 않은 부분을 보이지 않게 합니다.
visibility: hidden vs display: none vs opacity: 0
| 속성 | 공간 유지 | CLS 방지 | 선택 가능 |
|---|---|---|---|
| visibility: hidden | O | O | X |
| display: none | X | X | X |
| opacity: 0 | O | O | O |
visibility: hidden이 최적입니다. 공간을 유지하면서 텍스트 선택이 안 되므로 사용자가 숨겨진 텍스트를 복사할 수 없습니다.
수정된 코드
// 수정된 TypeWriter 컴포넌트 핵심 로직
function TypeWriter({ segments, speed = 45 }) {
const [count, setCount] = useState(0);
// 각 세그먼트를 순회하며 렌더링
return segments.map((seg, i) => {
const start = /* 이 세그먼트의 시작 인덱스 */;
const visibleCount = Math.max(
0, Math.min(seg.text.length, count - start)
);
return (
<span key={i}>
{/* 타이핑된 부분 — 보임 */}
{seg.text.slice(0, visibleCount)}
{/* 아직 안 타이핑된 부분 — 공간만 차지 */}
{visibleCount < seg.text.length && (
<span style={{ visibility: "hidden" }}>
{seg.text.slice(visibleCount)}
</span>
)}
</span>
);
});
}사용자 눈에는 완전히 동일합니다. 글자가 한 자씩 나타나고, 커서가 깜빡이고, 스크롤 시 트리거되는 것도 똑같습니다. 하지만 브라우저 입장에서는 텍스트의 전체 크기가 처음부터 확정되어 있으므로 레이아웃 변화가 0입니다.
스크린리더 접근성
타이핑 애니메이션 영역에는 aria-hidden="true"를 적용하고, 별도로 sr-only 클래스로 전체 텍스트를 제공합니다. 스크린리더 사용자는 애니메이션 없이 전체 텍스트를 바로 읽을 수 있습니다.
4두 번째 함정 — Suspense fallback
타이핑 애니메이션을 수정한 후 CLS가 0.481에서 0.095로 줄었습니다. 하지만 0.1 미만이 되어야 “Good” 등급입니다. 남은 0.095의 원인을 추적했습니다.
Header Suspense의 잘못된 fallback
레이아웃에 이런 코드가 있었습니다:
// layout.tsx
<Suspense fallback={<div className="h-20" />}>
<Header />
</Suspense>의도는 좋았습니다. Header가 로드되기 전에 80px 높이의 자리를 확보하려 한 것입니다. 그런데 문제가 있었습니다 — Header는 position: fixed입니다. fixed 요소는 document flow에 참여하지 않으므로, Header가 로드되어도 80px 공간은 필요하지 않습니다.
결과적으로 이런 일이 벌어졌습니다:
Suspense fallback이 80px 공간을 차지
Header 컴포넌트 로드 완료 → fixed로 렌더링 (flow에서 빠짐)
fallback의 80px 공간이 사라짐 → 아래 콘텐츠가 80px 위로 이동
해결
// 수정: fixed 요소에는 fallback이 필요 없음
<Suspense fallback={null}>
<Header />
</Suspense>fallback을 null로 바꾸자 CLS가 0.095에서 0이 되었습니다.
5CLS에 안전한 애니메이션 vs 위험한 애니메이션
모든 애니메이션이 CLS를 유발하는 것은 아닙니다. 핵심은 레이아웃을 변경하느냐입니다.
| 애니메이션 | CLS | 이유 |
|---|---|---|
| transform: translateY(60px → 0) | 안전 | transform은 레이아웃에 영향 없음 |
| transform: scale(0.95 → 1) | 안전 | 크기가 변해도 레이아웃은 고정 |
| opacity: 0 → 1 | 안전 | 시각적 변화만, 공간 불변 |
| Framer Motion fadeInUp (y: 60) | 안전 | 내부적으로 transform 사용 |
| fixed/absolute 요소 이동 | 안전 | document flow에 참여하지 않음 |
| 텍스트 콘텐츠 동적 추가 | 위험 | 높이/너비 변경 → 아래 요소 밀림 |
| 이미지 로드 (크기 미지정) | 위험 | 이미지 크기만큼 공간 확장 |
| 폰트 스왑 (크기 차이) | 위험 | 텍스트 리플로우 |
| Suspense fallback 제거 | 위험 | fallback 공간이 사라짐 |
Framer Motion의 fadeInUp은 안전합니다
사이트에는 카드가 아래에서 위로 슬라이드하며 나타나는 애니메이션({y: 60, opacity: 0} → {y: 0, opacity: 1})이 있었습니다. 처음에는 이것도 CLS 원인이라고 생각했지만, y 속성은 내부적으로 transform: translateY()로 처리되므로 레이아웃에 영향을 주지 않습니다. 수정할 필요가 없었습니다.
6결과
Mobile
Desktop
수정한 것은 딱 두 가지입니다: TypeWriter 컴포넌트에 Invisible Placeholder 패턴 적용, Suspense fallback을 null로 변경. 디자인은 1픽셀도 바뀌지 않았습니다. 사용자가 보는 화면은 완전히 동일하지만, 브라우저가 측정하는 레이아웃 안정성은 완벽해졌습니다. 이후 LCP 등 추가 최적화를 계속하려면 LCP 최적화 글을 참고하세요.
이 글의 핵심 정리
- ✓타이핑 애니메이션은 CLS의 주범 — 글자 추가 시 레이아웃이 변함
- ✓Invisible Placeholder: visibility:hidden으로 전체 텍스트 공간 확보 후 순차 노출
- ✓fixed 요소의 Suspense fallback에 높이를 주면 CLS 발생 — null로 변경
- ✓transform, opacity 기반 애니메이션은 CLS에 안전 — 수정 불필요
- ✓CLS 수정만으로 Mobile +25점 — 디자인 변경 없이 가능
본 데이터는 2026년 2월에 측정되었습니다. CLS 측정은 Lighthouse 시뮬레이션 기반이며, 실제 사용자 환경(CrUX 데이터)과 차이가 있을 수 있습니다. Lighthouse CLS 가중치는 버전에 따라 변경될 수 있습니다. 본 콘텐츠의 비상업적 공유는 자유이나, 상업적 이용 시 문의 페이지를 통해 연락 바랍니다.
댓글
(4개)로그인하면 댓글을 작성할 수 있습니다.
transform은 CLS에 안전하고, visibility:hidden은 공간을 유지한다 — 이 두 가지만 기억하면 되겠네요. 정리가 깔끔합니다.
CLS 수정만으로 Mobile이 45에서 70으로 25점이나 올랐다는 게 놀랍습니다. 디자인 변경 없이 이 정도 효과라니, 성능팀에 공유해야겠네요.
Suspense fallback이 CLS를 만든다는 부분이 새로웠습니다. fixed 요소에 fallback 높이를 주면 안 되는 거군요. 바로 확인해봐야겠습니다.
관련 글
© 2026 TreeRU. All rights reserved.
본 콘텐츠의 저작권은 TreeRU에 있으며, 출처를 밝히지 않은 무단 전재 및 재배포를 금합니다. 인용 시 출처(treeru.com)를 반드시 명시해 주세요.