main

setState는 동기 함수 or 비동기함수?

Articles
10

리액트(React)의 state는 컴포넌트 내부의 변경 가능한 값입니다. 클래스형 컴포넌트에서는 state를 사용하고, 함수형 컴포넌트에서는 useState 훅을 사용합니다. useState훅은 오로지 함수형 컴포넌트에서만 사용할 수 있습니다.

아래의 onDoubleClick 함수는 호출 시에 count가 2씩 증가하게 작성되었습니다. 하지만 우리가 예상하는 바와 다르게 작동할텐데요. 리렌더링 시 count는 2가 아닌 1이 됩니다.

...
const [count, setCount] = useState(0);
 
const onDoubleClick = () => {
  setCount(count + 1);
  console.log(count);
  setCount(count + 1); 
  console.log(count);
}

잘못 작성된 이유에 대해 setState 함수가 비동기 함수이기 때문이다라고 설명한다면 이는 정확하지 않은 답변입니다.
결론부터 이야기하자면

setState는 비동기 함수가 아니라 setState 함수의 호출이 비동기적으로 이뤄지는 것입니다.

조금 이해하기가 난해할 수 있지만 좀 더 깊게 이해해보고자 이 내용을 포스팅합니다.



setState를 비동기 함수라고 오해하는 이유

const [count, setCount] = useState(0); 
 
const onClick = () => {
  setCount(count+1);
  console.log(count) // 1 ? 
}
 
return (
  <div>
    <button onClick={onClick}>click me</button>
  </div>
)

원래 예상한다면, 아까 코드 에서 count가 0이라면 1증가한 1이 콘솔값에 찍혀야했을 것입니다.
그럼 setState는 비동기 함수일까요?

231030-214221
setState를 IDE에서 함수의 시그니처를 확인해보겠습니다.
함수 시그니처를 보면 분명 리턴 타입이 Promise가 아니라는 것을 확인할 수 있습니다.



클로저

갑자기 클로저요? 싶지만!
우리가 항상 듣는 클로저의 개념은 아래와 같습니다.

외부 함수가 종료되어도 안에 있던 내부함수가 여전히 외부함수의 지역변수를 참조할 수 있는 것
이것이 가능한 이유는 자바스크립트 함수가 선언될 때 자신의 상위 스코프 환경을 기억하고 있기 때문입니다.

export const Counter = () => {
	const [count, setCount] = useState(0);
    
    const onClick = () => {
    	setCount(count + 1);
    }
    
	return (
    	<div>
        	<button onClick={onClick}>Click me</button>
        </div>
    )
}

위의 컴포넌트를 보기 전에 먼저 함수형 컴포넌트가 있기 전 클래스형 컴포넌트가 먼저 존재했었음을 떠올려야 합니다.
클래스형 컴포넌트에서는 return 부분에 원래 render() 함수가 있었습니다. 즉, 현재의 JSX 부분이 예전의 render 함수 부분입니다. 위 컴포넌트는 JSX를 반환하며 이 JSX가 컴포넌트의 내부 함수가 되고, 컴포넌트 안에 선언된 상태값인 useState는 자연스럽게 외부 함수의 지역 변수가 됩니다.

그래서 내부 함수(JSX)가 선언될 때 자신의 상위 스코프 환경(컴포넌트의 지역변수 = useState)을 기억하기 때문에 JSX에서 useState값을 참조할 수 있는 것입니다.
쉽게 이야기하자면 상태 값은 렌더링 함수가 생성될 때, 클로져에 의해서 캡쳐됩니다. 즉, 렌더링 함수는 자신이 생성될 때의 상태값을 기억하게 됩니다.



리액트의 렌더링 원리가 비동기적으로 작동한다.

리엑트의 setState가 동기적 함수이고 마치 비동기 함수처럼 보이는 이유는 리엑트의 리렌더링 원리가 비동기적으로 작동하기 때문입니다. 리액트는 가상돔이라는 자신만의 돔 이미지를 유지하고 있습니다. 리액트는 렌더링 함수를 호출하여 가상 돔을 업데이트합니다. 렌더링 함수는 컴포넌트의 상태나 속성이 변경될 때마다 호출됩니다. 이를 통해 리엑트는 가상 돔이 최신 상태로 유지되도록 합니다. 즉, 컴포넌트가 어떻게 보여야 하는지에 대한 최신 정보를 반영합니다. 그런 다음 리액트는 가상 돔과 실제 돔을 비교하여 실제 돔을 업데이트하게 됩니다.

렌더링 함수를 통해 가상돔 업데이트 > 상태 속성 변경될 때마다 호출 > 최신화된 가상돔 > 실제 돔과 비교해 업데이트

즉, setState가 비동기 함수처럼 보이는 이유는 setState 함수 그 자체가 비동기 함수여서가 아니라 리액트가 가상돔을 사용하게 설계되어있기 때문입니다.

그리고 앞서 설명했던 렌더링 함수는 컴포넌트의 상태나 속성이 변경될 때마다 호출됩니다.에서 각 컴포넌트는 렌더링 시점에 참조하는 상태를 해당 시점의 스냅샷에 참조하고 있는 closure에 따라 console.log에 출력하게 됩니다.

231030-223522



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

가상돔이란 결국 리액트가 실제 돔을 추상화하여 메모리에 유지하는 자료구조입니다.


  • virtual DOM

리액트에서는 state나 props가 변경되면 컴포넌트가 리렌더링됩니다. 컴포넌트가 리렌더링되면 렌더링 함수가 호출되고, 이때 리액트는 새로운 가상 돔을 생성하여 이전 가상돔과 비교하여 변경된 부분만 실제 돔에 반영하게 됩니다.
이 과정을 reconciliation(조정)이라고 하는데요, 리액트의 fiber 아키텍쳐reconciliation을 진행할 때 render phasecommit phase의 두 단계로 나누어 진행하게 됩니다.
이때 render phase는 가상돔 트리를 순회하면서 변경된 부분을 찾는 과정이고, commit phase는 실제 돔에 변경 사항을 반영하는 과정입니다.

실제 돔에 업데이트하는 과정이 만약 동기적으로 진행된다면, 메인 스레드가 차단되거나 응답 지연이 발생해서 렌더링 과정이 지연될 수 있습니다. 이는 UX를 저해하는 요소가 됩니다.



결론

setState 함수는 동기 함수이지만 setState 함수 호출은 비동기적으로 일어난다.
그래서 상태의 업데이트 결과가 즉각적으로 바로 다음 코드 라인에 반영되지 않게 됩니다. 리렌더링이 발생해야 업데이트된 상태 값이 가상돔 트리에 반영되게 됩니다. 즉, 업데이트된 상태는 리렌더링된 후에 참조할 수 있게 됩니다. 이러한 리렌더링이 비동기적으로 일어나기 때문에 성능적인 이득이 생기게 됩니다.

  • 비동기적으로 일어나기 때문에 batch update(한 번에 모아서 업데이트)가 가능하고, (= 매번 변화가 발생할 때마다 바로바로 업데이트 x)
  • 비동기적으로 리렌더링이 일어나기 때문에 렌더링의 우선순위를 설정할 수 있게 됩니다.

Reference Doc

콘솔로그가 이상한건 setState가 비동기 함수여서가 아닙니다. (feat: fiber architecture)
setState는 '비동기 함수'인가요?


김다은 이모지
Daeun Kim
Junior Frontend Engineer