Concurrent Mode

"React 앱이 빠른 반응속도를 유지하도록 하고 사용자의 장치 기능 및 네트워크 속도에 적절하게 맞추도록 돕는 새로운 기능들의 집합체"

동시성 모드

" Concurrent React는 많은 작업을 한번에, 그들의 우선순위를 바꿔 가면서 동작하게 할 수 있게 한다." - Andrew Clark (facebook core developer)

Blocking vs Interruptible rendering

흔히 알다들 시피 Javascript 는 싱글 스레드다. 하나의 task 가 실행되는 동안 다른 task 는 block이 되는 것이다.
React 가 DOM node 를 생성하는 것과 컴포넌트 내 코드를 실행하는 update 를 rendering 하기 시작하면, 이 작업을 중단할 수 없다. 이것을 Blocking rendering 이라고 한다.
Concurrent Mode 에서는 rendering 이 block 되지 않는다. 중단 가능하다? 이것은 유저 경험을 향상시킴과 동시에 이전에 불가능했던 새로운 기능을 도입할 수 있다.

목록 필터 예시

우리가 흔히 예시로 구현하는 TODO 앱을 만든다고 생각해보자. 검색을 통해서 목록 필터링을 한다고 했을 때, 필터에 키를 입력할 때 마다 버벅이는 현상이 발생할 수 있다 (목록이 많을 수록 이러한 현상이 발생할 가능성이 높다). 이러한 현상은 왜 일어나는 것일까?
버벅거리는 이유는 간단하다. 렌더링이 시작되면 중간에 중단(interruption)될 수 없기 때문이다!
이러한 현상을 해결하기 위해 주로 debouncing 과 throttle 방법으로 해결하고는 한다.(참조) 하지만 위의 두 방법마저도 사용자 경험에 그렇게 좋지는 않다.
interruptible rendering 은 근본적으로 해당 문제가 발생하는 원인인 blocking rendering 을 피할 수 있도록 해준다.
위와 같이 거대한 dom redering 이 발생할 때 block rendering 의 경우는 랜더링하는 동안 텍스트 입력을 업데이트 하는 것을 차단하지만, inturruptible rendering 에서는 해당 테스트 입력을 업데이트 하는 것을 bolck 할 필요가 없다고 알려주는 것이다.브라우저가 입력에 대한 업데이트를 paint 하고 메모리 내에 있는 업데이트 목록을 계속 렌더링할 수 있도록 한다.
요약하자면, Concurrent mode 를 사용하면 React는 랜더링을 즉시 시작할 수도 있지만, 앱에 즉각적인 반응이 요구될 경우 작업을 잠시 중단할 수도 있다. 즉 업데이트의 ‘급함’의 정도를 결정해서 의도적으로 랜더링을 조절할 수 있다.

Suspense

React 16.6 버전에서는 코드를 불러오는 동안 “기다릴 수 있고”, 기다리는 동안 로딩 상태(스피너와 같은 것)를 선언적으로 지정할 수 있도록 <Suspense> 컴포넌트가 추가.

그게 뭔가요?

선언적으로 데이터를 비롯한 무엇이든 기다릴 수 있도록 해주는 새로운 기능.
컴포넌트가 렌더링되기 전까지 기다릴 수 있다.
Suspense는 데이터를 불러오는 라이브러리가 아니라, Suspense는 컴포넌트가 읽어들이고 있는 데이터가 아직 준비되지 않았다고 React에 알려줄 수 있는, 데이터 불러오기 라이브러리에서 사용할 수 있는 메커니즘. (리액트 공식 홈페이지에서는 다음과 같은 문구가 있다. Suspense는 기술적으로는 사용 가능한 상태이지만, 컴포넌트가 렌더링될 때 Suspense를 사용하여 데이터 불러오기를 시작하는 것은 현재 의도된 사용 방식이 아닙니다. 오히려, Suspense는 컴포넌트로 하여금 이미 불러오기가 완료된 데이터를 “기다리는 중”임을 나타내도록 해줍니다)

기존의 접근 방식 vs Suspense

