역주 : 시작하기 전에
아마 리액트 개발을 경험하면서 한번쯤은 상태가 두 번씩 업데이트되기도 하고, 상태 업데이트가 한 박자 늦기도 하는 등 의도한 대로 상태 업데이트가 되지 않은 경험을 해보신 적이 있을 것입니다.
이번 번역을 통해 함수 컴포넌트의 상태 업데이트에 대한 내용을 집중적으로 다뤄보려 합니다.
리액트 훅의 원리, 제대로 알고 계신가요?
원제 : We don't know how React state hook works
이 글은 다음 내용들을 포함합니다:
- 상태가 언제 갱신되는지
- 업데이트 큐와 지연 계산 (lazy computation)
- 배칭 (Batching)
- useState vs. useReducer
- 성능 최적화 방식
- 잦은 상태 업데이트 시
- 얕은 렌더링과 렌더링 회피
- 업데이트 함수의 동작 시기
상태는 언제 갱신될까요?
코드를 하나 살펴보겠습니다.
const MyComp = () => { const [counter, setCounter] = useState(0); onClick = () => setCounter(prev => prev + 1); return <button onClick={onClick}>Click me</button> }
버튼을 클릭해
setCounter
함수를 호출했을 때 무슨 일이 일어날지 예상할 수 있나요?
만약 예상할 수 있다면, 혹시 이렇게 생각하시나요?- 리액트가 업데이트 함수를 호출합니다. (
prev
⇒prev + 1
)
- 훅이 갖는 상태값을 1로 업데이트합니다. (1)
- 컴포넌트를 다시 렌더링합니다.
- 렌더 함수가
useState
를 호출하고, 갱신된 상태값을 사용합니다. (1)
만약 이렇게 생각하셨다면 여러분은 훅에 대해 잘못 알고 계신 것으로, 저 역시 몇몇 실험과 리액트 훅의 코드를 직접 보기 전까지는 이를 잘못 알고 있었습니다.
업데이트 큐와 지연 계산 (lazy computation)
알고 보니, 모든 훅은 저마다 업데이트 큐를 하나씩 갖고 있었습니다.
setState
함수를 호출하면 리액트는 업데이트 함수를 즉시 호출하는 대신, 이를 업데이트 큐에 삽입해 재렌더링을 예약합니다.해당 훅에서 업데이트가 여러번 수행될 수도 있고, 다른 훅이나 심지어는 다른 컴포넌트의 훅에서도 추가적인 업데이트가 일어날 수 있습니다. (예를 들어, 리덕스를 통한 상태 변경은 트리 내부의 여러 곳에서 업데이트를 유발할 수 있습니다.) 다만 모든 업데이트는 즉시 계산되어 실행되는 대신, 업데이트 큐에 삽입됩니다.
모든 업데이트가 완료되면, 리액트는 재렌더링이 예정된 모든 컴포넌트들을 재렌더링합니다. (역자 : 다만 화면에 렌더링되는 상태값의 업데이트는 아직 수행되지 않습니다.)
(역자 : 실제 화면에 렌더링되는) 상태값의 업데이트는
useState
가 렌더 함수 내에서 실제로 수행될 때 리액트는 업데이트 큐 내부의 액션 들을 순서대로 실행한 다음 최종 상태를 업데이트하고, 업데이트된 최종 상태를 반환하면서 진행됩니다.이렇게 리액트가 필요로 할 때만 새로운 상태값을 계산하는 것을
지연 평가(lazy computation)
이라 합니다.위에서 일어난 일들을 간단히 요약하자면 다음과 같습니다:
- 리액트는 훅에서 발생한 액션(업데이트 함수의 동작)을 업데이트 큐에 삽입합니다.
- 컴포넌트의 재렌더링을 예약합니다.
- 렌더링이 실제로 수행되면 다음 순서대로 동작합니다. (이는 나중에 더 자세히 다루게 됩니다.)
- 렌더 함수는
useState
를 호출합니다. useState
가 동작하는 동안, 리액트는 업데이트 큐에 삽입된 액션을 순서대로 실행하며, 모든 액션을 수행한 결과물을 훅의 상태값으로 저장합니다. (위의 카운터 예시에서는 1이 됩니다.)useState
는 1을 반환합니다.
배칭 (Batching)
따라서 리액트가 "좋아, 업데이트도 큐에 전부 추가했고 렌더링 예약도 끝났는데, 이제 내가 나설 차례인가?" 라고 말할 때, 업데이트가 끝났다는 것을 어떻게 알 수 있을까요?
이벤트 핸들러(onClick, onKeyPress 등등...)가 존재한다면, 리액트는 핸들러의 콜백을 배치 내에서 처리합니다.
배치는 동기적이며, 콜백을 수행함과 동시에 예정된 모든 렌더링을 취소하고 마지막 렌더링만을 실행합니다.
const MyComp = () => { const [counter, setCounter] = useState(0); onClick = () => { // 배치(batch) 작업이 시작됩니다. setCounter(prev => prev + 1); // 렌더링을 예약합니다. setCounter(prev => prev + 1); // 렌더링을 예약합니다. } // 렌더링은 이곳에서 한 번만 실행됩니다. return <button onClick={onClick}>Click me</button> }
만약 콜백에 비동기적으로 동작하는 코드가 있다면 어떨까요? 해당 코드는 배치 바깥에서 실행됩니다.
이럴 경우, 리액트는 이를 스케줄링하는 대신 곧바로 렌더링 단계로 넘어갑니다.
const MyComp = () => { const [counter, setCounter] = useState(0); onClick = async () => { await fetch(...); // 배치가 종료됩니다. setCounter(prev => prev + 1); // 렌더링을 예약하는 대신, 곧바로 렌더링합니다. setCounter(prev => prev + 1); // 렌더링을 예약하는 대신, 곧바로 렌더링합니다. } return <button onClick={onClick}>Click me</button> }
역주 : 아래 gif를 참고하시면 이해하기 쉬울 것입니다!


상태는 리듀서입니다
이전에 "리액트는 큐에 존재하는 액션을 순서대로 실행합니다." 라고 언급한 내용을 기억하시나요?
useState
는 내부적으로 다음 basicStateReducer
처럼 작성한 useReducer
로 구현되어 있습니다.function basicStateReducer(state, action) { return typeof action === 'function' ? action(state) : action; }
따라서
setCounter
함수는 실제로는 dispatch
와도 같으며, setCounter
내부에 전달하는 값이나 업데이트 함수가 액션이 되는 것입니다.위에서 다룬
useState
에 관한 모든 내용은 useReducer
에도 동일하게 적용되므로, 둘의 동작 원리는 동일한 메커니즘을 사용합니다.성능 최적화
'만약 리액트가 렌더링을 진행하는 동안 새로운 상태값을 계산한다면, 만약 상태값이 변하지 않을 때는 어떻게 렌더링을 피할 수 있을까?' 라는 생각이 드실 수 있는데요, 이는 '닭이 먼저냐, 계란이 먼저냐' 와 비슷한 문제입니다.
이 질문의 답은 두 단계로 나눌 수 있습니다.
이 과정은 다른 방법이 있습니다. 몇몇 경우에 리액트가 재렌더링을 피해야 하는 시점을 알 때는 렌더링을 하는 대신에 상태값을 즉시 계산하고, 계산한 결과물이 이전 상태와 동일할 때는 재렌더링을 예약하지 않습니다.
두 번째 시나리오는, 만약 리액트가 액션을 즉시 발생시킬 수 없는 상황입니다.
그러나 렌더링이 진행되는 중에 리액트가 어떤 변화도 없는 것을 확인하면, 모든 훅은 이전과 동일한 결과를 반환합니다.
리액트 팀의 공식 문서에서 이를 가장 잘 설명하고 있습니다.
+ 공식 문서 중 : 상태 업데이트 회피하기
현재 상태와 동일한 값으로 상태값 업데이트를 시도하면, 리액트는 하위 컴포넌트를 렌더링하거나 이펙트를 발생시키지 않습니다.
(리액트는 Object.is 기반의 비교 알고리즘을 사용합니다.)
리액트는 업데이트를 피하기 전에 특정 컴포넌트를 렌더링해야 할 수도 있지만, 리액트는 불필요할 때 렌더링 트리를 "깊게" 파고들지 않기 때문에 이는 문제가 되지 않습니다. 만약 렌더링 중 자원을 많이 소모하는 계산을 수행한다면,
useMemo
를 사용해 이를 최적화할 수도 있습니다.잠깐 설명했듯, 리액트는 렌더 함수를 실행하고 만약 변화가 없다면 함수 실행을 종료함으로써 컴포넌트와 하위 컴포넌트의 불필요한 렌더링을 수행하지 않습니다.
업데이트 함수는 언제나 실행될까요?
정답은 아니오 입니다. 예를 들어, 만약 렌더 함수의 동작을 막거나 중간에 멈추는 예외가 발생한다면
useState
호출은 이루어지지 않고, 업데이트 큐를 순회하지도 않습니다.또다른 경우는, 만약 다음 렌더링 단계를 실행하던 중 컴포넌트가 언마운트된다면(예를 들어, 부모 컴포넌트의 특정 조건값(flag)이 변경되었을 때) 이 때 역시 렌더 함수는 실행되지 않으며,
useState
역시 호출되지 않게 됩니다.