React와 Closure에 관하여

wgnator·2022년 8월 16일
3

함수형 React(React Hooks) 는 클래스를 생성하는 대신 클로저를 사용한다. 리액트를 사용하면서 가장 신기했던 부분이 바로 useState 의 사용법이었다. 우리는 보통:

const [state, setState] = useState()

을 사용하여 상태를 정의하고, state 라는 변수를 가져다 상태를 나타내는 곳에 사용하고, 상태를 변경할 때에는 setState를 사용하여 그 변수의 내용을 변경한다. 그러면 여기서 문제: state가 만약 object 라면 이 변수는 단순히 그 객체의 주소 값을 가지고 있기 때문에 다른 외부함수인 setState에게 그 주소 값을 넘겨주고 변경하면 그 내용이 변경되는 것은 알겠는데, 만약 값이 원시 값이라면? 원시 값은 javascript 에서는 immutable value이다. 다시 말해, 새로운 값을 할당 하게 되면 원시 값이 할당 된 변수가 참조하는 메모리의 값을 직접 변경할 수 없고, 새로운 메모리에 새 값을 할당한 후 참조하는 주소의 값을 재할당 한다. 따라서, const [state, setState] = ... 로 구조 분해 할당을 하는 시점에서 이미 윈시값의 state는 특정 메모리의 주소가 아닌 말 그대로 원시값 자체만을 가지게 되어, 다른 함수에서 원격으로 내용을 변경할 수 없다.

또 한가지 의문은: 함수형 컴포넌트는 말 그대로 함수이다. 한번 실행되고 생명 주기가 끝나면 내부의 값들이 소멸하는 것이 정상이다. 그런데 원시값 또는 객체를 가지고 있는 state 가 어떻게 다시 함수가 호출 되었을 때(리렌더링 될 때) 그대로 값을 갖고 있는 것일까?

위의 두 질문에 대한 해답은 closure 에 있다. 클로져는 '렉시컬 환경을 포함한 함수' 이다. 렉시컬 환경이란 정적으로 코드 평가시점에서 생성된, 한 함수가 참조할 수 있는 외부 변수들에 대한 내용을 담은 것이다.(함수 객체의 [[Environment]] 슬롯에 저장됨) Closure 가 처음에 낯설게 다가오는 이유는, 우리는 처음 공부할 때 함수가 실행되고 끝나면 리턴 된 값을 어디서 받아오지 않는 이상 함수 내부의 내용이 모두 소멸 된다고 배우는데, 클로져는 함수임에도 불구하고 내부에서 사용되는 변수의 값이 소멸되지 않고 유지된다는 데에 있다. 클로져는 내부에 특정 행위를 하는 함수와 그 함수가 필요로 하는 변수들을 같이 담은 하나의 함수인데, 이 함수의 리턴값을 그 내부 함수로 설정하면, 신기하게도 그 함수 밖에 있던 함수가 필요로 하던 변수와 함께, 하나의 '함수 + 환경'이 반환된다.

function counter () {
	count = 0;
    return function increment() {
   	count++;
    console.log(count)
    }
}

const increment = counter();
increment(); // 1
increment(); // 2

그럼 이제 클로저를 이용하여 리액트의 useState를 흉내내 보자.

const useState = (initialState) => {
  let state = initialState;
  const setState = (newState) => {
  	state = newState;
  };
  return [ state, setState ];
}

const [ count, setCount ] = useState(0)
setCount(count + 1)
console.log(count) // ?

위 코드는 본인이 처음에 작동이 잘 될 것이라고 생각해서 짜 봤던 코드이다. 그러나 결과는 생각처럼 1이 나오지 않고 초기값인 0이 나왔다.

