React 팀은 과연 18 버전을 릴리즈하기까지 어떤 목표를 가지고 이를 수행해 왔을까?
어떠한 가치에 중점을 두고 업데이트를 진행해 왔는지 그리고 무엇을 해결하려 했는지에 집중하며 React Conf를 통해 공유된 내용을 정리해 보았다.
기조 연설 영상은 본격적으로 기술에 대한 소개를 하기 전에 다음을 서두로 시작이 된다.
위와 같은 내용으로 미루어 보았을 때, React 팀은 이번 업데이트를 통해 개발자와 사용자 경험을 개선시키는 것에 상당한 가치를 두고 있음을 짐작해볼 수 있었다.
그럼 이제부턴 실제로 React 18 버전에 어떠한 내용이 추가되었고 변경되었는지 좀 더 기술적인 부분에 대해서도 알아보자.
앞의 기조 연설과 React Blog의 <What's New in React 18> 의 내용을 참고하여 작성하였다.
React 18에서 소개되는 여러 새로운 기능은 "동시 렌더링"이라는 low-level 기술에 기반하고 있다. 리액트는 이를 통해 애플리케이션 개발자에게는 구현의 세부 사항을 숨기고 사용자 경험에 집중할 수 있도록 하는 새로운 렌더링 모델을 제공하고 있다.
동시성이 내부적으로 어떻게 작동하는지 세부적으로 이해해야만 하는 것은 아니지만, 그것이 무엇인지 이해하는 건 상당한 도움이 될 수 있다. 리액트에서 동시성은 사용자에게 표시되기 전에 여러 버전의 UI 준비할 수 있도록 하며, 또한 다음 내용을 가능하게 한다.
따라서 이를 기반으로 아래에서 소개할 다양한 기능을 도입하였다. 이 새로운 기능들을 통해 개발자들로 하여금 애플리케이션에 높은 유연성과 뛰어난 사용자 경험을 제공하는 것을 기대하고 있다.
참고로 이러한 렌더링 동작은 신규 기능을 사용하는 앱의 일부에서만 활성화되며, 이전 버전에 존재하던 "Concurrent Mode"는 점진적인 채택 전략으로 대체되어 이제 "Concurrent Feature"만 존재하게 되었다.
영어에서 서스펜스란 단어는 "미결" 혹은 "정지"의 뜻도 가지고 있다. 이러한 의미를 떠올려 보며 리액트에서의 <Suspense>
컴포넌트를 다음과 같이 한 문장으로 설명할 수 있을 것 같다.
JSX 내에서 하위 항목(children)의 로드가 완료될 때까지 대체 항목(fallback)을 표시할 수 있도록 하는 React의 컴포넌트
💡 이 컴포넌트의 기본 아이디어는 'props
나 state
처럼 쉽게 네트워크를 통해 데이터를 읽는 것'으로부터 시작된다.
React 16-17 까지의 Suspense
Suspense에 대한 개념은 2018년부터 존재해 왔지만, 이 시절의 Suspense에는 제한 사항이 있었다.
공식에서 설명하는 사용 사례를 보면 React.lazy
API를 사용해 동적으로 코드를 불러오는 게 가능했는데, 서버 렌더링에서는 사용이 불가하고 오직 클라이언트에서만 사용할 수 있다는 제약이 있었다.
이에 React 팀은 Suspense를 그 이상으로 활용할 수 있도록, 클라이언트/서버 모두에서 애플리케이션의 모든 데이터에 대한 로딩 경험을 향상시키고자 Suspense 모델을 개선시켜 왔다.
기존의 데이터를 가져오는 방식의 문제점?
리액트 모델의 장점 중 하나가 바로 "컴포넌트를 위에서 아래로 읽어내려가며 코드의 작업을 쉽게 이해할 수 있는 점"이었는데, 데이터를 가져오기 위해 비동기 코드를 도입하는 순간 코드 읽기는 어려워진다. (코드가 무엇을 하는지 이해하기 위해 머릿속으로 이리 저리 찾아다녀야 하므로)
비동기 코드 처리를 위한 Data Fetching 라이브러리들이 여럿 존재하는데(SWR, React Query 등), 이들을 사용하면 리액트스러운 방식으로 코드를 위에서 아래로 읽는 데 용이해지며 수동으로 작성했을 때에 비해 실수나 경쟁 조건과 같은 버그 발생 확률이 적어 자주활용되는 추세이다.
아래 코드는 비동기로 데이터를 가져 와서 UI에 보여주려고 할 때 흔하게 작성되는 코드이다.
function List({pageId)}) {
const [items, isLoading] = useData(pageId);
if (isLoading) {
return <Spinner />;
}
return items[pageId].map(item =>
<li>{item}</li>
);
}
사실 이 코드가 잘못된 방식이라고 할 수는 없는데, 불러오는 데이터를 추가하거나 제거해야 하는 경우 약간의 어려움이 생기거나 고민해야 하는 사항이 더 많아지곤 한다.
💡 따라서 React 팀은 가독성 향상을 목표로, "데이터 읽기"와 "로딩 상태 처리"를 분리하자는 아이디어를 제안하였다.
React 18의 개선된 Suspense를 통해 데이터 가져오기 처리
// 데이터를 읽는 곳
function List({pageId}) {
const items = useData(pageId);
return items[pageId].map(item =>
<li>{item}</li>
);
}
// 로딩 상태를 처리하는 곳
<Suspense fallback={<Spinner />}>
<List pageId={pageId} />
</Suspense>
컴포넌트 내부에서 로딩 상태를 처리하는 대신 특별한 상위 컴포넌트로 래핑하는 방식이 고안되었으며 이를 위해 등장한 게 바로 <Suspense>
컴포넌트이다. React에게 "아직 하위 JSX 항목이 준비되지 않았으면 그동안 fallback을 표시해 줘"라고 말하는 것과도 같다.
이를 활용하면 코드 내부 어디엔가 숨겨져 있는 로딩 상태를 선언적으로 만들어 업무 흐름에 있어서도 긍정적인 영향을 미칠 것으로 기대된다.
또한 Suspense는 데이터 뿐만 아니라 모든 비동기 리소스에 대해서도 로드 상태를 제어할 수 있도록 확장될 예정으로, 그중 한 가지 예로 서버 측에서 렌더링되는 컨텐츠에 대해 아직 준비가 되지 않은 경우 placeholder HTML을 내보내고 준비가 완료되면 실제 컨텐츠의 HTML을 내보내는 등의 활용 케이스가 있다.
클라이언트와 서버 간에 효율적인 데이터 통신과 풍부한 상호 작용을 가능케 하기 위해 등장한 아키텍처, RSC(React Server Components)라고도 불림
서버 컴포넌트가 하는 일
주 목적은 성능 향상에 있다.
서버 컴포넌트는 개별적으로 데이터를 가져오고 서버에서 완전히 렌더링하며, 그 결과 HTML은 클라이언트 측 컴포넌트 트리로 스트리밍된다. 간단히 말해 서버는 비용이 많이 드는 작업인 렌더링을 처리하고, 클라이언트는 대화형 코드 조각만 처리하여 성능을 최적화할 수 있는 전략이다.
🧑🏫 여기서 대화형 코드 조각이란, 사용자와 상호 작용하면서 동적으로 변하는 코드 부분을 나타낸다(클라이언트 측에서 사용자와의 상호 작용을 담당하는 부분)
만약 상태 변경 등으로 다시 렌더링 되어야 할때는 서버에서 새로 고쳐지고, 기존 DOM에 매끄럽게 통합되어 hard refresh가 필요하지 않다. 이로써 클라이언트 상태가 유지되면서 서버에서 일부 뷰를 업데이트할 수 있다.
서버 컴포넌트의 이점
JavaScript 번들의 크기를 줄이고 초기 페이지 로딩 성능 향상에 도움이 될 수 있다.
서버에서 모든 종속성을 해결하고 코드를 렌더링하여, 이 처리된 결과와 클라이언트 컴포넌트만이 브라우저에 보내지기 때문이다.
동시 리액트와는 별도로 동작하는 기능이지만, 동시 기능들과 잘 동작하도록 설계되었다. 예시로 서버 컴포넌트는 데이터 가져오기와 렌더링이 모두 서버에서 발생하는데, Suspense가 서버 측에서도 대기 기간을 관리하여 전체 왕복 시간을 단축시켜 페이지 렌더링 속도를 높일 수 있었다.
참고 사항
다만 useEffect()
혹은 state
같은 라이프사이클 훅이나 WebSockets 기능을 사용할 수 없다는 제약은 존재한다.
또한 현 시점에서 Next.js App Router에는 기본적으로 서버 컴포넌트의 기능이 포함되기 때문에 추가적인 구성 없이 바로 사용해볼 수도 있다. 물론 클라이언트 컴포넌트도 여전히 사용 가능하며, 적재적소에 서버/클라이언트 컴포넌트를 구분하여 사용하면 된다.
👉 Next.js 공식 문서에서 서버 컴포넌트와 관련된 내용은 이 문서에서 확인할 수 있다.
여러 상태 업데이트 작업을 한 번의 Re-rendering 만으로도 이루어질 수 있도록 그룹화하여 성능을 향상시키기 위한 기능
React 17 또는 그 이전까지의 batching
자동 일괄 처리란, 동일한 클릭 이벤트 내에 2개의 상태 업데이트가 있을 때 모든 업데이트가 항상 하나의 Re-rendering으로 일괄 처리되는 것이다. 아래 코드의 동작을 보면 handleClick
이 호출될 때 마다 단일 렌더링이 일어나는 걸 확인할 수 있다.
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c+1); // 아직 리렌더링 되지 않음
setFlag(f => !f); // 아직 리렌더링 되지 않음
// 마지막에 한 번만 리렌더링 (이것이 일괄 처리!)
}
return (
<>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? 'blue' : 'black' }}>{count}</h1>
</>
);
이 경우 불필요한 리렌더링이 감소하여 성능에 좋으며, 하나의 상태만 업데이트에 반영되어 버그가 발생할 수 있는 "half-finished" 상태로 렌더링되는 것을 방지할 수 있다.
🤔 그런데 React 18 이전까지는 업데이트 일괄 처리가 언제 이루어져야 하는지에 대한 일관성이 없었다. 오직 리액트 이벤트 핸들러에서만 업데이트를 일괄 처리하였고 promises
, setTimeout
, native event handlers 등의 업데이트는 기본적으로 일괄 처리되지 않았다.
예로, 다음과 같은 경우에는 fetch callback에서 이벤트가 이미 처리된 이후 상태 업데이트가 일어났다.
fetchSomething().then(() => {
// 아래 업데이트는 일괄 처리되지 않음
setCount(c => c+1); // 리렌더링 발생
setFlag(f => !f); // 리렌더링 발생
});
새로운 일괄 처리 동작
이제 timeouts, promises, native event handlers 또는 기타 이벤트 내부에 있는 업데이트가 모두 일괄 처리된다.
fetchSomething().then(() => {
setCount(c => c+1);
setFlag(f => !f);
}); // 마지막에 한 번만 리렌더링 수행
만약 자동 일괄 처리를 적용시키고 싶지 않은 경우, flushSync를 사용할 수 있다.
(그렇지만 흔하게 사용되지 않으며 자칫하면 앱 성능을 저하시킬 수도 있다.)
긴급한 업데이트와 그렇지 않은 업데이트를 구분하기 위해 도입된 개념
리액트에게 어떤 업데이트가 긴급(urgent) 업데이트이고, 어떤 것이 트랜지션(transition)인지 알리기 위한 기능이다. 이를 활용하면 위해 UI를 차단하지 않고 상태 업데이트가 가능해 사용자 경험을 개선시키는 데 도움이 된다.
예시 상황
예를 들어 상단에 여러 개의 Tab이 존재하는 페이지를 떠올려 보자. 가령 사용자가 탭을 클릭했는데 마음이 바뀌어 바로 다른 탭을 클릭한 경우, 이전 Re-rendering이 완료되기까지 기다리지 않고도 탭을 전환할 수 있다.
바로 다음과 같은 상황이 바로 트랜지션을 적용하기 좋은 곳이다.
예시 코드
트랜지션 훅을 활용해 트랜지션 처리를 할 수 있다.
useTransition
에서 반환된 startTransition
함수를 사용하면 상태 업데이트를 트랜지션으로 표시할 수 있다.아래 코드는 위의 <예시 상황>에서의 처리를 구현한 예시 코드이다.
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<TabButton isActive={tab === 'about'} onClick={() => selectTab('about')}>
About
</TabButton>
<TabButton isActive={tab === 'posts'} onClick={() => selectTab('posts')}>
Posts
</TabButton>
<TabButton isActive={tab === 'contact'} onClick={() => selectTab('contact')}>
Contact
</TabButton>
...
</>
);
트랜지션은 동시 렌더링으로 인해 업데이트가 중단될 수 있도록 한다. 설명을 좀 더 덧붙이자면, 여러 작업이 동시에 진행될 수 있는 상황에 상대적으로 중요한 업데이트가 있다면 다른 작업은 중단시킬 수 있다는 의미이다. (사실 중단이라기 보단, 완료되지 않은 기존의 렌더링 작업을 폐기하고 최신 업데이트만 렌더링 하는 쪽에 더 가깝다)
지금까지 새롭게 소개되거나 혹은 이전 버전과는 다르게 동작하는 몇 가지 주요 기능에 대해 알아보았다.
React 18 버전 업데이트에서는 무엇보다 React 팀의 지속적인 혁신과 사용자 경험 향상에 대한 열정에 대해 느낄 수 있었다. 또한 대체적으로 애플리케이션의 성능 및 효율성, 유연성을 향상시키기 위한 노력이 많이 엿보였다.
대부분 업데이트가 기존에 존재하던 개념이나 기능을 완전히 대체하기 위해 등장한 것은 아니라서, 기존의 것들과 잘 융화시키며 적당한 곳에 잘 사용한다면 좋은 시너지를 얻을 수 있을 것 같다. 앞으로 계속 될 리액트의 발전 또한 기대가 된다.