React useState 훅과 클로저
클로저에 대해 개념은 알고 있었지만, 사실 면접 질문때나 쓰이는 것이고, 실제로 어떻게 쓰이는지에 대해서는 와닿지 못한 부분도 있었다. 그러던 도중 react deep dive를 공부하며, react 개발자가 아마도 가장 많이 사용하는 useState에 클로저가 쓰인다는 사실을 알았다! 어떻게 쓰이는 지 함께 살펴보자.
상태값을 어떻게 관리할까
보통 useState의 기본 사용법은 다음과 같을 것이다
import { useState } from 'react'
const [state, setState] = useState
인수로 state의 초깃값을 넘겨주고, 만일 아무것도 안넘겨주면 초깃값은 undefined일 것이다. 훅의 반환 값은 배열이고, 배열의 첫 번째 원소는 state 값 자체이며, 두 번째 원소는 setState 함수를 통해 해당 state 값을 변경할 수 있다.
리액트에서 렌더링은 함수 컴포넌트의 return을 실행한 다음, 실행 결과를 이전의 리액트 트리와 비교해 리렌더링이 필요한 부분만 업데이트해 이뤄진다. 그렇기 때문에 렌더링 방식이랑 메커니즘이 다른 변수를 통해서 상태값을 관리하는 것은 적절하지 못하다. (이전 글 react virtualDOM 참고)
그렇다면 다음 코드를 살펴보자.
import React from 'react'
const Component = () => {
const [,triggerRender] = useState()
let state = 'hello'
function handleButtonClick() {
state = 'hi'
triggerRender()
}
return (
<>
<h1>{state}</h1>
<button onClick={handleButtonClick}>hi</button>
</>
)
}
useState 반환값의 두 번째 원소를 실행해 리액트에 렌더링이 일어나게끔 변경했다. 그럼에도 여전히 버튼 클릭시 state의 변경된 값이 렌더링되고 있지 않다.
그 이유는 리액트의 렌더링은 함수 컴포넌트에서 반환한 결과물인 return의 값을 비교해 실행되기 때문이다.
즉, 매번 렌더링이 발생할 때마다 함수는 새롭게 실행이 되고, 실행한 함수에서 state는 매번 hello로 초기화 되므로 아무리 state를 변경해도 hello로 초기화 되는 것이다.
근데 렌더링이 될 때마다 초기화되는 변수(값)와는 달리, useState의 결과값은 어떻게 그 값을 유지할까?
그럼 우리가 알고있는 useState는 대체 어떻게 구현이 되있는 것일지 한번 최대한 비슷하게 구현한 코드를 살펴보자.
먼저 useState의 결과 값이 유지되도록, state를 함수로 하여 state 값을 호출할 때마다 현재 state를 반환하게 해보자.
function useState(initialValue) {
let initialState = initialValue;
function state() {
return initialState
}
function setState(newValue) {
initialState = newValue
}
return [state, setState];
}
const [value, setState] = useState(0);
setValue(1);
console.log(value()); // 1
위의 코드도 나쁘진 않지만, 우리에게 익숙한 useState훅은 state를 함수가 아닌 상수처럼 사용하고 있다. 어떻게 그게 가능한 걸까?
클로저를 이용해 상태를 관리하는 useState
이를 위해서 리액트는 클로저를 이용한 것이다. useState는 클로저를 통해 useState 내부의 선언된 함수(setState)가 함수의 실행이 종료된 이후(useState가 호출된 이후)에도 지역변수인 state를 계속 참조할 수 있다.
useState 작동 방식을 대략적으로 흉내 낸 코드는 다음과 같다.
const MyReact = (function() {
const global = {}
let index = 0
function useState(initialState){
if(!global.states) {
// 애플리케이션 전체의 states 배열 초기화, 최초 접근이면 빈 배열로
global.states = []
}
// states 정보를 조회해서, 현재 상태값이 있는지 확인
// 없다면 초깃값으로 설정
const currentState = global.states[index] || initialState
// 위에서 조회한 값으로 states의 값 업데이트
global.states[index] = currentState
// 즉시실행함수로 setter 만듬
const setState = (function() {
// 클로저로 index를 가둬두어서 동일한 index에 접근이 가능
let currentIndex = index
return function(value){
global.states[currentIndex] = value
//컴포넌트 렌더링이 들어가는 부분이다.(실제 코드는 생략)
}
})()
// useState를 쓸 때마다 index를 하나씩 추가하는데, 이는 하나의 state마다
// index가 할당되어있어, 그 index가 배열의 값(global.states)를 가리키고,
// 필요할 때마다 그 값을 가져오게 하는 것이다.
index = index + 1
return [currentState,setState]
}
function Component() {
const [value, setValue] = useState(0);
}
})();
실제 리액트 코드에서는 useReducer를 이용해 구현되어 있어 약간의 차이가 있다.
아무튼 여기서 함수의 실행이 끝났음에도 함수가 선언된 환경을 기억할 수 있는 방법이 바로 클로저인 것이다. 만약 클로저가 없다면, setState는 항상 index의 현재 값에 의존하게 된다. 즉, 컴포넌트가 여러 상태를 갖고 있을 때 마지막 index만 참조하므로, setState가 올바른 위치를 참조하지 않게 되는 것이다.
매번 실행되는 함수 컴포넌트 환경에서 state의 값을 유지하고 사용하기 위해 리액트는 클로저를 활용하고 있다.
훅에 대한 구현체를 github에서 타고 올라가다보면 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 라는 문구를 만나게된다(무섭다 ㅋㅋ) 위의 코드는 Preact의 구현을 기준으로 하고 있다. Preact는 react의 경량화 버전으로, 대부분의 리액트 API를 지원하고 있다.
결론
React의 useState는 클로저를 통해 상태값을 안정적으로 유지하며, 함수 컴포넌트가 여러 번 호출되더라도 각 상태값이 고유한 위치에 저장될 수 있게 한다. useState가 반환하는 setState 함수는 생성 당시의 상태 위치(index)를 클로저로 캡처하여, 해당 상태값만 정확히 업데이트하도록 구현되어 있다.
정리하자면, 클로저는 setState가 함수가 선언된 당시의 환경을 유지하게 해주기 때문에 컴포넌트가 매번 재실행될 때마다 상태가 초기화되는 것을 방지하고, 상태가 올바르게 유지될 수 있게 해준다.
참고
[서적] 모던 리액트 Deep Dive