SPA 환경에서 Layout Shift 최적화로 UX 개선하기

Layout Shift

Intro

Single Page Application인 React로 블로그를 개발하고 배포하며 가장 불편했던 점은, 역시 서버 사이드 렌더링의 구현이 어렵다는 점이다. (물론 서버 컴포넌트와 클라이언트 컴포넌트를 구분해서 구현하면 된다는 점은 알고있다)

다만, SSR 없이도 사용자 경험을 개선하기 위해 여러 가지 방법을 적용할 수 있는데, 그 중에서도 특히 중요도가 커지고 있는 지표가 Cumulative Layout Shift다. Next.js의 <Image> 컴포넌트가 제공하는 최적화된 이미지 렌더링 방식을 경험해보며, 직접 SPA 기반의 블로그 프로젝트에도 유사한 기능을 구현해 보고 싶었다.

1. CLS란 무엇인가?

Cumulative Layout Shift (CLS) 는 웹 페이지가 로드되는 동안 예상치 못한 레이아웃 변경이 발생하는 정도를 측정하는 웹 성능 지표 중 하나다. 구글의 웹 바이탈(Web Vitals) 지표 중 하나이며, 페이지가 로딩되는 동안 사용자가 이미 보던 요소가 이동하거나 클릭해야 할 버튼이 의도치 않게 위치가 달라져서 잘못 클릭하게 만드는 등의 상황을 방지하기 위해 고안되었다.

  • 왜 중요한가? 사용자는 페이지를 보는 동안 콘텐츠가 갑자기 움직이면 당황하거나, 원하는 버튼이 아닌 다른 버튼을 누르게 될 수도 있다. 이는 곧 사용자 경험(UX) 저하로 이어진다.
  • 어떤 방식으로 측정되는가? CLS는 영구적인 점수가 아니라, 로드 중 발생하는 레이아웃 이동의 축적치다. 페이지가 완전히 로드될 때까지 축적된 이동량이 점수로 환산되며, 0.1 이하가 좋음(Good), 0.25 이상이면 나쁨(Poor)으로 평가된다.

CLS의 구체적인 예시나 사용자 경험 저하에 어떻게 기여하는가 혹은 Next.js의 <Image> 컴포넌트가 동작하는 방식, 사용자 경험을 개선하는 방법 등에 대해서는 이전에 작성했던 Next.js에서 Image 태그가 동작하는 원리를 참고하면 도움이 될 것이다.

2. 리팩토링 과정

이 프로젝트에서는 크게 두 가지 영역에서 CLS 개선 목표를 세웠다.

  1. 게시글 상세 화면에서 이미지가 렌더링될 때 레이아웃이 시프트되는 문제
  2. 메인 페이지에서 전체 게시글 목록을 가져오는 동안 Footer 컴포넌트의 레이아웃이 시프트되는 문제

2.1 이미지 CLS 최적화

기존 코드

const PostContent = () => {
  return (
    <div>
      <img src="/images/post-1.jpg" alt="포스트 이미지" />
      <p>본문 내용...</p>
    </div>
  );
};

이전에는 <img> 태그만 단순히 사용했다. 이러한 방식은 다음과 같은 문제를 발생시킨다.

  • 이미지 로딩 이전에 이미지 영역이 어느 정도인지 알 수 없어서, 텍스트나 레이아웃이 예기치 않게 움직임
  • 네트워크 속도에 따라 렌더링 딜레이가 생기면, 마찬가지로 레이아웃 변동이 생겨 사용자 경험이 저하될 수 있음

리팩토링 코드

const PostImage = ({ src, alt }) => {
  const [비율, 비율설정] = useState(9 / 16);
  const [로딩완료, 로딩상태설정] = useState(false);

  useEffect(() => {
    const 이미지 = new Image();
    이미지.src = src;
    이미지.onload = () => {
      비율설정(이미지.naturalHeight / 이미지.naturalWidth);
      로딩상태설정(true);
    };
  }, [src]);

  return (
    <div style={{ paddingTop: `${비율 * 100}%`, position: 'relative' }}>
      {!로딩완료 && <div className="스켈레톤" />}
      <img
        src={src}
        alt={alt}
        style={{
          position: 'absolute',
          top: 0,
          width: '100%',
          height: '100%',
          opacity: 로딩완료 ? 1 : 0,
          transition: 'opacity 0.3s ease-in-out',
        }}
      />
    </div>
  );
};

주요 포인트는 다음과 같다.

  • 원본 이미지의 비율을 미리 계산하여 부모 컨테이너에 padding-top 트릭을 설정해, 이미지를 로딩하기 전에도 고정된 영역을 확보
  • 로딩 상태에 따라 스켈레톤 UI를 표시해 사용자에게 로딩 중임을 직관적으로 알림
  • 로딩 완료 시 페이드인 효과(transition)를 넣어 전환이 자연스러우며, 갑작스러운 레이아웃 변경을 최소화

이 과정을 통해 이미지가 불러오는 동안에도 레이아웃이 안정적이므로, CLS를 크게 줄이는 효과를 얻을 수 있다.

2.2 게시글 목록 CLS 최적화

기존 코드

const PostList = () => {
  const [글목록, 글목록설정] = useState([]);
  const [로딩중, 로딩상태설정] = useState(true);

  return (
    <div>
      {로딩중 ? (
        <스켈레톤목록 />
      ) : (
        글목록.map(() => <글카드 key={.id}={} />)
      )}
    </div>
  );
};

문제점은 다음과 같다.

  • 메인 페이지에서 전체 게시글 목록이 API 요청 등을 통해 비동기로 받아오는 사이에, Footer 같은 하단 컴포넌트가 먼저 렌더링되었다가 실제 데이터가 들어오면서 갑작스럽게 위치가 변동될 수 있음.
  • Context API 등을 통해 게시글 수를 미리 알 수 있더라도, 목록 자체가 렌더링되기 전까진 공간이 확보되지 않으므로 시프트 현상이 발생할 수 있음.

