setState & useState, 왜 비동기일까?(탐구일기, 리액트React)

setState, useState를 동기로 사용하는 방법

리액트(React)의 state는 컴포넌트 내부의 변경 가능한 값입니다.

클래스형 컴포넌트는 state를 사용하고, 함수형 컴포넌트는 useState 훅(클래스 내부에서는 동작 X)을 사용합니다.

그렇다면 일반적으로 사용하는 변수를 두고 왜 state를 사용해서 값을 관리할까요?

이는 state가 갖는 특성 때문인데요. 바로 값이 변경되면 리렌더링(Re-rendering)이 발생하기 때문입니다.

따라서 값이 변화함에 따라 실시간(!!!)으로 화면이 렌더링되고 변화된 값이 화면에 바로 반영됩니다.

값의 변화를 리액트도 알아차릴 수 있게 해주어야 하므로 값의 변경은 리액트가 제공하는 함수를 통해서만 이루어져야 합니다.

//클래스형 컴포넌트
class MyClass extends React.Component {
  constructor(props) {
    super(props);
    //state
    this.state = {
      cnt: 0  //초기화 0
    };
  }

updateState = () => {
  this.setState({ cnt: this.state.cnt + 1 });
}

render() {
    return (
      <div>
        <p>값:{this.state.cnt}</p> 
        <button onClick={this.updateState}>plus</button>
      </div>
    );
  }
}
//함수형 컴포넌트
import React, { useState } from 'react';

const MyFunc = () => {
  //useState 훅을 통해 state 사용
  const [cnt, setCnt] = useState(0); //초기화 0

const plusNum = () => {
   setCnt( cnt+1 );
}

return(
    <div>
       <p>값:{cnt}</p>
       {/* 훅이 반환하는 함수를 통해서만 값을 변경해야 함 */}
       <button onClick={plusNum}>plus</button>
    </div>
  )
}

클래스형 또는 함수형의 코드를 실행하면 다음과 같은 친구가 뜹니다.

plus를 누르면 값이 1씩 플러스 되는 것을 볼 수 있습니다.

하지만 다음과 같이 사용할 때는 결과가 어떨까요?

const plusNum = () => {
   setCnt(cnt+1);
   console.log('result:'+cnt);
}

의도한 것은 state에 cnt+1의 값을 설정하고 새로 설정된 값을 바로 콘솔창에 출력하고 싶은 것인데요. 결과는 다음과 같습니다.

콘솔창의 결과는 한 걸음 늦습니다.

왜 이런 결과가 발생하는 것일까요?

바로 비동기(Asynchronous)의 특성 때문인데요.


1. 동기와 비동기

동기(Synchronous) – 순서대로 하나씩 처리.

비동기(Asynchronous) – 순서가 아닌 이벤트에 따라 처리.

짱구의 일상을 통해 동기 작업을 확인해 보겠습니다.

엄마 : “짱구야! 액션분식 가서 떡볶이 3인분만 포장해 오겠니? 집에 가져와서 그릇에 옮겨 담고 엄마를 불러!”

짱구 프로세스는 1.떡볶이를 사러 나가서 2.주문을 하고 3.집에 들고 와서 4.다시 그릇에 담고 5.엄마를 부르는 과정이 모두 순서대로 동기로 진행됩니다.

호기심 많은 짱구는 엄마 말을 잘 들을리가 없겠지만요..

그렇다면 비동기 작업을 시키려면 어떻게 할까요?

비동기 엄마 : “짱구야! 액션분식 떡볶이 2인분이랑 쵸코비반점 짬뽕 두개를 배달 시켜서 도착하면 각자 그릇에 옮겨 담아줘! 배달이 오기 전까지는 방에 장난감 좀 치워주면 초코비를 줄지도 몰라~!”

작업을 각각 요청하고, 요청한 작업이 가게에서 진행되는 동안 짱구는 다른 작업을 할 수 있습니다.

