자바스크립트 Closure와 React Hooks 사이의 관계

Hyewon Kang·2023년 8월 18일
2
post-thumbnail

# 배경

황준일 님의 개발 블로그에 작성된 포스팅 가운데 Vanilla Javascript로 React UseState Hook 만들기를 보게 되었다. 그동안 React 함수형 컴포넌트를 이용한 개발은 많이 해봤지만 useState, useEffect 등의 Hook이 어떤 식으로 동작하는지는 알아본 적이 없는 것 같았다. 그래서 React Hook은 실제로 어떻게 동작하는지 궁금해졌고, 찾아보다보니 클로저와의 관계성을 발견하여 클로저 개념을 중심으로 동작 방식을 간단히 구현하고 이해해보고자 한다.

# 클로저란?

💡 클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical Environment)의 조합이다. - MDN

클로저의 정의를 다르게 표현하면,
클로저란 함수가 속한 렉시컬 스코프를 기억하여 렉시컬 스코프 밖에서 실행될 때에도 이 스코프에 접근할 수 있게 하는 기능이다.

let foo = 1 // global scope

function add() {
  foo += 1
  return foo
}

console.log(add()) // 2
console.log(add()) // 3
console.log(add()) // 4

foo 변수에 1을 더해주는 add 함수가 있다. 위 코드는 정상적으로 동작하지만 아래와 같이 작성할 경우 문제가 발생한다.

let foo = 1 // global scope

function add() {
  foo += 1
  return foo
}

console.log(add()) // 2
console.log(add()) // 3
foo = 9999
console.log(add()) // 10000

foo를 global에서 접근하여 임의로 수정이 가능하다는 점이다.

function add() {
  let foo = 1
  foo += 1
  return foo
}

console.log(add()) // 2
console.log(add()) // 2
foo = 9999 // 'foo' is not defined.
console.log(add()) // 2

다음은 외부에서 foo에 접근할 수 없지만 add가 정상적으로 동작하지 못한다.

function getAdd() {
  let foo = 1

  return function () {
    foo += 1

    return foo
  }
}

const add = getAdd()

console.log(add()) // 2
console.log(add()) // 3
foo = 9999 // 'foo' is not defined.
console.log(add()) // 4

이때 다음과 같이 클로저를 활용할 수 있다. 이렇게되면 getAdd 함수에서 add 함수를 반환해 함수를 add 함수를 호출할 때 마다 getAdd 함수에 선언되어있는 foo에 접근할 수 있다.

혹은 아래와 같은 모듈 패턴을 통해 클로저를 만들 수도 있다.

const add = (function getAdd() {
  let foo = 1

  return function () {
    foo += 1

    return foo
  }
})()

console.log(add()) // 2
console.log(add()) // 3
console.log(add()) // 4

이러한 클로저의 성질을 이용해 React Hook은 만들어졌다. useState를 클로저를 활용해 구현해보자.

# 클로저를 이용한 Hook의 구현

useState

useState 훅을 이용하면 함수형 컴포넌트에서 상태 값을 관리할 수 있다.
useState는 초기값을 넘겨받아 상태값(count)과 상태값을 변경하는 함수(setCount=setState)를 반환한다. 일반적으로 두 요소를 비구조화 할당을 통해 변수에 할당해 사용한다.

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

함수형 컴포넌트에서 이전과 현재 상태의 변경 여부를 감지하기 위해서는 함수가 실행됐을 때 이전 상태에 대한 정보를 가지고 있어야 한다. React는 이 과정에서 클로저 개념을 이용한다.

useState 구현하기

이번에는 useState를 간단히 구현해보자.

function useState(initialState) {
    let value_ = initialState;
    const state = value_;
    const setState = (newState) => {
        value_ = newState;
    };
    return [state, setState];
}

const [count, setCount] = useState(0);

console.log(count); // 0
setCount(1);
console.log(count); // 0

위 코드는 의도대로 동작하지 않는다. 이는 count 값이 한 번 가져오고 끝난 값이기 때문이다. 이를 의도대로 동작시키려면 const state = value_ 부분을 함수 형태로 바꿔, 값을 쓰는게 아니라 호출해주는 식으로 바꾼다면 호출할 때마다 값을 가져오기 때문에 setCount가 반영된 값을 얻을 수 있다.

