Hyunjung Im

Frontend Developer

github

클로저와 useState

2023-01-14


react 훅들과 클로저과 관련있다는 걸 이제야 알았고.. 반성의 의미로 포스트를 작성해본다. 💩

클로저란?

클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경의 조합이다. 간단히 말해 “함수가 선언될 시 그 주변 환경을 기억하는 것” 이다.

  • 보통 함수 내부의 지역 변수는 해당 함수의 생명 주기 즉 실행이 끝난 경우 지역변수도 함께 사라지게 된다. 하지만 해당 함수 내부다른 함수가 존재 하고 그 함수가 지역 변수 즉 부모의 변수를 참조하고 있으면 부모의 생명 주기가 끝나더라도 지역 변수가 사라지지 않고 그 변수에 계속적으로 접근할 수 있다. (설명을 위해 변수라고 했지만 변수를 렉시컬 스코프라고 바꾼다면 더 맞는 표현이 될 것 같다.)
  • 클로저는 렉시컬 스코프에 의존해 코드를 작성한 결과로 그냥 발생한다. 모든 코드에서 클로저는 생성되고 사용된다.

사실 계속 의미를 되짚어봐도 잘 모르겠다. 제일 와닿았던 말은 첫 줄에 있는 "함수가 선언될 시 그 주변 환경을 기억하는 것" 이 문장이었다. 이해가 안되니 클로저를 사용한 코드의 예시를 보자.

클로저 예시 01

var fn;

function foo() {
	const a = 2;

	function baz() {
		console.log(a);
	}

	fn = baz;
	// 전역변수 fn에 foo함수의 스코프를 가지는 baz함수를 주입시켰다.
}

function bar() {
	fn();
}

foo();

bar(); // console -> 2

fn안의 함수는 foo()함수의 환경을 기억하고 있어 변수 a의 값이 출력된다.

클로저 예시 02 - 모듈

  • 모듈은 클로저의 능력을 활용하면서 표면적으로는 콜백과 상관없는 코드 패턴들이 있다. 그중 가장 강력한 패턴이다.
function CoolModule() {
	const something = "cool";
	const another = [1, 2, 3];

	function doSomething() {
		console.log(something);
	}

	function doAnother() {
		console.log(another.join("!"));
	}

	return {
		doSomething: doSomething,
		doAnother: doAnother
	};
}

const foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1!2!3!
  • 이들 모두 CoolModule()의 내부 스코프를 렉시컬 스코프(당연히 클로저도 따라온다)로 가진다.
  • 위 코드와 같은 자바스크립트 패턴을 모듈이라고 부른다.
  • CoolModule() 함수내의 변수는 외부에서 접근할 수 없지만 안의 doSomething, doAnother 함수를 이용해 값을 출력해볼 수 있다.
  • 클로저를 이용해 getter/setter의 기능을 구현할 수 있다.

🧐 왜 모듈까지 설명하게 되었을까? 그 이유는 useState의 구현이 모듈로 이루어져있기 때문이다.

useState

여기서는 Deep dive: How do React hooks really work?의 예제를 사용했습니다.
(번역된 글도 있으니 꼭 읽어보시길 추천 [번역] 심층 분석: React Hook은 실제로 어떻게 동작할까?)
실제 구현이 궁금하다면 React의 useState 내부 동작 방식과 클로저글을 추천합니다.


react-closure (😇)


const [state, setState] = useState(initialValue);

useState는 배열을 return 하는 useState함수의 값을 구조분해 할당을 사용하여 getter인 state, setter인 setState를 값을 사용하는 방식이다.


state값이 바뀌면 리렌더링 된다는 건 알겠는데..🧐 왜 setState로 값을 바꾸자마자 바꾼 값을 state로 출력할 수 없을까?
-> 미리 말하자면 setState를 작동시킨 후 state와 setState는 서로 다른 클로저를 참조한다. setState가 참조하고 있는 건 이미 업데이트 된 클로저를 참조하고 state가 참조하고 있는 클로저는 setState를 실행시키기 전의 클로저 즉 렌더링이 일어나고 나서의 클로저를 참조하고 있다. (렌더링 후의 state, setState의 클로저는 같다. setState로 업데이트를 시켰을 때 클로저가 달라지는 것이다.)

예제 1

아주 기본적인 형태의 useState를 만들어보자.

function useState(initialValue) {
	let _val = initialValue; // 지역 변수 _val를 선언하고, initialValue를 할당합니다.

	function state() {
		// state함수는 내부 함수이고, 클로저입니다.
		return _val; // state()는 부모 함수에 정의된 _val을 참조합니다.
	}

	function setState(newVal) {
		// same
		_val = newVal; // _val를 변경합니다.
	}

	return [state, setState]; // 외부에서 사용하기 위해 값은 노출하지 않고 함수를 노출합니다.
}
const [foo, setFoo] = useState(0);

console.log(foo()); // logs 0 - the initialValue we gave
setFoo(1); // sets _val inside useState's scope
console.log(foo()); // logs 1 - new initialValue, despite exact same call
  • 근데 우리는 state값을 가져올 때 함수호출을 이용해서 가져오지 않는다. 그리고 이런 상태라면 setState되자마자 state의 값이 바뀌게 될 것 같다.

예제 2 - 오래된 클로저

function useState(initialValue) {
	let _val = initialValue;

	function setState(newVal) {
		_val = newVal;
		console.log("setState", _val);
	}

	return [_val, setState];
}

const [foo, setFoo] = useState(0);