짱구가 배달 주문(작업 요청)을 하고 도착하기 전까지(요청 처리중)는 장난감 치우기(다른 작업)를 진행하다가 배달이 완료(요청 작업 완료)되면 다시 그릇에 옮겨 담는(요청 관련 작업 진행) 식입니다.


2. 동기로 처리하기

그렇다면 위 샘플에서 콘솔 출력이 업데이트된 값으로 변경되지 않는 이유는 무엇일까요?

여러 글과 문답을 참고하여 내린 결론은 다음과 같습니다.

비동기 특성을 갖는 이벤트 루프에 의해 setCnt 작업은 뒤로 밀리고 console.log작업이 먼저 실행되기 때문입니다.

다음 코드와 같습니다.

const printSequence = () => {
    console.log('first Call');
    setTimeout(()=>{secondCall()},0)
    console.log('third Call');
}

const secondCall = () => {
    console.log('second Call');
}

콘솔창의 결과는요.

비동기 처리를 하는 함수들이 있지만 기본적으로 자바스크립트는 단일 프로세스이므로 동기로 작업을 합니다.

하지만 위와 같이 작업 순서가 뒤바뀌는 이유는 이벤트 루프라는 보이지 않는 손에 의해 비동기로 업무를 처리하기 때문입니다.

그렇다면 setTimeout을 통해 console.log 작업 시간을 뒤로 연기하면 작업이 끝난 뒤 로그를 찍을 테니 값이 제대로 표시되지 않을까요?

  const showAlert = () => {
    setCnt(cnt+1);
    setTimeout(()=>{
      console.log('result(3sec):'+cnt)}
    ,3000);
 }

plus를 다섯번 누른 각각의 결과는요.

값을 5까지 찍었으니 콘솔도 5까지 찍혀야 하지만 위와 같이 업데이트 되기 전의 값이 나옵니다.

이유가 무엇일까요?

바로 setTimeout 함수의 특성 때문인데요.

setTimeout 함수는 실행할 때마다 새로운 함수가 만들어지며 전달되는 함수 내부의 값은 변수가 아닌 전달하는 시점의 변수의 값(상수)이 전달되기 때문이라고 보면 됩니다.

따라서 3초가 아닌 1000초 뒤에 실행하더라도 실행되는 값은 전달하는 시점의 변수의 값입니다.

그렇다면 위 코드를 동기처럼 사용하는 방법은 무엇일까요?

state 설정 시 클래스형은 함수를 전달하고 함수형은 useEffect를 사용하면 됩니다.

 //클래스형
 updateState = () => {
    this.setState(
      { cnt: this.state.cnt + 1 },
      ()=>{ console.log(this.state.cnt) }
    )
 }


 //함수형
 const plusNum = () => {
    setCnt(cnt+1);
 }

 useEffect(()=>{
  console.log(cnt);
 },[cnt])

이제 의도한 대로 결과가 출력됩니다.


3. 비동기로 작동하는 이유

그렇다면 state 설정은 왜 비동기로 작동할까요?

위에서 설명한대로 state는 값이 변경되면 리렌더링이 발생하는데요.

변경이 하나라면 리렌더링이 한번만 발생하지만 수십 개, 수백 개의 값이 계속 변경된다면 리액트는 매번 렌더링만 하다가 생을 마감하고 말 것입니다. 속도는 말할 것도 없구요.

따라서 변경된 값들을 모아 한번에 업데이트를 진행하여 렌더링을 줄이고자 배치(Batch) 기능을 사용해 비동기로 작동한다고 볼 수 있습니다.

참고로 배치 업데이트는 16ms 주기라고 합니다!


18 버전에서 추가될 자동배치(Automatic Batching)는 기존에 이벤트 핸들러에서만 실행되던 배치가 이제는 setTimeout, Promise 등의 이벤트에서도 동작될 예정이며, flushSync() 등을 사용해 예외를 둘 수도 있다고 하여 많은 기대를 받고 있는 것 같아 미리 알아두면 좋을 것 같습니다.

자동배치 관련한 상세 설명(영어)