function useState(initialState) {
    let value_ = initialState;
    const state = () => value_; // 호출하는 방식으로 수정
    const setState = (newState) => {
        value_ = newState;
    };
    return [state, setState];
}

const [count, setCount] = useState(0);

console.log(count()); // 0
setCount(1);
console.log(count()); // 1

위 코드는 정상적으로 동작은 되지만, state가 변수가 아닌 getter 함수로 정의되어 있어 리액트의 useState와는 차이가 있다.

컴포넌트에서 Hook 사용하기

그래서 이번에는 hook을 React 모듈 안으로 넣어 state를 변수로 사용하도록 useState를 변경해보자. 즉, 클로저를 또 다른 클로저의 내부로 이동시켜 해결해보자.

const React = (function () {
    function useState(initialState) {
        let value_ = initialState;
        const state = () => value_;
        const setState = (newState) => {
            value_ = newState;
        };
        return [state, setState];
    }

    return { useState };
})();

그리고 안에 훅을 넣은 함수인 Component를 만든다.

const React = (function () {
    function useState(initialState) {
        let value_ = initialState;
        const state = () => value_;
        const setState = (newState) => {
            value_ = newState;
        };
        return [state, setState];
    }

    function render(Component) {
        const C = Component();
        C.render();
        return C;
    }

    return { render, useState };
})();

