FE 성능개선기 1부: 주문하기

서문

안녕하세요. 비즈FE 파트의 도비입니다. 저는 현재, 카카오의 배달서비스 주문하기와 입점을 위한 사장님 센터의 Frontend를 담당하고 있습니다.

올해 2023년, 비즈FE 파트가 속한 FE플랫폼 팀에서는 성능 개선을 팀 내 모든 서비스의 공동 목표로 선정하여 진행 중에 있습니다.

이번 연재에서는 이 과정에서 개선했던 서비스인 주문하기와 비즈니스폼의 성능 개선 내용을 전체 2회에 걸쳐 차차 소개하려고 합니다.

성능 측정 도구 - Pharus

성능 개선 진행 후, 실제로 성능이 개선되었는지에 대한 여부는 어떻게 확인할 수 있을까요? 

웹 페이지의 성능 점수를 확인할 수 있는 도구에는 대표적으로 Lighthouse(이하 라이트하우스)가 있습니다. 라이트하우스는 구글에서 개발한 Synthetic monitoring 도구로, 사용자가 지정한 일정한 환경 아래에서 웹 페이지의 성능을 측정할 수 있죠.

카카오에서도 이 라이트하우스를 기반으로 한 파루스(Pharus)를 사용하고 있는데요, 파루스는 외부로 공개되어 있지 않아 직접 사용해 보실 수는 없지만 간략하게 라이트하우스와의 차이를 설명해 보겠습니다.

파루스는 프로젝트 단위로 성능 측정 대상을 등록하고, 프로젝트 안에서 페이지 별로 다시 세부 항목을 구분하여 성능을 측정할 수 있습니다. 그 결과, 파루스를 사용하면 손쉽게 모든 프로젝트 내의 하위 페이지들을 GUI를 기반으로 매우 간편하게 모니터링할 수 있습니다. 또한, 6시간 간격으로 성능을 자동으로 측정하게 설정되어 있어, 매번 수동으로 측정해 주지 않아도 되는 편리성도 지녔습니다.

결과적으로 이렇게 6시간 간격으로 측정 리포트가 발행되고, 그 결과가 거의 영구적으로 서버에 저장되기에, 언제든 특정 작업이 웹 페이지의 성능에 영향을 미쳤는지 파악할 수 있습니다.

이외에도 편리한 기능에는 웹 페이지 로그인 설정, 유저 에이전트 설정 등이 있고, 자칫 복잡할 수 있는 설정도 아주 손쉽게 적용할 수 있습니다. 이렇게 FE플랫폼 팀에서는 파루스를 사용해, 4가지 요소(성능, 접근성, SEO, 권장 사항)들의 점수 중에서 성능 점수 90점을 기준으로 하여 각 프로젝트의 목표 달성 유무를 파악하고 있습니다.

따라서, 제가 맡고 있는 주문하기 기능도 파루스에서 측정한 성능 점수를 90점 이상 끌어올리는 것을 목표로 개선 작업을 진행했습니다.

주문하기 성능 개선 사례

주문하기는 카카오의 배달 플랫폼으로, 기능을 구현하는 데 사용한 주 기술 스택으로 React와 Next.js가 있습니다.

주문하기는 카카오톡 > 더 보기 내에서 접근하실 수 있으며, 주문하기는 크게 주문하기 홈, 매장 홈, 장바구니로 나눌 수 있습니다. 

아래 이미지에서처럼, 주문하기 홈은 주문하기 서비스의 진입 페이지로 사용자의 위치를 기반으로 존재하는 모든 매장을 리스트 형식으로 볼 수 있는 페이지로 이루어져 있습니다. 또한, 매장 홈은 유저가 주문하기 홈을 통해 매장을 클릭했을 때, 매장의 정보와 메뉴를 볼 수 있는 페이지입니다.

