무겁고 긴 목록을 최적화하세요!

웹에서 무한 스크롤 리스트를 제공하는 서비스들이 많은데요. 리스트 DOM의 복잡도에 따라 목록 스크롤이 늘어날수록 서비스가 느려질 수 있기 때문에 오늘 소개하는 방법을 적절히 사용하면 서비스 성능을 개선할 수 있습니다.

무한스크롤

애플리케이션에서 긴 목록(수백 또는 수천행)을 렌더링하는 경우 “windowing” 이라는 가상화 기법을 사용하여 최적화 할 수 있습니다. 이 기법은 리엑트 공식 사이트에서 추천하고 있으며, 화면에 실제로 보여지는 부분만 DOM으로 렌더링하도록 연산하여 시스템 부하와 과도하게 낭비되는 리소스를 방지 하여 더 나은 서비스를 제공할 수 있습니다.

Live Demo

http://play.codejs.co.kr/react-virtualized-examples

일반적인 목록 vs 가상화된 목록 비교

일반적으로 목록을 많이 보여주는 화면과 react-virtualized를 사용하여 최적화 한 차이를 비교 제공합니다.

일반적인 목록

목록이 많아질수록 자연스럽게 DOM이 늘어나고 그로 인해 부하가 생겨 서비스 성능 저하의 원인이 됩니다.

텍스트 목록에 react-virtualized 적용한 모습

예제의 500개 목록 중 화면에 보이는 부분만 DOM으로 랜더링 하여 부하를 최소화 할 수 있습니다.
그로 인해 API로 JSON을 받아와 렌더링 하는 최초 시간과 추가적으로 목록을 더할 때도 최소 5배 이상 빠른 결과를 얻을 수 있습니다.

이미지 목록에 react-virtualized 적용한 모습

이미지가 있는 목록도 문제 없습니다. 이미지가 로드된 이후 요소의 사이즈 정보도 얻을 수 있습니다.

사용된 주요 API

  • List : 창에 보이는 요소만 렌더링하는 컨테이너입니다. (문서)
  • AutoSizer : 단일 자식의 너비와 높이를 자동으로 조정하는 고차 컴포넌트입니다. (문서)
  • CellMeasurer : 사용자가 볼 수 없는 방식으로 일시적으로 렌더링하여 셀 크기를 자동측정하는 고차 구성 요소입니다. (문서)
  • CellMeasurerCache : CellMeasurer의 결과를 부모(여기서는 List)와 공유합니다. (문서)

적용하면 좋은 곳

  • 스크롤로 무한정 늘어날 수 있는 목록
  • 지속적으로 쌓이는 채팅 목록
  • 지속적으로 쌓이는 알림 목록

공식문서

화면에 보이는 요소를 감지하는 IntersectionObserver 알아보기

IntersectionObserver API란

IntersectionObserver API는 타겟 엘리멘트가 화면(viewport)에 보여지고 있는지 관찰하는 API입니다.
크롬 51버전부터 사용 가능하며, 현재 대부분의 모던 브라우저에서 지원합니다.
IntersectionObserver 이전엔 화면에 보이는 요소를 감지하려면 Scroll 이벤트를 사용하여 스크롤의 좌표와 화면의 크기를 더해서 관찰하려는 요소의 offset 좌표가 포함되는지를 계산해야 하는 번거로움뿐만 아니라 반복적인 스크롤 연산 처리 과정의 성능 이슈가 따릅니다. 그래서 아래와 같은 상황에 이 API를 활용하는 것을 권장합니다.

IntersectionObserver

어떻게 활용하면 좋을까요?

  • Image Lasy loading 구현할 때
  • Content Lasy loading 구현할 때
  • Infinite scrolling 구현할 때
  • 화면에 보이는 요소 에니메이션 처리할 때

주요 API 알아보기

구문

const observer = new IntersectionObserver(callback, options);

메서드

observe, unobserve, disconnect 메서드를 주로 사용하게 된다.

  • observe(targetElement): 타겟 엘리먼트에 대한 관찰을 시작할 때 사용합니다.
  • unobserve(targetElement): 타겟 엘리먼트에 대한 관찰을 멈출 때 사용합니다.
  • disconnect(): 다수의 엘리먼트를 관찰하고 있을 때, 이에 대한 모든 관찰을 멈추고 싶을 때 사용합니다.
  • takeRecords(): 관찰중인 엘리먼트의 IntersectionObserverEntry 객체를 배열로 반환한다.

옵션 속성

옵저버를 조정할 수 있는 옵션 객체로 위 구문의 options에 기입합니다.
지금은 이런 내용이 있다 정도만 알아두고, 자세히 다뤄보고자 할 때 다시 봐도 늦지 않는다.

  • root
    • 대상 요소 (element) 를 감시할 상위 요소.
    • default: null (document가 root로 사용된다)
  • rootMargin
    • 바깥 여백(Margin)을 이용해 Root 범위를 확장하거나 축소할 수 있습니다.
    • default: 0px 0px 0px 0px
  • threshold
    • 관측 대상이 화면에 어느 정도 노출될때 보인다고 판단할지 비율을 지정할 수 있다. (0.0 ~ 1.0 지정 가능하고, 0.0은 관측 대상이 1픽셀이라도 보이면, 1.0은 요소가 전부 보일때 노출됬다고 판단한다)
    • default: 0.0

