useState 동작을 직접 구현하려 하다보니 자바스크립트 기본기가 정말 중요하다는 것을 느꼈습니다. 이 글을 통해 간단하게 useState를 구현하면서 자바스크립트의 기본기와 더불어 useState의 동작 원리를 알아보려합니다.
첫번째 useState 구현 시도
우선 맨처음 useState를 구현했던 코드입니다. 해당 코드는 문제가 있습니다.
function useState(state) {
let _val = state;
function setState(newState) {
_val = newState;
}
return [_val, setState];
}
const [state, setState] = useState(0);
console.log(state);
setState(1);
console.log(state);
위 코드의 결과는 어떻게 나올 것이며 어떤 문제가 있을까요? 한번 곰곰이 생각해보자구요.
결과부터 말씀을 드리면 위 코드의 결과는 0 0 이 찍힙니다. 예상대로라면 0 1 이 찍혀야 하는데 말이죠.
그렇다면 왜 0 0 이 나올까요?
그 이유는 useState 함수는 _val 이 가리키는 값과 setState 라는 함수를 리턴하고 있기 때문입니다.
즉, state는 _val가 가리키는 값을 가지고 있고 setState는 함수를 가지고 있죠.
여기서 문제는 state에 _val가 가리키는 원시값이 할당되고 있다는 것이 문제입니다.
자바스크립트에서 원시 데이터는 불변합니다. 그렇다면 위 코드의 동작 원리인 클로저, 실행컨텍스트, 스코프 개념은 잠시 미뤄두고 자바스크립트의 원시값 할당 과정을 한 번 살펴볼까요?
자바스크립트에서 원시 데이터는 "불변"하기에 우리가 _val에 할당할 값을 0에서 1로 바꾼다고 해도, @2002 메모리 주소 안에 들어있는 0이라는 값이 1로 바뀌는 것이 아니라 새로운 메모리 주소 @2003에 1을 할당하여 _val가 새로운 주소를 가리키도록 합니다.
그러면 state에는 useState에서 반환하는 _val가 가진 "값"을 할당 받고있죠? 그렇다면 위 사진과 같이 State라는 변수는 @2002를 가리키고 있을겁니다.
이후 우리는 setState()라는 함수를 통해 useState의 지역변수 _val의 값을 1로 변경해주고있습니다. 이렇게 된다면 클로저인 useState의 함수 내부의 _val라는 지역 변수가 가리키는 값은 @2003 메모리 주소에 들어있는 값 1을 가리키게 될 것입니다.
위 그림을 보면 여전히 @1002 메모리 주소의 State라는 변수는 @2002 주소의 0을 가리키고 있죠. 결국 위 코드는 _val 지역 변수가 가리키는 값만 변경한 것이고, 실제 State가 가리키는 값은 변경할 수가 없는 것입니다.
따라서, setState로 내부 변수 _val가 가리키는 값을 변경해도 state는 변경되지 않는 것입니다.
그렇다면 위 문제를 어떻게 해결해야 할까요? 아마 다음과 같은 코드로 해결할 수 있을 것 같습니다.
두번째 useState 구현 시도
function useState(state) {
let _val = state;
function getState() {
return _val;
}
function setState(newState) {
_val = newState;
}
return [getState, setState];
}
const [state, setState] = useState(0);
console.log(state()); // 0
setState(1);
console.log(state()); // 1
그럼 위 코드가 동작하는 이유는 무엇일까요?
위 코드가 동작하는 이유는 state는 이제 더이상 _val가 가리키는 값이 아닌 함수를 할당받고 있기 때문입니다. 이제부터는 위 코드의 동작 원리를 이해하기 위해 클로저와 실행컨텍스트에 대한 이해가 필요합니다. 위 코드를 토대로 클로저와 실행컨텍스트를 그림으로써 한번 확인해보시죠.
아마 위의 그림처럼 콜스택 안의 실행컨텍스트가 동작할 것입니다.
여기서 중요한 것은 두번째 콜스택에서 useState 실행 컨텍스트가 종료되어 제거 되었음에도 _val 변수는 제거되지 않고 존재합니다.
그 이유는 JS의 가비지콜렉터 작동 방식 때문인데요. 가비지콜렉터는 '도달 가능성'이라는 매커니즘으로 동작합니다. 즉, 어떤 변수에 의해 도달 가능한 또 다른 변수는 가비지콜렉터가 수거해가지 않습니다.
아마도 이를 간단하게 메모리 구조로 나타내면 다음과 같을 수 있을 것 같습니다.
우선 위 메모리 구조는 완벽하지 않을 수 있음을 알립니다. 우선 빠른 설명과 이해를 위해 대략적인 구조만 나타내었습니다. 특히 함수 객체 같은 경우 메모리 할당이 지금처럼 이루어지지 않을 것입니다. 함수 객체의 메모리할당도 추후 좀 더 자세하게 알아보겠습니다.
위 그림에서 메모리 주소 3001에 해당하는 데이터는 그 어떤 곳에서도 참조를 하고 있지 않기 가비지콜렉터의 '도달 가능성' 이라는 매커니즘에 부합하지 않습니다. 따라서 3001 메모리 주소로부터 시작되는 참조들이 모조리 가비지콜렉터에 의해 수거되죠. 아마 1002, 1003 메모리도 _val 변수에 값 할당이 끝난후에는 가비지콜렉터에 의해 수거될 겁니다. 하지만 전역 컨텍스트가 종료되기 전까지는 5001, 5002 메모리 같은 경우에는 계속해서 도달 가능하고 해당 메모리에서는 4002, 4003 메모리에서 참조하고 있으며, 4002, 4003 메모리에서는 1001 메모리 즉 _val 변수를 참조하고 있습니다.
아마도 이렇기에 _val 지역 변수가 가비지콜렉터에 의해 수거되지 않는 것이라고 예상할 수 있을 것 같습니다.
그렇다면 왜 getState() 함수를 사용해야 우리가 원하는 변경된 _val 값을 얻을 수 있는 걸까요?
getState 함수가 할당된 state는 호출할 때마다 새로운 스코프( = 실행 컨텍스트)를 가집니다. 그때마다 클로저를 통해 useState의 지역변수 _val에 접근하여 최신화된 값을 리턴시켜줄 수 있는 것입니다.
세번째 useState 구현 시도
하지만 React에서는 state를 함수로써 호출 하지않고, 바로 값을 사용하죠. React와 최대한 비슷하게 구현하기 위해 위 함수를 모듈 형식으로 조금 고쳐보겠습니다.
const React = (() => {
function useState(initialValue) {
let _val = initialValue;
let state = () => _val;
let setState = (newValue) => {
_val = newValue;
}
return [state, setState];
}
function render(Component) {
let C = Component();
C.render();
return C;
}
return { useState, render }
})();
function ViewCount() {
const [ count, setCount ] = React.useState(0);
return {
render: () => {
console.log(count())
},
click: () => {
setCount(count + 1);
}
}
}
var App = React.render(ViewCount);
App.click();
App = React.render(ViewCount);
App.click();
App = React.render(ViewCount);
위 코드는 어떤 문제점이 있을까요?
여전히 count 상태 값을 받아오기 위해서는 getter function을 호출 해야하고, 이전과는 달리 click() 메소드를 실행했을 때 count 상태값이 업데이트 되지 않는 것을 볼 수 있습니다.
도대체 어떤 이유로 상태값이 업데이트가 되지 않으며, 어떻게 해야 getter function이 아닌 값으로 상태를 받아올 수 있을까요? 한번 고민해봅시다.
제 생각에는 해당 문제는 스코프 문제 인 것 같습니다. 위 코드는 맨 처음 제가 useState를 만들기 위해 시도했던 코드와 사실 다를게 없어요.
현재 ViewCount 함수는 render 함수의 파라미터로 들어갈 때마다 새롭게 재생성되고 있습니다. 그렇다면 재생성 될 때마다 스코프가 새롭게 생성이되고, 새롭게 생성되는 스코프내에서 React.useState 역시 새롭게 재생성되며 0으로 초기화가 되고 있는 것이죠. 이전에 생성되었던 ViewCount 함수 스코프는 바로바로 제거가 됩니다.
그렇다면 이 문제를 어떻게 해결할 수 있을가요?
바로 스코프 문제 즉, 클로저 문제를 해결하면 됩니다.
현재 상태 값으로 사용되고 있는 _val 지역 변수는 useState 함수 내부에 선언 되어있습니다. 그렇기에 useState 함수가 계속 생성이될때마다 0으로 초기화되는 것이죠.
그렇다면 저 _val 지역변수를 React 함수 내부에 선언하면 어떻게 될까요?
먼저 아래 코드를 보겠습니다.
4번째 useState 구현 시도
const React = (() => {
let _val;
function useState(initialValue) {
let state = _val || initialValue;
let setState = (newValue) => {
_val = newValue;
}
return [state, setState];
}
function render(Component) {
let C = Component();
C.render();
return C;
}
return { useState, render };
})();
function ViewCount() {
const [count, setCount] = React.useState(0);
return {
render: () => {
console.log(count);
},
click: () => {
setCount(count + 1);
}
};
}
var App = React.render(ViewCount);
App.click();
App = React.render(ViewCount);
App.click();
App = React.render(ViewCount);
위 코드는 세번째 시도 코드와 어떤 점이 다를까요?
바로 _val 지역 변수의 위치입니다. 위 코드를 실행해보시면 _val 변수의 위치를 바꾸고 값을 할당하는 방법만 바꿨을 뿐인데 정상적으로 작동할겁니다. 왜 그럴까요?
바로 이전 코드의 클로저 문제 때문입니다. React 변수에는 즉시 실행 함수로 반환되는 useState와 render 메소드를 가지고 있습니다. 이 과정에서 _val 지역 변수는 useState에서 참조하고 있기 때문에 클로저가 되는 것이죠.
그렇다면 아무리 ViewCount 함수가 재생성이되고 새로 재생성될때마다 이전 ViewCount 스코프가 사라지더라도 _val 지역 변수는 클로저에 의해 가비지컬렉터에게 수거되지 않고 메모리상에 남아있게 될겁니다.
그렇기에 click 메소드에서 count 값을 변경하였을 때 정상적으로 작동하게 되는 것이지요.
추가로 위 코드는 상태값을 불러올때 getter function을 사용하고 있지 않습니다. 왜그럴까요? 이 역시 클로저와 연관이 있습니다. getter function을 사용하지 않아도 해당 state는 늘 새로운 값을 리턴합니다. 그 이유는 뭘까요?
맨 처음 코드에서 위와 비슷한 코드를 짰을 경우 state에는 _val에 할당된 원시 값을 들고있고, setState 함수로 아무리 _val 변수에 새로운 값을 할당해도 한번 원시 값이 할당된 state는 변경사항을 반영할 수 없습니다.
하지만 위 코드에서는 state에서 클로저인 _val 또는 initialState의 값을 가지고 있습니다. 이후 render 함수에 ViewCount 함수가 파라미터로 들어가면서 새로운 실행 컨텍스트가 생기고 이때 state는 클로저인 _val 또는 initialState가 가지고 있는 원시값을 참조할 수 있습니다. 위 코드에서는 val의 값이 undefined -> 1 -> 2로 변경되고 있으며 _val 변수를 바라보고 있는 state는 계속해서 useState 실행 컨텍스트가 생겨날때마다 최신의 _val 원시값을 리턴할 수 있게 되는 것입니다. 어찌보면 초반에 state를 getter function으로 매번 새롭게 호출하여 새로운 스코프를 형성하고 최신의 값을 가져오게 하는 것과 같은 원리입니다.
하지만 위 코드도 문제가 있습니다.
function ViewCountAndText () {
const [ count, setCount ] = React.useState(0);
const [ text, setText ] = React.useState('Hello');
return {
render: () => {
console.log({ count, text })
},
click: () => {
setCount(count + 1);
},
changeText: (newText) => {
setText(newText);
}
}
}
var App = React.render(ViewCountAndText);
App.click();
App = React.render(ViewCountAndText);
App.changeText('Bye!');
App = React.render(ViewCountAndText);
위처럼 useState를 두번 호출 하고나서 click 메소드를 통해 state 값을 변경하게 되면 두 상태가 모두 하나의 상태를 바라보게 되는 아이러니한 상황이 펼처집니다. 이건 왜그런걸까요?
이 또한 클로저 때문에 발생한 문제입니다. 다시 React의 코드를 보겠습니다.
const React = (() => {
let _val;
function useState(initialValue) {
let state = _val || initialValue;
let setState = (newValue) => {
_val = newValue;
}
return [state, setState];
}
function render(Component) {
let C = Component();
C.render();
return C;
}
return { useState, render };
})();
현재 두 useState는 하나의 _val 지역 변수를 참조하게 됩니다. 맨 처음 count와 text가 각각 다르게 0과 Hello가 나타난 이유는 val가 초기화되지 않았고, initialValue만 존재하기 때문에 각기 다르게 0과 Hello라는 값을 가질수가 있었습니다.
하지만 이후 click, changeText 메소드를 통해서 count와 text를 변경하였지만 변경할 때마다 변경한 값을 count와 text가 동시에 바라보는 문제가 생기죠. 그 이유는 메소드를 통해 _val 지역 변수를 초기화해줬기 때문입니다.
render 함수를 통해 ViewCountText 함수가 계속 재생성되면서 최신화된 _val 지역변수의 값을 state에 할당하게 되어 count, text 모두 _val 변수로 동일하게 나타나게 되는 것입니다.
그렇다면 이를 어떻게 리액트처럼 여러개의 상태를 하나의 파일에서 관리할 수 있게 할 수 있을까요?
이 또한 클로저로 해결할 수 있습니다. 아래 코드를 보시죠!
const React = (() => {
let hooks = [];
let idx = 0;
function useState(initialValue) {
let state = hooks[idx] || initialValue;
let _idx = idx;
let setState = (newValue) => {
hooks[_idx] = newValue;
}
idx++;
return [state, setState];
}
function render(Component) {
idx = 0;
let C = Component();
C.render();
return C;
}
return { useState, render }
})();
위 코드처럼 hooks라는 배열을 React 함수 내부에 선언한 뒤, 배열의 인덱스를 체크할 idx 라는 변수를 선언해줍니다.
이후 state에는 hooks 배열의 인덱스(useState를 호출한 순서가 되겠죠) 참조하여 값이 있다면 hooks 배열의 해당 인덱스 상태를, 값이 없다면 initialValue를 할당합니다.
setState에서 상태를 변경할때도 해당 인덱스를 참조하여 상태를 변경해야겠죠?
이후 render를 할 때마다 idx 값을 0으로 초기화해줍니다. render 시마다 idx를 0으로 초기화 해줘야 useState의 순서가 보장이 될 수 있기 때문입니다.
최종 useState 구현
const React = (() => {
let hooks = [];
let idx = 0;
function useState(initialValue) {
let state = hooks[idx] || initialValue;
let _idx = idx;
let setState = (newValue) => {
hooks[_idx] = newValue;
}
idx++;
return [state, setState];
}
function render(Component) {
idx = 0;
let C = Component();
C.render();
return C;
}
return { useState, render }
})();
function ViewCountAndText() {
const [ count, setCount ] = React.useState(0);
const [ text, setText ] = React.useState('Hello');
return {
render: () => {
console.log({ count, text })
},
click: () => {
setCount(count + 1);
},
changeText: (newText) => {
setText(newText);
}
}
}
var App = React.render(ViewCountAndText);
App.click();
App = React.render(ViewCountAndText);
App.changeText('Bye!');
App = React.render(ViewCountAndText);
이제 코드를 실행하면 아래 사진처럼 원하는 값이 정확하게 나타날 겁니다.
이렇게 나타나는 이유는 ViewCountAndText 함수가 재생성 되며 render 함수 내에서 idx가 0으로 초기화되고, useState의 순서가 보장되면서 useState의 내부의 state가 hooks[idx]와 initialValue를 비교할 때 변경된 count(1), text('Bye')가 각각의 hooks 배열 인덱스 0, 1에 들어가게 되면서 hooks 배열의 인덱스 값에 초기화된 값이 들어가며 변경된 상태가 나타나게 되는 것 입니다.
마치며
useState를 직접 구현하면서 원시 데이터, 참조형 데이터, JS의 메모리 할당, 가비지컬렉터, 클로저, 실행 컨텍스트, 스코프 등 다양한 부분에 대해 배울 수 있었고, 클로저의 장점에 대해 특히 더 많은 것을 배울 수 있었던 것 같습니다. 그냥 이론적으로 알고만 있던 지식을 코드에 적용해보니 확실히 체화가 잘 되는 것 같아요.
위 내용은 React의 useState의 동작원리인 클로저를 통해 흉내낸 것으로, 실제 React의 useState와는 많이 다를 겁니다. 배치 업데이트, 렌더 방법 등등 많은 부분이 다릅니다.
다음번에는 useState를 흉내만 내는 것이 아닌 실제 React의 useState 코드를 깃허브를 통해 하나하나 분석해가면서 동작 원리를 더욱 잘이해해볼까 합니다.
'React' 카테고리의 다른 글
0.1초라도 빠르게 데이터 보여주는 방법 by TanStack Query(prefetchQuery) (0) | 2024.05.29 |
---|---|
렌더링을 빨리 빨리!! by 번들 사이즈 최적화 (2) | 2024.04.25 |
프론트엔드 성능 최적화(Image 압축 및 확장자 변경 자동화) (0) | 2024.04.13 |
프론트엔드 성능 최적화(Font) (0) | 2024.04.12 |
human error를 줄여 동료 개발자의 경험을 개선해보자! with console.log() (0) | 2024.04.08 |
같은 실수를 반복하지 않고, 한 번 학습한 내용을 오래 기억하기 위해 개발하면서 겪었던 트러블 슈팅과 학습한 내용을 정리하고 기록합니다 🧑💻