렌더링 직후 불러오기 (예를 들어, useEffect or componentDidMount 내에서 fetch)

waterfall 문제가 발생할 수 있다. (예시)
1. 사용자 정보 불러오기 시작 2. 기다리기… 3. 사용자 정보 불러오기 완료 4. 게시글 불러오기 5. 기다리기… 6. 게시글 불러오기 완료
JavaScript
복사
이를 고치는 것은 가능하지만, 앱이 거대해짐에 따라 많은 사람들은 이 문제를 방지할 수 있는 해결책을 원할 것.

불러오기 이후 렌더링

Promise.all 을 사용한다면 모든 데이터가 받아질 때까지 기다려야함.(posts 데이터를 받지 못하면 user 도 랜더링 되지 않는다?) 하지만 해당 케이스에서는 Pomise.all 을 제거해서 두 Promise 를 따로 기다리면 된다.
const promise = fetchProfileData(); function ProfilePage() { const [user, setUser] = useState(null); const [posts, setPosts] = useState(null); useEffect(() => { promise.then(data => { setUser(data.user); setPosts(data.posts); }); }, []); if (user === null) { return <p>Loading profile...</p>; } return ( <> <h1>{user.name}</h1> <ProfileTimeline posts={posts} /> </> ); } // 자식 컴포넌트들은 더 이상 불러오기를 발동시키지 않습니다 function ProfileTimeline({ posts }) { if (posts === null) { return <h2>Loading posts...</h2>; } return ( <ul> {posts.map(post => ( <li key={post.id}>{post.text}</li> ))} </ul> ); }
JavaScript
복사
하지만, 이러한 접근 방식은 데이터와 컴포넌트 트리의 복잡도가 커짐에 따라 점점 더 어려워집니다

불러올 때 렌더링 (예를 들어, Suspense와 함께 Relay 사용)

1.
이미 fetchProfileData() 내에서 요청을 발동시켰습니다. 이 함수는 프라미스가 아니라 특별한 “자원”을 돌려줍니다. 보다 현실적인 예시에서는, Relay와 같은 데이터 라이브러리에서 제공하는 Suspense 통합을 제공할 겁니다.
2.
React는 <ProfilePage>의 렌더링을 시도합니다. 자식 컴포넌트로 <ProfileDetails>와 <ProfileTimeline>을 반환합니다.
3.
React는 <ProfileDetails>의 렌더링을 시도합니다. resource.user.read()를 호출합니다. 아직 불러온 데이터가 아무 것도 없으므로, 이 컴포넌트는 “정지합니다”. React는 이 컴포넌트를 넘기고, 트리 상의 다른 컴포넌트의 렌더링을 시도합니다.
4.
React는 <ProfileTimeline>의 렌더링을 시도합니다. resource.posts.read()를 호출합니다. 또 한번, 아직 데이터가 없으므로, 이 컴포넌트 또한 “정지합니다”. React는 이 컴포넌트도 넘기고, 트리 상의 다른 컴포넌트의 렌더링을 시도합니다.
5.
렌더링을 시도할 컴포넌트가 남아있지 않습니다. <ProfileDetails>가 정지된 상태이므로, React는 트리 상에서 <ProfielDetails> 위에 존재하는 것 중 가장 가까운 <Suspense> Fallback을 찾습니다. 그것은 <h1>Loading profile...</h1>입니다. 일단, 지금으로서는 할 일이 다 끝났습니다.

장점

데이터를 불러오는 라이브러리가 Suspense 를 지원한다면 React 에서 로딩을 구현하는 것에 있어서 데이터적인 관점이 아니라 시각적인 관점으로 구현이 가능할 듯 하다.
Suspense 범위를 정함으로써 쉽게 경계를 구분할 수 있다.
조금이라도 더 빠른 API 호출을 통해서 빠른 렌더링이 가능하도록 한다 ⇒ UX 향상

Concurrent UI Pattern

트랜지션

