Concurrent React

dante Yoon·2022년 8월 4일
8

react

목록 보기
8/19
post-thumbnail

포스팅 오디오로 듣기

공식적으로 더 이상 concurrent mode가 아닌 concurrent feature이 되었다. 지금까지 어떻게 흘러온 것일까?

concurrent 라는 단어는 원래 async rendering이라는 용어로 불리었었다. 리엑트 코어팀(이었던) andrew clark은 몇년 전 react conf 18에서 async rendering을 준비해오고 있다고 말했다.

그가 처음 공식적으로 리엑트에서 비동기 렌더링 개념에 대해 언급한 것은 무려 리엑트가 16버전이었고 아직 훅도 나오지 않았을 때다.

18년도면 머신러닝을 공부하고 있었을 때였는데..

무거운 렌더링 동작이 일어나고 있을 때 유저 인터랙션이 일어난다면, 이 때 렌더링은 메인 스레드를 점령하고 있기 때문에 진행 중인 작업이 다 완료된 이후가 되어서야 유저는 user event 동작에 따른 인터렉션 반응을 목도할 수 있을 것이다.

useLayoutEffect를 이용해 useEvent를 구현하는 우회 방법을 제시하기도 했던 코어팀의 Sophie Alpert는 웹워커를 사용해 다른 스레드에서 우선순위가 높은 렌더링을 수행하는 방법에 대해 이상적이지 않다고 말했다.

코어팀이 원한 것은 렌더링 도중 유저 인터렉션이 일어났을 때 유연하게 렌더링 우선순위를 바꿔 사용자에게 최상의 인터렉션 경험을 제공하는 것이었다.

이는 앞서 andrew가 18년도 conf에서 언급했던 이전의 리엑트 렌더링 문제점, 유저 이벤트에 대한 렌더링 반응의 우선순위를 앞당기는 등의 행동을 할 수 있는 기능 제공에 리엑트 팀이 큰 관심을 기울였다는 말이기도 하다.

훅이 나온지 얼마 되지 않았을 때는 이러한 개념을 React Fiber라고 명명하고, 당시 나온 리엑트 서적들의 가장 마지막 목차는 누가 뭐래도 이 파이버에 대한 언급을 하고 마무리를 짓고는 했다.

기존 async rendering이라고 이름 붙였던 이러한 피쳐에 대해 코어팀은 Concurrent React로 재명명하고 연구를 이어갔다. async 뜻의 의미가 너무 포괄적이다는 이유였다.

그리고 현재 react18의 startTransition 훅이나 useDeferredValue와 같은 api 명세에 대한 힌트가 주어진다.

우선 순위에 따라 돔에 변경점을 적용하지 않고 부분적으로 화면을 렌더링한다. 이러한 목표를 가지고 작업을 해서인지 concurrent feature는 리엑트 전체적인 부분에 디폴트로 적용하는 것이 아닌, 개발자가 원하는 세부적인 부분에 직접 적용하는 것으로 api가 만들어지게 되었다.

그러다 갑자기 19년도에 Concurrent Mode라는 용어를 들고 나온다.

그리고 드디어 작년 12월에 해당 기능의 이름이 확정되었다.

Concurrent mode라는 용어는 이제 더 이상 존재하지 않습니다.

  • 높은 우선순위를 정하는 useDeferredValue 와
  • 낮은 우선순위를 정하는 startTransition api가 리엑트 18에서 공개되었다.

잠깐만, async rendering이라니 synchronous rendering도 있는거야?

여러분이 핸드폰을 바꾸듯 빠르게 회사 코드를 변경하는 얼리어답트가 아니라면, 아마 지금 사용하고 있는 리엑트가 하는 렌더링 방식이 synchronous한 렌더링 방식일 것이다.

아래에서 최 하단의 세개의 원은 동일한 외부 상태를 참조하고 있다. 리엑트가 모든 렌더링 트리를 업데이트 하는 동안 어떤 인터럽션도 일어날 수 없기 때문에, 세개의 원이 모두 파란색으로 업데이트 될 때까지 외부 상태 값 또한 변경될 수 없다.

모든 렌더링 트리의 업데이트가 마무리 되고 난 이후에야 외부 상태 값이 업데이트 된다.
리엑트가 렌더링 단계에 들어갈 시점에는 이미 모든 컴포넌트가 같은 외부상태 값을 참조하고 있는 시점이기 때문에, 다음 상태 값이 변경되는 시점과 관계 없이, UI간의 불일치는 절대로 일어나지 않는다.

https://github.com/reactwg/react-18/discussions/69

구현 상세가 꽁꽁 감춰진 concurrent feature.

아래 내용은 리엑트 공식 문서의 concurrent feature 부분의 일부를 내가 번역한 것이다.