간단한 예제로 동작 원리 알아보기

화면에 보이는 블럭은 노란 배경색이 차오르고, 블럭이 화면 밖으로 나가면 다시 배경색이 사라지는 예제입니다. (DEMO)

Browser compatibility

const io = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    // entry의 target으로 DOM에 접근합니다.
    const $target = entry.target;

    // 화면에 노출 상태에 따라 해당 엘리먼트의 class를 컨트롤 합니다.
    if (entry.isIntersecting) {
      $target.classList.add("screening");
    } else {
      $target.classList.remove("screening");
    }
  });
});

// 옵저버할 대상 DOM을 선택하여 관찰을 시작합니다.
const $items = document.querySelectorAll("li");
$items.forEach((item) => {
  io.observe(item);
});

// 특정 요소만 옵저버를 해제합니다.
// io.unobserve(targetElement);

// 옵저버 전체를 해제합니다.
// io.disconnect();

사용해보면 생각보다 간단한 인터페이스라 금방 실무에 다양하게 활용하실 수 있습니다.
아래 참고할만한 NPM 모듈이 있으니 복잡하지 않은 UI는 직접 구현해 보는 것을 추천합니다 🙂

브라우저 지원 현황

Polyfill을 사용하여 IE와 더 많은 하위 브라우저들도 지원할 수 있습니다.
Browser compatibility

참고

React Code Splitting

코드 분할

번들링은 훌륭하지만 앱이 커지면 번들도 커져서 로드 속도가 느려집니다. 프로젝트 초기라면 괜찮습니다. 하지만 프로젝트 규모가 커지고 최적화가 필요한 단계에서 Code Splitting을 한다면 서비스 퍼포먼스가 좋아지기 때문에 사용자들에게 보다 나은 사용자 경험을 줄 수 있습니다.

방법 #1. React.lazy

React.lazy 함수를 사용하면 동적 import를 사용해서 컴포넌트를 렌더링 할 수 있습니다.
(React.lazy는 React 16.6버전 부터 지원합니다.)

Before

import OtherComponent from "./OtherComponent";

After

const OtherComponent = import React.lazy(() => import('./OtherComponent'));

With Suspense

lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링되어야 하며, Suspense는 lazy 컴포넌트가 로드되길 기다리는 동안 로딩 화면과 같은 예비 컨텐츠를 보여줄 수 있게 해줍니다.

const OtherComponent = React.lazy(() => import("./OtherComponent"));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

하나의 Suspense 컴포넌트로 여러 lazy 컴포넌트를 감쌀 수도 있습니다.

const OtherComponent = React.lazy(() => import("./OtherComponent"));
const AnotherComponent = React.lazy(() => import("./AnotherComponent"));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </div>
  );
}

Route-based code splitting

앱 코드를 분리하는 단위는 선택이지만, 좋은 방법 중에 하나는 라우트 단위로 분할 하는 방법입니다.

import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import React, { Suspense, lazy } from "react";

const Home = lazy(() => import("./routes/Home"));
const About = lazy(() => import("./routes/About"));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/about" component={About} />
      </Switch>
    </Suspense>
  </Router>
);

코드 Build 결과

dist 디렉토리를 보면 라우팅 단위로 파일이 분리되어 생성된것을 확인 할 수 있습니다.

react-app
 dist/
  - index.html
  - main.b1234.js (contains Appcomponent and bootstrap code)
  - home.bc4567.js (contains Home)
  - about.bc4567.js (contains About)

/** index.html **/
<head>
  <div id="root"></div>
  <script src="main.b1234.js"></script>
</head>

흔히 발생할 수 있는 실수

import 하는 페이지 내부에 사용중인 컴포넌트가 사용하지 않는 다른 컴포넌트와 index.js에 export를 편의상 묶어 사용하는 경우가 있습니다. 이때 컴포넌트 import시 객체 디스트럭쳐링을 사용할 경우 내부적으로 사용하지 않는 컴포넌트도 함께 로드되기 때문에 코드 분할 시 큰 효과를 보지 못합니다. 그렇기 때문에 실사용 컴포넌트만 개별 임포트 하는것이 중요합니다.

// 예로, PageA를 코드 분할하려고 합니다.
const PageA = React.lazy(() => import("./PageA"));
function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <PageA />
      </Suspense>
    </div>
  );
}

// PageA.js 에선 Button 컴포넌트를 사용하려고 import하고 있습니다.
// X 사용하지 않는 다른 컴포넌트도 함께 로드됩니다.
import { Button } from "./components"; 
// O 개별 컴포넌트로 로드해야 불필요한 컴포넌트가 불려 번들 사이즈가 커지는것을 방지할 수 있어요.
import Button from "./components/Button";

