리액트에서
Concurrent mode
라는 키워드를 접했을 때mode
라는 단어에 on, off 처럼 끄고 켤수있는 기능인가? 라는 생각이 들어 공부하게 되었다.
일단 Concurrent mode
라는 단어를 직역하자면 동시성 모드
라는 뜻이다. 여기서 동시성은 동시에 두가지 이상의 일을 지원함을 의미한다. 이와 비슷한 의미인 병행성(parallelism)을 비교해서 알아보자.
동시성(concurrent)은 마치 아침식사를 준비할때 커피물을 끓이는 동안 식빵을 잘라 토스트를 준비하고 토스트를 굽는동안 다 끓은 커피물을 찻잔에 따르는 행위를 할 수 있는것 처럼 한가지 일이 끝날때까지 기다리지 않고 동시에 진행할 수 있는 상태를 의미한다.
병행성(parallelism)은 동시에 두가지 이상의 일을 실행할 수 있다는 뜻으로 마치 팔이 8개인 문어(사실 팔은 6개이고 2개는 다리)는 동시에 두 세 가지 일을 진행 할 수 있는것과 같다.
그렇다면 리액트에서 Concurrent mode
는 무엇일까?
리액트가 돌아가는 환경인 자바스크립트는 싱글 스레드로 동작하기 때문에 동기적으로 동작하는 어떤 작업이 오래걸린다면 그 동안 아무것도 할 수 없다. 그것은 화면을 보여주는 렌더링도 마찬가지이다.
리액트 16버전 이전에는 이런 렌더링 하는 과정이 동기적으로 이루어졌기 때문에 렌더링이 끝날때까지 사용자의 입력도 에니메이션도 처리하지 못하는 경우가 생기게 되었다.
리액트팀은 이를 해결하기 위해서 16버전에서 렌더링 엔진을 변경하게 되었고 스케쥴러를 통해 우선순위에 따라 급한작업이 있다면 먼저 처리할 수 있도록 해결하였다.
동시성 모드는 이러한 리액트팀의 노력으로 만들어진 동시성을 지원하는 환경을 의미한다.
리액트 18 버전이 업데이트 되면서 이전 버전에 대한 호환성을 유지하고 점진적이고 안전한 업그레이드를 지원하기 위해서 기존 ReactDOM.render
를 통한 lagacy mode
와 createRoot
를 통한 concurrent mode
를 선택할 수 있게 하였다.
// index.js
import ReactDOM from 'react-dom';
import App from './App';
const container = document.getElementById('app');
ReactDOM.render(<App />, container);
// index.js
import ReactDOM from 'react-dom';
import App from './App';
const container = document.getElementById('app');
const root = ReactDOM.createRoot(container); // createRoot를 사용한다.
root.render(<App />);
기본적으로 CRA(create-react-app) 으로 생성하게 되면
createRoot
를 사용하게된다.
동시성 모드를 사용하면 리액트 18버전에서 동시성 렌더링을 제공하는 새로운 기능을 사용할 수 있다. 대표적으로는 useTransition
과 useDeferredValue
훅이 있다.
useTransition
훅을 사용하면 isPending
과 startTransition
을 반환한다.
isPending
은 현재 상태 업데이트가 진행중인지 끝나서 보여줄 준비가 되었는지를 판단할 수 있도록 boolean
타입으로 제공된다.
startTransition
은 함수타입으로써 콜백함수를 인자로 받으며 콜백함수 안에서 우선순위를 낮춰 렌더링할 setState 함수를 실행한다.
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('about');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab); // setTab가 변경되서 업데이트되는 랜더링은 낮은 우선순위를 갖는다.
});
}
// ...
}
아래에는 startTransition
을 사용하지 않고 DOM을 조작했을때 DOM이 많아질수록 렌더링을 동기적으로 진행하기 때문에 이미 상태가 바뀌어서 이전 렌더링이 필요 없더라도 멈출 수 없고, 모든 조작이 Blocking 되어 사용자는 아무것도 할 수 없게 된다.
동기적으로 동작하는 렌더링 모습
보라색 박스가 commit 단계로 인한 직접적으로 그려지는 단계이다.
아래에는 startTransition
을 사용하고 나서 DOM을 조작했을때 우선순위에 따라 사용자 입력 같은 급한 작업은 렌더링 과정을 일시중단하고 먼저 처리함으로써 사용자에게 더 좋은 UX를 제공하게된다.
Transition마다 우선순위가 존재하며 불필요한 렌더링은 건너뛰고 마지막 상태만 반영한다.
결론적으로 useDeferredValue
와 useTransition
은 상태의 업데이트 우선순위를 낮춘다는 점에서 같은 동작을 한다. 하지만 startTransition
는 콜백함수 내부에 setState를 사용해야하지만, useDeferredValue
는 state 값을 인자로 받아서 지연된 값을 반환한다. 업데이트 중에 먼저 이전 값을 보여주고 백그라운드에서 업데이트가 완료되면 새 값으로 다시 렌더링을 시도한다.
// ...
const [state , setState] = useState("")
const deferredValue = useDeferredValue(state)
// ...
return (<div>
deferredValue.map((v)=> <li>{v}</li>) // 기존 State처럼 사용하면된다.
</div>)
위 코드에서 state
값은 계속해서 바뀌게 되지만 실제 사용하는 값인 deferredValue
에는 이전 값을 그대로 사용하다가 업데이트가 완료되면 변경된 값을 보여준다.
이외에도 리액트 18에서 동시성을 지원하기 위한 Streaming SSR
과 Selective hydration
기능들이 존재하지만 나중에 다루어 보도록 하자.
Reference