[React] 클로저와 useState Hooks (2)

2020. 11. 3. 00:49Frontend

 

 

https://morioh.com/p/1c10ff1d8da1

 

Overview

 

이번 포스팅은 리액트 함수형 컴포넌트에서 단순히 다음 두 함수의 실행결과가 다른 이유를 명확히 찾아내기 위해서 작성하게 되었습니다.

 

App.js

import React, { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const increase1 = () => {
    setCount(count+1);
    setCount(count+1);
    setCount(count+1);
  }

  const increase2 = () => {
    setCount(count => count + 1);
    setCount(count => count + 1);
    setCount(count => count + 1);
  }

  return (
    <div className="App">
      <h1>{count}</h1>
      <button onClick={increase1}>increase fn 1</button>
      <button onClick={increase2}>increase fn 2</button>
    </div>
  );
}

 

React Functional Component에서는 상태 관리를 위해 Hook을 사용합니다. (useState Hook에 대한 기본적인 내용은 여기를 참고해주세요) render() 함수를 통해 명시적으로 렌더하는 로직을 정의했던 기존의 Class Component 방식과는 다르게 함수형 컴포넌트에서는 함수 컴포넌트 호출이 곧 렌더이기 때문에 상태 관리를 내부적으로 하기에는 어려움이 있었습니다. 이를 해결하기 위해 등장한 것이 바로 React Hooks이며, useState, useEffect, useCallback, useMemo 등의 기능들을 통해 함수형 컴포넌트에서 여러 가지 상태 관리 로직들을 사용할 수 있습니다. 

 

 

물론 Hooks를 사용하면 편리한 점도 많고, React 공식문서에 Hooks를 사용할 때 주의해야 할 점들과, 여러 규칙들을 자세하게 설명해두어서 진입 장벽이 높지 않은 것은 사실입니다. 하지만 내부 구현과 정확한 원리, 구현을 위해 사용된 여러 개념들을 알고 있으면, 더 효율적인 코드 작성에 도움이 되며, Hooks를 개발한 사람들의 사용 의도대로 구현할 수 있기 때문에 이러한 부분들을 학습하는 것은 개발자에게 있어서 상당히 중요한 영역이라고 생각됩니다.

 

 

위의 두 함수가 좋은 예시가 될 수 있을 것 같습니다.

첫 번째 버튼(increase fn 1)을 눌렀을 때의 결과는 '3' 이 아닌 '1'입니다. 

반면 두 번째 버튼(increase fn 1)을 눌렀을 떄의 결과는 '3'입니다. 

 

 

두 함수의 내부 구현은 setCount 함수에 변수를 넣었는가, 함수를 넣었는가의 차이 외에는 동일하며 리액트 공식문서에서는 이에 대해 다음과 같이 설명하고 있습니다. 

 

If the new state is computed using the previous state, you can pass a function to setState.
The function will receive the previous value, and return an updated value.

 

해석하면, 새로운 상태가 바로 이전 상태를 사용해서 계산되어야 한다면, setState함수에 "함수"를 인자로 넣어야 하며, 이 경우 함수는 바로 이전 상태의 값을 바탕으로 새로운 값을 계산한다. 라는 것입니다. 반대로 해석하면 setState함수에 "값"을 인자로 넣는 경우, 함수는 바로 이전 상태의 값이 아닌 다른 상태의 값을 바탕으로 새로운 값을 계산할 수도 있다 라고도 해석할 수 있습니다. 

 

 

이 문장만 읽어서는 도무지 "이전 상태"가 무엇인지 알 수가 없습니다. 이에 대해서 리액트 공식문서의 다른 부분에서는 다음과 같이 설명하고 있습니다. 

React may batch multiple setState() calls into a single update for performance.

이 이전상태라는 개념이 헷갈리게 된 이유는 리액트의 독특한 Batch Process 때문입니다. 

 

 

React Batch Process

 

During subsequent re-renders, the first value returned by useState will always be the most recent state after applying updates.

 

리액트는 퍼포먼스를 위해서 독특한 Batch Process를 사용합니다. 즉, Synchronous 한 하나의 Lifecycle Method나, 이벤트 핸들러 안에서 일어나는 여러 번의 업데이트들은 한 번에 묶어서 처리한 후에 "마지막으로 Update 된 값으로 state값을 결정하고" 단 한번만 렌더링 한다는 것입니다. 따라서 다음 함수를 실행했을 때, 렌더링은 단 한 번만 일어나게 됩니다.

 