이번 성능 개선에서는 주문하기 홈과, 매장 홈 개선에 집중했는데요, 이 페이지들은 사용자들이 가장 많이 찾는 주요 페이지이면서 개선할 수 있는 부분이 가장 많다는 점이 특징입니다.

주문하기는 배달 플랫폼으로 메뉴와 매장별로 메뉴 사진과 매장 사진을 갖고 있는데요. 매장이 가장 많은 지역의 경우에는 300개가 넘는 매장을 보유하고 있고, 기존에는 첫 페이지 로딩 시점에 모든 매장을 한 번에 로딩하여 보여주고 있었습니다. 따라서 DOM의 크기가 매우 컸으며, 서버에서 응답으로 받는 매장 정보의 JSON 크기도 1MB에 육박했습니다. 결과적으로 첫 페이지가 처음 로딩될 때, 매우 긴 Total blocking time이 소요되었습니다.

이를 아래 사진에서 확인할 수 있듯, 파루스를 통해 측정한 성능 점수가 최악의 경우 20점이라는 경악할 만한 점수를 보여주었습니다. 다시 봐도 안타까운 점수였지만, 점수가 낮다는 것은 그만큼 개선할 것이 많다는 것이었기 때문에, 다시금 성능 개선의 의지를 다지는 계기가 되었습니다!

 

지금부터 본격적으로 어떻게 20점이라는 낮은 점수를 극복하고, 팀 내부 성능의 기준인 90점에 도달할 수 있었는지 성능 개선에 가장 큰 영향을 미친 두 가지 개선점을 통해 소개해 드리겠습니다.

아쉬운 주문하기 홈의 성능 지표

주문하기 성능 개선 첫 번째 - 이미지 지연 로드

이미지 지연 로드는 이미 많은 분들이 적용해 본 기능일 것이라 예상됩니다. 이 기능은 주문하기 페이지에서도 이미 적용되어 있었습니다. 반면, 실질적인 문제는 해당 기능이 특정 웹 브라우저나 환경에 따라 동작하지 않는 경우가 있다는 점이었습니다.

기존에 사용하던 방법은 Chrome 76 이상부터 사용 가능한 이미지 태그의 “loading=lazy” 속성-값 쌍(Attribute-value pair)을 넣어 적용하고 있었습니다. 하지만 사파리 브라우저 및 낮은 버전의 안드로이드 웹뷰에서는 잘 동작하지 않았고, 이는 브라우저 자체 스펙이다 보니 원하는 대로 커스텀하기도 어려웠습니다.

따라서, 지연 로딩을 직접 구현하기로 결정했습니다. 이때 요구되었던 지연 로딩의 스펙은 아래와 같습니다.

  1. 이미지 태그가 사용자의 화면 근처에 들어왔을 때 로드되어야 합니다.
  2. 로드되는 시점을 개발자가 컨트롤할 수 있어야 합니다.
  3. 적용하기 쉽도록 재사용 가능한 컴포넌트로 만들어야 합니다.

먼저 첫 번째 요구 사항을 달성하기 위해 IntersectionObserver를 이용하여 개발에 착수했습니다. IntersectionObserver는 원하는 페이지의 요소가 사용자와 교차하는 영역이 달라지는 경우 이를 포착할 수 있게 해주는 API입니다. 주문하기에서는 IntersectionObserver가 지원되지 않는 하위 안드로이드 버전(Chrome 50 이하)에서도 동작해야 했기에, 폴리필도 함께 추가했습니다. 또한, root-margin 옵션을 적용해, 원하는 타겟이 관측되는 시점을 조절할 수 있었고, 두 번째 요구 사항도 쉽게 달성할 수 있을 것이라 생각했습니다.

