treeru.com
개발

CLS 0.5를 0으로 — TypeWriter 애니메이션의 함정과 해결

2026-01-06
Treeru

이미지를 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.1Good감점 없음
0.1 ~ 0.25Needs 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까지 올라간 겁니다.

기존: 글자가 하나씩 추가

D|
Digi|
Digital Gro|

너비가 계속 변함 → CLS 발생

수정: 전체 공간 확보 후 노출

D
Digi
Digital Gro

너비 고정 → CLS 0

3해결: Invisible Placeholder 패턴

아이디어는 단순합니다. 전체 텍스트를 처음부터 렌더링하되, 아직 타이핑되지 않은 부분을 보이지 않게 합니다.

visibility: hidden vs display: none vs opacity: 0

속성공간 유지CLS 방지선택 가능
visibility: hiddenOOX
display: noneXXX
opacity: 0OOO

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 공간은 필요하지 않습니다.

결과적으로 이런 일이 벌어졌습니다:

1

Suspense fallback이 80px 공간을 차지

2

Header 컴포넌트 로드 완료 → fixed로 렌더링 (flow에서 빠짐)

3

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

Performance
4570
CLS
0.4810

Desktop

Performance
8088
CLS
0.2520

수정한 것은 딱 두 가지입니다: 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 가중치는 버전에 따라 변경될 수 있습니다. 본 콘텐츠의 비상업적 공유는 자유이나, 상업적 이용 시 문의 페이지를 통해 연락 바랍니다.

웹사이트 성능 최적화가 필요하신가요?

Treeru가 CLS, LCP 등 Core Web Vitals를 분석하고 최적화해 드립니다.

무료 상담 신청하기
T

Treeru

웹 개발, IT 인프라, AI 솔루션 분야의 실무 인사이트를 공유합니다. 기업의 디지털 전환을 돕는 IT 파트너, Treeru입니다.

공유

댓글

(4개)
4.38/ 5

로그인하면 댓글을 작성할 수 있습니다.

2026-01-18
454.0

transform은 CLS에 안전하고, visibility:hidden은 공간을 유지한다 — 이 두 가지만 기억하면 되겠네요. 정리가 깔끔합니다.

2026-01-15
4.554.5

CLS 수정만으로 Mobile이 45에서 70으로 25점이나 올랐다는 게 놀랍습니다. 디자인 변경 없이 이 정도 효과라니, 성능팀에 공유해야겠네요.

2026-01-12
555.0

Suspense fallback이 CLS를 만든다는 부분이 새로웠습니다. fixed 요소에 fallback 높이를 주면 안 되는 거군요. 바로 확인해봐야겠습니다.

관련 글

© 2026 TreeRU. All rights reserved.

본 콘텐츠의 저작권은 TreeRU에 있으며, 출처를 밝히지 않은 무단 전재 및 재배포를 금합니다. 인용 시 출처(treeru.com)를 반드시 명시해 주세요.