React에서 상태 관리와 클로저 문제 해결
안녕하세요 Ocean입니다.
최근 프론트엔드 면접에서 React에서 상태를 어떻게 관리하는지, 그리고 이 과정에서 발생하는 클로저 문제에 대해 질문을 받았습니다. 사실 이 문제는 생각해본 적이 없어서 답변을 잘 못했었는데, 여러 자료를 찾아보니 좋은 글들이 많았습니다. 그래서 이 기회를 통해 관련 자료들을 정리해보았습니다.
참고 글
2. React Hook은 실제로 어떻게 동작할까? (번역)
3. React Hooks: 마술이 아니라 그저 배열일 뿐이다 (번역)
React의 useState훅은 함수형 컴포넌트에서 상태를 관리하는 중요한 도구입니다. 또한 useState는 클로저를 설명하는 아주 좋은 예이기도 합니다.
이번 글에서는 useState를 자바스크립트로 가볍게 구현하면서 흔히 발생할 수 있는 클로저 문제를 살펴보고, 이를 어떻게 해결할 수 있는지에 대해 다뤄보겠습니다. :)
버전 1 코드 (클로저를 활용한 useState)
function useState(initialValue) {
let state = initialValue;
function setState(newValue) {
state = newValue;
console.log(state);
}
return [state, setState];
}
// 사용 예시
const [count, setCount] = useState(0);
console.log(count); // 0
setCount(1); // 1
console.log(count); // 0 -> count가 update되지 않음.
React의 useState를 의사 코드로 가볍게 구현했습니다. 위 useState 함수는 내부의 상태를 기억하는 state, 상태를 update하는 setState가 있습니다. 위 코드는 어떻게 state를 보호할까요? 이 때 클로저라는 개념이 등장합니다.
위의 useState 함수는 state라는 클로저 변수와 setState라는 클로저 함수로 이루어져 있습니다.
- 초기값 initialValue를 받아서 state에 초기화
- setState 함수를 통해서 내부 state에 접근
- state와 setState를 반환
이렇게 클로저를 형성하여 내부 state를 보호하도록 합니다.
하지만 위의 코드는 우리의 의사와 다르게 동작합니다.
위 코드에서 setCount(1)을 실행하고도 count가 업데이트 되지 않는 이유는 무엇일까요??
바로 useState(0)을 호출할 때 state -> count로 값에 의한 전달(Call by Value)이 되기 때문입니다.
더 자세히 설명해 보겠습니다.
- const [count, setCount] = useState(0); 이 때, initialValue인 0을 state에 할당하고, 구조 분해 할당을 통해 count, setCount로 반환 받음
- state => count는 원시값이기 때문에 값 자체를 복사해서 새로운 공간을 할당
- 따라서 setState가 가르키고 있는 state와 count는 다른 공간을 바라보고 있는 전혀 다른 변수가 됨
문제 해결 방법
위 문제를 해결하기 위해서는, 값에 의한 전달이 일어나지 않게 하면 됩니다.
따라서 state를 원시값이 아닌 참조형 값으로 바꿔 보겠습니다.
버전 2 코드 (state를 함수로 접근)
function useState(initialValue) {
let innerValue = initialValue;
function state() { // innerValue를 state 함수를 통해서 접근
return innerValue;
}
function setState(newValue) {
innerValue = newValue;
console.log(innerValue);
}
return [state, setState]; // innerValue에 접근하는 함수를 전달
}
// 사용 예시
const [count, setCount] = useState(0);
console.log(count()); // 0
setCount(1); // 1
console.log(count()); // 1 -> count가 update
초기 버전에서는 값을 직접 반환하여 count가 초기 상태로 캡처 되었기 때문에, setCount를 통해 state를 변경해도 여전히 초기 값을 보여주었습니다. 그러나 state를 함수(참조형)로 구현함으로써, 클로저를 통해 innerValue의 현재 상태를 항상 참조할 수 있게 되어, 값 업데이트가 정상적으로 반영됩니다.
이 useState를 활용해서 React 컴포넌트 의사 코드를 작성해 보겠습니다.
function Counter1() { // Counter1 컴포넌트
const [count1, setCount1] = useState(0);
return {
click: () => setCount1(count1() + 1),
render: () => console.log(count1()),
};
}
function Counter2() { // Counter2 컴포넌트
const [count2, setCount2] = useState(0);
return {
click: () => setCount2(count2() + 1),
render: () => console.log(count2()),
};
}
const C = Counter1();
C.render(); // 0
C.click();
C.render(); // 1
const C2 = Counter2();
C2.render(); // 0
Counter는 useState를 활용해서 count라는 상태와 setCount라는 업데이트 함수를 반환 받습니다. 또한 Counter 컴포넌트들은 자신의 상태를 업데이트하는 click 메서드와 현재 상태를 보여주는 render 메서드를 가지고 있습니다.
위 코드를 보면 각 Counter 컴포넌트 인스턴스 모두 자신의 상태를 개별적으로 잘 보관하고 있습니다.
이제 문제가 없을까요??
React의 함수형 컴포넌트는 state가 업데이트되면 해당 컴포넌트 함수를 처음부터 다시 실행합니다.
이를 통해 side effect를 최소화하고, 상태 변화에 따른 예측 가능한 UI 업데이트가 가능해 집니다.
function useState(initialValue) {
let state = initialValue; // 매번 새로 생성되는 지역 변수
function setState(newValue) {
state = newValue;
}
return [state, setState];
}
function Counter() {
const [count, setCount] = useState(0); // 매번 새로 초기화
return {
click: () => setCount(count + 1),
render: () => console.log('Current count:', count),
};
}
// state가 변경되어 Counter가 새로 실행될 때 마다 useState가 새로 초기화
하지만 위의 useState는 컴포넌트 함수가 재실행될 때 마다 다시 0으로 초기화됩니다. 그 결과 상태 업데이트 후에도 상태값이 유지되지 않아서, UI가 제대로 렌더링되지 않는 문제가 발생합니다.
컴포넌트가 리렌더링 될 때 마다 useState를 초기화하지 않고, 이전 state를 기억하게 하려면 state를 인스턴스의 지역 변수가 아닌 전역에서 관리해야 합니다.
문제 해결 방법
위 문제를 해결하기 위해서는 state를 useState 함수의 지역 변수가 아닌, 전역 변수로써 선언해야 합니다.
버전 3 코드 (state를 전역에서 관리)
const MyReact = (function () { // IIFE를 활용한 모듈 패턴, 중첩 클로저
let _val; // 전역 상태
return {
render(Component) {
const Comp = Component(); // Render 시에 컴포넌트 실행
Comp.render(); // 렌더링 메서드 호출
return Comp; // 컴포넌트 반환
},
useState(initVal) {
_val = _val || initVal; // 중첩 클로저
function setState(newVal) {
_val = newVal;
}
return [_val, setState]; // state와 업데이트 함수 반환
},
};
})();
앞선 문제를 해결하기 위해 React는 state를 컴포넌트 함수의 외부 전역 저장소에 관리하여 상태 값이 컴포넌트와 독립적으로 유지되도록 합니다.
function Counter() {
const [count, setCount] = MyReact.useState(0);
return {
click: () => setCount(count + 1),
render: () => console.log('render: ', count),
};
}
const App = MyReact.render(Counter); // 첫 번째 컴포넌트 렌더링
App.click(); // 상태 업데이트
MyReact.render(Counter); // 상태 업데이트 후 다시 렌더링
이제 state는 매 컴포넌트 실행마다 새로 초기화되지 않고 계속 유지되도록 설계되었습니다. 이제 상태값이 계속해서 갱신되고, 컴포넌트가 리렌더링 될 때 이전 상태를 유지할 수 있습니다.
하지만 이 코드도 문제가 있습니다.
const App = MyReact.render(Counter); // 0
App.click();
MyReact.render(Counter); // 1
// Counter2 인스턴스 추가
const App2 = MyReact.render(Counter2); // 1
App2.click();
MyReact.render(Counter2); // 2
state를 전역 상태로 관리함으로써 리렌더링 사이에 이전 값을 올바르게 가지고 있을 수는 있지만 이제 여러 개의 Counter 인스턴스를 생성 시에 state가 모두 MyReact의 _val에 의존하고 있기 때문에 컴포넌트 별로 독립적으로 관리할 수가 없습니다.
딜레마에 빠졌습니다. 리렌더링 시에 이전 state 보존을 위해서 전역으로 값을 관리하는데, 전역으로 값을 관리하니 여러 개의 인스턴스가 모두 한 값을 바라보게 되었습니다.
React에서는 이를 배열로 해결했습니다.
const MyReact = (function () {
const componentHooks = new WeakMap(); // 각 컴포넌트별 훅 배열 추적
return {
render(Component) {
// 컴포넌트별 고유한 훅 배열 확인 및 초기화
if (!componentHooks.has(Component)) {
componentHooks.set(Component, []);
}
const hooks = componentHooks.get(Component);
let currentHook = 0;
// 훅 컨텍스트를 제공하는 래퍼 함수
const useState = (initialValue) => {
// 현재 컴포넌트의 특정 훅 인덱스에 대한 상태 관리
if (!hooks[currentHook]) {
hooks[currentHook] = initialValue;
}
const hookIndex = currentHook;
const setState = (newState) => {
hooks[hookIndex] = newState;
};
currentHook++;
return [hooks[hookIndex], setState];
};
// 컴포넌트에 useState 주입
const boundComponent = Component.bind(null, { useState });
const Comp = boundComponent();
Comp.render();
return Comp;
},
// 기존 render에서 useState를 분리
useState(initialValue) {
throw new Error('useState must be called within a component');
}
};
})();
function Counter(deps) {
const { useState } = deps;
const [count, setCount] = useState(0);
return {
click: () => setCount(count + 1),
render: () => console.log('Counter render:', count),
};
}
function Counter2(deps) {
const { useState } = deps;
const [count2, setCount2] = useState(0);
return {
click: () => setCount2(count2 + 1),
render: () => console.log('Counter2 render:', count2),
};
}
// 첫 번째 Counter 컴포넌트 렌더링
const App = MyReact.render(Counter);
App.click(); // 상태 업데이트
MyReact.render(Counter); // 상태 업데이트 후 다시 렌더링
// 두 번째 Counter2 컴포넌트 렌더링
const App2 = MyReact.render(Counter2);
App2.click(); // 상태 업데이트
MyReact.render(Counter2); // 상태 업데이트 후 다시 렌더링
이 코드는 React의 훅 시스템을 단순화하여 구현한 예시로, 컴포넌트별 고유한 상태를 관리하기 위해 WeakMap을 사용합니다. WeakMap은 각 컴포넌트를 키로 하고, 그에 해당하는 상태 배열을 값으로 저장하여 컴포넌트마다 독립적인 상태를 관리할 수 있게 합니다.
render 메서드는 컴포넌트를 실행할 때마다 해당 컴포넌트의 상태 배열을 초기화하고, useState 훅을 사용해 상태 값을 설정하거나 변경할 수 있도록 합니다. useState는 상태 값을 배열 형태로 반환하며, 상태를 업데이트하는 함수도 제공하여 컴포넌트 내에서 상태를 변경할 수 있습니다.
컴포넌트는 render 메서드를 통해 렌더링되며, 상태가 변경되면 다시 렌더링하여 변경된 상태를 반영합니다. 이 시스템은 React의 상태 관리와 훅을 단순화한 형태로, 각 컴포넌트의 상태를 독립적으로 추적하고, 훅을 이용해 상태를 업데이트하고 재렌더링하는 방식으로 동작합니다.
이렇게 React의 상태 관리와 클로저 문제를 해결하는 방법에 대해 정리해봤습니다. useState가 어떻게 클로저를 활용하고, 상태 업데이트 및 리렌더링을 어떻게 처리하는지 이해하는 데 도움이 되셨길 바랍니다.
'WEB > REACT' 카테고리의 다른 글
React의 의존성 주입 (1) | 2025.03.27 |
---|---|
forwardRef와 useImperativeHandle (1) | 2024.12.22 |
useReducer (1) | 2024.06.29 |
useContext (0) | 2024.06.19 |
useMemo (0) | 2024.06.19 |