세부적인 구현을 위해, 먼저  IntersectionObserver가 필요한 경우 언제든 쉽게 사용할 수 있도록 ObserverManager라는 Class 생성했습니다. ObserverManager 클래스에서는 IntersectionObserver를 내부적으로 호출하여 인스턴스를 생성하고 해당 인스턴스를 통해 타겟을 관측할 수 있도록 했습니다.

				
					class ObserverManager {
  callbacks = new Map();


  handleIntersection = entries => {
   for (const entry of entries) {
     const { target, isIntersecting } = entry;
     const callback = this.callbacks.get(target);
     if (typeof callback === "function") {
       callback(isIntersecting, target);
     }
   }
 };


 constructor({ observerOptions }) {
   this.observer = new IntersectionObserver(
     this.handleIntersection,
     observerOptions
   }
 }
 observe(target, callback) {
   this.observer.observe(target);
   this.callbacks.set(target, callback);
 }


 unobserve(target) {...}


 disconnect() {...}
}

				
			

또한, ObserverManager의 인스턴스를 만들어 이것을 트리 상위에서 React Context로 제공했습니다. 이를 이용해, 컴포넌트 트리 내에서 단일한 IntersectionObserver 인스턴스를 통해 다양한 곳에서 쉽게 옵저버에 접근할 수 있었습니다.

				
					export function ObserverProvider(props) {
 const { children } = props;
 const [observer, setObserver] = useState(null);


 // 초기화 및 클린업
 useEffect(() => {
   const ob = new ObserverManager({
     observerOptions: {
       …
     },
   });
   setObserver(ob);
   return () => {
     ob.disconnect(); // 모든 콜백함수 초기화 및 옵저빙 타겟 삭제
   };
 }, []);
 return (
   <ObserverContext.Provider value={observer}>
     {children}
   </ObserverContext.Provider>
 );
}


const ObserverContext = createContext(new ObserverManager({ enabled: false }));


export default function useIntersectionObserver() {
 return useContext(ObserverContext);
}

				
			

이후, LazyImage라는 지연 로딩 전용 이미지 컴포넌트를 생성했습니다. 이 컴포넌트에서는 아래와 같은 동작을 수행합니다.

  • useIntersectionObserver를 이용하여 옵저버를 가져옵니다.
  • 해당 이미지 노드를 타겟으로 이미지 노드가 노출될 때, src 값을 이미지에 넘겨줍니다.

 

이렇게 LazyImage 컴포넌트를 생성하고 필요한 매장홈과 주문하기 홈에 img 태그를 LazyImage 컴포넌트로 교체만 해주는 것으로 쉽게 지연로드를 적용했습니다. 이후 이미지 지연로드를 적용한 매장 홈의 리포트를 살펴보았더니 성능 점수는 무려 20점에서 43점으로 두 배나 증가했습니다! 또한, 페이지가 사용자 입력(클릭, 키보드 등) 입력에 응답하지 않는 시간을 의미하는 TBT(Total blocking time)가 약 6050ms에서 3080ms까지 절반 정도가 줄어들었습니다. 하지만 아직까지도 목표까지 거의 50점에 가까운 “벽”이 존재했고, 계속해서 더 개선해 나아가야 했습니다.

이미지 지연 로드 적용 전
이미지 지연 로드 적용 후

주문하기 성능 개선 두 번째 - Windowing 적용

수많은 이미지를 늦게 로드시켰음에도 첫 페이지 로드는 여전히 느렸습니다. 파루스를 통해 확인한 가장 큰 문제점은 너무나도 큰 DOM의 크기였습니다. 메뉴가 가장 많은 매장의 경우 무려 6358개나 되는 DOM 요소를 지니고 있었습니다. 파루스에서도 아래 이미지처럼, 이 부분을 수정해야 한다고 경고를 띄워주었습니다.

처음에는 메뉴 API에 페이지네이션을 요청해, 이용자가 스크롤의 끝에 왔을 때 요소를 다시 그리는 무한 스크롤 방식을 도입하여 DOM 요소의 개수를 최대한 줄여보려 했습니다. 하지만 매장 홈의 카테고리 클릭 동작으로 인해 페이지네이션의 적용은 힘들어 보였습니다. 아래 GIF처럼, 카테고리 클릭 시 해당되는 카테고리 위치로 바로 이동해야 했는데요, 카테고리 클릭 시마다 API를 통해 데이터를 내려받으며 생기는 지연 시간으로, 전체 동작이 이상해질 우려가 있었습니다.

