왜 이미지만 700MB를 다운로드하는 거죠?: 배민우리동네 콘텐츠 피드 이미지 최적화 사례

Nov.26.2024 송요창

Web Frontend

이 메시지를 받았을 때 대수롭지 않게 생각했습니다.

이미지 로딩이 오래 걸린다는 제보 이미지

에이 얼마나 느리겠어. 문제가 있어도 와이파이 문제겠지!

그리고 분석도구를 이용해서 확인해보니 이미지만 도합 700MB를 다운로드하고 있었습니다. 😱
단단히 잘못된 게 있겠네요!

배민우리동네 콘텐츠 피드 만들기

배민우리동네팀에서는 2024년 10월부터 서울 송파구에서 단골의 추천을 수집하고 있습니다.

지금은 서울 광진구에서도 수집중이며, 향후 더 많은 지역에서 단골의 추천을 수집할 예정에 있습니다.

저희팀은 이렇게 수집된 데이터를 5가지 주제로 선별하여 탐색할 수 있도록 만들었습니다. 단순히 콘텐츠만 추가했는데 왜 이미지를 많이 다운로드하는 걸까요?

배민우리동네 단골의추천 콘텐츠 예시

그 많은 이미지는 어디서 오나?

안 보이는데 이미지를 로딩하나?

의문을 가지고 가장 먼저 이미지가 화면에서 보이지 않지만, HTML에는 들어가 있어서 로딩을 시작하지 않나 체크했습니다.

아이폰 프레임. 그 안에 화면에 노출되는 부분가 그렇지 않은 이미지가 늘어서 있는 예시

예상대로 화면에 나타나지 않아도 모두 로딩하고 있었습니다.

IntersectionObserver API를 이용해서 화면에 노출될 때만 이미지를 로딩하도록 수정했습니다.

IntersectionObserver API는 대상을 주기적으로 관찰하여 노출 여부를 확인하도록 도와줍니다.

// 예시 코드로 실제 적용된 코드와는 차이가 있습니다.
import React, { useEffect, useRef, useState } from 'react';

interface ObserverImageProps {
  src: string;
  alt: string;
  className?: string;
  width?: number | string;
  height?: number | string;
  /** 이미지가 로드되기 전에 표시할 플레이스홀더 이미지 */
  placeholderSrc?: string;
  /** 이미지가 뷰포트에 들어왔을 때 실행될 콜백 */
  onIntersect?: () => void;
}

const ObserverImage = ({
  src,
  alt,
  className = '',
  width,
  height,
  placeholderSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', // 1x1 투명 GIF
  onIntersect
}: ObserverImageProps) => {
  const [imageSrc, setImageSrc] = useState<string>(placeholderSrc);
  const [isLoaded, setIsLoaded] = useState<boolean>(false);
  const imgRef = useRef<HTMLImageElement>(null);

  useEffect(() => {
    // IntersectionObserver 인스턴스 생성
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // 이미지가 뷰포트에 들어왔을 때
          if (entry.isIntersecting) {
            setImageSrc(src);
            onIntersect?.();
            // 한 번 로드된 후에는 더 이상 관찰할 필요가 없으므로 해제
            observer.unobserve(entry.target);
          }
        });
      },
      {
        // 옵저버 옵션
        rootMargin: '50px', // 뷰포트 기준 50px 여유를 두고 미리 로드
        threshold: 0.1 // 10%만 보여도 로드 시작
      }
    );

    // 현재 이미지 요소 관찰 시작
    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    // 컴포넌트 언마운트 시 옵저버 해제
    return () => {
      observer.disconnect();
    };
  }, [src, onIntersect]);

  return (
    <img
      ref={imgRef}
      src={imageSrc}
      alt={alt}
      className={`${className} ${!isLoaded ? 'opacity-0' : 'opacity-100'}`}
      width={width}
      height={height}
      onLoad={() => setIsLoaded(true)}
      style={{
        transition: 'opacity 0.3s ease-in-out',
        ...(!isLoaded ? { filter: 'blur(10px)' } : {})
      }}
    />
  );
};

export default ObserverImage;

그런데 왜 줄지 않지?

엄청나게 다운로드 용량이 감소할 줄 알았는데 그렇지 않았습니다. 천천히 생각을 정리해봐야겠군요.

5개 주제가 각각 3개씩 콘텐츠를 노출하고, 여기에 이미지는 최대 10장씩 포함 가능합니다. 최대로 노출 가능한 이미지는 5 x 3 x 10 = 150개입니다. 이미지가 한 장에 1MB라고 해도 700MB는 납득할 수 없는 수치입니다.

각 주제가 최대 30개의 콘텐츠를 포함하고, 모두 이미지를 다운로드 받는다면? 5 x 30 x 10 = 1500개. 5장씩 이미지를 가진다고 하고 1MB라고하면 대략 750MB 정도 될겁니다. 모든 이미지를 다운로드하고 있는게 아닐까요? 🖼️💣💥

