React에서 useState를 통해 정의한 state는 어떻게 계속해서 최근 값을 기억하고 있는가? React를 사용하면서 많이 궁금했던 부분이다. 이런 것은 알아내기 어렵다고 생각해 겁 먹고 따로 막 찾아보지 않았었다! 근데 최근 들은 원티드 프리온보딩 과정에서 해답을 얻을 수 있었다. 그리고 조금 더 검색해봤는데 JSConf에서 swyx(Shawn WANG)이라는 분이 이와 관련해 발표한 영상을 찾았다. 덕분에 좀 더 가슴에 와닿게 이해할 수 있었다.
결론은 클로저 덕분이라고 한다.
한 번 클로저가 무엇인지 간략하게 알아보자. 또한 클로저가 왜 필요한지 그 용도를 알아보고, 그래서 이 클로저를 이용해 useState, 나아가 useEffect는 React에서 어떻게 구현되어 있는지 간단하게 구현하면서 알아보자!
만약 클로저에 대해 알고 계신다면 곧바로 Closure on useState, useEffect로 가시면 될 것 같습니다.
const outer = function() {
let count = 0;
return function increaseCount() {
count = count + 1;
return count;
}
}
const result = outer();
console.log(result());
console.log(result());
console.log(result());
* 실행 컨텍스트, 콜 스택, Lexical Environment라는 소리가 나오는데 혹여나 익숙치 않은 단어라면 다음 동영상들을 적극 추천드립니다. 실행 컨텍스트
위의 코드를 보자. result라는 변수에는 outer 함수가 실행되어 반환된 increaseCount라는 함수가 할당된다. 즉 result는 여기서 increaseCount랑 동일하다.
중요한 점은, outer 함수는 본인의 역할이 끝났기 때문에 생명 주기가 마감되었다. 이 말은 실행 컨텍스트가 콜 스택에서 pop, 즉 제거되었다는 소리다. 클로저를 모르는 상태에서는 count를 선언한 outer 함수를 실행시킬 수 있는 코드 환경인 실행 컨텍스트가 없어졌으니 count는 참조할 없게 되고, 위에서는 Reference Error가 발생할 수 있다고 생각할 수 있다. 내가 그랬다 ^.^
하지만 사실 outer 함수의 Lexical Environment와 실행 컨텍스트는 별개이다. 단지 outer 함수의 Lexical Environment를 가리키는, 혹은 접근할 수 있는 주소를 실행 컨텍스트가 가지고 있을 뿐이다. 따라서 만약 내부 중첩 함수인 increaseCount가 outer 함수의 count를 참조하고 있다면 count가 등록되어 있는 Lexical Environment는 GC에 의해 제거되지 않는다. 따라서 increaseCount를 담고 있는 result 함수는 제거되지 않은 outer 함수의 Lexical Environment에 등록된 count에 계속 접근할 수 있게 된다.
결론적으로 클로저란 외부 함수(outer)의 변수(count)를 내부에 중첩된 함수(increaseCount)가 참조하고 있다면, 외부 함수가 생명 주기를 마감하더라도 그 변수에 계속 접근할 수 있는 함수를 말한다. 또한 이 때 그 변수를 자유 변수라고 말하기도 한단다.
죄송합니다... 제가 봐도 처음 보는 사람이라면 이해하기 어려울 것 같습니다... 이 또한 다음 동영상을 적극 추천드립니다... 클로저
그래서 클로저는 왜 필요한건가? 어떤 상황을 위해서!!!
다음과 같은 예시가 존재한다.
상태 기억
const factorialWithCache = (() => {
const cache = {};
return function factorial(value) {
if (value <= 1) {
cache[value] = 1;
return 1;
}
if (cache[value]) return cache[value];
cache[value] = value * factorial(value - 1);
return cache[value];
};
})();
const result = factorialWithCache(4);
console.log(result);
const result2 = factorialWithCache(3);
console.log(result2);
factorialWithCache 함수가 실행될 때 마다 내부 cache 객체는 갱신된다. 4를 인자로 넘겼을 때는 1, 2, 3, 4에 대해 다음과 같이 cache가 값을 가지고 있을 것이다.
{ '1': 1, '2': 2, '3': 6, '4': 24 }
따라서 3을 인자로 넘겼을 때는 별다른 연산 혹은 재귀 과정 없이 2번째 if문에서 곧바로 함수 실행이 끝나고 6이 반환된다. 이는 클로저를 활용해, 계속해서 갱신된 cache라는 객체를 기억하고 있기 때문이다.
상태 은닉
const outer = function () {
let count = 0;
return function increaseCount() {
count = count + 1;
return count;
}
}
const result = outer();
console.log(result()); // 1
console.log(result()); // 2
console.log(count); // Reference Error
count라는 함수는 이제 result를 통해서만 접근이 가능해진다. 밖에서 직접적으로 count에 접근할 수 없어진다.
상태 공유
const makeStudent = (function makeClass(classTeacherName) {
return function inner(name) {
return {
name: name,
getTeacherName: () => classTeacherName,
setTeacherName: _name => {
classTeacherName = _name;
},
};
};
})('포켓몬');
const x = makeStudent('메타');
const y = makeStudent('타몽');
x.setTeacherName('메타몽');
console.log(x.getTeacherName()); // 메타몽
console.log(y.getTeacherName()); // 메타몽
즉시 실행 함수를 통해 makeStudent에는 classTeacherName을 참조하는 inner 함수가 곧바로 반환되어 할당되었다. 즉 makeStudent는 inner 함수이다. x만 setTeacherName으로 classTeacherName을 바꿔주었으나, y도 classTearcherName이 메타몽으로 찍힌다. 즉 한 쪽에서 바꾸면 다른 한 쪽에도 바뀐대로 공유가 된다는 뜻이다.
좋다! 이제 react는 클로저를 어떻게 활용해서 useState, useEffect 등의 훅들을 구현했는지 간단히 알아보자.
JSconf에서 이에 대해 발표한 Shawn Wang 형님은 바닐라 자바스크립트로 보여주신다. 따라서 render 등의 함수를 사용하지 않는다.(마지막엔 사용하긴 한다) 따라서 UI에 곧바로 변화된 state를 나타내주는 것이 아니라 console.log로 변화된 state를 확인한다. 이것 또한 좋은 방법이라 생각하지만, 그냥 render 함수 쓰면서 UI에서 곧바로 변화된 state를 확인할 수 있도록 그냥 React 환경에서 구현해보았다.(원티드 멘토님이 강의해주신 코드와 Shawn Wang 형님의 코드를 짬뽕해봤읍니다)
useState
필요한 state가 1개일 때 useState는 어떻게 구현하면 되는지 보면 다음과 같다. 버튼을 클릭하면 count라는 state를 1씩 증가시키고 이 count state를 화면에 보여주는 것이 전부이다.
/* eslint-disable */
import React from 'react';
import ReactDOM from 'react-dom/client';
const root = ReactDOM.createRoot(document.getElementById('root'));
const { useState, render } = (function makeMyHooks() {
let value;
const useState = (initValue) => {
const state = value || initValue;
const setState = (newValue) => {
value = newValue;
render();
};
return [state, setState];
};
const render = () => {
root.render(<App />);
};
return { useState, render };
})();
function App() {
const [count, setCount] = useState(0);
const clickHandler = () => {
setCount(count + 1);
};
return (
<div>
<h1>Closure on React Hooks</h1>
<div>
<h3>State 1. count</h3>
<p>Count: {count}</p>
<button onClick={clickHandler}>Increase Count</button>
</div>
</div>
);
}
render();
makeMyHooks는 즉시 실행 함수로, 처음에 바로 실행된다. 그리고 render 함수와 value를 참조하는 useState 함수를 만들어 반환시킨다. 즉 makeMyHooks는 위 코드에서 맨 처음에 1번만 실행된다. 따라서 let value;
라는 코드가 다시 실행될 일이 없고, 맨 처음 실행되었을 때만 undefined이다.
이후 App 컴포넌트가 선언되고 맨 밑의 render();
를 통해 App 컴포넌트가 화면에 렌더링 된다. 이 때 render는 makeMyHooks가 실행되면서 반환된 render이다.
유의할 점은, 재렌더링이 된다는 것은 App이라는 컴포넌트가 다시 실행된다는 것이다. 그러면 App안에서 사용하는 useState도 다시 호출된다. 따라서 useState 안의 코드들도 다시 실행된다. 이점을 유의하자. 그러고 나서 useState 함수를 보면, 맨 처음 App 컴포넌트가 실행되면서 useState가 맨 처음 실행된다. 그러면 이 때 initValue는 0이고 value는 undefined이다. 따라서 state에는 initValue인 0이 들어가게 된다.
* 배열 디스트럭처링은 선언한 이름과 반환하는 이름이 일치하지 않아도 괜찮다.
* || 연산자
이후 버튼을 클릭해 setCount를 실행시키면 value는 1이 되고 render 함수가 실행되어 App 컴포넌트가 다시 실행된다. 그러면 또 useState가 실행된다. 이 때 initValue는 0이고, 이 때 value는 undefined가 아니다! 이전에 setCount로 인해 증가한 1이다. 따라서 count는 initValue인 0이 아니라 1로 남을 수 있게 된다. 그리고 이 1을 바탕으로 <p>Count: {count}</p>
JSX 구문을 통해 화면에 렌더링된다. 계속해서 버튼을 클릭해도 마찬가지이다.
좋다! 근데 훅을 1번만 사용할건... 아니지 않나? 위와 같이 내버려두면 훅을 여러 개 쓸 때 다음과 같은 문제가 발생한다.
function App() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
const clickHandler = () => {
setCount(count + 1);
};
const changeHandler = (event) => {
setText(event.target.value)
}
return (
<div>
<h1>Closure on React Hooks</h1>
<div>
<h3>State 1. count</h3>
<p>Count: {count}</p>
<button onClick={clickHandler}>Increase Count</button>
</div>
<br />
<br />
<hr />
<br />
<br />
<div>
<h3>State 2. text</h3>
<input onChange={changeHandler}></input>
<div>{text}</div>
</div>
</div>
);
}
허허 괴랄하지 않은가.
이는 두 state가 같은 value를 공유하기 때문에 생긴 문제이다. 따라서 각자 hook마다 공간이 필요하다. 그러면? 배열을 이용하면 된다 ㅎㅎㅎ
const { useState, render } = (function makeMyHooks() {
const hooks = [];
let index = 0;
const useState = (initValue) => {
const state = hooks[index] || initValue;
hooks[index] = state;
const currentIndex = index;
const setState = (newValue) => {
hooks[currentIndex] = newValue;
render();
};
index++;
return [state, setState];
};
const render = () => {
index = 0;
root.render(<App />);
};
return { useState, render };
})();
아주 그냥 둘이 따로따로 잘 노는 것을 확인할 수 있다.
코드를 뜯어보자면, hooks는 각각의 useState, 나중에 살펴볼 useEffect 등의 리액트 훅들이 차지할 공간인 배열이다. index는 각각의 공간을 찾아갈 수 있게 해주는 친구인거고!
자, 맨 처음 App 컴포넌트가 실행되어 화면에 렌더링 된다고 생각해보자. 그러면 count를 위한 useState와 text를 위한 useState가 실행될 것이다. 각각의 useState 마지막에는 index++을 해준다. 이는, 다음 훅은 나랑 안 겹치게 다음 배열의 요소 공간을 차지해주세용!하는 것이다. count를 위한 useState를 실행하면 hooks는 [0]이 되어 있을 것이고, text를 위한 useState가 실행되면 hooks는 [0, '']이 된다는 말이다.
여기서 또 주목해야 할 점은 currentIndex라는 변수를 하나 더 선언해 useState가 실행될 때의 index를 잡아두었다는 것이다. 이유는, setState는 나중에 실행되는 함수이다. 따라서 count를 위한 index가 0임에도 불구하고 나중에 setState가 실행되었을 때는 index가 2로 되어있을 것이다.(count를 위한 useState가 끝나면 0++ 되어 1이, text를 위한 useState가 끝나면 index는 1++ 되어 2가 될 것이다) 따라서 setState가 hooks의 알맞은 곳을 업데이트할 수 있게 index를 잡아두어야 한다.
또 다른 점은 render에서 index = 0 코드가 추가되었다는 점이다. 이 코드가 없다면, App 컴포넌트가 render 함수에 의해 재렌더링 될 때마다 index는 끝없이 증가한다. 따라서 올바르게 hooks를 업데이트하지 못한다. 재렌더링 될 때마다 0으로 기준을 다시 맞춰줘야 count를 위한 useState, text를 위한 useState가 본인에게 할당된 hooks의 공간을 계속해서 올바르게 사용할 수 있을 것이다.
useEffect
후..! 이제 useEffect로 가보자. useEffect는 알다시피 의존성 배열에 담긴 요소가 바뀌면 useEffect로 넘긴 callback 함수가 실행되는 훅이다. 따라서 의존성 배열의 요소들의 값이 바뀌었는지 안바뀌었는지 확인하는 과정이 필요하다.
const { useState, useEffect, render } = (function makeMyHooks() {
const hooks = [];
let index = 0;
const useState = (initValue) => {
const state = hooks[index] || initValue;
hooks[index] = state;
const currentIndex = index;
const setState = (newValue) => {
hooks[currentIndex] = newValue;
render();
};
index++;
return [state, setState];
};
const useEffect = (callback, deps) => {
const oldDeps = hooks[index];
let hasChanged = true;
if (oldDeps) {
hasChanged = deps.some((dep, idx) => !Object.is(dep, oldDeps[idx]));
}
if (hasChanged) callback();
hooks[index] = deps;
index++;
}
const render = () => {
index = 0;
root.render(<App />);
};
return { useState, useEffect, render };
})();
별거 없다! 그냥 이전의 의존성 배열을 oldDeps에 임시 저장해두고 새로운 deps와 oldDeps를 비교할 뿐이다. deps의 모든 요소와 oldDeps의 모든 요소를 각각 Object.is로 비교하고 있다. 그리고 not 연산자 !를 썼기 때문에, 하나라도 다른게 나오면 곧바로 some은 true를 반환하고 hasChanged는 true가 되어 callback이 실행될 것이다. 모두 같아서 하나라도 true가 되지 못한다면 some은 false를 반환한다.
* Object.is, 자바스크립트에서 NaN === NaN은 false가 찍힌다. 하지만 Object.is(NaN, NaN)하면 true가 찍힌다. 이러한 범위까지 커버해주려고 Object.is를 사용한 것.
생각해볼 점들이 몇 가지 있다. 의존성 배열이 빈 배열일 경우, useEffect는 맨 처음 컴포넌트가 mount될 때 callback 함수가 1번 실행되는 것을 알고 있을 것이다. 위 코드를 통해서도 똑같다. 맨 처음 App 컴포넌트가 실행되어 useEffect가 실행될 때 oldDeps는 undefined이다. index++ 위의 hooks[index]코드가 실행된 적이 없으니까 hooks에는 useEffect에 해당하는 아무런 요소가 없다. 따라서 undefined이다. 그러면 useEffect의 첫 번째 if 문을 건너뛸 것이고, hasChanged는 그대로 true이기 때문에 callback 함수가 실행된다. 즉 맨 처음 useEffect의 callback 함수는 실행된다는 뜻이다. 그리고 some 메서드는 빈 배열일 경우 곧바로 false를 반환한다.(밑의 MDN 링크 참조 부탁드립니다)
그리고 useEffect 또한 자신의 호출이 끝나면 다음에 올 수도 있는 훅을 위해, 다음 공간 쓰세용~라는 뜻으로 index++을 해준다.
의존성 배열에 count만 넣고 돌려봤다. 그러면 다음과 같이 count가 올라갈 때만 useEffect의 callback 함수가 발생한다. 이 때 callback 함수는 다들 아시겠지만 console.log이다. text만 넣었을 때에도, 혹은 둘 다 넣었을 때도, 빈 배열을 넣었을 때에도, 배열을 넣지 않았을 때에도 잘 작동한다.
* some 메서드
요거다.
만약에 다음과 같이 코드를 짰다고 가정해보자.
function testComponent() {
if (Math.random() > 0.5) {
var [count, setCount] = useState(0);
}
const [text, setText] = useState('');
return (
<div>
<h1>Closure on React Hooks</h1>
<div>
<h3>State 1. count</h3>
<p>Count: {count}</p>
<button onClick={clickHandler}>Increase Count</button>
</div>
<br />
<br />
<hr />
<br />
<br />
<div>
<h3>State 2. text</h3>
<input onChange={changeHandler}></input>
<div>{text}</div>
</div>
</div>
)
}
count는 50퍼 정도의 확률로 useState가 실행될 수도 있고 안될 수도 있다. 그러면 index가 제대로 증가할까? 그렇지 않다. 따라서 hooks 공간이 엉키게 된다. 재렌더링 될 때마다 어쩔 땐 실행되고, 어쩔 땐 실행 안되고! 그러면 뒤의 훅들은 본인의 공간을 제대로 못 찾을 수 있다. 따라서 리액트 훅은 최상단에서 호출해주자!
페이스북 횽들은 어떻게 구현해놨을까 궁금해서 한번 뒤져봤다.
https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js
https://github.com/facebook/react/blob/main/packages/react-debug-tools/src/ReactDebugHooks.js
ㅋ... 타고타고 들어가서 봐도 지금의 미천한 내 지식으론 이해 못 했다. ㅎㅎㅎ
작성하면서 느낀 건데 다른 분들이 이해하기 쉽게 쓰는 것은 정말 어려운 것 같다. 결국엔 내가 이해한 방식대로 써버린다. 글도 뒤죽박죽이고 ㅎㅎㅎ
글을 읽으면서 이해가 안되어 오히려 화가 나셨다면 진심으로 사과드립니다... 저 또한 그런 적이 많았기에...
미천하지만 전체 코드는 다음 링크에서 확인해주시면 감사드리옵니다.
https://github.com/metacode22/closure-on-react-hooks
또한 다음 동영상을 보시는 것도 추천드립니다.
https://www.youtube.com/watch?v=KJP1E-Y-xyo
그리고 피드백 및 더 공부할 요소는 무엇인지 알려주시면 정말 감사드리겠습니다.