setState는 '비동기 함수'인가요?

Sheryl Yun·2023년 4월 11일
8

React.js

목록 보기
8/24
post-thumbnail

예전에 면접에서 'setState가 비동기처럼 동작하지만, 비동기 함수는 아니다' 라는 얘기를 들은 적이 있다.

함수 안에서 setState 아래에 console.log를 찍으면 제대로 값이 찍히지 않는 경험을 여러 번 해보았기 때문에 '~지만,' 까지의 앞부분은 이해되었다. 하지만 뒷문장은 대체 무슨 뜻인가?

비동기처럼 동작하는데 비동기 함수는 아니다.. 최근에 크롬 모바일 첫 화면에서 운좋게 이와 관련된 포스팅이 떴다. '콘솔로그가 이상한 건 setState가 비동기 함수여서가 아닙니다 (setState is not async)'라는, 예전에도 몇 번 velog 포스팅으로 도움을 받았던 단테 님의 글이었다.

useState 함수의 시그니처

vscode에서 Ctrl 키를 누르고 함수나 변수를 클릭하면 타입이나 origin이 뜬다. 그동안은 무슨 말인지 알아보기 힘든 암호같다고 생각해서 잘 보지 않았는데, 이것이 원래 함수와 변수의 모양을 알려주고 이걸 시그니처라고 하는 것 같다. useState의 시그니처는 다음과 같이 생겼다. 글에 있는 예시가 아니라 내 프로젝트를 켜서 Ctrl과 함께 useState를 직접 눌러봄

const [currentTab, setCurrentTab] = useState('5');

위의 useState를 Ctrl 키를 누른 채로 눌러보았다. (이전 채용 과제를 복습하던 레포인데 어쩌다 초기값이 '5'가 되었을까..)

(alias) useState<string>(initialState: string | (() => string)): [string, React.Dispatch<React.SetStateAction<string>>] (+1 overload)
import useState
Returns a stateful value, and a function to update it.

@version — 16.8.0

@see — https://reactjs.org/docs/hooks-reference.html#usestate

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

흡.. 여기서 내가 알아볼 수 있었던 건 예전에는 useState와 string 정도 뿐이었다. 근데 이제는 좀 더 자세히 봐야겠다는 생각이 들었다.

꺾쇠< >가 있는 부분은 타입일 것이고, initialState는 useState 상태값의 초기값, 오른쪽 React.Dispatch의 React.SetState는 그 상태값을 변경시키는 함수일 것이다. (() => string) 부분이 React.Dispatch<React.SetStateAction<string>>라는 긴 타입으로 표현되었다.

overload는 '누적 값'인가 추측했는데 사전에 찾아보니 '과부하' 또는 '과적'이라는 뜻이었다. 상태값이 문자열('5')인데 +1 overload라고 하는 걸 봐서 숫자 1을 더해준다는 뜻은 아닌 것 같아서 검색해보았다.

'Multiple type signatures for a single function are called overloads.'

함수 하나에 여러 타입 시그니처가 붙은 것을 overload라고 한다고 한다. React.Dispatch<React.SetStateAction<string>>에서 타입이 2개 겹친 걸 가리키는 건가?

Returns a stateful value, and a function to update it.

이 문장은 '상태값(a stateful value)과 그것을 업데이트하는 함수(a function to update it)를 반환한다'는 useState의 설명이다.

리액트는 18버전을 사용했는데 useState 훅은 16.8 버전인 걸로 봐서 훅이 리액트 버전을 따라가지는 않나보다. useState 공식 문서 링크와 함께 정식 alias 시그니처 문장을 더 간단화한 타입도 제공했다.

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

string은 대문자 S로 대체, React.~ 대신 하위 타입을 바로 import한 형식이다.

글에서 '함수 시그니처'라는 말이 나와서 이렇게 길게 찾아보았다. 실제 글에서 강조하려 했던 건 다음 문장이다.

useState 함수의 리턴 타입은 Promise가 아니다.

반환 값이 Promise가 아니라는 것은 곧 비동기가 아니라는 말과 동일하다.
그럼 setState는 왜 그렇게 이상하게 동작하는 걸까? 글을 좀 더 읽어보기로 했다.

클로저 (Closure)

우리가 코딩을 좋아하는 이유는 공부한 내용을 실생활에 바로 사용할 수 있어서입니다.

맞다. 내가 코딩을 하는 이유도 이것이다. 배운 내용을 (항상은 아니지만) 바로바로 코드에 적용할 수 있다. 불필요한 개념적인 것들을 외워 합격 후에는 다신 쓰지 않을 지식이 아니라 코딩이라는 작업에 쉼없이 적용을 하면서 바로바로 익히고 더 깊은 개념으로 들어가는 즐거움.. 필자는 바로 이런 방식으로 클로저라는 개념을 접할 것을 권한다.

그동안 기술 면접을 대비해서 달달 외운 클로저 개념은 이렇다.