일반적으로 상태가 갱신될 때 화면의 즉각적인 변화를 기대합니다. 애플리케이션이 사용자 입력에 반응하는 것을 유지하고 싶기 때문입니다. 하지만 화면에 나타나는 변화를 지연하고 싶은 경우도 있습니다. 예를 들어 한 페이지에서 다른 페이지로 전환할 때 다음 화면에 필요한 코드나 데이터가 전혀 준비되어 있지 않으면 순간적으로 빈 화면에 로딩 중인 모습이 보이고 답답할 수 있습니다. 이전 화면을 좀 더 길게 보여주고 싶을 때도 있습니다. - react official
해당 예시 를 보면 Next 버튼을 누르면 페이지 데이터가 사라지고 전체 화면에 로딩화면을 다시 보게 된다. 새 데이터를 불러오는 동안 화면 변화를 생략하려고 한다고 가정해보자
React 는 이 문제를 해결하기 위해 새로운 useTransition 내장 훅을 제공한다. (concurrent 모드가 적용되어야함)
function App() { const [resource, setResource] = useState(initialResource); const [startTransition, isPending] = useTransition({ timeoutMs: 3000 });
JavaScript
복사
startTransition - 함수. React 에 어떤 상태변화를 지연하고 싶은지 지정 가능
isPending - 트랜지션 진행 여부
useTransition 내에 있는 timeoutMs 는 지정한 만큼의 시간이 지나면 지연하지 않고 다음 화면을 보여주는 것.
<button onClick={() => { startTransition(() => { const nextUserId = getNextId(resource.userId); setResource(fetchProfileData(nextUserId)); }); }} >
JavaScript
복사
만약 위의 예시가 오히려 사용자 경험이 나쁘다고 느껴진다면 isPending 값을 사용해보자
<button disabled={isPending} onClick={() => { startTransition(() => { const nextUserId = getNextId(resource.userId); setResource(fetchProfileData(nextUserId)); }); }} > Next </button> {isPending ? "Loading..." : null} <ProfilePage resource={resource} />
JavaScript
복사

이 모든 것은 User Experience 를 위하여...

Concurrent 모드 기능의 모든 기능의 공통 주제는 바로 사용자 경험에 대한 연구 결과가 실제 UI와 통합되도록 돕는 것 에 있다
UX(사용자 경험)에 중점을 둔 팀은 가끔 이러한 비슷한 문제들을 일회성 솔루션으로 해결하곤 합니다. 그러나, 그런 솔루션들은 오래 지속하기가 어렵고 유지하기도 어렵습니다. Concurrent 모드를 통한 목적은 UI 조사 결과를 추상화시키고 그것을 사용할 관용적인 방법을 제공하는 것입니다. UI 라이브러리로서, React는 이러한 일을 할 수 있습니다. - React official site
최근 React 팀에서 개발자 경험에 대해서 강조해왔지만 거대한 App 에서 사용자 경험에 대한 충분한 가이드를 제공하지 못한 것에 대한 자성의 목소리가 나온듯 하다.
UX 를 전혀 생각하지 않고 개발하는 것은 아니지만 항상 개발 관점에서 먼저 생각하는 것 같은데 좀 더 UX 를 신경쓰면서 쉽게 UX 친화적인 구현을 할 수 있게 될 것 같다. 늦어도 22년 즈음에는 무리 없게 사용할 수 있지 않을까????

ps

현재 실험 배포판에서만 사용 가능합니다
변경사항이 종종 발생한다
현재 많은 수의 서드파티 라이브러리 등을 사용하고 있다면 호환이 될지 안될지도 모릅니다
그냥 프로덕션용으로는 아직 무리라는 말
현재 ReactDOM 만 지원하고 있어서 React Native 에서는 사용이 안될 것 같다. 하지만 언젠가 지원하는 업데이트가 올라올 것이라 생각이 든다.
현재 Suspense 를 지원하는 data fetching 라이브러리는 Relay 만 있다고 하는데(공식 문서 상으로) 써드 파티들도 곧 따라오지 않을까 예상. 그런데 그냥... Promise 로 구현하면 사용할 수 있지 않나 생각하는데 자세히 살펴봐야할 듯 하다.

참고