login
react
setstate
비동기
렌더링
공식문서
setState는 비동기일까? (feat. React 공식 문서)2023-06-08
// some Component
const [count, setCount] = useState(0);

const tripleIncrement = () => {
    setCount(count + 1);
    console.log(count);  // 0
    setCount(count + 1);
    console.log(count);  // 0
    setCount(count + 1);
    console.log(count);  // 0
}

return (
    <div>
        <span>{count}</span>
        <button onClick={tripleIncrement}>+3</button>
    </div>
)

React에서 setState를 통한 상태의 변경이 즉각 반영되지 않음을 설명하는 예시 코드는 정말 익숙하다.

그래서인지 조금만 구글링을 해보면, 지금도 수많은 블로그들이 ‘setState는 비동기적이다’라고 단언하고 있다. 나 역시 그동안 막연하게 그렇다고만 생각했었다.

그런데 setState는 정말 비동기 함수일까?

React 공식문서를 차근차근 읽으며 고민해본 결과, “setState 함수는 비동기적이 아니다”라고 말 할 수 있겠다는 결론을 내렸다.

그 이유를 설명해보려고 한다.

React의 UI 업데이트 프로세스

리액트는 UI를 구축하기 위한 라이브러리이다. 그리고 ‘상태’라는 개념을 사용하여, 상태의 변화가 곧 UI에 반영되는 메커니즘을 가지고 있다.

상태는 주로 사용자의 인터랙션에 의한 ‘이벤트’에 의해 변경된다. 이벤트가 발생하면 내부적으로 작성된 event handler 함수의 로직에 의해 상태의 변화가 발생하고, 리액트는 변화된 상태를 바탕으로 변경되어야할 UI를 계산한다. 그리고 모든 계산이 완료되면 변경 사항을 브라우저가 실제 사용자가 보고있는 화면에 반영한다.

공식 문서에서는 이 과정을 trigger render commit 이라는 용어를 사용하여 구분한다.

trigger

먼저 trigger는 사용자의 인터랙션 등으로 인해 이벤트가 발생하여 상태 업데이트 로직이 호출되는 단계다.

사용자가 버튼을 클릭하면, click event가 발생한다. event handler 내부에는 클릭에 따라 변화해야 하는 동작들(ex. 배경 색상의 변경, 텍스트 내용의 변경, 모달 팝업 등등)과 관련된 로직이 포함되어 있고, 대부분 setState를 호출하여 해당 상태들을 변경하는 방식일 것이다.

공식 문서에서는 state의 setter를 설명하며 다음과 같은 표현을 사용한다.

… you can trigger further renders by updating its state with the set function. Updating your component’s state automatically queues a render.

‘queues a render’ 즉, setState의 역할은 렌더링이라는 특정한 동작에 대한 예약 혹은 요청 행위라고 볼 수 있다.

다시 위의 예시 코드를 살펴보자.

// some Component
const [count, setCount] = useState(0);

const tripleIncrement = () => {
    setCount(count + 1);
    console.log(count);  // 0
    setCount(count + 1);
    console.log(count);  // 0
    setCount(count + 1);
    console.log(count);  // 0
}

return (
    <div>
        <span>{count}</span>
        <button onClick={tripleIncrement}>+3</button>
    </div>
)

사용자가 버튼을 클릭하면 이벤트 핸들러인 tripleIncrement 함수가 호출된다. 이제 공식 문서의 설명에 따라 함수 내부의 setCount(count + 1) 의 의미를 좀 더 자세히 생각해볼 수 있다.

setCount(count + 1) 는 ‘다음 렌더링을 요청한다. 그리고 다음 렌더링에 수행할 동작 목록(queue)에 count 상태를 count + 1로 변경해라 라는 명령을 추가한다’로 해석할 수 있다.

그리고 tripleIncrement 함수는 이러한 명령을 세 번 반복하고 있는 것에 불과하다.

이처럼 setState 함수의 역할은 리렌더링을 요청하고, 그때 수행할 동작을 큐에 추가하는 것이 전부다. 또한 실행이 완료되기를 기다렸다가, 그 결과 값을 가지고 추가적인 계산이나 기타 동작을 수행하지도 않는다.

이 일련의 과정 자체에는 어떠한 비동기적인 로직도 개입되어 있지 않다.

따라서 setState는 비동기 함수가 아니다 라고 생각했다.

질문에 대한 답은 구했다. 그러나 몇 가지 의문점이 남는다.

  • 왜 사람들은(나는) setState를 비동기 함수라고 생각했을까?
  • 그리고 count + 1을 세 번이나 계산 했는데 왜 count의 최종값은 여전히 1인가?

이를 해소하기 위해 렌더링 과정에 대해 조금 더 자세히 알아야할 필요가 있다.

State Snapshot

setCount 의 의미를 확인했으니, tripleIncrement 이벤트 핸들러에는 렌더링 요청이 세 번이나 있었음을 알 수 있다. 그리고 그때마다 count 상태를 1씩 증가시켰다.