리엑트는 내부 깊숙한 곳에 우선순위 큐, 다중 버퍼링과 같은 정교한 알고리즘을 자체적으로 구현하고 있다. 리엑트 개발자들은 구체적인 구현은 내부 깊숙한 곳에 숨겨놓았기 때문에, 유저들은 기본적인 API만 사용할 수 있다.

리엑트를 개발한 사람들은 리엑트를 사용하는 개발자들이 수면 아래에서 어떻게 동시성이 일어나는지 이해하는 것을 기대하지 않는다.
하지만 리엑트의 렌더링 모델에 근본적인 변화를 가져왔기 때문에, 어떻게 변경되었는지는 알 필요가 없더라도 무엇이 바뀌었는지는 아는 것이 중요하다.

리엑트의 동시성에서 중요한 포인트는 렌더링이 중간에 중단될 수 있다는 점이다. concurrent feature을 코드에 적용하기 전에는, 이전 버전과 동일하게 리엑트는 동기적으로 렌더링한다.

렌더링이 시작되고 나면, 유저가 화면에서 UI 변경점을 확인하기 전까지는 임의로 중단시킬 수 없다. concurrent 모드에서는 변경점을 렌더링하다 중간에 멈추고 나중에 다시 재개할 수 있다.
심지어 진행 중이었던 렌더링을 완전히 포기할 수도 있다. 전체 렌더링 트리가 평가될때까지 돔 조작을 제일 나중으로 미룬다.
이 기능 덕분에 리엑트는 메인 스레드를 막지 않고 백그라운드에 새로운 화면을 준비해둘 수 있다.
이것은 렌더링 시간이 오래걸리는 작업이 현재 진행 중이더라도 유저 인터렉션에 UI가 즉각적으로 반응할 수 있도록 도와준다. 이 덕분에 리엑트 개발자는 유저에게 좋은 사용 경험을 제공해줄수 있다.

리엑트가 렌더링 도중 우선순위가 높은 일에 스레드를 양보하기 때문에, 네트워크 통신이나 유저 인터렉션이 컴포넌트가 참조하는 상태 값을 변경시킬 수 있는 위험성이 존재한다.

또 다른 예제는 재사용 가능한 상태(state)이다. concurrent react는 화면에서 ui를 부분적으로 없앴다가 이전 상태 값을 사용할 때 재사용할 수 있다. 웹사이트 사용자가 현재 스크린을 벗어났다가 다시 돌아왔을 때 리엑트는 이전 화면을 복구한다.

향후 리엑트 18 마이너 버전이 업그레이드 되면서 OffScreen 컴포넌트를 통해 이러한 패턴을 제공될 계획이다. 이 컴포넌트를 사용해 새로운 UI를 유저가 알아차리지 못하게 백그라운드에서 준비할 수 있다.

Tearing

tearing은 무엇인가?
wiki Screen tearing

의도치 않게 여러 가지의 UI가 표현되는 것을 의미한다.
자바스크립트는 싱글 스레드에서 동작하므로 이런 일이 벌어지지 않는다.
React18의 concurrent feature에서는 렌더링 도중 우선순위가 높은 UI 변경에게 스레드를 위임하므로 이러한 문제가 일어날 수 있다. 하나의 동작을 수행하다가 다른 동작을 수행하기 때문이다.

어떻게 이런일이 발생하는지 이해할 수가 없다.

전 리엑트 코어팀이었던 Flarnie Marchan는 React Conf19에서 이러한 문제가 발생하는 이유에 대해 설명했다.

다음은 리엑트가 렌더링하는 트리를 도식화 한 것이다.

위의 네모난 두 개는 트리의 컴포넌트들을 무슨 색으로 렌더링 할지를 결정하는 버튼이다.
이 트리를 모두 빨간색으로 렌더링하는데는 꽤나 오랜 시간이 걸린다고 가정하자.

concurrent feature 이 트리에 적용하고, 빨간색으로 렌더링 도중 버튼 클릭을 통해 사용자가 초록색 버튼을 클릭한다면, 이미 렌더링이 끝난 두번째 까지의 동그라미만 빨간색으로 표시되고, 나머지 버튼들은 초록색으로 표시가 될 것이다.

각 컴포넌트가 상태를 읽어오는 시점이 다르고, 이 상태가 도중에 변경되었으므로 프로그래머의 의도와는 다르게 한 앱에서 여러개의 상태와 UI가 불일치 하는 tearing 문제가 생기게 된다.

실제 예제 코드를 보자.
https://github.com/reactwg/react-18/discussions/69

큰일났다. 자바스크립트가 갑자기 멀티 스레드 프로그래밍 언어가 된 것인가?
concurrent feature는 개발자가 선택한 일부 코드에 대해서만 적용되기 때문에, 유의 사항을 잘 숙지하고 이를 방지하는 코드를 사용해야 한다. 개선안이나 개선 코드나 개선된 라이브러리 모두 훅 기반을 위주로 작업이 되기 때문에 이제 훅 사용은 선택이 아니라 필수가 되었다.

내부 상태 / 외부 상태

