개발자
류준열

모달 렌더링 속도 66% 향상 (약 150ms -> 50ms)

버벅임 발견

레이지로딩이 안되는 이미지들이 있어서, 확인해보니 모달이 꺼져있는 상태를 visibility:hidden으로 처리하여 항상 렌더링된 상태였다.

이를 display:none으로 바꾸니 레이지로딩은 되었지만 모달을 킬 때 아래와 같은 버벅임이 생겼다.

버벅임

버벅임 performance tab

겉보기엔 display: none -> block 정도의 변화인데, 실제로는 더 무거운 일이 일어나고 있었다.

display:none은 언마운트가 아니다.

이 문제를 해결하면서 잘못 알고 있던 내용을 바로잡았다.

display:none은 렌더트리에 없기 때문에 언마운트라 생각했는데 그게 아니라 css 속성으로 가려지는 것 뿐이었다.

간단히 실험해보았다.

  • React 컴포넌트는 그대로 살아있고
  • DOM에도 그대로 존재하며
  • CSS로 렌더트리에서 제외될 뿐이다.
  • 즉, display:none은 마운트된다.

Next의 RSC payload는 렌더트리가 아닌 DOM이다.

Next의 rsc payload는 렌더트리가 아닌 DOM에 관한 데이터다. (공식문서)

그렇기 때문에 DOM에 포함된 display:none은 '존재하는 것'으로 취급된다.

원인 발견

display가 none에서 block으로 바뀌는것뿐인데 왜 버벅임이 생기는지를 찾다가 react-slick을 지우면 버벅임이 사라지는 것을 발견했다.

react-slick의 inner-slider.js 에서 문제를 찾을 수 있었다.

(display:none으로 react-slick을 숨길때 레이아웃이 망가지는 이슈는 다른 사람들도 이미 겪은 문제였다.)

코드 분석

react-slick의 라이프사이클은 다음과 같다.

  1. componentDidMount 에서 this.updateState(spec, true, ...) 호출
  2. updateState -> initializedState(spec) 호출
  3. listRef, trackRef 를 기준으로 슬라이더 width 등 계산
  4. ResizeObserver로 컨테이너 크기를 구독하며 width 변경시마다 onWindowResized -> resizeWindow -> updateState로 다시 계산

아래 내용은 너무 방대하고 지루할텐데 요약하면 display:none일때 0이었던 width/height 정보를 실제값으로 변환하며 생기는 연산이 무거웠기 때문에 버벅임이 인지되었던 것이다.

display:none 상태에서 react-slick 마운트
// Modal.tsx
function Modal() {
  return (
    <div className="modal">
      <Slider>...</Slider> {/* react-slick */}
    </div>
  );
}

// tailwind에서 hidden은 display:none이다.
<div className={isOpen ? 'block' : 'hidden'}>
  <Modal />
</div>

여기서 <Modal />은 이미 마운트 되어 있고 <Slider />(react-slick)에서도 componentDidMount가 실행된다.

하지만 wrapper는 display:none 이라 width/height가 0인 상태로 레이아웃이 계산된다.

마운트 시점의 react-slick

참고: https://github.com/akiran/react-slick/blob/master/src/inner-slider.js

componentDidMount = () => {
  // 1. 마운트 시점
  let spec = { listRef: this.list, trackRef: this.track, ...this.props };

  // 2. 여기서 초기 상태 계산 + setState
  this.updateState(spec, true, () => {
    this.adaptHeight();
    this.props.autoplay && this.autoPlay("update");
  });

  // 3. ResizeObserver 등록
  this.ro = new ResizeObserver(() => {
    this.onWindowResized();
  });
  this.ro.observe(this.list);
};

// 4. 상태 계산
updateState = (spec, setTrackStyle, callback) => {
  let updatedState = initializedState(spec); // <= 여기서 width 계산
  spec = { ...spec, ...updatedState, slideIndex: updatedState.currentSlide };

  let targetLeft = getTrackLeft(spec);
  spec = { ...spec, left: targetLeft };

  let trackStyle = getTrackCSS(spec); // <= translate3d 스타일 계산

  if (setTrackStyle) {
    updatedState["trackStyle"] = trackStyle;
  }

  this.setState(updatedState, callback);
};

위 코드의 4.상태계산에서 initializedState에서 width 계산 로직은 다음과 같다.

