왜 setState는 비동기적으로 동작하는가?

현진·2023년 1월 15일
50

React

목록 보기
5/7
post-thumbnail

이 글에서는 setState의 비동기적 동작에 대한 이유를 찾아봅니다.

setState의 동작

먼저 setState가 어떻게 동작하는지 알아보도록 하겠습니다.

export default function App() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
    alert(count);
  };

  return (
    <div className="App">
      <button onClick={increment}>{count}</button>
    </div>
  );
}


버튼을 누르면 increment 함수가 실행되고 차례대로 setCount(count + 1), alert(count)가 실행됩니다. 만약 setCount(count + 1)이 동기적으로 실행되었다면 alert(count)가 출력하는 것은 1이여야 합니다. 하지만 결과는 0이 출력됩니다.

이는 setCount가 비동기적으로 실행되었다는 것을 의미합니다.
setState를 비동기적으로 설계했을까요?

Why is setState asynchronous? 이슈 번역

검색을 통해 Why is setState asynchronous?(2017년 11월 11일)라는 이슈를 찾을 수 있었습니다.
먼저 이슈를 남긴분의 글을 요약 및 번역해보겠습니다.

저는 setState가 비동기인 이유를 이해하려고 노력했지만 해답을 찾지 못했고 아마도 그것이 리액트 코드에 대한 역사적인 이유(아키텍처적인 문제)이고 아마도 지금은 바꾸기 어려울 것이라는 결론에 도달했습니다. 하지만 Dan은 분명한 이유가 있다고 했으니 궁금합니다.

어쨌거나, 저는 몇가지 이유를 생각해봤으나 너무 쉽게 반박할 수 있기 때문에 이 이유가 전부일 수는 없다고 생각합니다.

1. 비동기 렌더링에는 비동기 setState가 필요합니다.

많은 사람들이 처음에는 렌더링 효율성 때문이라고 생각합니다. 하지만 저는 이것이 이 동작에 대한 이유라고 생각하지 않습니다. 왜냐하면 비동기 렌더링을 하는 동안 setState를 동기적으로 유지하는 것은 간단하다고 생각하기 때문입니다. 아래 코드는 예시입니다.

Component.prototype.setState = (nextState) => {
  this.state = nextState
  if (!this.renderScheduled)
     setImmediate(this.forceUpdate)
}

2. 어떤 상태가 렌더링되었는지 알기 위해서는 비동기 setState가 필요합니다.

가끔 듣는 또 다른 주장은 요청된 상태가 아니라 렌더링된 상태에 대해 추론하기를 원한다는 것입니다. 그러나 저는 이 원칙도 많은 장점이 있는지 확신하지 못합니다. 개념적으로 저에게 이상하게 느껴집니다. 렌더링은 부작용(side effect)에 관한 것이고 상태는 사실(fact)에 관한 것입니다.

비슷한 예를 들어보자면, 인쇄할 때까지 자신이 작성한 워드 문서의 마지막 버전을 읽을 수 없다면 꽤 어색할 것입니다.(타이핑 했는데 자신이 타이핑 한 상태를 바로 읽지 못한다.)



저는 리액트 팀이 setState의 비동기적 특성이 종종 야기하는 혼란을 알고 있다는 것을 의심하지 않습니다. 따라서 현재 동작에 대한 또 다른 매우 좋은 이유가 있다고 생각합니다. 좀 더 이야기 해줘 :)

이 이슈에 대한 Dan의 답변(2018년 1월 25일)을 번역해봤습니다.

먼저 우리들은 일괄(batch) 업데이트를 위해 재조정을 연기하는 것이 유익하다는 것에 동의합니다. 이 의견에 동의한다는 것은 setState()를 사용해서 동기적으로 렌더링하면 많은 경우에 비효율적일 것이고, 업데이트를 여러 개 받을 가능성이 있는 경우 업데이트를 일괄 처리하는 것이 좋다는 것입니다.

예를 들어 우리가 브라우저의 click 핸들러 안에 있고 Child와 Parent 컴포넌트에서 모두 setState를 호출했을 때 우리는 Child를 두번 렌더링하지 않고 dirty 상태로 표시한 다음 브라우저 이벤트가 종료하기 전에 Child와 Parent를 함께 리렌더링합니다.

당신이 묻는 것은 우리가 batching을 똑같이 한 후에 재조정을 기다리지 않고 setState 업데이트를 즉시 this.state에 반영할 수 없냐는 것입니다. 하나의 분명한 대답이 있다고 생각하지 않지만(두 솔루션 모두 절충안이 있음) 생각할 수 있는 몇 가지 이유가 있습니다.

