Posts [React] setState의 비동기성
Post
Cancel

[React] setState의 비동기성

리액트에서는 상태 관리를 위해 useState 훅을 통하여 state를 만들고 setState를 통해 state 값을 변화시킨다.

아래 코드를 한 번 실행해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import React, { useState } from "react";

const App2: React.FC = () => {
  const [state, setState] = useState(1);

  const addState = () => {
    setState(state + 1);
    console.log(state);
    // setState 함수가 호출된다고 해서 state가 바로 변경되어 반영되지 않는다.
    // addState 함수 호출이 종료되고 난 후 변경된 상태 값들이 한꺼번에 batch update(일괄 업데이트)가 이루어진다.
    // 따라서 아직 state가 update되지 않았기 때문에 console.log(state)를 하면 이전 값이 출력된다.
  };

  console.log("render");

  return (
    <div>
      <button onClick={addState}>+</button>
      <p>state: {state}</p>
    </div>
  );
}

export default App2;

실제 + 버튼을 클릭해보면 콘솔에 이전 state 값이 출력되는 것을 확인할 수 있다.

useState 를 처음 사용하는 사람이라면 변경된 state 값이 콘솔에 출력되어야 하는게 아닌가? 라는 의문을 가질 수 있다.

이러한 현상 때문에 setState를 호출 이후 바로 상태값을 참조하여 다른 작업을 하게 되면 문제가 발생하게 된다.

Note: 이러한 문제를 해결하기 위해 react-thunk, react-saga 를 사용해 동기화 처리를 해준다고 하는데 추후 학습 후 정리해보자.

그렇다면 이러한 현상이 발생하는 원인은 무엇일까?

React batch update(일괄 업데이트)

React는 batch update를 16ms 단위로 진행한다. 쉽게 말하면 16ms동안 변경된 상태 값들을 모아 단 한 번의 랜더링으로 진행한다는 것을 의미한다. 이러한 행동은 웹 페이지의 렌더링 회수를 줄여 좀 더 빠른 속도로 동작하게끔 만들기 위해서이다.

Redux의 창시자인 Dan Abramov의 말에 따르면, 현재 batch update의 적용은 setState reaction 이벤트 핸들러 내부의 업데이트만 기본적으로 해당된다고 한다. (button과 같은 onClick과 같은 이벤트 핸들러 함수들을 생각하면 된다)

다음 예제 코드를 실행시켜보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React, { useState } from "react";

const App2: React.FC = () => {
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);
  const [state3, setState3] = useState(0);

  const addState = () => {
    setState1(state1 + 1);
    setState2(state2 + 1);
    setState3(state3 + 1);
  };

  console.log("render");

  return (
    <div>
      <button onClick={addState}>+</button>
      <p>state1: {state1}</p>
      <p>state2: {state2}</p>
      <p>state3: {state3}</p>
    </div>
  );
}

export default App2;

스크린샷 2022-04-18 오후 9 09 17

실제 버튼을 두 번 클릭했을때 서로 다른 상태를 변화시키는 함수인 setState1, setState2, setState3 세 개를 호출했는데 실제 렌더링은 클릭 1회당 1번씩 밖에 렌더링되지 않는 것을 확인할 수 있다.

여기서 주목할만 한 점이 있다. 위에서 분명 이괄 업데이트는 setState reaction 이벤트 핸들러 내부에서 작동해야한다고 언급했다. 하지만 실제로 useEffect 에서도 batch update 가 일어나는 것을 확인할 수 있다. 아래 예제 코드를 실행해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import React, { useEffect, useState } from 'react';

const App2: React.FC = () => {
  const [toggle, setToggle] = useState(false);
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);
  const [state3, setState3] = useState(0);

  useEffect(() => {
    console.log("=== start useEffect1 ===");
    setState1(state1 + 1);
    setState2(state2 + 1);

    console.log("=== end useEffect1 ===");
  }, [toggle]);

  useEffect(() => {
    console.log("=== start useEffect2 ===");
    setState3(state3 + 1);

    console.log("=== end useEffect2 ===");
  }, [toggle]);
  
  const handleClick = () => {
    setToggle(!toggle);
  }

  console.log("render");

  return (
    <div>
      <button onClick={handleClick}>+</button>
      <p>state1: {state1}</p>
      <p>state2: {state2}</p>
      <p>state3: {state3}</p>
    </div>
  );
}

export default App2;

스크린샷 2022-04-18 오후 9 23 02

버튼을 클릭하면 setToggle 이 호출되어 컴포넌트가 리렌더링되고 (toggle 값이 변경되었기에) useEffect 내부의 함수가 다시 실행됨으로써 다시 한 번더 렌더링 되는 것을 확인할 수 있다. useEffect가 두 번 실행됐음에도 한 번에 모아 처리하는 것을 확인할 수 있다.