internal state / external state 에 대해 들어보았는가?

https://www.youtube.com/watch?v=oPfSC5bQPR8

  • internal state: useState , useReducer, context, useRef
  • external state: redux, mobx, zustand, zotai, recoil, useRef

internal state를 변경할 때 리엑트는 즉각적으로 상태를 변경하지 않고 큐를 업데이트 하고 렌더링을 스케쥴 한다. 리엑트가 렌더링을 시작하면 내부적인 알고리즘과 비교 연산을 통해 큐 전체에서 어떤 값을 참조할지를 결정하고 이 덕분에 렌더링에서 tearing이 발생하지 않는다.

타이머를 통해 렌더링 도중 setState가 일어나더라도, 리엑트는 이번 렌더링에 큐에 들어간 해당 상태 업데이트가 렌더링 트리 변경을 일으킨 상태 값과 연관이 없다면, 렌더링을 다 완료한 이후에야 상태 업데이트를 반영할 렌더링을 다시 수행한다.

만약 렌더링을 유발시킨 상태에 대한 업데이트가 렌더링 도중 일어난다면, 리엑트는 똑똑하게 다시 렌더링을 수행할 필요 없이 최신 상태를 참조하여 렌더링을 수행한다.

여기서 키 포인트는 리엑트가 tearing을 막기 위해 내부적으로 상태 업데이트 큐잉에 대한 알고리즘을 구현했다는 것인데, 외부 상태를 사용할 경우 이러한 리엑트의 노력이 깨지게 된다.

상태가 렌더링 도중 새로운 값으로 큐에 추가되는게 아니라 그 값 자체가 변경될 수 있기 때문에, 외부 상태를 사용할 때는 아래 세가지 중 한가지를 만족해야 한다.

  • 리엑트에게 상태가 업데이트 했으니 다시 렌더링 해야 한다고 알려준다.
  • 리엑트가 렌더링을 중단하고 다시 최신 값을 참조해 렌더링 하게 한다.
  • 리엑트가 렌더링 중에는 상태 값을 변경 못하게 해야 한다.

이러한 조건에 의거해서 external store를 제공하는 라이브러리들은 concurrent feature를 대비하여야 하며 제시하는 해결점은 세 단계의 레벨로 나뉘어진다. 레벨이 높을 수록 좋은 방법이다.

레벨 1: tearing을 감수한다.

UI 불일치를 보는 것을 감수한다.

useSubscription 훅은 첫 렌더링 이후 synchronous update를 실행해서 tearing을 잡는다. 렌더링 도중 에러가 발생하면 리엑트는 가장 가까운 상위 레벨의 에러 바운더리에서 다시 한번 synchronous rendering을 실행한다. 이 방법을 쓰는 것은 concurrent rendering을 완전히 포기하는 것이며 최악의 경우 유저가 flash 효과를 화면에서 보게 된다.

레벨 2: 바로잡기

tearing을 바로잡는 대신 경우에 따라 렌더링이 오래 걸리는 것을 감수하는 방법으로
useSyncExternalStore 훅을 사용하면 rendering 도중에 일어나는 external state의 변경사항을 발견하고 tearing이 발생하기 전에 렌더링을 다시 실행한다. 렌더링이 다소 오래 걸릴 수 있다는 단점이 있지만 tearing이 발생하지 않는다는 것을 보장한다.

레벨 3: 속도의 개선

속도와 tearing 모두 손해보지 않고 사이드 이펙트를 해결하는 방법으로 internal state를 사용한다면 이미 레벨 3를 달성하고 있는 것이다.

다른 방법은 변경 불가능한 스냅샷을 변경 가능한 상태 저장소에 넣고 렌더링 중간에는 스토어를 변경하지 않는 것이다. 이러한 기능은 아직 연구 중이다.

profile
성장을 향한 작은 몸부림의 흔적들

2개의 댓글

comment-user-thumbnail
2022년 8월 8일

한번 기회가 된다면 concurrent feature의 세부 구현을 이해해보고 싶다는 생각이 드네요.. 재밌게 잘 읽었습니다!!

답글 달기
comment-user-thumbnail
2022년 11월 13일

오오!! 글 너무 재밌어요!
concurrent feature가 언제부터 논의되었는지, 어떤 방향, 어떤 목적으로 흘러왔는지 이해할 수 있었어요.
양질의 자료를 참고하셔서 공식문서나, 타블로그에서 보기 힘든 내용들이 많아서 좋았습니다!!
감사합니다!

질문 하나 해도 괜찮을까요??!
Internal store / external store에서 잘 잡히지 않는 부분이 있습니다.
가령 recoil(external store) 의 구현체도 Context + useState(internal store) 등으로 구현되어 있다고 생각했는데 이게 아닌가 보군요?
아니면.. useState 기반으로 만들어진 라이브러리들은 internal store라고 인식할 수 있는 것인지, 헷갈립니다!

답글 달기