리액트의 가장 기본적이고 핵심적인 상태값을 변경하는 setter 함수(메소드)에 대해서 정리하고자 한다.
이 setter 메소드는 클래스형 컴포넌트라면 this.setState가 빌트인 되어 있고 함수형 컴포넌트라면 직접 정의해서 사용할 수 있다.
클래스 방식이나 함수형 모두 글의 맥락을 보면 동일하므로 여기에서는 클래스형 컴포넌트를 사용하여 설명한다.
그러면 왜 setState는 비동기로 동작할까?
리액트에서는 상태값은 아주 핵심적이고 특별하다. 어떤 상태값이 변경되면 그 영향하에 있는 컴포넌트들은 렌더링이 된다. 그리고 어떤 컴포넌트가 렌더링되면 그 하위 컴포넌트들 역시 리렌더링 된다. 이렇게 연쇄직인 방법으로 데이터의 변화가 돔 요소에 실시간으로 반영되어 우리에게 보여지는 것이다.
그러면 왜 setState가 비동기를 동작하는지에 대한 답을 찾기 전에 우선 클래스형 컴포넌트에서 상태값을 변경하는 예를 살펴보고 진행하도록 하겠다.
다음 코드를 보자.
import {Component} from 'react' class SetStateTest extends Component { state = {count: 0}; onClick = () => { this.setState({count: this.state.count + 1}, console.log('state changed')) // 1** } render() { console.log("rendering...") // 2** return( <> <div>{this.state.count}</div> <button onClick = {this.onClick}>count+</button> </> ) } } export default SetStateTest;
//** 표시한 부분이 핵심이다.
버튼을 누르면 counter가 증가하고 화면에 couter값이 보여지는 어플이다.
setState의 두 번째 파라미터값은 마무리 함수로 state값이 변경된 후 실행된다고 생각하면 된다. 이렇게 setState와 render가 이루어지는 부분에 콘솔로 로그를 찍어보면
state changed
rendering...
이렇게 state 값이 바뀐 후 렌더링이 일어난다. 여기까지는 어렵지 않다.
이상한 반응
//1**과 같은 코드를 두번 넣어보자.
onClick = () => { this.setState({count: this.state.count + 1}, console.log('state changed')) // 1-1** this.setState({count: this.state.count + 1}, console.log('state changed')) // 1-2** }
그런 후 실행화면에서 버튼을 눌러보면 다음과 같이 동작한다.
1. 버튼을 1회 클릭할 때 마다 화면에 보여지는 counter 값은 1씩 증가된다.
2. 버튼을 1회 클릭할 때마다 콘솔화면에 다음과 같이 표시된다.
state changed
state changed
rendering..
이런 현상이 일어나는 이유는 우리가 처음에 물음을 던진 주제가 원인이다. 바로 setState가 비동기적으로 동작하기 때문이다.
비동기란?
비동기에 대해서 모르시는 분들을 위해 간단한 설명이 필요할 듯 싶다.
예를 들어 음식점에서 음식을 시켰을 때 주문을 받는 사람이 음식도 같이 한다면 주문이 된 순서대로 음식을 해야하기 때문에 음식이 만들어지는 순서도 주문된 순서와 동일하다. 왜냐면 주문을 받은 후 음식을 하는 동안은 주문을 받지 못하기 때문이다. 이것이 동기라면
비동기는 주문을 받는 자와 주방장이 따로 있어서 주문을 받는 사람은 주문을 주방에 전달하기만 하면 된다. 그러면 음식이 주방에서 만들어지는 동안 주문을 받는 사람은 계산도 할 수 있고 기타 자잘은 서비스들을 할 수 있는 것이다. 여기에서 음식을 만드는 작업이 헤비한 작업에 속한다. 컴퓨터로 치면 I/O작업이 그렇다.
현대의 하드디스크나 네트워크 어댑터에는 작은 CPU가 들어가 있어서 메인 CPU가 해당 장치로 작업을 의뢰하면 의뢰한 작업이 끝나기를 기다리지 않고 메인 CPU는 다른 일을 처리할 수 있고 그 동안 의뢰한 장치의 cpu는 자신의 일을 한 후 응답을 다시 메인 CPU에 알려준다. 보통의 하나의 작업 사이클에서 I/O 작업이 대부분을 차지하는 것이 현실이기 때문에 이런 방식은 메인 CPU의 자원을 효율적으로 쓸 수 있게 해준다. 만약 수치연산처럼 메인 CPU의 작업이 대부분을 차지하는 작업이라면 비동기는 그다지 효용성이 없다는 것이 이런 이유다.
비동기를 음식점으로 비유하자면 실제 메인 프로세스는 위 음식점의 예에서 주문을 받는 사람이 하는 작업이다. 주문을 받는 사람은 주문은 받은 즉시 바로 주문을 주방(하드디스크 등)에 전달한다. 그러면 주방에서는 어떤 식으로든 음식을 만들게 될 것이다. 그 동안 메인 태스크에서 여러 작업들이 이루어진다.
인터넷의 경우 응답시간까지 매우 짧아 보이지만 CPU 입장에서는 한세월이다. 따라서 여러 사이트에 10개의 요청을 해서 응답을 받는 일이 있다면 순서대로 하는 것은 매우 비효율적이다.
비동기로 동시에 여러 사이트에 주문을 넣고 다른 일을 하면서 기다릴 수 있다. 여러 응답이 거의 동시에 와도 실제로 처리되는 것은 아주 짧은 순간이기 때문에 거의 1개의 사이트에 요청하는 것과 거의 대등한 속도로 10개의 요청을 처리할 수 있다.
비동기에 대해서는 할 말이 많지만 다시 주제로 돌아가 보자.
렌더링의 효율성
내 생각에 리액트에서 위와 같이 setState를 비동기로 처리하는 이유는 효율성 때문이다.
앞서 리액트는 상태값이 바뀌면 렌더링이 된다고 했었다. 그렇다면 위와 같이 setState가 컴포넌트에서 여러개 사용될 때는 setState가 사용된 만큼 화면이 렌더링이 되야 한다. 만약 화면에 100개의 객체가 각각 값을 바꾸는 앱이 있다고 하자. 그러면 한순간에 100번의 렌더링이 이루어질 것이다. 이건 뭔가 문제가 있다.
사실 리액트에서는 객체의 값이 변할 때 해당 컴포넌트의 setState를 모두 취합한 후에 한번에 주문을 넣어버리고 주문의 결과(음식)이 모두 나온 후에 한번만 렌더링하도록 한다. 이렇게 하면 100개의 값이 한번의 렌더링으로 모두 갱신될 수 있다.
그러면 이상한 현상이 발생하는 이유는? 뭔가?
사실 이 현상에 대한 프로그래밍적인 정확한 메커니즘은 잘모른다. 하지만 여러 자료들을 찾아본 결과 대략적인 메카니즘은 다음과 같다.
- setState함수는 비동기로 처리되는데 메인 프로세스 중에 호출된 모든 setState가 모두 처리되기 전에 컴포넌트가 렌더링 되지는 않는다.
- 상태에 대한 동일한 key에 접근하여 값을 변경하는 setState에 대해서는 가장 마지막에 있는 setState만 적용이 된다.( 정확한 원리는 조사중)
그렇다면 해결 방법은?
아주 간단하다. setState에 전달할 인수를 상태값이 아닌 함수를 전달한다.
onClick = () => { this.setState((prev)=>({count: prev.count + 1}), console.log('state changed')) // ** this.setState((prev) => ({count: prev.count + 1}), console.log('state changed')) // ** }
이렇게 전달된 함수의 안자로 현재 컴포넌트의 상태값(prev)이 통체로 전달된다.
setState는 모두 비동기로 처리되지만 렌더링 전에 모두 묶음으로 다 처리됨이 보장되며, 또한 처리 순서도 실행 순서대로 처리됨이 보장된다. 디시말해 큐(queue)에 setState가 넣어져서 순서대로 처리된다고 보면 된다.(이 역시 정확히 알아보는 중).
또한, setState에 전달된 함수의 인수는 state의 가장 최신값임을 보장받는다는 것이 그 요지다.
다시 정리하면
1. setState는 모두 비동기로 처리됨.
2. setState는 모두 묶음(batch) 방식으로 렌더링 전에 처리됨.
3. setState에 갱신될 상태값을 넣으면 동일한 key에 대해서는 가장 마지막 setState만 갱신됨. (내생각: 혹시 setState가 비동기로 처리될때 클로저처럼 환경을 캡쳐하는 건 아닌지 생각된다. setState는 비동기 큐로 전달되므로 이렇게 하면 마지막 setState만 의미있게 되는건 아닌지..)
4. setState에 함수를 전달하면 함수의 인수는 상태값(state)의 가장 최신값임을 보장받게 된다.
혹시 이 동작의 메커니즘에 대해서 좀 더 정확히 잘 아시는 분은 답글 부탁드립니다.