FE Lazy Loading 적용기

안녕하세요! 트렌비 Growth Marketing 개발팀의 FE개발자 이리입니다!
자세한 제 소개는 인터뷰 내용을 참고해주세요!

목차

  1. 개요
  2. 문제가 되는 부분은 어딜까? - 원인 분석과 해결책 찾기
  3. Lazy Loading…?
  4. Lazy Loading을 적용하는 방법!
  5. 개선 후 결과

개요

트렌비의 세일스캐너 부분은 만족스러운 사용자 경험을 위해 다양한 변화와 실험을 하고 있는 페이지입니다.
참고로, 현재는 좀 더 매력적인 페이지를 구성하기 위해 준비 중입니다.

어떤 제품을 구매하면 좋을까?
지금 할인 중인 제품이 뭐가 있을까?
지금 T(Time).P(Place).O(Occasion)에 적합한 제품이 어떤 게 있을까?

이번 변화는 위와 같은 다양한 사용자의 니즈를 해소하기 위해,
트렌비의 MD가 직접 엄선한 카테고리와 상품들을 모두 진열했습니다.

그러다 보니 정말 많은 상품이 진열됐고, 그로 인해 증가한 상품 이미지들은 예상치 못한 이슈를 남겨주었습니다.

로딩이 한——참 걸리는 페이지... (중간의 흰 페이지는 아직 컴포넌트조차도 못 불러온 상황입니다)

로딩이 한——참 걸리는 페이지… (중간의 흰 페이지는 아직 컴포넌트조차도 못 불러온 상황입니다)

문제가 되는 부분은 어딜까? - 원인 분석과 해결책 찾기

데스크톱에서는 어떻게든 기다려서 로딩을 할 순 있었지만,
모바일에서는 한참 걸리는 로딩어마어마한 리소스 요청 덕분에 아예 앱이 뻗어버리는 일이 생겼습니다.

위와 같은 문제에 직면해서 원인을 분석하고 해결책을 찾기 시작했습니다.

원인 분석을 위해 시도했던 방법

1. 동시에 로딩하는 콘텐츠의 개수가 문제가 될까?
API 응답에서 내려오는 콘텐츠의 카테고리 개수를 제한해 보기
→ 카테고리별로 상품을 6~12개까지 표시하는데, 안정적으로 로딩할 수 있는 카테고리는 30개가 전부였습니다.

한 번에 너무 많은 콘텐츠를 로딩하면 문제가 발생했습니다.

2. 모바일에서만 안 되는 걸 보면, 모바일 네트워크의 속도가 느려서 그런 건 아닐까?
데스크톱에서 인터넷의 속도를 제한시켜 해당 부분 로딩을 테스트하기
→ 데스크톱에서는 정말 오래 걸렸지만, 어쨌든 로딩이 되기는 했습니다.

네트워크의 속도는 근본적인 원인이 아니었습니다.

결과적으로, 한 번에 많은 콘텐츠를 로딩하지 않도록 개선하는 작업이 필요합니다.

고려했던 해결책

  1. 모든 리소스를 가져와서, 20개 정도의 리스트만 보여준 뒤 더 보기를 구현하는 방법
    많은 양의 이미지를 최초에 로딩하는 것 자체가 문제였기 때문에, 증상은 나아질 바 없었습니다.
  2. API 자체에 Pagination(“이전/다음 페이지”를 끊어 보여주는 기능)을 걸어서 특정 스크롤 위치에 도달하는 경우 지속적으로 API 요청
    프론트단에서 해결해야 할 문제라고 생각했기에, 백엔드 공수가 필요한 작업은 최후의 단계로 생각했습니다.
  3. 이미지를 최초엔 처음 필요한 만큼만 로딩하고, 나머지는 필요한 타이밍에 로딩
    가장 자연스럽고 전체적인 공수를 적게 사용하여 해결되었고, 부가적으로 리소스 이슈도 줄일 수 있었습니다!

우리는 해결책 3번의 방법을 사용하기 위해 Lazy Loading을 적용했습니다.

Lazy Loading…?

웹 페이지에 접근하면 그 안에 있는 내용이 모두 다운로드 됩니다.
하지만, 사용자가 접근한 첫 화면 이후에 다운로드 된 모든 리소스를 살펴본다는 보장은 없습니다.
이럴 경우, 사용자는 실제로는 필요하지 않은 리소스까지 다운로드하느라 시간과 메모리 낭비가 발생하게 됩니다.
Lazy Loading을 적용한다면 위와 같은 상황을 방지할 수 있습니다.

페이지에 접근할 때 필요한 섹션만 로드하고, 나머지는 사용자가 필요할 때까지 로딩을 지연시키는 개념입니다.

