[Reactjs] setState() 왜 비동기로 처리되는가?

Dongmin Jang
12 min readAug 14, 2018

--

사실 저는 그동안 setState 가 왜 비동기로 처리되는지에 대한 고민을 한적이 없습니다. 누군가가 sns에 올린 글을 보고서야 이게 비동기로 처리되는구나 하고 인지하게 되었습니다. 이왕 알게된거 왜 비동기로 처리되는지 확인해보려 합니다.

아래 글에 ‘gaearon’님이 작성한 글을 봅시다.
gaearon 님의 답변글을 아래와 같이 번역(?) 해봤습니다.

먼저, 저는 일괄(batch) 업데이트를 위해 조정(reconciliation?)을 지연하는 것이 도움이 된다는데 동의합니다. 이것은 많은 경우에 setState()를 동기적으로 re-rendering 하는 것이 비효율적이라는 것에 동의 한다는 것입니다. 그리고 우리가 여러 항목을 얻을 것을 알고 있다면, 일괄(batch) 업데이트 하는 것이 좋습니다.

한가지 예를 들겠습니다. click handler 가 있는데, 이는 Child와 Parent의 setState를 호출합니다. 그런데 Child 가 두번 반복해서 re-render 가 일어나는 것을 원치는 않습니다. 대신에 dirty라고 표시를 하고, 브라우저 이벤트가 끝나기 전에 함께 re-render 처리 되기를 원합니다.

질문 : 조정의 작업이 끝나기를 기다리지 않고 this.state를 즉시 업데이트 하기 위해 setState를 하는데, 왜 batching 이 진행 되지 않나요? (아래 해석 좀 해주세요…영어 어렵습니다.)
한가지 명확한 이유가 있는 것은 아니고, 제가 생각하는 몇가지 이유는 있습니다.

why can’t we do the same exact thing (batching) but write setState updates immediately to this.state without waiting for the end of reconciliation. I don’t think there’s one obvious answer (either solution has tradeoffs) but here’s a few reasons that I can think of.

내부 일관성 보장

state는 동기적으로 업데이트가 되지만, props 는 그렇지 않습니다.
(부모 컴포넌트가 re-render 될 때까지, props를 알 수가 없습니다. 만약 이를 동기적으로 처리 하려고 한다면 batching 은 윈도우 밖으로 나갈 것입니다.) (뭔가 잘 안된다는 표현이겠죠?)

React에 의해 제공된 objects (state, props, refs) 는 각각이 내부적으로 일관성을 갖습니다. 이는 당신이 저 objects만 사용한다면, 그것들은 완전한 reconciled tree를 가르키게 될 것입니다. ( 해당 트리의 이전 버전일지라도). 이게 왜 중요할까요?

state 만 사용할 때, 동기적으로 flush 된다면( 질문자의 제안대로..) 다음과 같이 작동할 것입니다:

console.log(this.state.value) // 0
this.setState({ value: this.state.value + 1 });
console.log(this.state.value) // 1
this.setState({ value: this.state.value + 1 });
console.log(this.state.value) // 2

그러나, state를 컴포넌트간 공유하기 위해 옮겨져야 한다면, 부모 컴포넌트로 이동시켜야 합니다 :

-this.setState({ value: this.state.value + 1 });
+this.props.onIncrement(); // 부모컴포넌트에 동일하게 구현되어 있어야 함

setState() 에 의존하는 전형적인 React App들에서는 이러한 방법이 React-specific refactoring의 가장 일반적인 방법이라고 강조하고 싶습니다.

그런데, 여기서 문제가 있습니다.

console.log(this.props.value) // 0
this.props.onIncrement();
console.log(this.props.value) // 0
this.props.onIncrement();
console.log(this.props.value) // 0
직접 해봤는데, 정말 이렇게 되는군요. 그런데 실무에서는 이런 코드를 써본 적이 없어요.

this.state는 바로 flush 되었지만, this.props는 그렇지 않습니다. 부모 컴포넌트의 re-render 없이 this.props 는 즉각적으로 flush 되지 않습니다. 즉, 일괄처리 (batching) 를 포기해야 한다는 것입니다. (경우에 따라서, 많은 성능 저하가 있을 수 있습니다.)

이것이 어떻게 문제가 될 것인지에 대한 더 미묘한 사례들이 있습니다. 예를 들면, props(flush되지 않은), state(즉시 flush 되도록 고안 된) 를 섞어서 새로운 state를 만드는 것 입니다 : #122 (comment). Refs도 같은 문제를 보입니다 : #122 (comment).