따라서 클릭 이벤트가 발생하면 총 세 번의 리렌더링을 수행하고, 각 렌더링마다 count 상태는 1씩 증가하여 최종적으로 화면에 보여지는 count는 3이 되는 것이 자연스러운 결과다.

만약 ‘렌더링 → 상태 업데이트’ 의 과정이 동기적으로 수행 됐다면 그랬을 것이다.

하지만 리액트는 그렇게 동작하지 않았다.

먼저 count + 1 이라는 계산식이 실제로는 어떤 값으로 계산되는지 알기 위해, 공식 문서의 state as snapshot이라는 표현을 살펴보자.

“Rendering” means that React is calling your component, which is a function. The JSX you return from that function is like a snapshot of the UI in time. Its props, event handlers, and local variables were all calculated using its state at the time of the render.

즉, ‘한 번의 렌더링 과정’에서는 변화를 계산하는 데에 사용할 상태를 snapshot처럼 기억하고 있다는 것이다. (자바스크립트 클로저!)

새로운 snapshot은 다음 ‘회차’의 렌더링이 완료되고 그 결과를 반영하여 새롭게 업데이트 될 것이다.

이는 렌더링 과정 중에는 언제나 동일한 snapshot을 참조하기 때문에 계산에 참조하는 상태 값 또한 언제나 동일하다는 것을 의미한다.

다시 코드를 살펴보자.

const [count, setCount] = useState(0);

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

...

클릭 이벤트 발생으로 인해 tripleIncrement 함수 내부의 코드가 실행되는 시점의 snapshot에 기록된 count 상태의 값은 초기값인 0이다.

따라서 tripleIncrement 함수 내부에서 참조하는 실제 값은 다음과 같다.

const tripleIncrement = () => {
    setCount(0 + 1);
    setCount(0 + 1);
    setCount(0 + 1); // 모든 리렌더링이 끝나고 사용자에게 보여지는 count를 계산하는 로직
}

다시 코드를 해석하면, ‘다음 렌더링을 요청한다. count 상태의 값을 1로 업데이트 해라’라는 지시를 세 번 반복하고 있다.

따라서 세 번의 렌더링이 발생하지만, 세 번 모두 count를 1로 업데이트 하기 때문에 count의 값이 3이 되지 않는 것이다.

이 사실을 알고나면 당연히 이 과정이 어딘가 비효율적이라는 생각이 든다.

사용자의 화면에 최종적으로 보이게 되는 count의 값은 마지막으로 계산된 세 번째 렌더링의 결과값이다.

물론 위의 예시에서는 세 번의 계산에서는 count 상태의 값이 모두 동일하기 때문에 앞선 두 번의 setCount(1) 가 중복이라고 명확하게 인식되지만,

설령 중간 과정에서 계산되는 상태의 값이 동일하지 않다고 하더라도 결국 마지막에 화면에 남아있는 값이 아닌 나머지 값들을 계산하는 과정(렌더링)은 굳이 필요가 없어 보인다.

이처럼 비효율적인 렌더링을 최소화하기 위해 리액트는 또다른 방법을 사용하고 있다.

Batching

그 방법이란 다음 렌더링에 수행할 동작을 queue에 추가하는 과정에서의 배치 처리이다.

React waits until all code in the event handlers has run before processing your state updates.

리액트는 불필요한 렌더링 과정을 최소화 하기 위해, 이벤트 핸들러 내의 모든 상태 변화 로직이 실행(setState의 호출)될 때까지 기다린다. 그리고 배치(batch) 처리를 통해 동일한 상태에 대한 중복되는 업데이트 로직을 제거하고 해당 상태의 최종적인 결과를 계산하기 위한 로직만 다음 렌더링 과정에 포함시킨다.

setState가 호출된 횟수만큼 매번 리렌더링을 수행하는 것이 아니라, 한 번에 처리해도 무방한 setState들을 모았다가 ‘한 회차의 렌더링 과정’에서 결과적으로 필요한 동작만 처리해버리는 것이다.

배치 처리를 고려하여 다시 이벤트 핸들러 함수를 해석해보자.

const tripleIncrement = () => {
    setCount(0 + 1);
    setCount(0 + 1);
    setCount(0 + 1); // 렌더링에 최종적으로 포함되는 로직
}

결국 위 코드가 배치 처리까지 완료된 이후 최종적으로 의미하는 바는 단 한 번의 ‘다음 렌더링에 count 상태의 값을 1로 업데이트 해라’ 가 된다.

정리

처음으로 돌아가 ‘setState는 비동기적으로 동작하는가?’ 라는 질문을 다시 떠올려보자.

만약 tripleIncrement 이벤트 핸들러 내부에서 setCount(count + 1)의 호출마다 리액트가 즉각 (리)렌더링을 수행하여 그때마다 count의 값이 count + 1로 업데이트 되었다면 ‘setState는 동기적으로 동작한다’라는 명제에 이견이 생기지 않았을 것이다.