Lazy Loading을 적용했을 때, 고려해야 할 부분은 다음과 같습니다.

  1. 이미지를 로딩하는 부분에만 Lazy Loading을 적용하면 될까?
  2. Lazy Loading이 적용된 부분이 로딩 될 때, 이미지만 빈 공간으로 나오는 그 잠깐의 타이밍을 해소할 수 없을까?

만약 이미지 컴포넌트에 직접 적용을 시켰다면, 잠깐 동안은 다음과 같이 보일 것입니다.

만약 이미지 컴포넌트에 직접 적용을 시켰다면, 잠깐 동안은 위와 같이 보이게 됩니다.

이런 문제를 해결하기 위해 생각한 방법은, 이미지를 로딩 해주는 컴포넌트 자체를 Lazy Loading 시키는 것입니다.
이제 직접 적용해 보겠습니다!

Lazy Loading을 적용하는 방법!

먼저 이번에 제가 적용할 Lazy Loading 기술은 Intersection Observer입니다.

Intersection Observer란? → 대상 요소와 상위 요소 또는 최상위 document의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 방법

쉽게 얘기해서, 화면에 내가 지정한 대상이 보이는지를 관찰하는 API입니다.

위 기술을 활용하여 Lazy Loading을 적용하는 원리는 다음과 같습니다.

각 리스트를 뿌려주는 컴포넌트에 Intersection Observer를 적용시켜 현재 보이고 있는 부분은 해당하는 리스트 컴포넌트를 로딩하고, 그렇지 않은 경우는 공용으로 사용되는 Loading 컴포넌트를 대신 보여줍니다.

예제 코드는 다음과 같습니다.

자바스크립트의 IntersectionObserver를 직접 사용하여 작성한 예제

const TaggedProductsList = ({ tag }) => {
  const [showList, setShowList] = useState(false)
  const observerRef = useRef(null)
  let observer = null
	
  useEffect(() => {
    if (observerRef.current && !showList) {
      observer = new IntersectionObserver(([entries]) => {
        if (entries.isIntersecting) { // 현재 observerRef로 지정한 대상이 보여지고 있는지 확인
          setShowList(true)
        }
      })

      observer.observe(observerRef.current)
    }

    return () => {
      if (observer) {
        observer.disconnect(observerRef)
      }
    }
  }, [showList, observerRef])
	
  return (
    <div ref={observerRef}>
      {showList ? ( // showList의 값에 따라 로딩스크린을 표시하거나 상품을 보여준다.
        <TrenbeListContainer>
          {tag.products.map((tagProduct) => <TrenbeProductCard data={tagProduct} />))}
        </TrenbeListContainer>
      ) : (
        <LoadingScreen />
      )}
    </div>
  )
}

위와 같은 자바스크립트 코드 대신, React 환경에서는 아래와 같은 React Hook을 사용할 수 있습니다.

‘react-intersection-observer’ NPM 모듈을 사용하여 작성한 예제

import { useInView } from 'react-intersection-observer'

const TaggedProductsList = ({ tag }) => {
  const [showList, setShowList] = useState(false)
  const { ref, inView } = useInView({ // inView를 통해 보여지는지 구분한다.
    // 각종 option을 추가해서 IntersectionObserver처럼 사용할 수 있다.
    threshold: 0,
    triggerOnce: true,
  });
	
  useEffect(() => {
    if (inView && !showList) {
      setShowList(true)
    }
  }, [inView, showList]);
	
  return (
    <div ref={ref}>
      {showList ? ( // showList의 값에 따라 로딩스크린을 표시하거나 상품을 보여준다.
        <TrenbeListContainer>
          {tag.products.map((tagProduct) => <TrenbeProductCard data={tagProduct} />))}
        </TrenbeListContainer>
      ) : (
        <LoadingScreen />
      )}
    </div>
  )
}

상황과 기호에 맞춰서 원하는 방식으로 작성하여 사용하면 됩니다.

개선 후 결과

로딩 스크린이 나오지만, 필요한 부분만 로딩되기 때문에 빠른 속도로 로딩되는 걸 확인 가능합니다.

로딩 스크린이 나오지만, 필요한 부분만 로딩되기 때문에 빠른 속도로 로딩되는 걸 볼 수 있습니다.

최초에 로딩되는 순간에 호출되는 리소스: 120mb (이미지 전체) → 평균 9.6mb (필요한 이미지)
첫 페이지 로딩 완료 속도: 약 30초 → 평균 약 5.5

시간으로 비교했을 때, 500% 정도의 성능 개선이 있었습니다.

물론, 극단적으로 이미지가 많이 호출되는 페이지라서 더 눈에 띄는 결과를 보여준 것도 있지만,
리소스를 아끼고 로딩 속도를 합리적으로 개선할 수 있는 방법임에는 분명합니다.
필요한 경우 사용자 경험을 위해 적재적소에 활용하면 좋을 것 같습니다.