이러한 예제는 이론적이지 않습니다. 사실 React, Redux bindings 는 React props와 non-React state를 섞는 바람에 이러한 문제들이 있었습니다 : reduxjs/react-redux#86, reduxjs/react-redux#99, reduxjs/react-redux#292, reduxjs/redux#1415, reduxjs/react-redux#525.

Mobx 사용자들은 이러한 문제에 봉착하지 않았는지 모르겠습니다. 그러나 제 직관으로는 이러한 문제에 봉착 했으나 그들 스스로 이게 문제라고 여기진 않은 듯 합니다. 아니면 props를 사용하지 않고 대신에 Mobx mutable objects를 사용 했을 수도 있습니다.

그래서 React 는 이것을 어떻게 해결 했을까요? React에서는 this.statethis.props 를 조정하고, flushing 한 후에 업데이트 합니다. 그래서 리팩토링 전, 후 전부 ‘0’을 출력 되었습니다. 이것이 state를 안전하게 옮겨지도록 하는 것입니다.

맞습니다. 어떤 경우에서는 불편함을 줍니다. 특히 더 많은 00배경을 가진 사람들이 한 곳에서 완전한 state 업데이트를 나타내는 방법을 생각하는 대신 여러번 state를 mutate를 하길 원하는 경우 입니다. 비록 저는 (글쓴이) state 업데이트에 집중하는 것이 디버깅 관점에서 더 명확하다고 생각하지만, 위 방법 또한 공감할 수 있습니다: #122 (comment).

렌더링을 위한 소스로 사용하지 않는다면, 변이가능한 object를 즉시 읽을 수 있는 state로 옮길 수 있는 옵션이 있습니다. MobX가 그렇게 해줄 것입니다.

만약 진행 되는 상황이 파악 가능하다면, 트리 전체를 flush 하는 방법도 있습니다. 그 API는 ReactDOM.flushSync(fn) 입니다. 문서화 되어 있지 않지만, 16.x 릴리즈 싸이클에 될 것입니다. call의 내부에서 발생하는 업데이트에 대한 완전한 re-rendering을 강제할 것 이기에 신중히 사용해야 합니다. 이 방법은 props, state, refs 간 내부 일관성의 보장을 깨지는 않습니다.

정리하자면, React model 은 항상 깨끗한 코드를 주도하지 않지만, 내부적으로 일관되며 state를 안전하게 옮기는데 보장해줍니다.

(뭔말이야… 해석을 발로 했네요. 그냥 느낌만 전달 받으세요.)

동시 업데이트 가능

개념적으로, React 는 각 컴포넌트가 단일 업데이트 큐를 가지고 있다고 간주합니다. 이것이 이 논의가 당연한 이유입니다 : 업데이트가 정확한 순서대로 될 것이라는 것에 의심의 여지가 없기 때문에 this.state에 즉각적으로 업데이트를 적용할 것인지 아닌지를 논의합니다. 그런데 그럴 필요가 없습니다.

최근에 우리는 비동기 렌더링에 대해서 많이 얘기했습니다. 저는 그것이 무슨이 무슨 의미인지 잘 전달하지 못했다는 것에 인정합니다. 하지만 그것이 R&D의 본질입니다 : 당신은 아마도 개념적으로 제안하는 아이디어를 이해하려하겠지만, 진정한 의미를 제대로 파악하는 것은 충분한 시간을 보낸 후일 것입니다.

비동기 렌더링에 대해서 설명하는 한가지 방법은 React가 이벤트 핸들러, 네트워크 응답, 애니메이션등 과 같은 분류에 따라 setState() 호출의 우선순위를 할당 할 수 있다는 것입니다.

예를 들어 메세지를 입력한다면 TextBox컴포넌트 안의 setState() 호출은 즉즉시 flush되야 합니다. 그러나 타이핑하는 동안에 새 메세지를 수신하는 상황이라면, 새로운MessageBubble의 특정 임계치 (예.. 1초)까지 지연하는 것이 쓰레드가 블록킹되서 타이핑이 버벅거리는 것보다 나을 것입니다.

만약 특정 update에 ‘낮은 우선순위’가 부여되면 해당 렌더링을 milliseconds 단위의 작은 청크로 나누어 유저가 알아차리지 못하게 할 수 있습니다.