function Component() {
    const [count, setCount] = React.useState(0);

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

var App = React.render(Component); // [Function: state]
App.increase();
var App = React.render(Component); // [Function: state]

현재는 콘솔에 state function이 출력되므로 value_을 함수 외부에 선언하고, 를 수정해보자.

let value_;

const React = (function () {
    function useState(initialState) {
        const state = value_ || initialState;
        const setState = (newState) => {
            value_ = newState;
        };
        return [state, setState];
    }

    function render(Component) {
        const C = Component();
        C.render();
        return C;
    }

    return { render, useState };
})();

function Component() {
    const [count, setCount] = React.useState(0);

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

var App = React.render(Component); // 0
App.increase();
var App = React.render(Component); // 1

이제 정상적으로 동작되도록 구현한 것 같다. 그러나 아직 하나의 문제가 존재한다.

위 구현은 컴포넌트가 단 하나의 state만을 가진다. 그러나 실제 구현에서는 여러 개의 Hook을 가질 수 있다.

const [count, setCount] = React.useState(0);
const [text, setText] = React.useState("apple");

따라서 React는 state를 useState 외부에 배열 형식으로 관리한다. useState에 저장된 state들은 배열에 순서대로 저장되고, 이러한 state 배열은 컴포넌트를 구분 짓는 유일한 키를 통해 접근할 수 있다.

배열과 인덱스를 이용해 위 코드를 변경하면 다음과 같다.

const React = (function () {
    let hooks = [];
    let idx = 0;

    function useState(initialState) {
        const state = hooks[idx] || initialState;
        const setState = (newState) => {
            hooks[idx] = newState;
        };

        idx++; // 다음 훅을 받을 수 있게 인덱스 증가
        return [state, setState];
    }

    function render(Component) {
        const C = Component();
        C.render();
        return C;
    }

    return { render, useState };
})();

function Component() {
    const [count, setCount] = React.useState(0);
    const [text, setText] = React.useState('apple');

    return {
        render: () => console.log({ count, text }),
        increase: () => setCount(count + 1),
        type: (word) => setText(word),
    };
}

var App = React.render(Component); // { count: 0, text: 'apple' }
App.increase();
var App = React.render(Component); // { count: 1, text: 'apple' }
App.type('orange');
var App = React.render(Component); // { count: 'orange', text: 'apple' }

increase()는 잘 동작하지만, type()을 실행할 때 count가 ‘orange’로 바뀌게 된다. 이는 App 컴포넌트가 render 되면 useState 함수를 호출하고, 그때마다 계속해서 index가 증가되기 때문이다. 따라서 render 될 때마다 index를 0으로 초기화 해주자.

const React = (function () {
    let hooks = [];
    let idx = 0;

    function useState(initialState) {
        const state = hooks[idx] || initialState;
        const setState = (newState) => {
            hooks[idx] = newState;
            console.log(hooks);
        };

        idx++;
        return [state, setState];
    }

    function render(Component) {
        idx = 0;
        const C = Component();
        C.render();
        return C;
    }

    return { render, useState };
})();

function Component() {
    const [count, setCount] = React.useState(0);
    const [text, setText] = React.useState('apple');

    return {
        render: () => console.log({ count, text }),
        increase: () => setCount(count + 1),
        type: (word) => setText(word),
    };
}

var App = React.render(Component);
App.increase();
var App = React.render(Component);
App.type('orange');
var App = React.render(Component);

// 실행 결과
// { count: 0, text: 'apple' }
// { count: 0, text: 'apple' }
// { count: 0, text: 'apple' }

이번에는 상태가 바뀌지 않는 문제가 발생한다. 이는 render된 후에 useState가 호출되므로 원래 의도했던 배열의 0, 1번 인덱스에 저장되는 것이 아니라 증가된 index(2)에 저장되기 때문이다.

{ count: 0, text: 'apple' }
[ <2 empty items>, 1 ]
{ count: 0, text: 'apple' }
[ <2 empty items>, 'orange' ]
{ count: 0, text: 'apple' }

이를 해결하기 위해 또 클로저 개념을 이용해서 setState 안의 idx 값이 useState에 의해 변하지 않도록 freeze 시켜준다. 이를 통해 정상적으로 동작하는 결과를 얻을 수 있다.

const React = (function () {
    let hooks = [];
    let idx = 0;

    function useState(initialState) {
        const state = hooks[idx] || initialState;
        const _idx = idx;
        const setState = (newState) => {
            hooks[_idx] = newState;
        };

        idx++;
        return [state, setState];
    }

    function render(Component) {
        idx = 0;
        const C = Component();
        C.render();
        return C;
    }

    return { render, useState };
})();

function Component() {
    const [count, setCount] = React.useState(0);
    const [text, setText] = React.useState('apple');

    return {
        render: () => console.log({ count, text }),
        increase: () => setCount(count + 1),
        type: (word) => setText(word),
    };
}

var App = React.render(Component); // { count: 0, text: 'apple' }
App.increase();
var App = React.render(Component); // { count: 1, text: 'apple' }
App.type('orange');
var App = React.render(Component); // { count: 1, text: 'orange' }

useState의 동작 원리

실제 React의 useState는 어떻게 구현되어 있을까? useState 내부 함수를 뜯어보자. (node_modules/react/cjs/react.development.js or ReactHooks.js)

useState는 초기값을 넘겨받아 dispatcher 객체의 useState 함수에 넘겨준다.
dispatcher를 반환하는 resolveDispatcher() 함수는 아래와 같다.

resolveDispatcher() 함수는 ReactCurrentDispatcher라는 객체의 current 프로퍼티를 반환하고 있다. (중간의 if 조건문은 우리가 Hook을 컴포넌트 외부에서 사용했을 때 에러를 발생시키는 부분이다.)
그렇다면 ReactCurrentDispatcher를 확인해보자.

ReactCurrentDispatcher는 단지 전역에 선언된 current라는 속성을 가지는 변수다.

  • 해당 코드 한 눈에 보기
    /**
     * Keeps track of the current dispatcher.
     */
    var ReactCurrentDispatcher = {
        /**
         * @internal
         * @type {ReactComponent}
         */
        current: null,
    };
    
    function resolveDispatcher() {
        var dispatcher = ReactCurrentDispatcher.current;
    
        {
            if (dispatcher === null) {
                error(
                    'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
                        ' one of the following reasons:\n' +
                        '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
                        '2. You might be breaking the Rules of Hooks\n' +
                        '3. You might have more than one copy of React in the same app\n' +
                        'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
                );
            }
        } // Will result in a null access error if accessed outside render phase. We
        // intentionally don't throw our own error because this is in a hot path.
        // Also helps ensure this is inlined.
    
        return dispatcher;
    }
    
    function useState(initialState) {
        var dispatcher = resolveDispatcher();
        return dispatcher.useState(initialState);
    }

지금까지의 과정을 요약하면 다음과 같다.

  1. useState(를 포함한 모든 Hook)는 React 모듈에 선언되어 있는 함수로,
  2. useState가 실행될 때마다 dispatcher를 선언하고 useState 메서드를 실행해 그 값을 반환받는다.
  3. dispatcher는 전역 변수 ReactCurrentDispatcher로부터 가져온다.

결론적으로, 함수형 컴포넌트가 클로저를 통해 선언되는 시점에 접근 가능했던 외부 상태 값에 계속 접근할 수 있는 것이다. 함수형 컴포넌트에서 상태값을 변경하면 외부 값이 변경되고, 리렌더링(=함수 재호출)을 통해 새로운 값을 받아오게 된다.

핵심은 useState 리턴 값의 출처가 전역에서 온다는 점이다.
여기서 우리는 리액트가 실제로 클로저를 활용해 함수 외부의 값에 접근한다는 사실을 알 수 있게 된다.
(useState 내부에 대한 더 자세한 설명은 여기서 확인할 수 있다.)

# Hook의 규칙

React 공식 문서에는 Hook을 사용할 때 지켜야 하는 두 가지 규칙을 제시하고 있다.

그 중 첫 번째 규칙인 “최상위(at the Top Level)에서만 Hook을 호출해야 합니다”는 위의 설명대로 컴포넌트가 렌더링될 때마다 항상 동일한 순서로 Hook이 호출되는 것을 보장하기 위한 규칙이다. 즉, 컴포넌트에서 여러 Hook을, 여러 번 호출할 때 Hook의 호출 순서를 보장해야 한다는 의미인데 왜 이러한 규칙이 필요한 것일까?

React가 Hook이 호출되는 순서에 의존하기 때문에 모든 렌더링에서 Hook의 호출 순서는 같다. 이는 다음과 같은 의미를 가진다.

// 첫 번째 렌더링
useState(1); // const [num, setNum] = useState('1');
useEffect(funcA);
useState(false);  // const [bool, setBool] = useState(false);
useEffect(funcB);

// 두 번째 렌더링
useState(1); // 순서: 1
useEffect(funcA); // 순서: 2
useState(false); // 순서: 3
useEffect(funcB); // 순서: 4

...

즉, Hook의 호출 순서가 모든 렌더링에서 동일하다. 만약 이때 조건문 안에서 funcA effect를 호출한다면 어떤 일이 일어날까?

if (num > 10) {
	useEffect(funcA);
}

결과는 아래와 같아진다. 조건이 false이기 때문에 useEffect(funcA) hook을 건너 뛰어 Hook의 호출 순서는 달라지게 된다.

useState(1); // 순서: 1
// useEffect(funcA);  // 🔴 Hook을 건너뛴다.
useState(false); // 순서: 2
useEffect(funcB); // 순서: 3

React는 이전 렌더링 때처럼 컴포넌트 내에서 두 번째 Hook 호출이 funcA effect와 일치할 것이라 예상했지만 그렇지 않게 된다. 그 시점부터 건너뛴 Hook 다음에 호출되는 Hook이 순서가 하나씩 밀리면서 버그를 발생시키게 된다.

이것이 공식문서에서 설명하는 컴포넌트 최상위에서 Hook이 호출되어야만 하는 이유다.

# 요약

  • 컴포넌트 함수가 다시 실행(렌더링) 되더라도 상태 값이 초기화되지 않는다. 어떻게 이게 가능할까? → 클로저를 통해 해결
  • React에서 함수형 컴포넌트의 상태 관리를 위해서는 컴포넌트 외부에 저장된 값을 사용하며, 클로저를 통해 해당 값에 접근해 비교하고 변경한다.
  • useState는 컴포넌트 내부에서 값을 변경시키는 것이 아니라, 컴포넌트 외부의 값을 변경시키기 때문에 상태가 변경된 직후 컴포넌트가 가진 값은 이전의 값을 그대로 참조한다.
  • 각 컴포넌트의 상태 정보는 순서대로 배열에 저장되기 때문에 Hook을 조건문이나 반복문 안에서 사용하면 잘못된 순서의 값을 참조하게 될 수 있다.

# 참고

https://www.netlify.com/blog/2019/03/11/deep-dive-how-do-react-hooks-really-work/
https://medium.com/humanscape-tech/자바스크립트-클로저로-hooks구현하기-3ba74e11fda7
https://ko.legacy.reactjs.org/docs/hooks-rules.html
https://velog.io/@ggong/useState-Hook과-클로저
https://seokzin.tistory.com/entry/React-useState의-동작-원리와-클로저
https://www.rinae.dev/posts/getting-closure-on-react-hooks-summary

profile
강혜원의 개발일지 입니다.

1개의 댓글

comment-user-thumbnail
2023년 8월 18일

이런 유용한 정보를 나눠주셔서 감사합니다.

답글 달기