✅ 들어가며
javascript deep dive를 읽으며 js코어 개념을 익히곤했다.
그러나 나는 이 개념이 실제 어떻게 실무에서 쓰이고 있는지, 또 어떻게 활용을 해야하는지 늘 의문이였다.
특히 클로저는 변수를 은닉화 할 때 쓴다고 하는데, 은닉을 활용하면 더 코드를 파악하기 힘들지 않을까란 의문이 있었고
이걸 활용하게 될 날이 올까란 생각도 있었다.
그런데 이미 리액트 useState가 클로저를 활용한 개념이라고 한다!
아래 글은 JSConf 의 useState관련 영상을 보며 공부한 내용이다.(※ 영상)
✅ useState와 클로저
1️⃣ 클로저란 무엇인가?
클로저(Closure)는 내부함수가 외부 함수의 변수에 접근할 수 있도록 해주는 JavaScript 기능이다.
클로져를 통해 함수가 “privte”한 변수를 갖을 수 있게 해준다. (변수 은닉화)
즉, 클로저를 사용하면 외부 함수의 변수들이 내부 함수에서 계속 유지되며, 외부 함수의 실행이 종료되더라도 해당 변수에 접근할 수 있게되는 것이다.
클로저 예제코드
function getAdd() {
let foo = 1; // 클로저로 접근가능
return function () {
foo += 1;
return foo;
};
}
const add = getAdd();
console.log(add()); // 2
console.log(add()); // 3
console.log(add()); // 4
getAdd 함수는 내부에 foo라는 변수를 가지고 있고, 반환되는 함수는 foo에 접근하고 수정한다.
foo는 add 함수가 호출될 때마다 갱신된다.
2️⃣ 클로저 개념을 활용한 리액트 useState 만들기
리액트의 useState 는 내부적으로 클로저를 사용하여 상태를 관리를 한다.
이 원리를 알아보기 위해 직접 useState를 만들며 알아보겠다.
우선, 클로저가 없다면 어떤 오류가 나오는지 먼저 살펴보자.
2-1. 클로저를 사용하지 않은 useState 예시
function useState(initVal) {
let _val = initVal;
const state = _val; // 클로저 사용X
const setState = newVal => {
_val = newVal;
};
return [state, setState];
}
const [count, setCount] = useState(1);
console.log(count); // 1
setCount(2);
console.log(count); // 1 (변경되지 않음)
setState(2)를 실행해도, count는 여전히 1로 고정된 값이 나온다.
setState는 _val을 업데이트하는 함수이지만, state는 useState가 처음 실행될 때 _val의 값을 그대로 가져와 복사된 값을 유지하기 때문이다.
state는 클로저를 사용하지 않았기 때문에, useState 함수 실행이 끝난 후 상태 값이 가비지 컬렉션에 의해 삭제된다.
2-2. 클로저를 사용한 useState 구현
function useState(initVal) {
let _val = initVal;
const state = () => _val; // 클로저를 사용하여 _val을 동적으로 반환
const setState = newVal => {
_val = newVal;
};
return [state, setState];
}
const [count, setCount] = useState(1);
console.log(count()); // 1
setCount(2);
console.log(count()); // 2 (변경됨)
클로저를 사용하면 state는 최신 값을 동적으로 가져올 수 있다.
외부 함수인 useState가 종료된 후에도, 내부 함수인 state와 setState는 외부 함수의 실행 컨텍스트에 있는 변수 _val을 계속 참조할 수 있다. 때문에 _val은 가비지 컬렉션의 대상이 되지 않고, 계속해서 상태 값으로 유지된다.
이렇게 state는 _val를 클로저로 참조하고 있기 때문에 state()를 호출하면 최신 값을 동적으로 가져올 수 있는 것이다.
2-3. 컴포넌트에서 useState Hook 사용하기
리액트 컴포넌트는 여러 useState를 배열에 저장하고, 이를 통해 여러 상태를 관리하게 된다.
이 과정에서 각 useState 호출은 클로저로 관리되는 상태 값들을 지속적으로 참조하게 된다.
const React = (function () {
let hooks = [];
let idx = 0;
function useState(initVal) {
const _idx = idx;
const state = hooks[idx] || initVal;
const setState = newVal => {
hooks[_idx] = newVal;
};
idx++; // 인덱스를 증가시켜서 다음 훅을 처리
return [state, setState];
}
function render(Component) {
idx = 0;
const C = Component();
C.render();
return C;
}
return { useState, render };
})();
function Component() {
const [count, setCount] = React.useState(1);
const [text, setText] = React.useState('apple');
return {
render: () => console.log({ count, text }),
click: () => setCount(count + 1),
type: (word) => setText(word),
}
}
var App = React.render(Component); // { count: 1, text: "apple" }
App.click();
var App = React.render(Component); // { count: 2, text: "apple" }
App.type('pear');
var App = React.render(Component); // { count: 2, text: "pear" }
1) hooks, idx , _idx는 클로저로 작동된다.
이 값들은 외부 함수(React 즉시실행함수)에서 정의되었고, 내부 함수인 useState와 render에서 참조하고 있다.
해당 값을 참조하고 있는 곳이 있으므로 가비지 컬렉팅 되상이 되지 않아 값이 유지된다.
클로저 덕분에 외부함수(React 즉시실행함수)가 실행이 끝나도, hooks와 idx는 가비지 컬렉팅되지 않고, 상태 값들을 계속 유지할 수 있다.
때문에 내부함수(useState , render)도 이 값에 계속 접근할 수 있는 것이다.
2) 클로저를 활용한 setState 인덱스 고정
setState가 호출될 때마다 인덱스가 변동되는 문제를 해결하기 위해 클로저를 이용해 state 할당 당시의 인덱스를 고정한다.
count는 0인 idx를 갖고, text는 1인 idx를 갖게된다.
hooks 배열은 [count, text] 가 저장되고 각 hook은 고정된 idx를 통해 접근할 수 있는 것이다.
이런 방식으로 각 상태가 의도한 배열 위치에 정상적으로 저장되고 업데이트 되는 것이다.
✅ Hooks 규칙에 대해
위 코드에서 React 컴포넌트와 Hooks를 직접 만들면서 왜 hooks를 배열로 만들어야 하는지와 hook규칙의 이유에 대해 알 수 있었다.
이에 대해 좀 더 설명하자면 이렇다.
1. hooks 배열과 인덱스 관리를 통해 hook이 실행된다.
위의 “클로저를 활용한 setState 인덱스 고정” 에서 말한 것 처럼 hook은 “고정된 idx”를 가지고 이 인덱스를 통해 상태 값을 관리한다.
나아가 React는 하나의 렌더링 사이클 동안 호출된 모든 hooks를 “배열”에 저장한다.
이렇게 hooks 배열에서 상태의 저장 위치가 고정된 idx를 통해 보장되는 것이다.
이를통해 hooks가 여러개 있을시 hook의 실행 순서를 보장하고, 여러 상태 관리를 순서대로 저장할 수 있다.
2. Hooks 규칙이 있는 이유 : hook 호출 순서 보장 위해
function MyComponent() {
const handleClick = () => {
const [state, setState] = React.useState(0); // ❌ React Hook 규칙 위반
console.log(state);
};
return <button onClick={handleClick}>Click Me</button>;
}
리액트는 Hooks는 항상 컴포넌트의 최상위에서만 호출되어야 하는 규칙이 있다.
그 이유는 렌더링될 때마다 Hook의 호출 순서를 보장하기 위해서다.
렌더링이 일어날 때마다 Hooks가 일정한 순서로 호출되어야만,
각 Hook이 배열에 저장된 상태값을 올바르게 참조하고, 상태를 업데이트할 수 있게된다.
<이 hook의 규칙을 어긴다면?>
만약 이 규칙을 어기고 조건문이나 반복문 안에서 Hook을 호출한다면, 매 렌더링마다 Hook의 호출 순서가 바뀔 것이다.
이렇게되면 hooks 배열에 상태 값이 저장되는 순서가 깨지므로, hook의 실행 순서가 달라지게 될 것이고
상태 값들이 올바르게 참조되지 않거나 업데이트되지 않게 된다.
예를들면, 위의 ‘컴포넌트에서 useState hook 사용하기’ 예제에서
hooks 배열은 [count, text] 순서로 저장되어 count와 text를 각각 고유한 인덱스 0, 1에 할당한 상태이다.
여기서 만약 count 상태의 useState hook의 호출이 조건부로 실행이 된다면 이 고유한 idx값이 틀어지게된다.
text useState의 idx가 0이될때도 1이 될때도 있게되어 hook의 호출 순서가 깨진다.
때문에 hooks 배열에 올바른 상태를 참조하지 못하거나 상태를 제대로 업데이트 하지 못하게 되는 것이다.
(text 상태를 참조했는데 idx 0인 count값이 참조될 수도 있고, setText로 상태 업데이트시 count값을 업데이트 해버리는 오류 발생!)
✅ 마치며
useState와 클로저의 원리에 대해 배우며
리액트에서 빈번히 사용되는 개념에 이러한 js코어 원리가 담겨있다는 것을 알고 코어 개념의 활용을 알 수 있었다.
또한 hook의 규칙의 의미까지 알게되었다.
여태까진 맹목적으로 규칙이 있으니 따라왔는데, 그 규칙의 원리까지 알게되어 좀 더 리액트를 이해할 수 있게되었다.
나아가 hook이 배열에 일일히 다 기억되고 있다는 것도 흥미로웠다.
내부 동작을 알기 전엔 리액트가 다 알아서 해주니 약간 매직처럼 느껴졌는데
이렇게 리액트가 한땀한땀 열일하고 있었구나라는 생각이 든다.
'프론트엔드 > React' 카테고리의 다른 글
Controlled vs Uncontrolled (input요소 value와 defaultValue 차이) (1) | 2025.02.16 |
---|---|
Custom React 구현하기 (2) | 2024.12.22 |
useState 의 setState는 비동적으로 동작 (0) | 2024.05.06 |
useMemo, useCallback에 대해 (0) | 2023.12.10 |
[TIL] React router6 (0) | 2023.09.17 |