이와 같은 성능 최적화는 흥미롭거나 설득력 있는 것이 아닐 수 있다는 것을 알고 있습니다. 이렇게 얘기할 수도 있습니다 : “MobX에는 이것이(성능 최적화) 필요하지 않습니다. 우리의 업데이트 추적은 re-render를 피할 수 있을 정도로 빠릅니다.”
이 사실이 모든 경우에 해당 된다고 생각하지 않습니다. (예.. MobX의 속도에 상관 없이 당신은 DOM 노드를 만들어야 하고 새로 마운트 된 view를 위해 렌더링 해야 합니다). 만약 이게 사실이고, 항상 읽고 쓰는 것을 추적하는 특정 JS 라이브러리에 wrapping object를 사용하는 것이 괜찮다고 의식적으로 생각한다면, 당신은 아마도 이러한 최적화로부터 큰 이득을 얻지 못 할 것입니다.

(발번역이 이렇게 나오는군요. 제가 읽어도 무슨 말인지 모르겠습니다.)

그러나 비동기 렌더링은 성능 최적화에 대한 것만은 아닙니다. 우리는 React 컴포넌트 모델이 할 수 있는 것에서의 근본적 변화라고 생각합니다.
예를 들면, 한 화면에서 다른 화면으로 넘어가는 경우를 고려해봅시다. 일반적으로 새 화면이 렌더링 되는 동안에 스피너가 보여집니다.

그런데 만약에 내비게이션이 충분히 빠르다면 ( 1초이내), 스피너가 깜박이고 바로 사라져서 UX 저하를 가져올 것입니다. 더 심한 상황에서 서로 다른 비동기 의존(dependency)들과 함께 여러 레벨의 컴포넌트가 있다면, 한개씩 깜박이는 스피너들을 연속적으로 마주하게 될 것입니다. 이는 시각적으로도 좋지 않을 것이고 DOM reflows 때문에 앱의 실제 동작 속도가 느려질 것입니다. 또한 많은 보일러플레이트 코드들의 소스(직역했는데 당췌 무슨 뜻?)이기도 합니다.

다른 화면을 render하는 단순한 setState()를 실행한다면, 백그라운드에서 업데이트 된 화면을 렌더링 하는 것은 어떨까요? 직접 조정하는 코드를 작성하지 않는다고 생각해봅니다. 업데이트가 특정 임계치를 넘어 갈 경우 스피너를 보여줄지 선택할 수 있습니다. 그게 아니면, 전체의 새로운 서브트리의 비동기 의존이 충족 될 때, React가 매끄럽게 변화하도록 합니다. 게다가 대기 상태중에는 기존 화면이 작동하면 유지됩니다. ( 예를 들면.. 전환할 다른 아이템을 선택하도록..) 그리고 너무 오래 걸리면, 스피너를 보여주도록 React 가 강제합니다.

현재의 React 모델과 생명주기에 적용된 몇가지로(some adjustments to lifecycles) 우리는 실제로 이것을 구현 해냈습니다. Andrew Clark 는 지난 몇주간 이러한 기능을 사용했습니다. 그리고 곧 RFC에 게시할 것입니다.

this.state가 즉각 flush 되지 않기 때문에 이러한 것이 가능한 것임을 기억하게요. 만약 즉시 flush 된다면, 기존 화면이 계속 노출되며 작동하는 사이에 백그라운드에서 새로운 화면이 렌더링 될 방법이 없습니다. 그것들의 독립적인 state 업데이트는 충돌이 날 것입니다.

나는 이 모든 것을 발표함으로써 Andrew Clark 의 공로를 뺏고 싶지는 않습니다. 다만 최소한 흥미롭게 전달 됐으면 합니다. 앞서 얘기한 것들이 vaporware 처럼 들릴 수도 있고, 우리가 아직 무엇을 하고 있는지 잘 모르는 것처럼 들릴 수도 있다는 것을 알고 있습니다. 몇달 안에 다른 방법으로 당신을 설득할 수 있었으면 합니다. 그러면 React 모델의 유연함에 감사하게 될 것입니다. 그리고 제가 이해하기로는, 적어도 부분적으로 state 업데이트를 즉각적으로 flush 하지 않는 덕분에 이러한 유연성이 가능합니다.

정리하자면…
React 의 objects( props, state, Refs) 를 비동기적으로 flush 하면서 생기는 이점은 성능 and UI 와 연관이 있는 듯 합니다.
reconciled tree 를 직접 업데이트 하면서 동기적으로 처리하려면 성능적으로 문제가 될 수 있나 봅니다. 그리고 비동기적으로 rendering을 하게 되면서 화면이 멈추지 않고, 서비스가 진행 될 수 있으니 이 부분도 크게 작용하는 듯 싶습니다.

원글 보시고 번역 좀 수정해주세요.

그리고 찾아보니..

Vuejs도 비슷한 내용이 있습니다.

완전히 동일한 내용은 아니지만, 이러한 노력을 React, Vue 모두 하고 있구나 하고 이해하면 좋을 듯 합니다.

--

--