console.log(foo); // 0
setFoo(1); // log -> 'setState', 1
console.log(foo); // 0
setFoo(2); // log -> 'setState', 2
console.log(foo); // 0
  • _val 자체를 return했기 때문에 여기서 foo의 클로저는 더 이상 업데이트 되지 않고 갇혀있다.
  • setFoo()를 아무리 많이 호출해도 setFoo의 클로저와 foo의 클로저는 다르기 때문에 값은 더 이상 업데이트 되지 않는다.

예제 3 - React 복제본 만들기

// 01

const MyReact = (function () {
	let _val; // 모듈 MyReact 스코프 안에 state를 잡아놓습니다.

	return {
		render(Component) {
			// component 함수가 인자로 들어가는 render 메서드를 정의.
			const Comp = Component();
			Comp.render();

			return Comp;
		},

		useState(initialValue) {
			_val = _val || initialValue; // 매 실행마다 새로 할당됩니다.

			function setState(newVal) {
				_val = newVal;
			}

			return [_val, setState];
		}
	};
})();

모듈 패턴을 이용한 작은 React 복제본.

  • React와 마찬가지로 컴포넌트 상태를 추적한다.(이 예제에서는 컴포넌트 _val 상태만 추적함).
  • 이 디자인을 통해 MyReact는 함수 구성 요소를 ‘렌더링’하여 올바른 클로저로 매번 내부 _val의 값을 할당할 수 있다.
// 02

function Counter() {
	const [count, setCount] = MyReact.useState(0);

	return {
		click: () => setCount(count + 1),
		render: () => console.log("render:", { count })
	};
}

let App;

App = MyReact.render(Counter); // render: { count: 0 }
App.click(); // setCount(count + 1)을 실행합니다.
App = MyReact.render(Counter); // render: { count: 1 }
// state가 바뀌면 rerendering이 일어나니 똑같이 rendering을 일으켜 값을 확인합니다.

MyReactrender를 실행시켜 클로저를 업데이트 시켜줌으로써 useStatesetState로 인해 바뀐 state _val값을 업데이트 할 수 있게되었다. 근데 우리가 쓰는 setState에서는 안에 콜백함수를 이용할 수 있었다. 그것과 똑같이 만들어보자.

예제 3 - 2 - React 복제본 만들기

// 01
// 나머지는 위와 같고, 주석이 써져있는 부분만 다름

const MyReact = (function () {
	let _val;

	return {
		render(Component) {
			const Comp = Component();
			Comp.render();

			return Comp;
		},

		useState(initialValue) {
			_val = _val || initialValue;

			function setState(newVal) {
				_val = typeof newVal === "function" ? newVal(_val) : newVal; // newVal이 함수면 인자 _val을 넣어 실행해주고 값이라면 새로 할당해줍니다.
			}

			return [_val, setState];
		}
	};
})();
  • setState에 콜백함수를 넣을 수 있게 하기 위해 setStatenewVal 매개변수의 타입으로 삼항연산자 조건을 걸어주었다.
    • 타입이 함수라면 _val을 인자로 함수 실행, 함수가 아니라면 값 할당
function Counter() {
	const [count, setCount] = MyReact.useState(0);

	return {
		click: () => {
			// test를 위해서 두 개의 setState를 만들었다.
			setCount((prev) => {
				// test용 console.log
				console.log("첫 번째 setCount");
				console.log("prev", prev);
				console.log("count", count);

				return prev + 1;
			});

			setCount((prev) => {
				// test용 console.log
				console.log("두 번째 setCount");
				console.log("prev", prev);
				console.log("count", count);

				return prev + 1;
			});
		},
		render: () => console.log("render:", { count })
	};
}
let App;

App = MyReact.render(Counter); // render: { count: 0 }
App.click();
// 첫 번째 setCount
// prev 0
// count 0
// 두 번째 setCount
// prev 1
// count 0
App = MyReact.render(Counter); // render: { count: 2 }

App.click();
// 첫 번째 setCount
// prev 2
// count 2
// 두 번째 setCount
// prev 3
// count 2
App = MyReact.render(Counter); // render: { count: 4 }

useState는 클로저를 이용해 만들어졌기 때문에 useState()의 값을 할당받은 후 setState를 한 번이라도 동작시키게 되면 state의 클로저와 setState의 클로저는 같은 스코프를 참고하지 않는다. state의 클로저를 set한 결과물에 맞춰주려면 rendering이 일어나 올바른 클로저로 업데이트 되어야 한다.

정리

  • setState를 작동시키면 state와 setState의 클로저는 달라진다.
    • 같은 setState를 여러 개 사용할 때 위의 setState에서 바꾼 값을 이용하려면 setState에 콜백을 넣어 인자값을 이용하면 리렌더링 되기 전에도 바뀐 값을 이용할 수 있게 된다. (setState의 클로저는 동일하므로 서로 같은 클로저를 참조하기 때문)
  • useState가 구현된 부분을 찾아보게 되면 의존성을 주입받는 부분은 reconciliation 모듈로 따로 분리되어 있다고 한다. 구현부를 보는 것도 이해하는 것에 큰 도움이 되겠지만 큰 프로젝트의 모듈을 살펴보는 게 생각보다는 쉽지 않은 작업이라 내 선에서 이해될 수 있는 만큼만 정리해보았다.
    추후에 모듈들을 살펴보면서 fiber도 함께 구경해보고 싶다. 훅들의 관리를 연결리스트로 해주고 있다고 하는데 궁금궁금.
  • react 렌더링부터 다시 정리하면서 찾아보고 있는데 너무 모르고 쓴 부분이 많은 것 같다. 렌더링, 라이프 사이클에 대해 확실히 익힌 후에 최적화를 생각했더라면 더 쉽게 접근할 수 있었겠구나 싶다. 순서가 반대로 되어버렸지만 이제 알게된 부분이 많은 만큼 더 공부해야겠다.

출처

읽어볼 글