“페이지네이션을 적용할 수 없는 상황에서 어떻게 클라이언트단에서 렌더링 하는 DOM의 크기를 줄일 수 있을까?”를 고민하던 중에 찾은 해결책은 Windowing이라는 렌더링 기법이었습니다. React의 공식 문서에서도 이를 렌더링 최적화 기법 중 하나로 소개하고 있습니다. 간단하게 이를 설명하자면, 아래와 같이 유저에게 실제로 보이는 부분만 렌더링을 진행하고 나머지 부분은 가상화하여 DOM의 크기를 획기적으로 줄일 수 있는 렌더링 기법입니다.

페이지네이션을 적용할 수 없는 상황에서 Windowing 기법은 아주 적절한 방법이 될 것이라는 느낌을 받았습니다. 이 방법을 적용하기 위해, 가장 먼저 React 공식 문서에서 소개하는 Windowing 라이브러리 react-virtualized와 react-window를 살펴보았습니다.

하지만 두 라이브러리 모두 리스트 영역을 한정하여, 해당 영역만 스크롤된다는 가정 하에 구현되었기 때문에, 전체 화면이 스크롤되는 주문하기 화면에는 적합하지 않았습니다. 따라서, 주문하기에서는 직접 컴포넌트를 만들어 Windowing을 적용해 보기로 결정했습니다.

먼저 매장 홈에 Windowing을 적용하기 위해 진행했던 내용을 세 가지 단계로 나눠보면 아래와 같습니다.

  1. 재사용이 가능하게 하기 위한 컴포넌트 제작
  2. 사용자 화면에 보이는 요소 감지
  3. 화면에 들어오지 않는 요소를 빈 공간으로 채우기

 

1. 재사용이 가능하게 하기 위한 컴포넌트 제작

Windowing을 적용하고자 하는 리스트를 인자로 받는 ”WindowedList” 컴포넌트를 생성했습니다. “WindowedList”의 실제 props는 아래와 같습니다.

				
					type WindowedListProps = {
  items: JSX.Element[];      // 실제로 그릴 리스트 요소.
  chunkSize?: number;     // 각 리스트 요소를 몇 개 단위로 묶을 것인지.
  itemHeight:: number;    // 리스트 요소 하나의 높이.
  …
};

				
			

“WindowedList” 내부에서는 리스트로 구성된 아이템들을 청크 단위로 분리합니다. 해당 청크 별로 사용자 화면상의 가시성을 파악하여, 각 청크에 속한 리스트들의 렌더링을 여부를 결정합니다. 만약 “chunkSize”를 “2”로 설정했다면, 각 리스트 요소를 2개씩 묶어 하나의 청크에 넣어줍니다. 따라서, 아래와 같이 청크 리스트가 생성됩니다.