1. 내부 일관성 보장(Guaranteeing Internal Consistency)

상태는 동기적으로 업데이트되더라도 props는 업데이트되지 않습니다.(상위 컴포넌트를 다시 렌더링하기 전에는 props를 알 수 없으며 이를 동기적으로 수행하면 일괄 처리(batching)을 할 수 없습니다. => 재조정이 동기라면 일괄 처리를 할 수 없음.)

현재 리액트에서 제공하는 객체(state, props, refs)는 내부적으로 서로 일관성이 있습니다. 즉 만약 여러분이 그 객체들을 사용한다면 완전히 조정된 트리(fully reconciled tree)를 참조하도록 보장됩니다. 이것이 왜 중요한가요?

상태만 사용할 때 동기식으로 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

그러나 하나의 상태를 몇몇 하위 컴포넌트에서 같이 사용하기 위해 부모 컴포넌트로 이동해야한다고 해보겠습니다.(Lifting State Up)

-this.setState({ value: this.state.value + 1 });
+this.props.onIncrement(); // Does the same thing in a parent

그러나 이것은 우리의 코드를 손상시킵니다!

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는 플러시되지 않기 때문입니다. 그리고 부모를 다시 렌더링하지 않고는 this.props를 즉시 flush 할 수 없습니다.(부모를 리렌더링하지 않고서는 업데이트된 props를 자식에게 넘겨줄 방법이 없다.) 즉 일괄 처리를 포기해야 합니다.(경우에 따라 성능이 크게 저하될 수 있음)

이것에 대한 더 미묘한 케이스들도 있습니다. 예를 들어 만약 여러분이 props(아직 반영되지 않음)와 state(제안해주신 것처럼 바로 반영)를 사용하여 새로운 state를 만드는 상황입니다#122. Ref도 마찬가지 문제가 존재합니다#122.

그렇다면 오늘날 리액트에서는 이 문제를 어떻게 해결할까요? 리액트에서 this.state와 this.props는 재조정 및 flush 후에만 업데이트되므로 리팩토링 전후에 모두 0이 프린트되는 것을 볼 수 있습니다. 이것은 상태 끌어올리기를 안전하게 만들어줍니다.

이것은 경우에 따라 불편할 수 있습니다. 특히 다양한 백그라운드를 가지고 있고 상태를 한번에 업데이트하기 보다 여러번 업데이트하고 싶어하시는 분들에게 말이죠. 저는 그것에 공감할 수 있지만, 상태 업데이트를 집중적으로 유지하는 것이 디버깅 관점에서 더 명확하다고 생각합니다#122.

수행 중인 작업을 알고 있는 경우 전체 트리를 flush 할 수 있습니다. API는 ReactDOM.flushSync(fn)입니다. 실제로 호출 내부에서 발생하는 업데이트에 대해 완전한 리렌더링을 강제하므로 매우 드물게 사용해야 합니다.이렇게하면 props, state 및 refs 간의 내부 일관성 보장이 깨지지 않습니다.

요약하면 리액트 모델은 항상 가장 간결한 코드로 이어지지는 않지만 내부적으로 일관성이 있으며 상태 끌어올리기의 안전성을 보장합니다.

동시 업데이트 활성화(Enabling Concurrent Updates)

개념적으로 리액트는 컴포넌트당 하나의 업데이트 큐(대기열)이 있는 것처럼 동작합니다. 이것이 바로 토론이 의미있는 이유입니다. 우리는 업데이트가 정확한 순서로 적용될 것이라는데 의심의 여지가 없기 때문에 this.state에 업데이트를 즉시 적용할지 여부를 논의합니다.

최근에 우리는 "비동기 렌더링"에 대해 많이 이야기하고 있습니다. 이것이 의미하는 바를 제대로 전달하지 못했다는 점은 인정하지만 이것이 R&D의 본질입니다. 개념적으로 유망해 보이는 아이디어를 추구하지만 충분한 시간을 보낸 후에야 그 의미를 진정으로 이해합니다.

우리가 "비동기 렌더링"을 설명하는 한 가지 방법은 리액트가 이벤트 핸들러, 네트워크 응답, 애니메이션등의 출처에 따라 setState() 호출에 다른 우선순위를 할당할 수 있다는 것입니다.

예를 들어 메시지를 입력하는 경우 TextBox 컴포넌트의 setState 호출을 즉시 flush 해야합니다. 그러나 입력하는 동안 새 메시지를 수신하는 경우 스레드 차단으로 인해 입력이 더듬거리는 것보다 특정 임계값(예: 1초)까지 새 메시지의 렌더링을 지연하는 것이 좋습니다.