export const initializedState = (spec) => {
  const listNode = spec.listRef;
  const trackNode = spec.trackRef && spec.trackRef.node;

  // display:none 상태면 여기서 전부 0
  let listWidth = Math.ceil(getWidth(listNode));
  let trackWidth = Math.ceil(getWidth(trackNode));

  // ...
};

즉, display:none 이면 모달이 닫혀있는 상태에서 width/height가 0으로 레이아웃 계산이 들어가 초기 state로 설정된다.

모달을 열었을때 발생하는 일

이제 모달이 열리면 외부에서 CSS만 바뀐다.


// Modal.tsx
function Modal() {
  return (
    <div className="modal">
      <Slider>...</Slider> {/* react-slick */}
    </div>
  );
}

<div className={isOpen ? 'block' : 'hidden'}>
  <Modal />
</div>

이때, width/height가 실제 값으로 변경되고, react slick 내부 componentDidMout에 등록해둔 ResizeObserver가 이 변화를 감지한다.

// ResizeObserver

componentDidMount = () => {
  // ...
  this.ro = new ResizeObserver(() => {
    if (this.state.animating) {
      this.onWindowResized(false);
    } else {
      this.onWindowResized();
    }
  });
  this.ro.observe(this.list); // <- 
};

onWindowResize는 resizeWindow를 호출하고 resizeWindow는 updateState를 호출한다.


// onWindowResized

onWindowResized = (setTrackStyle) => {
  if (this.debouncedResize) this.debouncedResize.cancel();

  this.debouncedResize = debounce(
    () => this.resizeWindow(setTrackStyle),
    50, // <= 50ms 디바운스
  );

  this.debouncedResize();
};

// resizeWindow

resizeWindow = (setTrackStyle = true) => {
  const isTrackMounted = Boolean(this.track && this.track.node);
  if (!isTrackMounted) return;

  let spec = {
    listRef: this.list,
    trackRef: this.track,
    ...this.props,
    ...this.state,
  };

  // 다시 initializedState -> getTrackCSS -> setState
  this.updateState(spec, setTrackStyle, () => {
    if (this.props.autoplay) this.autoPlay("update");
    else this.pause("paused");
  });
};

updateState는 위에서 보았듯 다음과 같이 intializedState -> getTrackCss -> setState를 발생시킨다.

updateState = (spec, setTrackStyle, callback) => {
  let updatedState = initializedState(spec); // <= 여기서 width 계산
  spec = { ...spec, ...updatedState, slideIndex: updatedState.currentSlide };

  let targetLeft = getTrackLeft(spec);
  spec = { ...spec, left: targetLeft };

  let trackStyle = getTrackCSS(spec); // <= translate3d 스타일 계산

  if (setTrackStyle) {
    updatedState["trackStyle"] = trackStyle;
  }

  this.setState(updatedState, callback);
};

정리하면 모달 오픈때 발생하는 일은 다음과 같다.

  1. display: none -> block
  2. ResizeObserver 콜백 실행
  3. onWindowResized (50ms 디바운스)
  4. resizeWindow -> updateState 호출
  5. intializedState -> getTrackCss -> setState를 발생시키며 레이아웃 재계산 및 리렌더링

5번에서 슬라이더 내부의 구조가 복잡하여 메인 스레드에서 레이아웃 연산이 한번에 몰리고 작업 시간이 약 60ms를 넘어서는 것이다. (최상단 스크린샷 참고, 16ms를 넘으면 사람 눈에 포착된다.)

visibility:hidden 일 때 버벅이지 않았던 이유

반면 visibility:hidden 처리했을때 버벅임이 보이지 않았던 이유는 이미 켜진 레이아웃 상태로 렌더링되어 있었기 때문이다. (mdn)

  • width/height가 0이 아니라 실제 값으로 계산된 상태에서 마운트 된다.
  • ResizeObserver 입장에서는 크기 변화가 없다.

해결 및 결과

원인을 알고나면 해결책은 의외로 간단할때가 있다. 지금이 그렇다.

display:none 으로 모달을 끄는 것이 아니라, 조건부 렌더링으로 껐다. (아래 이슈 때문에 마우스 호버시 이미지 사전로딩도 함께 적용했다)

모달 렌더링 속도 개선: 150ms -> 47ms (아래 이미지 참고)

ASIS

버벅임을 거쳐서 150ms 후 전체 모달 렌더링 완료

<div className = {isOpen ? 'visible' : 'hidden'><Modal /></div>

TOBE

47ms 후 모달 렌더링 완료

<>{isOpen && <Modal />}</>

참고자료