function PageA() {
  return (
    <div>
      <h1>PageA</h1>
      <Button>OK</Button>
    </div>
  );
}

// components/index.js
export { default as Loader } from "./common/Loader";
export { default as Button } from "./common/OtherSetting";
export { default as InsideSearch } from "./common/InsideSearch";

 

방법 #2. Loadable Components

React.lazy와 Suspense는 서버 사이드 렌더링을 지원하지 않습니다. 코드 분할을 클라이언트, 서버사이드 모두 가능하도록 만들고 싶다면 Loadable Components를 사용하면됩니다. (React 공식 문서에서 권장하는 방법)

React.lazy와 비교

Library Suspense SSR Library splitting import(./${value})
React.lazy
@loadable/component

Install

npm install @loadable/component

# or use yarn
yarn add @loadable/component

loadable Import

import loadable from "@loadable/component";

const OtherComponent = loadable(() => import("./OtherComponent"));

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

With Suspense

React Suspense를 지원하여 함께 사용할 수 있습니다.

import React, { Suspense } from "react";
import { lazy } from "@loadable/component";

const OtherComponent = lazy(() => import("./OtherComponent"));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

Full dynamic import

다이나믹하게 import() 하는 Loadable Component를 만들어서 코드의 반복을 줄일 수 있습니다.

import loadable from "@loadable/component";

const AsyncPage = loadable((props) => import(`./${props.page}`));

function MyComponent() {
  return (
    <div>
      <AsyncPage page="Home" />
      <AsyncPage page="Contact" />
    </div>
  );
}

번외, React-loadable

React-loadable 모듈은 React 공식 문서에서도 오랫동안 권장하던 방법이었습니다. 그러나 현재는 더 이상 유지 관리되지 않으며 Webpack v4 + 및 Babel v7 +와 호환되지 않는 부분들이 있습니다.
그래서 React.lazy 또는 Loadable components로 변경하는것을 추천합니다.

참고

WebRTC 소개

WebRTC (Web Real-Time Communication)는 웹 브라우저 간에 플러그인 설치 없이 스트림과 데이터를 상호 통신할 수 있도록 설계된 JavaScript API입니다.

WebRTC는 구글이 오픈소스화한 프로젝트에서 기원하였으며, 그 뒤로 국제 인터넷 표준화 기구(IETF)가 프로토콜 표준화 작업을, W3C가 API 정의를 진행하였으며, 음성 통화, 영상 통화, P2P 파일 공유 등으로 활용 될 수 있습니다.

WebRTC 지원 브라우저

최신 버전의 주요 브라우저들이 지원을 하고있습니다.

WebRTC 지원 브라우저

하지만 경험상 Edge브라우저는 개발과정에 디버깅이 힘들고 타 브라우저에 비해 성능도 떨어집니다.
Edge 크로미움 버전이 추후 나온다니 좋은 성능을 보여주기를 기대합니다.
경험상 크롬에서 먼저 개발하고 어느정도 안정화가 되면 브라우저를 확장해 나가는것을 추천합니다.

WebRTC 주요 API

  • GetUserMedia
    사용자의 카메라와 마이크 접근을 담당
  • GetDisplayMedia
    화면공유를 위한 접근을 담당
  • RTCPeerConnection
    Peer간의 연결을 위한 인스턴스를 생성하고, 연결 후 스트림 전송에 사용 (생성시 STUN 서버 요청)
  • RTCDataChannel
    Peer간의 Data를 주고 받을 수 있는 Tunnel API (WebSocket과 유사하지만, P2P라 속도가 보다 빠름)

WebRTC P2P기반 NAT 및 방화벽 통과 기법

  • STUN (Session Traversal Utilities for NAT) 서버
    사내망 환경에 NAT를 통해 공인IP, Port를 알아내는 역할을 함
  • TURN (Traversal Using Relays around NAT) 서버
    P2P 연결이 안되는 환경일때 트래픽을 중계하는데 사용함
  • ICE (Interactive Connectivity Establishment)
    P2P 간 다이렉트로 통신을 위해 STUN, TURN 등의 기술을 종합 활용하여 라우팅 경로를 찾아내는 기술로 UDP hole punching (P2P간 공인IP가 아니더라도 최대한 연결 가능하도록 하는 기법)을 지원.

WebRTC P2P 연결 절차

WebRTC Connection Flow

위 과정은 아래의 webrtc-internals 화면을 열어서 함께 보는것이 이해하는데 도움이 됩니다.

WebRTC Connection Step 확인

크롬 주소창에 chrome://webrtc-internals 입력하면 WebRTC 연결 흐름과 상태를 볼 수 있습니다.
실제 개발을 할 때 개발자 콘솔창을 보는 시간 만큼 자주 보게되는 화면입니다.

WebRTC 디버깅

WebRTC 활용한 서비스

참고