특정 업데이트에 낮은 우선순위가 있다고 하면, 그 렌더링을 몇 밀리초의 작은 청크로 분할하여 사용자의 눈에 띄지 않게 할 수 있습니다.

이와 같은 성능 최적화가 그다지 흥미롭거나 설득력있게 들리지 않을 수도 있다는 것을 알고있습니다.

그러나 비동기 렌더링은 성능 최적화에 관한 것만은 아닙니다. 우리는 이것이 리액트 컴포넌트 모델이 할 수 있는 것의 근본적인 변화라고 생각합니다.

예를 들어서 한 화면에서 다른 화면으로 이동하는 경우를 생각해보세요.

일반적으로 새 화면이 렌더링되는 동안 스피너를 표시합니다. 그러나 네비게이션 이동이 충분히 빠르면(약 1초 이내) 스피너가 깜빡이고 즉시 숨겨져서 사용자 경험이 저하됩니다. 더 나쁜 경우, 서로 다른 비동기 종속성(데이터, 코드, 이미지)을 가진 여러 수준의 컴포넌트가 있는 경우 하나씩 짧게 깜빡이는 일련의 스피너를 볼 수 있습니다. 이것은 시각적으로 불쾌하고 모든 DOM reflow 때문에 실제로 앱을 느리게 만듭니다.

다른 뷰를 렌더링하는 간단한 setState()를 수행할 때 백그라운드에서 업데이트된 뷰 렌더링을 시작할 수 있다면 좋지 않을까요? 조정 코드를 직접 작성하지 않고 업데이트가 특정 임계값(예: 1초)보다 오래 걸리는 경우 스피너를 표시하도록 선택할 수 있고, 그렇지 않으면 전체 새 하위 트리의 비동기 종속성이 충족될 때 리액트가 원활한 전환을 수행할 수 있다고 상상해 보십시오. 게다가 우리가 "대기"하는 동안 "오래된 화면"은 대화형 상태(interative)를 유지하고(예: 전환할 다른 항목을 선택할 수 있도록) 리액트는 시간이 너무 오래 걸리면 스피너를 표시해야 합니다.

현재 리액트 모델과 생명주기에 대한 일부 조정을 통해 실제로 이를 구현할 수 있습니다.

이것은 this.state가 즉시 flush되지 않기 때문에 가능합니다. 즉시 flush된다면 "이전 버전"이 여전히 표시되고 상호작용하는 동안 백그라운드에서 뷰의 "새 버전" 렌더링을 시작할 방법이 없습니다. 그들의 독립된 상태 업데이트가 충돌합니다.

그리고 내가 이해하는 한, 적어도 부분적으로 이러한 유연성은 상태 업데이트를 즉시 flush하지 않기 때문에 가능합니다.

왜 setState는 비동기적으로 작동하는가?

위 이슈의 질문자 내용을 요약해보면 렌더링은 사이드 이펙트에 관련된 것이고 상태는 fact 관한 것입니다. 따라서 상태가 업데이트되면 상태를 동기적으로 업데이트해야 한다는 것이었습니다.

이 예를 뒷받침하는 주장을 코드적으로 표현해보면 다음과 같습니다.

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 업데이트를 바로 동기적으로 반영하는 것입니다.

Component.prototype.setState = (nextState) => {
  this.state = nextState // 상태를 먼저 반영
  if (!this.renderScheduled)
     setImmediate(this.forceUpdate) // 렌더링을 스케줄링
}

결국 setState의 동작은 상태를 먼저 반영하고 렌더링을 스케줄링하는 형태로 할 수 있다는 것입니다.

하지만 크게 두가지 이유로 setState는 비동기적으로 설계됩니다. 즉 상태를 먼저 반영하는 방식을 선택하고 있지 않습니다.

첫번째로는 리액트 내부 일관성 보장의 이유입니다. 리액트에서 state, props, refs는 내부적으로 서로 일관성이 있습니다. 이 의미는 이 객체들을 사용하는 경우 완전히 조정(reconciled)된 트리를 참조하도록 보장된다는 것입니다.(리액트에서 JSX를 React.createElement라는 함수를 통해 자바스크립트 객체의 트리형태로 표현)

리액트에서 가장 많이 하는 리팩토링중 하나인 상태 끌어올리기를 예로 들어보면, 하위 컴포넌트에서 상위 컴포넌트의 상태를 바꿨을 때 동기적으로 반영이 된다하더라도 상태를 props를 통해서 하위 컴포넌트에 전달해주기 때문에 렌더링(함수 컴포넌트 호출)을 하지 않으면 업데이트된 상태를 props를 통해서 내려줄 수 없다는 이유입니다.

