이번글은 Vanilla Javascript로 React UseState Hook 만들기 글을 보고 궁금하여 직접 공부하고 개발한 것을 정리한 글입니다.
해당 글에서 제기한 의문의 핵심은 이러하다. useState 를 통한 상태값 변경 시 상태 값이 변경됨에 따라 렌더링이 실행되면 반드시 해당 function(컴포넌트)이 재 실행되어야 하는데 값 초기화 구문이 실행되지 않고 유지될 수 있을까?
1. JavaScript 로 useState 기본 구현해보며 알아보기
(1). React의 useState
function Counter () {
const [count, setCount] = useState(1);
// 전역 객체 window에 increment를 할당하여 돔에서 호출할 수 있게 한다.
window.increment = () => setCount(count + 1);
return `
<div>
<strong>count: ${count}</strong>
<button onclick="increment()">증가</button>
</div>
`;
}
이 코드에서 중요한 점은 setCount를 호출해도 컴포넌트가 재렌더링될 때마다 count가 초기화되지 않는다는 점이다. useState는 어떻게 이 상태를 유지할 수 있을까?
(2). JavaScript 로 useState 기본 기능 구성
우선 useState가 어떻게 작동하는지 살펴보자.
<div id="app"></div>
function useState(initState) {
// 상태를 초기화하는 함수, 구현은 아직 비워두었다.
}
function Counter() {
const [count, setCount] = useState(1);
window.increment = () => setCount(count + 1);
return `
<div>
<strong>count: ${count}</strong>
<button onclick="increment()">증가</button>
</div>
`;
}
function render() {
const $app = document.querySelector('#app');
$app.innerHTML = Counter();
}
render();
이 코드에서 중요한 점은 useState가 count와 setCount를 반환한다는 점이다. setCount를 호출하면 render 함수가 실행되고, 화면이 다시 그려진다.
하지만 여기서 문제는, useState를 호출할 때마다 상태가 초기화된다는 점이다. 이를 해결하려면 상태 값을 어떻게 유지할지 고민해야 한다.
function useState(initState) {
let state = initState; // state를 정의한다.
const setState = (newState) => {
state = newState; // 새로운 state를 할당한다.
render(); // render를 실행한다.
};
return [state, setState]; // state와 setState를 반환한다.
}
useState가 호출될 때마다, state는 항상 initState로 초기화된다. 그 결과, 위 코드에서는 state 값이 계속 초기화되어 count는 항상 1을 갖게 된다.
이 문제를 해결하려면 state 값을 외부에서 관리해야 한다. 이를 위해 아래와 같은 방식으로 상태를 처리할 수 있다
let state = undefined;
function useState(initState) {
if (state === undefined) {
state = initState; // 상태가 없을 때만 초기화
}
const setState = (newState) => {
state = newState; // 새로운 state를 할당한다.
render(); // render를 실행한다.
};
return [state, setState];
}
function Counter() { /* 생략 */ }
function render() { /* 생략 */ }
render(); // 렌더링 실행
두개 이상의 상태 관리할 때는 고유의 키값을 두어 충돌없이 상태를 관리 할 수 있습니다.
let currentStateKey = 0; // useState 호출 횟수
const states = []; // 상태를 보관할 배열
function useState(initState) {
if (states.length === currentStateKey) {
states.push(initState); // 새 상태를 추가
}
const state = states[currentStateKey];
const setState = (newState) => {
states[currentStateKey] = newState; // 상태를 갱신
render(); // 렌더링 실행
};
currentStateKey += 1; // 호출 횟수 증가
return [state, setState];
}
function Counter() { /* 생략 */ }
function Cat() { /* 생략 */ }
const render = () => {
app.innerHTML = `
<div>
${Counter()}
${Cat()}
</div>
`;
currentStateKey = 0; // 상태 초기화
}
* React의 useState는 내부적으로 Closure를 사용하여 상태를 관리합니다. 각 상태 변수는 컴포넌트가 렌더링될 때마다 고유한 값을 유지하게 되며, setState를 호출하여 상태를 변경할 수 있습니다. 중요한 점은, 상태가 변경될 때마다 render()가 호출되어 UI가 갱신되고, 이때 useState는 동일한 순서로 호출되어야 한다는 것입니다.
이때, React가 상태를 관리하는 방식은 각 상태를 private하게 다루며, 상태 값이 변경될 때마다 컴포넌트를 리렌더링하고, 그 안에서 상태를 적절히 업데이트합니다.
잠깐 JavaScript Closure 를 조금 더 살펴 보면,
JavaScript Closure
Closure는 자바스크립트에서 함수와 그 함수가 참조하는 외부 상태(lexical environment)의 결합을 의미합니다. 이를 통해 함수 내부에서 정의된 변수는 함수 외부에서 직접 접근하거나 수정할 수 없는 특성을 가집니다. 간단히 말하면, Closure는 함수가 private 변수를 갖는 것처럼 동작하게 해줍니다.
function makeFunction() {
let name = 'blog';
function displayName() {
console.log(name);
}
function changeName(newName) {
name = newName;
}
return { displayName, changeName };
}
const myFunctions = makeFunction();
myFunctions.displayName(); // blog
myFunctions.changeName("blog.tisrory");
myFunctions.displayName(); // blog.tistory
위의 코드에서 makeFunction 내부의 name 변수는 displayName과 changeName 함수가 참조할 수 있지만 외부에서 직접 접근할 수 없습니다. 이를 통해 외부에서 접근할 수 없는 상태를 관리할 수 있게 됩니다.
2. useState 고도화
해당 글이 매력적이었던 이유는 최적화 부분이었는데, 최적화는 실제 React 의 useState 와 유사해지게끔 3가지에 대해 진행하였습니다.
(1). useState 최적화: 동일한 값이 전달될 때 렌더링 방지
useState의 가장 중요한 특징 중 하나는 상태 값이 변경될 때만 컴포넌트가 리렌더링된다는 점입니다. 상태가 이전 값과 동일하다면, 렌더링을 방지해야 합니다.
function useState(initState) {
const key = currentStateKey;
if (states.length === key) {
states.push(initState);
}
const state = states[key];
const setState = (newState) => {
if (newState === state) return; // 값이 변경되지 않으면 리렌더링하지 않음
if (JSON.stringify(newState) === JSON.stringify(state)) return; // 객체나 배열이 동일한지 비교
states[key] = newState;
render(); // 상태가 변경되면 렌더링
};
currentStateKey += 1;
return [state, setState];
}
(2). 여러 setState 호출 시 비효율성 해결: debounce 활용
과거 react 공식문서를 보았을 때, React 에서는 이러한 상태변화를 한번에 모아서 한다는 문구가 적혀있었는데 당시에는 정확히 왜 이런글이 적혀있었는지 이해하고 넘어가지 않았는데 해당 글을 보고 렌더 최적화와 관련이 있음을 알게 되었다. requestAnimationFrame 을 활용하는 이유에 대해서는 원본 글을 참고 바랍니다. 원본 글에서는 어찌되었든 해당 방식은 setTimeout과 동일한 효과를 가지며 이를 자세히 알기 위해서 하나의 글을 추천하였는데요.
간단히 요약하자면 다음과 같습니다.
자바스크립트의 이벤트 루프와 그 동작 방식을 설명하고 있습니다. 이벤트 루프는 자바스크립트가 비동기적으로 동작하면서 단일 스레드로 여러 작업을 처리할 수 있게 해주는 중요한 개념입니다. 이 글의 주요 내용은 다음과 같습니다:
1. ECMAScript와 이벤트 루프:
- ECMAScript 자체에는 동시성이나 비동기 작업에 대한 언급이 없고, 자바스크립트 엔진(예: V8)이 단일 호출 스택을 사용하여 순차적으로 요청을 처리합니다.
- 실제 비동기 작업은 자바스크립트 엔진이 구동되는 환경(브라우저나 Node.js 등)이 담당하며, 이때 이벤트 루프와 태스크 큐가 동작하게 됩니다.
2. 단일 호출 스택과 Run-to-Completion:
- 자바스크립트는 Run-to-Completion 방식을 따르며, 현재 실행 중인 함수가 끝날 때까지 다른 함수는 실행되지 않습니다.
- 예시 코드에서 setTimeout을 사용해 baz 함수가 호출되었지만, 이는 foo와 bar가 실행을 마친 후에 실행됩니다.
3. 태스크 큐와 이벤트 루프:
- 비동기 작업은 태스크 큐에 콜백을 추가하고, 이벤트 루프는 호출 스택이 비어있을 때 태스크 큐에서 콜백을 하나씩 꺼내 실행합니다.
- 예시 코드에서 setTimeout을 통해 foo, bar, baz가 순차적으로 실행되며, 각 콜백은 호출 스택이 비워진 후 실행됩니다.
위 글을 토대로 setTimeout 과 requestAnimationFrame의 차이점에 대해서 조금 더 고찰해보면
우선, 같은 효과를 본다고 한 이유는, 두 함수 모두 비동기적으로 코드 실행을 지연시키고, 실행 시점을 약간 늦추는 효과를 준다는 점에서 비슷하기 때문입니다. 즉, 두 함수 모두 다음 이벤트 루프 사이클이 끝나고 나서 콜백을 실행하게 되므로 비슷한 시점에서 실행될 수 있습니다. 가 결론입니다.
약간의 차이점이라면, setTimeout은 지정한 시간이 지난 후 실행되며, requestAnimationFrame은 화면을 갱신하기 전에 실행됩니다.
다음 이벤트 루프 사이클에서 실행:
- setTimeout은 지정된 시간이 지나면 이벤트 큐에 넣어지고, 이벤트 루프가 큐에서 이를 처리할 때 실행됩니다. 이 시점은 다음 이벤트 루프에 해당합니다.
- requestAnimationFrame은 브라우저가 다음 화면 갱신을 준비하는 시점에서 실행됩니다. 즉, 브라우저의 렌더링 주기에 맞춰 실행됩니다.
같은 효과인 이유를 풀어서 얘기하면 애니메이션을 만들 때, setTimeout을 사용해 16ms 간격으로 애니메이션을 구현하려 할 때, 사실 그 타이밍은 정확히 맞아떨어지지 않더라도 "약 60fps"와 비슷한 효과를 낼 수 있습니다. 반면, requestAnimationFrame은 화면을 갱신하는 주기인 60fps 마다 실행되므로 그렇습니다.
다시 코드로 돌아와서, 여러 setState 호출로 인해 불필요한 렌더링이 발생하는 문제를 해결하기 위해 debounce 기법을 사용하여 렌더링을 최적화할 수 있습니다. 여러 상태 변경이 동시에 발생할 경우, 일정 시간 내에 하나의 렌더링만 발생하도록 합니다.
const debounceFrame = (callback) => {
let nextFrameCallback = -1;
return () => {
cancelAnimationFrame(nextFrameCallback);
nextFrameCallback = requestAnimationFrame(callback);
};
};
(3). render 함수 추상화: 상태와 렌더링 관계 관리
useState와 render는 서로 밀접하게 연결되어 있기 때문에, 이를 효율적으로 관리하기 위해 추상화된 함수들이 필요합니다. render는 상태 변경이 있을 때마다 호출되어야 하며, 여러 상태를 관리하는 로직을 내부에서 캡슐화해야 합니다.
상태 관리 추상화: 여러 상태를 관리할 때, 상태를 업데이트하고 관리하는 로직이 분리되어 있어야 하는이유는 useState 훅은 상태와 상태를 업데이트하는 함수 setState를 반환하는데, 이들 모두 상태 관리 로직을 캡슐화하여 컴포넌트가 어떻게 상태를 변경할지, 언제 render를 호출할지를 추상화함으로써 코드를 깔끔하게 만들기 때문입니다.
function MyReact() {
const options = {
currentStateKey: 0,
renderCount: 0,
states: [],
root: null,
rootComponent: null,
};
function useState(initState) {
const { currentStateKey: key, states } = options;
if (states.length === key) states.push(initState);
const state = states[key];
const setState = (newState) => {
states[key] = newState;
_render(); // 상태가 변경될 때 렌더링
};
options.currentStateKey += 1;
return [state, setState];
}
const _render = debounceFrame(() => {
const { root, rootComponent } = options;
if (!root || !rootComponent) return;
root.innerHTML = rootComponent();
options.currentStateKey = 0;
options.renderCount += 1;
});
function render(rootComponent, root) {
options.root = root;
options.rootComponent = rootComponent;
_render();
}
return { useState, render };
}
const { useState, render } = MyReact();
이 글을 쓰게된 이유는 다른 개발자의 블로그글을 보다가 감명깊게 보게되어서 공부겸 기록겸 쓰게 되었다. 아무래도 실무를 하다보면 기술을 당연시 여기고 사용하게 되는데, 해당 기술을 파헤쳐보면서 리버스 엔지니어링 하는 과정이 굉장히 매력적으로 다가왔다.
특히 deBounce 기법을 적용하면서 reuqestAnimation 기능을 활용하면서 최적화 하는 부분이나, 가벼운 코드를 짜더라도 추상화와 모듈화를 고민하여 짜는 모습을 보며 많이 감명받았다.
이러한 기본기를 더욱 다듬기 위해 vanilla javascript 로 구성하는 프레임워크 라이브러리 기술들을 당분간 꾸준히 작성할 예정이다.
'개발' 카테고리의 다른 글
[Javascript] JSON Class - 1 커스텀 JSON 클래스 만들어보기 (3) | 2025.02.17 |
---|---|
Axios - Timeout, Retry 설정법 (API Request & Response) (1) | 2023.08.11 |