// 함수를 실행하는 시점에 count = 0이라고 가정.
const increase1 = () => {
    setCount(count+1);
    setCount(count+1);
    setCount(count+1);
 }

 

setCount()라는 useState Hook를 사용해서 상태 업데이트를 3번 진행했지만, React의 Batch Process로 인해 하나의 이벤트 핸들러 안에 있는 동기적인 상태 업데이트는 단 한 번만 진행되게 됩니다.

 

따라서 increase1 함수가 호출되어 setCount가 3번 호출되지만, 실제로 리 렌더링 후 state에 최종 적용되는 값은 마지막 setCount(count+1)의 값이며, 이때 count의 값은 초기값인 0이기 때문에 결과적으로 실행 후 count의 값은 1이 되며,  단 한번의 리 렌더링만 이루어지게 되는 것입니다.

 

/* React Batch Process에 의해 다음과 같이 실행하면  값이 10이 나옵니다.*/

  const increase1 = () => {
    setCount(count+1);
    setCount(count+1);
    setCount(count+10);
  }

 

이로 미루어 보았을 때, 위에서 언급했던 바로 "이전의 상태"란, Batch Process로 인해 상태 업데이트가 한꺼번에 진행될 때, 동일한 setCount를 여러 번 실행할 경우, 실행과 실행 사이의 상태를 의미한다고 해석할 수 있습니다. 

 

예를 들어 아래의 실행은 결과적으로는 마지막 상태만 반영하여 리렌더시키지만, 리액트에서는 함수형 인자를 통해 첫 번째 실행의 결과에도 접근할 수 있는 방법을 제공한다는 것입니다. 

/* 
첫번째 count + 1은 두번째 count + 1에 의해 무시되며,
첫번째 count + 1의 결과 값에 접근할 수 있는 방법이 없다.
*/
setCount(count+1); 
setCount(count+1); 
// count = 0이었을 때 실행결과 count = 1;
/* 
첫번째 count + 1은 두번째 count + 1에 의해 무시되지 않는다.
두번째 count는 첫번째 count + 1의 결과이다.
*/
setCount(count => count+1); 
setCount(count => count+1); 
// count = 0이었을 때 실행결과 count = 2;

 

React.useState의 내부 구현

 

React팀에서 제공하는 공식 React Source Code를 보면, React useState Hook이 어떻게 구현되어 있는지를 확인할 수 있습니다. 

 

Initialize Hook

 

우선 컴포넌트가 마운트되었을 때 Hook을 초기화하는 함수는 다음과 같습니다.

function mountState(initialState) {
  var hook = mountWorkInProgressHook();

  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  hook.memoizedState = hook.baseState = initialState;
  var queue = hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState
  };
  var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
  return [hook.memoizedState, dispatch];
}

- mountWorkInProgressHook() 실행하여 hook 변수에 할당합니다. 초기에는 null이 할당되지만, 함수의 끝에는 다음과 같은 포맷이 됩니다.

{
  memoizedState: 0, 
  baseState: 0, 
  queue: {
    last: null,
    dispatch: dispatchAction.bind(null, currentlyRenderingFiber$1, queue),
    lastRenderedReducer: basicStateReducer(state, action),
    lastRenderedState: 0, 
  },
  baseUpdate: null,
  next: null,
}

- useState의 초기값으로 함수가 들어온 경우 이 함수를 실행해서,  값이 들어온 경우 해당 값을 초기값으로 설정합니다.

- memoizedState와 dispatch를 리턴하여 초기 설정을 완료합니다.

 

 

이때, hook변수에 할당된 next는 LinkedList의 일종으로, 하나의 컴포넌트 안에서 여러개의 hook을 사용했을 때, 이를 LinkedList를 사용해서 연결해주는 역할을 합니다. (React Hook 규칙 중에 hook을 조건문 안에 넣지 말고 컴포넌트의 최상단에 위치시켜야 한다는 이유가 바로 여기 있습니다.) 따라서 컴포넌트 마운트 시에 hook이 여러 개 있다면, 이 hook들은 다음과 같이 next를 통해서 연결되는 구조를 가지게 됩니다. 

 

(3개의 hook이 정의된 component 마운트시 hook변수 구조)