즉 state, props, refs 들은 완전히 조정된 트리를 참조하도록 보장되는데 부모의 상태는 업데이트 되었는데 렌더링이 되지 않아서 최신 상태를 props로 받지 못하는 상태는 완전히 조정된 트리를 참조한다고 볼 수 없습니다.

만약 완전히 조정된 트리를 만들기위해서 state를 변경할 때마다 재조정을 하는것은 완전히 조정된 트리(fully reconciled tree)를 만들지만 이는 batching을 할 수 없게 만듭니다.

정리해보면 상태를 동기적으로 업데이트하는 것은 리액트가 현재 state, props, refs에 대한 정확한 트리를 만드는 것을 어렵게 한다는 것입니다. 만약 상태를 동기적으로 업데이트하면서 정확한 트리를 만들려면 상태를 업데이트할 때마다 재조정을 해야하는데 이는 batching을 없애야 하므로 trade-off라고 할 수 있습니다.

두번째 이유는 동시성 기능을 위함입니다.

동시성이란 두개 이상의 독립적인 작업을 잘게 나누어 Context Switching을 하며 동시에 실행되는 것처럼 보이도록 프로그램을 구조화하는 방법입니다.

동시성 기능을 활용하면 렌더링을 잘게 쪼개어 상태 업데이트에 우선순위를 두어 좀더 긴급한 상태 업데이트를 먼저 수행할 수 있습니다.

리액트는 이벤트 핸들러, 네트워크 응답, 애니메이션등의 출처에 따라 setState() 호출에 다른 우선순위를 할당할 수 있습니다.

이러한 동시성 기능이 가능하게 하려면 상태 업데이트를 동기적으로 수행하면 안됩니다. 상태 업데이트를 즉시 반영한다는 것은 그 작업의 수행 시간과는 관계없이 큐처럼 순서대로 반영한다는 이야기이고 이는 사용자의 UX에 영향을 끼칩니다.

결국 리액트팀에서 상태를 업데이트하는 방식 중 비동기적으로 상태를 업데이트하는 방식을 채택했고 이는 리액트 내부 일관성 보장과 동시성 기능을 가능하게합니다.

리액트를 만든 Jordan Walke가 2013년에 setState에 관한 이유에 답변한 글입니다.
처음에 setState를 동기적으로 하는게 맞다고 생각했지만 setState를 비동기적으로 하면 상태 업데이트를 뭉쳐서 한번에 할 수 있다고 합니다.

setState 코드 보기

setState를 호출하는 것은 state를 바로 업데이트하지 않고 렌더링을 큐(대기열)에 넣습니다.

리액트 코드에서 상태를 업데이트하는 setState 함수의 코드는 다음과 같습니다.

Component.prototype.setState = function(partialState, callback) {
  if (
    typeof partialState !== 'object' &&
    typeof partialState !== 'function' &&
    partialState != null
  ) {
    throw new Error(
      'setState(...): takes an object of state variables to update or a ' +
        'function which returns an object of state variables.',
    );
  }

  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

주목해야할 부분은 마지막 줄입니다. 상태를 업데이트할 때는 큐에 상태를 업데이트하는 동작을 넣을 뿐입니다.

결론

상태를 업데이트하는데에는 다양한 방법이 있을 수 있습니다. 리액트에서는 상태를 비동기적으로 업데이트하도록 설계했고 이를 통해 리액트 내부의 일관성을 보장하고, 동시성 기능 통해 상태 업데이트의 우선 순위를 고려할 수 있으며, 상태 업데이트의 일괄 처리(batching)을 가능하게 합니다.

profile
https://hyunjinlee.com

4개의 댓글

comment-user-thumbnail
2023년 1월 23일

setState가 비동기로 동작하는 이유에 대한 깊은 이해에 도움이 되었어요~!

답글 달기
comment-user-thumbnail
2023년 3월 2일

우와 ...!

답글 달기
comment-user-thumbnail
2023년 4월 8일

동시성 기능을 읽고 나니까 이해가 되네요!! 좋은 글 감사합니다ㅎㅎ

답글 달기
comment-user-thumbnail
2023년 11월 1일

Component.prototype.setState = function(partialState, callback) {
if (
typeof partialState !== 'object' &&
typeof partialState !== 'function' &&
partialState != null tiny fishing
) {
throw new Error(
'setState(...): takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
}

this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
Thank

답글 달기