즉, useEffect 내부에서 서로 다른 상태를 변화시키는 setState 함수 3개를 호출하였음에도 렌더링은 한 번만 일어나는 것을 확인할 수 있다. 즉, 이벤트 핸들러 함수처럼 batch update 가 일어나는 것을 확인할 수 있다.

하지만 이 기능(batch update)이 모든 상황에서 작동하는 것은 아니다. async/await, then/catch, setTimeout, fetch 와 같은 비동기 작업을 사욯아는 이벤트 핸들러에서는 별도의 상태 업데이트가 수행되지 않는다.

리액트 setState의 비동기성

아래 예제 코드를 실행해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const App2: React.FC = () => {
  const [state1, setState1] = useState({value: 0});

  const handleClick = () => {
    const startTime = new Date().getTime();

    for(let i=0;i<10000;i+=1) {
      setState1({value: state1.value + 1});
    }

    const endTime = new Date().getTime();
    console.log("finish " + (endTime - startTime) + "ms!");
  }

  console.log("render");

  return (
    <div>
      <button onClick={handleClick}>+</button>
      <p>state1.value: {state1.value}</p>
    </div>
  );
}

export default App2;

스크린샷 2022-04-18 오후 9 31 29

스크린샷 2022-04-18 오후 9 34 36

위 예제 코드를 실행해보면 여러번 setState를 호출하는데 기존 value 값에 1을 더하기 때문에 10000 을 기대할 수 있다. 하지만 결과는 1만 증가된다. 그리고 batch update 시간 16ms 보다 더 오래걸리는 것을 확인할 수 있다.(25ms)

이는 리액트가 setState를 만나게되면 인자로 전달받은 객체를 하나로 합친뒤에 업데이트하기 때문이다. 이는 또한 렌더링 횟수를 줄여 성능을 최적화하기 위해서이다.

하나로 합친다는 뜻은 Object.assign() 함수를 생각하면 이해하기 쉽다.

1
2
3
4
5
6
7
8
9
10
11
const Lim = { age: '10' }
const Kim = { age: '90' }

hisAge = Object.assign({}, Kim);
console.log(hisAge.age);
>>> 90


hisAge = Object.assign({}, Kim, Lim);
console.log(hisAge.age)
>>> 10

두번째 Object.assign()에서 확인할 수 있듯이, 객체가 동일한 키(age)를 가지고 있다면 가장 마지막에 전달된 객체의 키값(age:’10’)이 덮어쓰여지는 것을 볼 수 있다.

이런 과정을 Object Composition이라 부르는데, 바로 setState에서도 이와 같은 일이 벌어진다. 위에서 객체를 하나로 합친뒤에 업데이트 한다고 한 것처럼 바로 Kim과 Lim 객체가 하나로 합쳐져 업데이트 되는 상황이 그대로 일어나게 된다.

즉, 아래의 상황에서 가장 마지막에 실행되는 setState가 이전의 setState들을 덮어쓰게 되는 상황이기에 value는 결과적을 1만 더하게 된다.

그러면 만약에 setState를 통해 갱신된 state 값을 이벤트 핸들러 함수내에서 다시 사용해야 된다면 어떻게 하면 될까?

1) 함수형 업데이트 사용(함수형으로 setState 사용)

1
2
3
4
5
6
7
8
9
10
const handleClick = () => {
const startTime = new Date().getTime();

for(let i=0;i<10000;i+=1) {
    setState1((state) => ({value: state.value + 1}));
}

const endTime = new Date().getTime();
console.log("finish " + (endTime - startTime) + "ms!");
}

setState에선 객체가 아닌 함수를 인자로 넣었다. 함수형 setState가 호출되면 merge할 객체가 없기 때문에 호출된 순서대로 함수를 큐에 넣게 된다.

이런식의 setState는 render가 한 번만 일어나게 된다는 장점이 있다.

2) 콜백을 통한 setState

1
2
3
setState1({value: state1.value + 1}, () => {
    console.log("state1.value: " + state1.value);
});

setState의 두번째 인자로 콜백함수를 넘어줌으로써, value값이 1이 더지해고 더해진 value값을 바로 콘솔에 출력될 수 있다고 한다.

하지만 내가 직접 테스트한 결과 콘솔에 다음과 같은 오류가 출력되었다.

스크린샷 2022-04-18 오후 9 57 43

useState, useReducer 훅으로부터의 상태 업데이트는 두번째 콜백 인자를 지원하지 않는다고 적혀있는데 이와 관련해선 추후 더 찾아보고 정리하도록 하겠다.

Note: 아래 출처 포스팅들이 가장 이해하기 쉽게 잘 작성되어 있어서 한 번 꼭 읽어보면 좋을 것이다.(구글링했을때 가장 상단에 노출된 포스팅이기도 하다..)

출처

This post is licensed under CC BY 4.0 by the author.