리팩토링 코드

const PostList = () => {
  // 전체 게시글이 10개라고 가정
  const 전체게시글수 = 10;
  // 게시글 수에 맞게 미리 스켈레톤 배열 준비
  const 스켈레톤목록 = Array.from({ length: 전체게시글수 });

  const [글목록, 글목록설정] = useState(스켈레톤목록);
  const [로딩중, 로딩상태설정] = useState(true);

  useEffect(() => {
    fetch('/api/posts')
      .then((res) => res.json())
      .then((data) => {
        글목록설정(data);
        로딩상태설정(false);
      })
      .catch((err) => {
        console.error('데이터 로딩 실패:', err);
        로딩상태설정(false);
      });
  }, []);

  return (
    <div>
      {로딩중
        ? 스켈레톤목록.map((_, index) => (
            <div key={index} className="스켈레톤 아이템" />
          ))
        : 글목록.map(() => <글카드 key={.id}={} />)}
    </div>
  );
};

게시글 상세 페이지와는 다르게, 메인 페이지에서는 사용자가 읽어야할 컨텐츠가 많지 않다. 또한 그렇게 길지 않은 시간(약 0.2s) 안에 렌더링이 완료된다.

따라서 별도의 디자인이 적용된 스켈레톤 UI가 사용된다면 짧은 시간 내에 깜빡이며 등장하는 것은, 오히려 사용자를 시각적으로 불편하게 만든다 생각했다.

주요 포인트는 다음과 같다.

  • 스켈레톤 UI를 미리 실제 게시글 개수만큼 생성함으로써, DOM 구조와 레이아웃을 미리 확보
  • 이때, 스켈레톤 요소들은 실제 콘텐츠와 유사한 크기만 가지고, 불필요한 디자인이나 긴 애니메이션은 넣지 않음 (매우 짧은 시간 내에 데이터가 로딩되는 경우에는 과도한 스켈레톤 UI가 시선만 빼앗을 수 있음)
  • 실제 데이터가 로딩되면 스켈레톤을 대체하여 레이아웃은 거의 변하지 않도록 함

이를 통해 게시글 목록을 가져오는 동안 발생하는 Footer 컴포넌트 시프트 문제가 크게 완화된다.

3. SPA 환경에서 CLS 최적화를 위한 전략

SPA에서는 클라이언트 측 렌더링 때문에 초기 로딩과 동적 데이터 로딩 과정에서 CLS가 쉽게 발생할 수 있다. 다음의 전략들을 함께 염두에 둔다면, CLS뿐만 아니라 다른 웹 바이탈(Web Vitals) 지표도 함께 개선할 수 있다.

1) 고정된 크기의 컨테이너 사용

  • 이미지, 광고, 비디오 등은 로딩 전에도 고정된 폭과 높이를 미리 지정한다.
  • 비율이 고정된 경우 padding-top 방식 혹은 aspect-ratio 등을 활용해 레이아웃을 안정적으로 잡는다.

2) 폰트 최적화

  • 웹 폰트 로딩 시 폰트 교체로 인한 FOIT(Flash of Invisible Text)FOUT(Flash of Unstyled Text) 현상이 발생하여 레이아웃이 튀는 것처럼 보일 수 있음.
  • font-display: swap; 혹은 block; 등을 적절히 사용해, 미리보기 폰트에서 로딩된 폰트로 전환 시 시프트가 크지 않도록 한다.

3) 리소스 로딩 우선순위

  • SPA에서는 처음에 필요한 리소스(Above-the-fold 콘텐츠)를 우선 로드하도록 구성한다.
  • preload, prefetch, async, defer 같은 속성을 잘 활용해 중요도 높은 자원은 빠르게, 덜 중요한 자원은 뒤늦게 로드해 초기 CLS를 최소화한다.

4) 코드 스플리팅과 번들 최적화

  • React, Webpack, Vite 등으로 코드 스플리팅을 적용해, 사용자 입장에서는 처음 필요한 화면만 빠르게 로드되도록 한다.
  • CSS도 큰 파일 하나로 묶기보단, 컴포넌트 단위로 필요할 때 불러오는 등 최적화 전략을 생각해볼 수 있다.

5) 애니메이션/트랜지션

갑작스러운 margin, padding, height 변경이 아닌, transform: translate()opacity 기반 트랜지션을 사용하면 레이아웃에 직접 영향을 주지 않아 CLS를 완화할 수 있다.

6) 지속적인 모니터링

Lighthouse, PageSpeed Insights, Web Vitals 등으로 CLS 점수를 추적하고, 변경사항이 있을 때마다 성능이 어떻게 달라지는지 확인한다.

마무리

항상 느끼지만, 기술의 필요성을 진정으로 인식하는 순간은 불편함을 직접 경험하는 순간이다. SPA를 개발하면서도 초기 렌더링 중 불안정한 레이아웃 때문에 사용성이 떨어진다는 느낌을 받았고, 이를 CLS 최적화로 해소해보며 사용자 경험이 한층 좋아졌음을 체감했다.

작은 레이아웃 변경이라도 사용자의 시선과 행동에 영향을 줄 수 있으므로, 개발 과정에서 UI가 어떻게 로딩되는지 꾸준히 살펴보는 게 중요하다. CLS 최적화는 단순히 성능 지표가 아니라, 사용자에게 안정감을 주는 필수 요소다. 앞으로도 이러한 성능 지표를 지속적으로 모니터링하고 최적화해, 더 좋은 경험을 제공하려고 한다.

Reference