이번 주 사내 스터디 주제는 리액트 18 버전의 변경점이에요. 18 버전이 릴리즈 된 지 1년 정도 지났지만, 제가 참여 중인 프로젝트는 16 버전을 사용하고 있었기 때문에 최신 버전에 대한 변경 사항을 잘 알지 못하고 있었어요. 이번 스터디를 기회로 리액트에 어떤 변화가 있었는지 학습하고, 학습한 내용을 바탕으로 토이 프로젝트를 진행하기로 하였습니다. (+ 제 토이 프로젝트는 여기서 확인할 수 있어요!)
국내에 리액트 18 버전에 대한 좋은 글들이 이미 많기 때문에 스터디에서 어떤 내용을 공유해야 할지 많은 고민이 있었는데요, 구글링을 하던 중 Suspense의 변경점을 소개하는 좋은 글을 발견해서 이를 (제 입맛대로) 번역하고 편집해 보았습니다.
Suspense는 16 버전에서 처음 등장했어요. 당시에는 React.lazy()
를 이용한 코드 스플리팅에서만 Suspense를 사용할 수 있었는데, 이마저도 SSR에서는 사용할 수 없었습니다. 리액트 팀은 코드 스플리팅 뿐만 아니라 모든 비동기 작업(데이터, 이미지 불러오기 등)에서 Suspense를 사용할 수 있도록 기능을 확장하고 싶어 했어요.
18 버전에서 동작이 2가지 변경되었고, 새로운 기능이 2가지 추가되었어요.
useLayoutEffect()
가 실행돼요.자세한 내용은 아래에서 계속 살펴볼까요?
import { lazy, Suspense } from 'react';
import Panel from './Panel';
const Comments = lazy(() => import('./Comments'));
export default function App() {
return (
<Suspense fallback={<p>Loading</p>}>
<Panel>
<Comments />
</Panel>
</Suspense>
);
}
위 예제에서 <Panel>
은 일반 컴포넌트이고 <Comments>
는 lazy 컴포넌트에요. <Comments>
가 렌더링 할 준비가 되지 않았다면 화면에서 <Panel>
을 숨기고 Loading
이라는 fallback UI를 보여주어야 해요. 시간이 지난 후 <Comments>
가 준비되면 Loading
을 제거하고 <Panel>
과 <Comments>
를 보여주어야 합니다. 17 버전의 Suspense와 18 버전의 Suspense는 결과적으로 동일하게 동작하는 것처럼 보이지만, 내부에서 조금 다르게 동작하고 있어요.
<Panel>
을 DOM에 배치하되 <Comments>
자리는 비워둬요.<Comments>
가 준비되지 않았기 때문에 <Panel>
에 display: none
속성을 추가해요.Loading
fallback UI를 보여줘요.<Panel>
은 보이지 않지만, 기술적으로 마운트 된 상태이기 때문에 effect를 실행해요.<Comments>
가 준비될 때까지 기다린 후, 렌더링을 시도해요.Loading
을 지우고, DOM에 이미 존재했던 <Panel>
에 <Comments>
를 배치해요.<Panel>
의 display: none
속성을 제거해요.이런 동작은 몇몇 라이브러리를 사용하는데 문제가 발생하기도 했어요.
1. <Panel>
을 DOM에 배치하지 않고 버려요.
2. Loading
fallback UI를 보여줘요.
3. <Comments>
가 준비될 때까지 기다린 후, 렌더링을 시도해요.
4. Loading
을 지우고, <Panel>
과 <Comments>
를 배치해요.
5. <Panel>
의 effect를 실행해요.
컴포넌트가 완전히 준비되었을 때만 커밋 하기 때문에 더욱 직관적이고, effect에서 항상 완전한 트리를 관찰(observe) 할 수 있게 되었어요.
useLayoutEffect()
가 실행돼요.import { lazy, Suspense, useState } from 'react';
import AutoSize from './AutoSize';
const Photos = lazy(() => import('./Photos'));
const Comments = lazy(() => import('./Comments'));
export default function App() {
const [tab, setTab] = useState('photos');
return (
<div>
<button
type="button"
onClick={() => setTab('photos')}
>
Photos
</button>
<button
type="button"
onClick={() => setTab('comments')}
>
Comments
</button>
<Suspense fallback={<p>Loading</p>}>
<AutoSize>
// Photos에 height: 50px, Comments에 height: 80px을 지정해 주었어요.
{tab === 'photos' ? <Photos /> : <Comments />}
</AutoSize>
</Suspense>
</div>
);
}
위 예제는 Photos
버튼과 Comments
버튼을 클릭하면 아래 컨텐츠를 변경할 수 있는 예제에요.
import React, { useRef, useLayoutEffect } from 'react';
export default function AutoSize({ children }) {
const autoSizeRef = useRef(null);
useLayoutEffect(() => {
console.log(autoSizeRef.current?.clientHeight);
}, [autoSizeRef.current?.clientHeight]);
return (
<div ref={autoSizeRef}>
{children}
</div>
);
}
<AutoSize>
컴포넌트의 useLayoutEffect()
에서 children
의 높이를 로그로 확인해 보겠습니다.
위에서 17 버전의 Suspense는 lazy 컴포넌트(<Comments>
)가 준비되지 않았다면 부모 컴포넌트(<Panel>
)를 먼저 렌더링 한 후, display: none
속성을 적용해서 가린다고 이야기했었죠? 이때 부모 컴포넌트는 기술적으로 마운트 된 상태이기 때문에 부모 컴포넌트 내부의 effect를 실행하게 돼요. 문제는 lazy 컴포넌트를 준비하면서 fallback UI를 보여주는 사이에 effect가 실행되어버리기 때문에 lazy 컴포넌트의 높이를 제대로 가져오지 못한다는 것입니다. 나중에 lazy 컴포넌트가 렌더링 되어도 이를 알려주지 않기 때문에 여전히 높이를 알 수 없게 돼요.
초기 렌더링에서 lazy 컴포넌트의 높이 값을 0으로 가져왔고, 시간이 지난 후 렌더링 되었을 때 높이 값을 다시 가져오지 않는 것을 확인할 수 있어요.
lazy 컴포넌트가 렌더링 될 때 useLayoutEffect()
를 실행해요. 만약 lazy 컴포넌트를 숨기고 fallback UI를 보여주어야 한다면 useLayoutEffect()
를 정리(cleanup) 합니다. 덕분에 useLayoutEffect()
내부에서 lazy 컴포넌트의 높이 값을 정확히 가져올 수 있네요!
초기 렌더링에서 lazy 컴포넌트의 높이 값을 정확히 가지고 오는 것을 확인할 수 있어요.
17 버전에서는 SSR에서 Suspense를 사용할 수 없었어요. 하지만 18 버전에서는 HTML 스트리밍을 지원하는 서버 렌더러가 추가되었기 때문에 스트림을 생성할 수 있게 되었어요. 이 스트림은 Suspense를 이용해서 준비되지 않은 트리를 대기하고 fallback HTML(예: 스피너)을 내보낼 수 있습니다. 컨텐츠가 준비되면 리액트는 올바른 위치에 컨텐츠를 삽입하기 위해 인라인 <script>
와 컨텐츠 HTML을 함께 내보냅니다. 이를 통해 서버에서 페이지의 일부가 느리게 준비되더라도 사용자는 점진적으로 로딩되는 페이지를 볼 수 있고, 개발자는 더 나은 사용자 경험을 제공할 수 있게 되었어요.
또한 lazy 컴포넌트가 아직 준비되지 않았지만 Suspense로 래핑 된 경우, 리액트는 청크 파일이 로드될 때까지 기다리지 않고 이미 렌더링 된 부분들을 hydrate 할 수 있어요. 모든 청크 파일을 로드할 때까지 hydration을 대기할 필요가 없기 때문에 성능이 크게 향상될 수 있습니다.
이 부분은 더욱 자세히 정리된 글이 있습니다. 여기에서 확인해 보세요!
import { lazy, Suspense, useState } from 'react';
const Photos = lazy(() => import('./Photos'));
const Comments = lazy(() => import('./Comments'));
export default function App() {
const [tab, setTab] = useState('photos');
return (
<div>
<button
type="button"
onClick={() => setTab('photos')}
>
Photos
</button>
<button
type="button"
onClick={() => setTab('comments')}
>
Comments
</button>
<Suspense fallback={<p>Loading</p>}>
{tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>
</div>
);
}
위 예제는 버튼을 누르면 fallback UI가 나타나고 시간이 지난 후 컨텐츠가 보여지게 되어있어요. 하지만 때로는 fallback UI를 보여주지 않고 이전 UI를 유지하는 것이 더 나은 사용자 경험을 만들 때가 있습니다.
탭 변경 동작이 transition임을 리액트에게 알려줄게요. 그러면 리액트는 버튼을 누를 때 fallback UI를 보여주는 대신 이전 UI를 유지하고, 컴포넌트가 준비되면 해당 컴포넌트로 전환합니다.
import { lazy, Suspense, useState, useTransition } from 'react';
const Photos = lazy(() => import('./Photos'));
const Comments = lazy(() => import('./Comments'));
export default function App() {
const [tab, setTab] = useState('photos');
const [isPending, startTransition] = useTransition();
const handleClickButton = (tabName) => {
startTransition(() => setTab(tabName));
};
return (
<div>
<button
type="button"
onClick={() => handleClickButton('photos')}
>
Photos
</button>
<button
type="button"
onClick={() => handleClickButton('comments')}
>
Comments
</button>
<Suspense fallback={<p>Loading</p>}>
<div style={{ opacity: isPending ? 0.2 : 1 }}>
{tab === 'photos' ? <Photos /> : <Comments />}
</div>
</Suspense>
</div>
);
}
하지만 탭 전환에 짧은 시간이 걸리더라도, 사용자가 버튼을 눌렀을 때 상호작용이 되었다고 보여주는 것이 좋겠죠? 이 때 사용하면 좋은 것이 isPending
이에요.
fallback UI를 보여주는 대신 이전 UI를 유지하고, opacity: 0.2
를 적용해서 상호작용이 되었음을 사용자에게 보여줄 수 있습니다.
useTransition
에 대한 문서를 처음 보았을 때 현업에서 사용할 일이 있을까 싶었는데, Suspense와 함께 사용하면 다양한 대기 상태를 표시해 줄 수 있다는 점이 가장 흥미로웠습니다. 앞으로 Suspense에 어떤 변화가 생길지 궁금해지는 것 같아요!
이 글은 Suspense in React 18에서 핵심만 가져왔기 때문에, 더 자세한 내용은 원문에서 확인할 수 있어요.