내용과 사진을 더 자세하게 탐색하고 싶은 사용자를 위해 상세화면을 제공합니다. 웹페이지를 추가하여 이를 탐색하게 하려고 했으나, 이 과정에서 데이터를 한 번 더 불러오고, 화면 전환도 매끄럽지 않아 포기했습니다. 대신 보이지 않는 부분에 상세 화면을 그려둔 뒤 애니메이션을 이용해 이동시키는 방식으로 구현했습니다.

콘텐츠 클릭 시 상세 화면에 하단에서 위로 올라오는 단계별 예시 이미지

눈치채셨나요? 보이지 않는 부분이 문제였습니다. 화면 하단에 위치시켜서 보이지만 않을 뿐 모든 이미지를 HTML에 추가했기때문에 모든 이미지를 로딩하고 있었던 것이죠. 😱

단골의 추천 상세화면은 왼쪽이나 오른쪽으로 쓸어넘기는(스와이프) 동작으로 콘텐츠를 탐색할 수 있습니다. 그렇다보니 실제로는 1개의 화면이 아니라 최대 30개의 화면이 좌우로 붙어있는 구조입니다. 700MB나 이미지를 다운로드하게 된 이유는 숨겨진 이미지를 로딩했기 때문입니다.

단골의 추천 상세 화면이 프레임 외부로 여러개 붙어있는 구조

수정 결과: 약 90% 감소

원인을 발견했으니 수정해야겠죠. 간단한 방법부터 사용합니다. 단골의 추천 상세화면이 사용자에게 노출되는 때 즉, 단골의 추천 콘텐츠를 클릭 했을 때만 이미지를 로딩하도록 했습니다.

  { /** 단골의 추천 상세가 노출(Open)일 때만 이미지 목록이 나타나는 예시 */ }
  {open === true && <Images />}

그리고 이미지 목록에도 앞서 언급한 IntersectionObserver API를 활용해서 화면에 노출될 때 이미지를 로딩하도록 변경했습니다. 그 결과 약 90% 용량이 줄어들었습니다.

이미지 감소 90%

여기서 끝이 아니다: 안보이는 곳에 숨어있는 이미지 줄이기

11월 18일에 더 많은 콘텐츠를 노출시키자 다시 이미지 다운로드 용량이 60MB에 육박했습니다. 기존과 달리 더 많은 콘텐츠가 나오자 자연히 이미지가 더 많아진 게 원인입니다. 어떻게하면 보이지 않는 이미지를 줄일까 고민하다 앞선 접근과 같이 보이지 않는 부분에서 다운로드하는 이미지를 줄이도록 했습니다.

  • 처음 보이는 추천이라도, 4번째 이미지부터는 화면에 노출될 때 로딩하도록 한다.
  • 단골의 추천 상세화면에서 나오는 가게 이미지는 단골의 추천 상세가 노출될 때 로딩하도록 한다.
  • 프로필 이미지도 화면에 노출될 때 로딩하도록 한다.

이 3가지 조치를 더하자 이미지 다운로드 용량이 약 5MB 이내로 감소했습니다. 🎉

이미지 감소 최종 5MB

더 해볼만한 부분은 없나?

현재 사용되는 이미지는 사용자가 리뷰나, 단골의 추천 작성 시에 제출한 이미지를 적정한 수준으로 리사이즈해서 사용중입니다. 이 이미지가 대량으로 표시되는 배민우리동네 콘텐츠 피드에서는 현재 이미지보다 더 작은 이미지를 사용해도 무방합니다.

이미지 포맷도 WebP로 변경하면, 25~35%가량 용량 감소를 기대할 수 있습니다.

  • 더 작은 이미지 썸네일 적용
  • WebP 적용

사용자 경험을 위한 작은 관심

이번 성능 개선을 통해 몇 가지 중요한 교훈을 얻을 수 있었습니다:

  1. 사용자 경험에서 데이터 사용량은 매우 중요한 요소입니다. 특히 모바일 환경에서는 더욱 그렇죠.
  2. 눈에 보이지 않는 곳에서 발생하는 성능 이슈를 발견하기 위해서는 개발 도구를 활용한 지속적인 모니터링이 필요합니다.
  3. 때로는 간단한 해결책(Lazy Loading)으로도 큰 효과를 얻을 수 있습니다.

앞으로도 WebP를 적용하고 이미지 크기를 최적화하면 추가로 30~40% 이미지 용량을 감소할 수 있을 것으로 예상합니다. 이러한 작은 개선들이 모여 더 나은 사용자 경험을 만들어낼 수 있을 것입니다.

무엇보다 중요한 것은, 성능 개선은 한 번의 시도로 끝나는 것이 아니라 지속적인 관심과 모니터링이 필요한 과정이라는 점입니다. 앞으로도 사용자들이 더 쾌적하게 서비스를 이용할 수 있도록 노력하겠습니다.