위에서 정의된 청크들의 가시성이 변경되면  “WindowedList”에서는 실제 렌더링할 청크와 하지 않을 청크를 분리하여 아래와 같이 반환함으로써, 원하는 DOM 요소만 렌더링 할 수 있도록 구현하였습니다.

				
					export default function WindowedList(props: Props) {
  ... 
  return (
    <div style={{ position: "relative" }}>
      <ul className={className}>
        // 화면 상에 보이지 않는 요소
        {skippedChunksBefore}
 
        // 화면 상에 보이는 요소
        {chunksToRender}


        // 화면 상에 보이지 않는 요소
        {skippedChunksAfter} 
      </ul>


      // 청크 가시성 판단을 위한 Probe 컴포넌트
      <div style={{ position: "absolute", top: 0, left: 0 }}>
        {Array(chunksCount)
          .fill(null)
          .map((_, index) => (
            <Probe ... />
          )
        }
      </div>
    </div>
  );
}

				
			

2. 사용자 화면에 들어오지 않는 요소 감지

“WindowedList”에서 리스트 요소를 청크로 나누었는데요, 실제 청크의 가시성을 파악은 하위 컴포넌트인 “Probe” 가 담당하도록 하였습니다. “Probe”의 Props는 아래와 같습니다.

				
					type ProbeProps = {
  chunkHeight: number; // Probe가 담당하는 chuck의 높이     
   onVisibilityChange?(index: number, visible: boolean): void; // IntersectionObserver 콜백 함수
   . . .
};
				
			

먼저, “Probe”는 청크 하나와 1:1로 매칭됩니다. “Probe”가 대응되는 청크의 가시성을 감지해야 하므로 둘 모두 같은 위치와 같은 높이에 존재해야 합니다. “Probe”와 청크를 동일한 위치에 두기 위해, “position: absolute”를 설정했습니다. 또한, 청크의 높이를 알려주는 “chunkHeight”를 Props로 받고 “Probe”의 높이로 설정해 주었습니다. (참고: “chunkHeight”는 청크 하나가 담고 있는 리스트 요소의 개수와 리스트의 높이를 곱하여 계산할 수 있습니다.) 

마지막으로 사용자 화면에 청크가 보이거나 보이지 않게 되었을 때, “WindowedList”에게 알리기 위한 “onVisibilityChange”도 전달받습니다. “Probe” 컴포넌트 내부에서는 이미지 지연 로드에서 만들었던 “useIntersectionObserver” 훅을 통해 IntersectionObserver를 사용하여, 대상 청크의 가시성을 여부를 판단하고 “onVisibilityChange” 실행하여 알립니다.

 
실제 “Probe” 컴포넌트는 아래와 같습니다.

				
					// 화면에 표시할 청크를 알아내기 위해 사용하는 너비 0짜리 div입니다.
export default function Probe(props: Props) {
  const { chunkHeight, index, onVisibilityChange } = props;
  const observer = useIntersectionObserver();
  
  const [node, setNode] = useState<HTMLDivElement | null>(null);
  useEffect(() => {
    if (node) {
      observer.observe(node, 
        (visible: boolean) => onVisibilityChange?.(index, visible)
      );
      return () => {
        observer.unobserve(node);
        onVisibilityChange?.(index, false);
    };
  }
}, [observer, node, index, onVisibilityChange]);


  return <div ref={setNode} style={{ height: `${chunkHeight}px` }} />;
}

				
			

3. 화면에 들어오지 않는 요소를 빈 공간으로 채우기

이제 리스트 요소를 청크로 나누었고, 가시성 또한 감지할 수 있게 되었습니다! 마지막으로 실제 보이는 부분만 렌더링 하고 나머지 공간을 빈 태그로 채우는 작업을 진행했습니다. 이를 위해 청크들의 가시성 여부를 Boolean 타입으로 담은 배열을 생성했습니다.

청크의 가시성 변화가 감지되면 “onVisibilityChange” 실행되고, 청크들의 가시성 여부를 담은 배열의 값을 화면에 보인다면 “true” 아니라면 “false”로 변경합니다. 해당 배열이 변경되면 값이 “true”인 청크들은 실제 렌더링할 리스트 요소들을 반환합니다. “false”의 값을 가진 청크들은 리스트 요소들의 높이를 계산하고 계산된 높이를 가진 빈 DOM 요소를 반환합니다. 즉, 실제 렌더링될 리스트 요소와 완전히 같은 높이를 가진 DOM 요소이지만 내부는 비어있는 것입니다.

이렇게 3단계를 거치며 “WindowedList”와 “Probe”를 완성했습니다. 하지만 주문하기 홈에 적용하는 과정에서 약간의 문제를 경험했습니다.

리스트 요소의 높이가 다른 경우

매장 홈의 경우, 모든 리스트의 크기가 같기 때문에 리스트의 개수만 알더라도 전체 리스트의 요소의 높이를 계산할 수 있었습니다. 하지만 주문하기 홈에서는 리스트의 크기가 동적이었기 때문에, 리스트의 개수만으로는 전체 높이를 계산할 수 없었습니다. 따라서, 구현된 개선 방법을 그대로 적용하면, 렌더링 되지 않는 빈 공간의 크기와 실제 요소들의 크기가 틀어지면서 주문하기 홈의 스크롤이 떨리거나, 뒤로 가기로 주문하기 홈에 진입했을 때 스크롤의 위치가 이상한 위치에 놓이게 되는 문제가 발생했습니다.

위 문제를 해결하기 위해, 리스트 요소의 높이를 단일한 값으로 생성하지 않고 아래와 같이 각 리스트 별로 높이를 모두 담은 배열을 생성했습니다. 이를 통해, 렌더링 되지 않는 리스트 요소의 높이를 정확하게 계산할 수 있었습니다.

				
					type WindowedListProps = {
  items: JSX.Element[];     // 실제로 그릴 리스트 요소.
  chunkSize?: number;     // 각 리스트 요소를 몇 개 단위로 묶을 것인지.
  - itemHeight: number; // 리스트 요소 하나의 높이.
  + itemHeights: number[]; // 리스트 요소 높이를 담은 배열.
  …
};

				
			

이렇게 만들어진 “WindowedList”를 매장홈과 주문하기 홈에 성공적으로 적용할 수 있었습니다. 결과적으로 초기 렌더링되는 DOM 엘리먼트의 개수를 약 6000개에서 500개 정도로 줄일 수 있었습니다. 또한, 파루스 리포트 상에서 TBT가 3000ms에서 560ms까지 극적으로 감소했고, 성능 점수 또한 40점에서 목표에 근접한 85점까지 도달할 수 있었습니다!

이후 필요하지 않은 패키지 줄이기, gzip 적용, 사용하지 않는 컴포넌트 제거 등 기타 성능 개선을 통해 안정적으로 초기 목표였던 성능 점수 90점을 달성할 수 있었습니다!

드디어 돌파한 90점.. 영롱한 초록색

맺으며

지금까지 주문하기 페이지를 개선하면서 가장 효과적이었던 성능 개선 사례 두 가지를 소개드렸습니다. 이번 글에는 포함되지 않았지만, 약 3개월 동안 파루스 리포트에서 권장하는 기준을 달성하기 위해 수백 줄의 코드라인을 수정했습니다.

본 성능 개선을 진행하며, 단순히 사용자 경험이 개선되는 결과뿐 아니라, 스스로도 프로젝트의 레거시 코드 파악 및 리팩토링을 경험하는 좋은 과정이 되었습니다. 독자분들도 사용자와 개발자 모두에게 이득이 될 수 있는 성능 개선을 진행해 보시는 것을 적극 권장드리며, 소개드린 주문하기 페이지 개선 사례가 차후 도움이 되길 바랍니다.

또한 함께 작업하며 수고해 주신  joy.x2, tirr.c, hash.table에게도 감사의 인사를 전합니다!

 

[성능 개선 2부에서 계속됩니다!]

📚 참고 문서:

📚 관련 글 목록:

카카오톡 공유 보내기 버튼

Latest Posts

제5회 Kakao Tech Meet에 초대합니다!

Kakao Tech Meet #5 트렌드와 경험 및 노하우를 자주, 지속적으로 공유하며 개발자 여러분과 함께 성장을 도모하고 긴밀한 네트워크를 형성하고자 합니다.  다섯 번째

테크밋 다시 달릴 준비!

(TMI: 이 글의 썸네일 이미지는 ChatGPT와 DALL・E로 제작했습니다. 🙂) 안녕하세요, Kakao Tech Meet(이하 테크밋)을 함께 만들어가는 슈크림입니다. 작년 5월에 테크밋을 처음 시작하고,