위 코드는 두가지 문제점이 있다. 첫째로, 아까 처음 언급하였던 state 변수는 return [ state, ...] 로 디스트럭처링 하는 순간 state는 같은 스코프(같은 함수 안의 같은 레벨)의 값이기 때문에 당연히 원시 값 자체만 전달이 될 뿐, 해당 변수에 대한 주소 값을 따로 가지지 못한다. 그렇다면 이 문제를 어떻게 해결해야 할까? 정답은, state 변수를 단순한 원시 값이 아닌 '참조하는 외부 환경값'으로 만들어 주어야 한다. 다시 말해, state 변수를 한 스코프 밖으로 빼 주어야 한다.

그렇다면 위 코드에서 분해할당 된 setState는 내부에 있었던 state 변수에 대한 정보를 잘 가지고 있었을까? 그렇다. 단순히 밖에서 분해할당 된 state 변수만 따로 놀고 있을 뿐이다.

const React = (()=>{
  let state;
  const useState = (initialState) => {
    state = initialState;
    const setState = (newState) => {
    	state = newState;
    };
    return [ state, setState ];
  }
  return { useState }
})();

const [ count, setCount ] = React.useState(0)
setCount(count + 1)
console.log(count) // ?

자, 이번엔 잘 작동 되겠지. 라고 생각했지만 다시 결과는 0였다. 무엇이 문제 였을까? 문제는, 또 같은 현상을 빚어내고 있는 내 자신이다. count 를 React 밖에서 디스트럭처링 하는 순간, count 는 또 다시 원래의 '환경' 의 스코프를 잃어버린다. 이건 다시 생각해 보면 우리가 클로저를 사용하는, 말 그대로 'closure', 즉 내부에서 사용되는 변수가 밖에서 접근하지 못하는 폐쇠적인 특징을 이용하려는 기본 의도에 반하는 행위이다. 그럼, 우리가 보통 작성하는 함수형 컴포넌트에서는 어떻게 이 디스트럭처링이 가능한 것일까?

function Counter() {
  const [count, setCount] = React.useState(0);
  ...
}

해답은, 바로 React가 컴포넌트를 품는다는데에 있다.

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
	<App />
  </React.StrictMode>
);

React가 만약 거대한 클로저라면, 그 안에서 실행되는 컴포넌트 함수(useState 를 호출해서 디스트럭처링 하는 함수)들은 상위 스코프로 react가 관리하는 상태값들을 환경으로 갖고 있는 내부에 존재하는 또 다른 클로져들이다. 그렇기 때문에 우리가 보는 [state, setState] = useState() 의 state는 상위 스코프에서 물려받는 변수인 것이다.

그러나 useState는 여러 함수에서 호출되고, 각자 다른 값을 할당할 수 있게 되어있다. 그래서 React 내부에서는 hook 들에 대한 내부 값(상태, 콜백 함수) 등을 차례로(매우 중요) 관리하는 배열이 있다. 이러한 이유 때문에 리액트 공식 문서에는 각 컴포넌트의 훅을 호출하는 차례를 매번 동일하게 유지하기를 강조한다. (다시 말해, 조건부로 훅을 호출할지 말지 하는 행위를 하지 말라는 것이다)

그렇다면 다음으로 드는 생각은: 그럼 결국 각 컴포넌트의 상태값들이 거대한 React 클로져(또는 클래스 인스턴스) 안의 전역에 저장되는 것이라면, 우리가 왜 그리 전역 상태 관리를 위해 또 다른 외부 라이브러리인 Redux, Recoil 등을 설치하고 하는 것일까. 아예 React 자체적으로 전역적으로 다른 상태값을 access 할 수 있는 방법을 제시하면 되지 않을까? 아마 useContext 가 이런 상태값을 provide 하는 boundary를 정해주는 역할을 내부적으로 할 것 같은데, 이에 대한 것은 추가적인 연구 후 다음 포스팅에서 다루어 보겠다.

참고:
https://www.netlify.com/blog/2019/03/11/deep-dive-how-do-react-hooks-really-work/
https://tkdodo.eu/blog/hooks-dependencies-and-stale-closures

profile
A journey in frontend world

0개의 댓글