개념: 외부 함수가 종료(소멸)되어도 안에 있던 내부 함수가 여전히 외부 함수의 지역 변수를 참조할 수 있는 것
이것이 가능한 이유는 자바스크립트 함수가 선언될 때 자신의 상위 스코프 환경을 기억하고 있기 때문이다.
클로저의 사소한 단점은 지역 변수가 살아 있으면서 계속 메모리를 차지한다는 점인데, 이는 클로저의 사용이 끝난 후에 개발자가 명시적으로 null을 할당하면 GC(가비지 컬렉터)가 수거해가게 할 수 있다.

위 글 쓸 때 뻥 안 치고 암것도 안 보고 머릿속 내용을 그대로 썼다. 근데 이렇게 공부해서 도대체 어디에 써먹을 것이냐가 문제였다. 필자는 클로저 개념을 리액트에 적용해볼 것을 권했다. 글에서 거의 한 문단으로 정리되어 있어 이해하기 편했다.

다음은 글에 있는 예시를 컴포넌트 이름만 넣어 살짝 수정한 예시이다.

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값을 참조할 수 있는 것이다.

리액트가 가상돔을 사용해서 더 빠르다?

리액트가 가상돔을 사용해서 화면 전체의 리렌더링이 아닌 일부분만 수정해서 렌더링하기 때문에 더 빠르다는 생각을 나도 갖고 있었다. 하지만 정확히 맞는 말이 아니라고 한다.

글에 적힌 인터뷰 내용의 필자의 답변을 간추려보았다.

가상돔은 실제 돔을 업데이트시키기 전에 화면 구성 시 필요한 상태 값들을 자바스크립트의 클로저 개념으로 참조한다. 각 컴포넌트가 참조하고 있는 상태 값들이 많은데 이것들을 모두 리렌더링 때마다 실제 돔 업데이트로 연결시키는 것은 비효율적이다. 리액트에서는 가상돔이라는 트리 구조를 활용하여 리렌더링 때마다 변경되는 부분만 골라서 실제 돔을 업데이트시킨다.

리액트가 명령형 방식보다 무조건 빠르다기보다는 그냥 개발자의 생산성을 올릴 수 있을 정도로만 빠르다고 보는 것이 맞다. 명령형으로 돔을 업데이트하는 jQuery 방식에 비해 리액트는 선언형 방식과 가상 돔을 이용해서 실제 돔을 업데이트한다.

리액트의 setState가 동기 함수인데 마치 비동기 함수처럼 보이는 이유는 리액트의 리렌더링 원리가 가상 돔을 통해 비동기적으로 작동하기 때문이다.

리액트에서 렌더링 함수는 컴포넌트의 상태나 속성이 변경될 때마다 호출된다. 이 렌더링 시점에 참조하는 상태는 클로저에 의해 해당 시점의 컴포넌트의 상태값을 참조하고 이 최신 참조 값이 가상돔에 반영된다. (가상 돔이 최신 상태로 유지됨) 그리고 가상 돔과 실제 돔을 비교하여 마침내 실제 돔을 업데이트한다.

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

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

순서

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

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

총 정리

글의 댓글 중에 다음과 같은 질문 댓글이 있었다.

리액트 공식 홈페이지에서
setState 호출은 비동기적으로 이뤄진다고 합니다.

이 분이 공유하신 리액트 공식문서 링크에 들어가보니 '왜 setState가 잘못된 값을 주는 걸까요?'라는 소제목 문단에서 'setState 호출은 비동기적으로 이뤄진다' 는 내용이 있었다.

단테님의 답변: 'setState가 비동기 함수가 아니라 setState 함수의 '호출'이 비동기적으로 이루어지는 것이다'

함수가 비동기적으로 호출되는 것과 함수 자체가 비동기인 것은 다릅니다.
(예를 들어) setTimeout 함수는 비동기적으로 작동하나, (setTimeout 함수 안에 들어가는) 콜백 함수는 비동기 함수가 아닐 수 있습니다.

setState의 역할은 컴포넌트의 state 객체를 업데이트하는 것이다. 리액트에서는 state가 변경되면 컴포넌트가 리렌더링된다.

setState 함수는 동기 함수이지만
setState 함수 호출은 비동기적으로 일어난다.
그래서 상태의 업데이트 결과가 즉각적으로 바로 다음 코드 라인에 반영되지 않는다.

리렌더링이 발생해야 업데이트된 상태 값이 가상돔 트리에 반영된다.

즉, 업데이트된 상태는 리렌더링된 후에 참조할 수 있다.

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


리액트가 비동기적으로 업데이트되어야 하는 이유인 fiber 아키텍쳐에 대한 상세한 내용도 글에 있었지만 이는 나중에 공부해보려 한다. 리액트 setState의 작동 원리에 대해 알아볼 수 있는 시간이었다.

profile
영어강사, 프론트엔드 개발자를 거쳐 데이터 분석가를 준비하고 있습니다 ─ 데이터분석 블로그: https://cherylog.tistory.com/

0개의 댓글