그러나 살펴본 것과 같이 setState의 역할은 새로운 렌더링 요청하고 상태 변화 로직을 enqueue 하는 것에 그치고 있다.

또한 나머지 상태 변화 로직들이 모두 실행되고 배치 처리까지 ‘기다렸다가’ 비로소 렌더링 단계로 넘어가 실질적인 상태 변화가 이뤄지기에 마치 setState가 비동기적으로 동작하는 것으로 착각한 것이다.

보다 정확하게 표현하자면,

“setState 함수 자체는 동기적으로 동작하지만, 리액트가 setState 함수를 사용하여 상태를 업데이트 하는 리렌더링 프로세스는 비동기적이다”

정도로 말 할 수 있지 않을까?

이번 포스팅의 주요 내용은 여기까지다.

번외 1. Updator function

우리는 다음과 같이 setState의 인자로 함수를 할당하여 상태를 업데이트 하는 경우, 같은 회차의 렌더링에서도 값이 증가한다는 것을 알고 있다.

const tripleIncrement = () => {
    setCount(n => n + 1);  // 1
    setCount(n => n + 1);  // 2
    setCount(n => n + 1);  // 3
}

어째서 이런 결과가 나오는 걸까?

  1. React queues this function to be processed after all the other code in the event handler has run.
  2. During the next render, React goes through the queue and gives you the final updated state.

배치 처리되는 일반적인 setState 동작과는 다르게 setter의 인자에 함수를 전달한 경우, 해당 함수 자체가 다음 렌더링을 위한 queue에 추가된다.

따라서 위 예시의 경우, queue에 3개의 n => n + 1 함수가 추가된다.

그리고 렌더링 과정에서 각 함수의 계산 결과가 다시 다음 함수의 인자(n)로 전달되어 계산되기 때문에, 동일한 회차의 렌더링임에도 불구하고 updator function을 사용하면 렌더링의 결과가 달라진다.

tripleIncrement 라는 이벤트 핸들러의 이름에 맞는 동작을 위해서는 이처럼 updator function을 사용하는 것이 옳은 해결책인 것이다.

번외 2. 동일한 값의 업데이트

setState가 언제나 리렌더링을 요청하는 것은 아니다.

새로운 값이 기존의 값과 동일하다면, setState는 리렌더링을 요청하지 않는다.

이때 값의 비교에 Object.is 메서드를 사용한다.

따라서 기존의 상태와 새로운 상태의 값이 동일한 원시값이거나, 참조값이 같은 객체라면 setState가 호출되더라도 리렌더링이 이루어지지 않는다.

번외 3. Commit 단계

리액트의 UI 업데이트 3단계 중 마지막 단계인 commit에 대해서는 별도의 설명을 하지 않았다.

setState 논의와 직접적인 관계가 없었기 때문이다. 정말 간략하게만 짚고 넘어가자면,

상태 변화에 따라 변경되어야 할 UI를 계산하는 과정(렌더링)이 끝나면 그 결과를 DOM에 반영함으로써 사용자의 화면도 비로소 업데이트 된다.

이 과정에서도 -virtual DOM을 이용한 재조정 과정을 거치며- 굳이 변경이 필요없는 부분은 건너뛰고, 실질적인 변경 사항이 발생한 DOM 노드에 대해서만 그 내역이 업데이트 된다. 그리고 브라우저에 의해 repaint 된다.

이밖에도 UI 업데이트 프로세스와 관련하여 기억하고 있으면 좋을 두 가지 사실은 useEffect와 useRef의 동작 시점이다.

먼저 useEffect에 대한 설명이다.

Every time your component renders, React will update the screen and then run the code inside useEffect. In other words, useEffect “delays” a piece of code from running until that render is reflected on the screen

즉, useEffect 내부에 정의된 코드는 commit 단계까지 완료된 이후 비로소 실행됨을 알 수 있다. 불가피한 경우가 아니라면, 렌더링 단계에서 상태 변화와 관련된 코드를 처리하는 것이 불필요한 리액트 렌더링, 나아가 브라우저의 repaint까지 줄일 수 있을 것이다.

또한 useRef를 사용하는 DOM 조작도 commit 단계와 관련이 있다.

React sets ref.current during the commit. Before updating the DOM, React sets the affected ref.current values to null. After updating the DOM, React immediately sets them to the corresponding DOM nodes.

render 단계에서 useRef를 사용한 DOM의 참조는 불가능하다. 정확하게는 최초 render에서는 불가능하고, 이후의 리렌더링 과정에서는 최신 DOM 데이터를 참조할 수 없다. render 단계에서 계산된 결과가 DOM에 반영되는 시점은 render가 종료된 이후인 commit 단계이기 때문이다.

피드백은 언제나 감사합니다.
오류 정정, 질문, 토론거리를 자유롭게 댓글로 남겨주세요.
블로그 소스코드에 대한 리뷰, PR도 적극 환영합니다!
► Github Repository 바로가기

댓글을 작성하려면 로그인이 필요합니다.