{
  memoizedState: 0, // first hook
  baseState: 0,
  queue: { /* ... */},
  baseUpdate: null,
  next: { // second hook
    memoizedState: false, 
    baseState: false,
    queue: { /* ... */},
    baseUpdate: null,
    next: { // third hook
      memoizedState: {
        tag: 192,
        create: () => {},
        destory: undefined,
        deps: [0, false],
        next: { /* ... */}
      }, 
      baseState: null,
      queue: null,
      baseUpdate: null,
      next: null
    }
  }
}

 

 

Update Hook

 

위와 같은 hook구조에서 상태 변경이 일어났을 때, 즉, setState함수의 호출이 일어났을 때의 구조 변경은 다음과 같습니다. (여기서는 편의상 count를 set하는 setCount라고 가정하겠습니다.)

 

0. update가 일어나기 전 hook의 상태 (setCount 호출 전)

{
  memoizedState: 0, 
  baseState: 0,
  queue: {
  	last: null,
    dispatch: dispatchAction.bind(bull, currenctlyRenderingFiber$1, queue),
    lastRenderedReducer: basicStateReducer(state, action),
    lastRenderedState: 0,
  },
  baseUpdate: null,
  next: null
}

 

1. queue의 last값이 할당됨

last에는 setCount를 통해 넘어온 액션과, React의 Batching Process를 통해 최종적으로 업데이트될 상태를 담고 있는 eagerState 변수, 그리고 action으로부터 eagerState를 계산하는 eagerReducer의 값이 세팅됩니다. 

{
  memoizedState: 0, 
  baseState: 0,
  queue: {
   last: {
      expirationTime: 1073741823,
      suspenseConfig: null,
      action: 1, // setCount를 통해 설정한 값.
      eagerReducer: basicStateReducer(state, action),
      eagerState: 1, // 실제로 상태 업데이트를 마치고 렌더링되는 값.
      next: { /* ... */},
      priority: 98
    },
    dispatch: dispatchAction.bind(bull, currenctlyRenderingFiber$1, queue),
    lastRenderedReducer: basicStateReducer(state, action),
    lastRenderedState: 0,
  },
  baseUpdate: null,
  next: null
}

 

여기가 바로 이번 포스팅을 하게된 이유가 밝혀지는 부분입니다!

사용자가 넘긴 action으로부터 Batch Process이후에 최종 반환될 상태인 eagerState를 계산하는 함수는 Reducer입니다. 이 Reducer에 넘기는 action은 함수일 경우 이전 상태를 파라미터로 넘겨주어 함수를 실행한 값을 리턴하고, 값일 경우 그냥 그 값을 리턴합니다. 

 

이렇게 리듀서를 이용해서 값을 할당해 주기 때문에 action에 함수를 넣어주면 update시에 리듀서가 함수를 이용해서 eagerState를 계산하고 다음 update로 넘어가게 되므로, 지속적으로 값이 업데이트가 될 수 있습니다.

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

 

처음에 언급했던 카운터 예를 들어 보면 다음과 같습니다.

queue: (setCount(count+1));

last: {
	  ...other options // 필요한 부분만 남겨놓고 생략하였음.
      action: count + 1,
      eagerReducer: basicStateReducer(state, action),
      eagerState: count + 1, 
      next: {
      	last: {
        	... otherOptions,
            action: count + 1,
            eagerReducer: basicStateReducer(state, action),
            eagerState: count + 1, 
            next: null
        }
      }
 },

 

queue: (setCount(count => count + 1))

last: {
	  ...other options // 필요한 부분만 남겨놓고 생략하였음.
      action: count => count + 1,
      eagerReducer: basicStateReducer(state, action),
      eagerState: count + 1, 
      next: {
      	last: {
        	... otherOptions,
            action: count => count + 1,
            eagerReducer: basicStateReducer(state, action),
            eagerState: (count + 1) + 1, 
            next: null
        }
      }
 },
반응형

'Frontend' 카테고리의 다른 글

[React] Timer 만들기  (0) 2021.01.06
[Safari] 내 iPhone 브라우저 Inspect하기  (0) 2021.01.04
[Browser] 웹 페이지 로드 과정  (0) 2020.10.27
[CSS] CSS Sprite란?  (0) 2020.10.07
[Javascript] 이벤트 루프(Event Loop)  (0) 2020.10.06