개발자
류준열

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

버벅임 발견

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

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

버벅임

버벅임 performance tab

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

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

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

아래처럼 간단히 실험해보았다.

display:none은 마운트된다.

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

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

그렇기 때문에 DOM에 포함된 display:none은 마운트 되었다가 css에 의해 렌더트리에서 제거되는 것 뿐이다.

원인 발견: react-slick

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

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

코드 분석

componentDidMount 에서 슬라이더는 this.updateState를 호출해 내부 스타일(ex: width)등을 계산한다.


// componentDidMount

componentDidMount = () => {
  ...
  let spec = { listRef: this.list, trackRef: this.track, ...this.props };
  this.updateState(spec, true, () => {
    this.adaptHeight();
    this.props.autoplay && this.autoPlay("update");
  });
  ...
  this.ro = new ResizeObserver(() => {
    ...
    this.onWindowResized();
  });
};

// updateState

updateState = (spec, setTrackStyle, callback) => {
    let updatedState = initializedState(spec);
    spec = { ...spec, ...updatedState, slideIndex: updatedState.currentSlide };
    let targetLeft = getTrackLeft(spec);
    spec = { ...spec, left: targetLeft };
    let trackStyle = getTrackCSS(spec);
    if (
      setTrackStyle ||
      React.Children.count(this.props.children) !==
        React.Children.count(spec.children)
    ) {
      updatedState["trackStyle"] = trackStyle;
    }
    this.setState(updatedState, callback);
  };

// initializedState

export const initializedState = spec => {
  // spec also contains listRef, trackRef
  let slideCount = React.Children.count(spec.children);
  const listNode = spec.listRef;
  let listWidth = Math.ceil(getWidth(listNode));
  const trackNode = spec.trackRef && spec.trackRef.node;
  let trackWidth = Math.ceil(getWidth(trackNode));
  let slideWidth;
  ...

이때 updateState는 곧바로 initializedState를 호출하고, 여기서 listRef랑 연결된 컨테이너가 display:none 상태면 모든 레이아웃 정보가 0으로 저장된 상태로 setState되어 마운트된다.

또한 initializedState 내부의 getTrackCSS는 translate를 계산한다.


// getTrackCSS

export const getTrackCSS = spec => {
  ...
  if (spec.useTransform) {
    let WebkitTransform = !spec.vertical
      ? "translate3d(" + spec.left + "px, 0px, 0px)"
      : "translate3d(0px, " + spec.left + "px, 0px)";
    let transform = !spec.vertical
      ? "translate3d(" + spec.left + "px, 0px, 0px)"
      : "translate3d(0px, " + spec.left + "px, 0px)";
    let msTransform = !spec.vertical
      ? "translateX(" + spec.left + "px)"
      : "translateY(" + spec.left + "px)";
    style = {
      ...style,
      WebkitTransform,
      transform,
      msTransform
    };
}

그러다 외부에서 display:block 으로 전환되는 순간에 ref와 연결된 this.list 를 감시하던 observer가 크기 변화를 발견하고 onWindowResized()를 호출한다.

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

onWindowResized는 50ms 디바운스를 거쳐 resizeWindow를 호출하고 resizeWindow는 스타일을 재계산하여 setState한다.

// onWindowResized

onWindowResized = setTrackStyle => {
  if (this.debouncedResize) this.debouncedResize.cancel();
  this.debouncedResize = debounce(() => this.resizeWindow(setTrackStyle),50);
  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
  };
  this.updateState(spec, setTrackStyle, () => {
    if (this.props.autoplay) this.autoPlay("update");
    else this.pause("paused");
  });
  ...
};

// updateState
updateState = (spec, setTrackStyle, callback) => {
  let updatedState = initializedState(spec);
  spec = { ...spec, ...updatedState, slideIndex: updatedState.currentSlide };
  let targetLeft = getTrackLeft(spec);
  spec = { ...spec, left: targetLeft };
  let trackStyle = getTrackCSS(spec);
  if (setTrackStyle) {
    updatedState["trackStyle"] = trackStyle;
  }
  this.setState(updatedState, callback);
};

즉, 모달을 킬때 display:none 상태에서 display:block 으로 전환될때 ResizeObserver -> updateState 순환이 일어나고 그때 translate가 새로 계산되어 setState되면서 버벅임이 보이는 것이다.

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

해결 및 결과

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

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

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

ASIS

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

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

TOBE

47ms 후 모달 렌더링 완료

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

참고자료