개발자
류준열
모달 렌더링 속도 66% 향상 (약 150ms -> 50ms)
버벅임 발견
레이지로딩이 안되는 이미지들이 있어서, 확인해보니 모달이 꺼져있는 상태를 visibility:hidden으로 처리하여 항상 렌더링된 상태였다.
이를 display:none으로 바꾸니 레이지로딩은 되었지만 모달을 킬 때 아래와 같은 버벅임이 생겼다.


겉보기엔 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의 라이프사이클은 다음과 같다.
- componentDidMount 에서 this.updateState(spec, true, ...) 호출
- updateState -> initializedState(spec) 호출
- listRef, trackRef 를 기준으로 슬라이더 width 등 계산
- 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);
};
정리하면 모달 오픈때 발생하는 일은 다음과 같다.
- display: none -> block
- ResizeObserver 콜백 실행
- onWindowResized (50ms 디바운스)
- resizeWindow -> updateState 호출
- 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 />}</>