안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

리액트 (React)

[React] Hook 동작(구현) 원리 간단히 알아보기

피그브라더 2021. 6. 7. 20:47

요즘에는 React로 개발할 때 클래스를 많이 사용하지 않는 듯하다. React 16.8 버전부터 도입된 Hook API가 상당한 인기를 얻고 있기 때문이다. 필자 역시도 React를 처음 공부할 때는 클래스 컴포넌트를 작성하는 방법부터 시작했지만, 지금은 사실상 Hook 기반의 함수형 컴포넌트만을 사용하여 개발을 하고 있다. 그만큼 Hook은 굉장히 편리하고 강력한 여러 기능들을 제공한다.

 

그러나 Hook에도 약점이 하나 있다. 그것은 바로, 추상화(Abstraction)가 꽤 깊게 되어 있어서 그 내부 동작 원리를 잘 알지 못하고 사용하는 경우가 많다는 것이다. 이것이 약점인 이유는, 내부 동작 원리와 긴밀한 관련이 있는 어떠한 문제가 발생하는 경우 그 문제의 원인을 빠르게 파악해내기 어려워지기 때문이다.

 

그래서 필자는 Hook의 동작 원리를 대략적으로나마 공부해보기로 결정하였고, 그 결과를 이 포스팅에 작성하고자 한다. 이를 통해 Hook과 관련된 문제의 해결에 있어서 더욱 숙련된 개발자가 될 수 있기를 바라는 바이다.

 

물론, 모든 편리한 기능의 동작 원리를 이해해야 하는 것은 아니다. 그렇게 되면 라이브러리의 활용 효율이 굉장히 떨어질 것이다. 하지만 Hook 정도의 중요한 기능이라면 대략적으로나마 그 동작 원리를 알아보기 위해 시간을 쏟을 만하다고 생각하였다.

 

1. 클로저(Closure) 기본 개념

React에서 Hook을 구현하는 핵심 원리는 바로 JavaScript의 클로저(Closure)이다. 다만 여기서 클로저의 개념에 대해 자세히 다루지는 않을 것이다. 클로저의 개념을 잘 모른다면 MDN 공식 문서를 통해 먼저 공부하고 오기를 권장한다. 그렇지 않으면 본 포스팅의 내용을 이해하기 어려울 것이다.

 

다음은 클로저를 활용한 기본 예시 코드이다. 클로저의 개념을 이해하고 있다면 어렵지 않게 해석할 수 있을 것이다.

function useFoo() {
    let foo = 0;

    function getFoo() {
        return foo;
    }

    function setFoo(value) {
        foo = value;
    }

    return [getFoo, setFoo];
}

const [getFoo, setFoo] = useFoo();

console.log(getFoo());  // print 0

setFoo(1);
console.log(getFoo());  // print 1

기본적인 설명을 덧붙이자면 다음과 같다. 일반적으로 함수가 반환하고 나면 그 함수 내에 선언된 지역 변수들은 메모리 상에서 사라지지만, 위 예시에서 foo라는 지역 변수는 useFoo() 함수가 반환하고 나서도 클로저라는 별도의 메모리 공간에 남아있게 된다. 이는 useFoo() 함수가 반환하는 getFoo() 함수와 setFoo() 함수가 그 지역 변수를 사용하기 때문이다. 이러한 경우 foo라는 지역 변수가 getFoo() 함수와 setFoo() 함수에 의해 붙잡혔다고 표현하기도 한다.

 

2. useState() 동작 원리 알아보기

위에서 되짚은 클로저의 개념을 토대로 React의 useState() 함수를 간단히 흉내 내보자. useState() 함수의 코드는 위에서 살펴본 useFoo() 함수의 코드와 거의 동일하다. 다만, 실제 React 모듈처럼 useState() 함수와 render() 함수를 MyReact 모듈 내에 정의하였다. MyReact 모듈의 render() 함수는 실제로 React가 컴포넌트를 렌더링 할 때 호출하는 함수와 대응된다고 생각하면 된다.

const MyReact = (function () {
    function useState(initialValue) {
        let _state = initialValue;

        function getState() {
            return _state;
        }

        function setState(value) {
            _state = value;
        }

        return [getState, setState];
    }

    function render(Component) {
        return Component();
    }

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

 

그리고 다음과 같이 간단한 React 컴포넌트를 하나 정의하여 이 컴포넌트가 실제로 렌더링 되는 상황을 상상해보자. 최초 렌더링 이후 사용자가 [+1] 버튼을 클릭하여 _state라는 지역 변수의 값을 1만큼 증가시키고, 이로 인해 컴포넌트가 다시 렌더링 되는 상황이다.

function Counter() {
    const [getCount, setCount] = MyReact.useState(1);

    const count = getCount();
    console.log(count);

    return (
        <div>
            <h1>Simple Counter : {count}</h1>
            <button onClick={() => setCount(count + 1)}>+1</button>
        </div>
    );
}

/* First render */
MyReact.render(Counter);  // 1

/* Assume the user clicks the [+1] button here. */

/* Second render (Assume this re-rendering was caused by clicking the button.) */
MyReact.render(Counter);  // 1 (Why?)

※ 참고로 테스트를 위해서는 Counter 컴포넌트가 React 엘리먼트가 아닌 setCount() 함수를 호출하는 어떤 함수를 메소드로 가지는 객체를 반환하도록 하면 된다. 그러면 MyReact.render(Counter)의 반환 값에 해당하는 객체를 가지고 그 메소드를 호출할 수 있다. 여기서는 실제 컴포넌트와 유사한 생김새를 갖도록 하기 위해 React 엘리먼트를 반환하는 코드를 작성한 것이다. (ⓐ)

 

그러나 리렌더링 시에도 출력되는 값은 여전히 1일 것이다. 왜냐하면 _state라는 지역 변수가 useState() 함수를 호출할 때마다 useState() 함수의 메모리 공간에 새로 할당되기 때문이다. 따라서 _state라는 지역 변수가 useState() 함수의 외부에서 할당되어 여러 번 호출되는 useState() 함수가 이 지역 변수를 공유할 수 있도록 해야 한다. 즉, 다음과 같이 수정하면 된다.

const MyReact = (function () {
    let _state;  // declared outside useState() for sharing

    function useState(initialValue) {
        function getState() {
            return _state || initialValue;
        }

        function setState(value) {
            _state = value;
        }

        return [getState, setState];
    }

    function render(Component) {
        return Component();
    }

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

 

이제 동일한 컴포넌트로 실제 렌더링 상황을 가정해 보면, 정상적으로 2가 출력될 것이다. (이 역시 ⓐ와 동일하게 테스트 가능하다.)

/* First render */
MyReact.render(Counter);  // 1

/* Assume the user clicks the [+1] button here. */

/* Second render (Assume this re-rendering was caused by clicking the button.) */
MyReact.render(Counter);  // 2

 

그러나 이것이 끝이 아니다. 실제 useState() 함수가 반환하는 배열의 첫 번째 요소는 함수가 아닌 값이어야 하기 때문이다. 이를 위해서는 useState() 함수를 다음과 같이 수정해주면 된다. 그리고 Counter 컴포넌트도 함께 수정해준다.

const MyReact = (function () {
    let _state;  // declared outside useState() for sharing

    function useState(initialValue) {
        const state = _state || initialValue;

        function setState(value) {
            _state = value;
        }

        return [state, setState];
    }

    function render(Component) {
        return Component();
    }

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

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

    return (
        <div>
            <h1>Simple Counter : {count}</h1>
            <button onClick={() => setCount(count + 1)}>+1</button>
        </div>
    );
}

 

여기까지가 컴포넌트의 상태 값 하나를 관리하기 위한 useState() 함수의 구현이었다. 그러나 실제로는 한 컴포넌트에서 useState() 함수를 여러 번 호출하는 경우가 대부분이다. 만약 위 코드를 기반으로 useState() 함수를 한 컴포넌트에서 두 번 이상 호출하면 어떻게 될까?

function Counter() {
    const [count, setCount] = MyReact.useState(1);
    const [title, setTitle] = MyReact.useState('Simple Counter');

    return (
        <div>
            <h1>{title} : {count}</h1>
            <button onClick={() => setCount(count + 1)}>+1</button>
            <button onClick={() => setTitle('')}>Clear Title</button>
        </div>
    );
}

그러면 setCount() 함수와 setTitle() 함수가 둘 다 _state라는 하나의 지역 변수를 바꾸므로 당연히 올바른 동작이 되지 않는다.

 

따라서 여러 개의 상태 값을 관리하려면 다음과 같이 지역 변수를 배열로 선언하여 여러 상태 값들을 관리해야 한다. 또한, React에서 Hook은 호출 순서에 따라 참조하는 값이 결정되기 때문에 인덱스 값을 기반으로 현재 참조하는 상태 값을 추적할 수 있게 해야 한다.

const MyReact = (function () {
    let _states = [];
    let idx = 0;

    function useState(initialValue) {
        const state = _states[idx] || initialValue;
        const currIdx = idx;  // capture current hook's index

        function setState(value) {
            _states[currIdx] = value;
        }

        idx += 1;  // set next hook's index
        return [state, setState];
    }

    function render(Component) {
        idx = 0;  // reset hook's index
        return Component();
    }

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

 

여기까지가 본 포스팅에서 소개하는 useState() 함수의 동작 원리이다. 당연히 실제 구현은 훨씬 더 복잡하겠지만 이 정도로만 이해하고 넘어가도 Hook의 마법을 이해하는 데 큰 무리는 없을 것으로 생각한다.

 

3. useEffect() 동작 원리 알아보기

useState() 함수가 상태 값의 관리를 위한 Hook이라면, useEffect() 함수는 사이드 이펙트의 수행을 위한 Hook이다. useState() 함수의 동작 원리를 알아보았으니, useEffect() 함수의 동작 원리도 한 번 알아보자. useState() 함수의 동작 원리를 이해했다면 크게 어렵지 않을 것이다.

 

useEffect() 함수의 기본적인 사용 방법은 다음과 같다. 사이드 이펙트의 수행을 위한 콜백 함수를 첫 번째 인자로 넘겨주고, 두 번째 인자로는 그 사이드 이펙트를 수행할지 말지 여부를 결정하기 위해 비교할 값들의 의존성 배열을 넘겨준다.

function Counter() {
    const [count, setCount] = MyReact.useState(1);
    const [title, setTitle] = MyReact.useState('Simple Counter');

    useEffect(() => {
        console.log('Side effect');
    }, [count, title]);
    
    // ....
}

즉, Counter 컴포넌트를 렌더링 할 때마다 저장되어 있는 배열의 값들과 현재 배열의 값들을 비교하여 다르다면 첫 번째 인자로 넘겨준 콜백 함수를 실행하도록 하는 것이다. 이를 위해서는 기존 배열의 값들을 저장해 두고 있어야 한다.

 

따라서 useEffect() 함수의 대략적인 구현은 다음과 같다. _states 지역 변수는 앞서 소개한 것과 같은 것이다. 의존성 배열을 저장하는 것은 상태 값을 저장하는 것과 마찬가지 원리이기 때문이다.

function useEffect(callback, depArray) {
    const oldDepArray = _states[idx];  // check old dependency array

    // check two array's diff
    let hasChanged = true;
    if (oldDepArray) {
        hasChanged = depArray.some((dep, i) => !Object.is(dep, oldDeps[i]));
    }

    if (hasChanged) callback();  // call callback if change occurs

    _states[idx] = depArray;  // save current dependency array for next comparison
    idx += 1;  // set next hook's index
}

 

Summary
결국, React에서 함수형 컴포넌트가 Hook에 의해 지역 상태를 가질 수 있는 원리는 다음과 같이 요약된다. React 모듈의 변수(= 즉시 실행 함수의 지역 변수)로 존재하는 인덱스 값은 함수형 컴포넌트를 하나 렌더링 할 때마다 0으로 초기화된다. 그리고 해당 함수형 컴포넌트에서 Hook을 하나 호출할 때마다 해당 인덱스 값을 참조 및 갱신(+1)한다. 이러한 원리로 각 함수형 컴포넌트의 렌더링마다 인덱스가 0부터 시작하는 여러 개의 지역 상태를 가질 수 있는 것이다. useEffect() Hook도 결국은 의존성 배열이라는 지역 상태를 저장한다는 측면에서 마찬가지 원리로 구현된다.

Question
그러나 여전히 하나의 궁금증이 남아 있다. 하나의 컴포넌트를 트리의 여러 곳에서 렌더링 하거나, 여러 개의 컴포넌트를 렌더링 하는 경우에 React는 각 렌더링을 무슨 방법으로 구분하는가이다. 각 렌더링은 서로 다른 지역 상태 집합을 가져야 하기 때문에, 분명 컴포넌트를 하나 렌더링 할 때마다 해당 렌더링이 참조해야 하는 지역 상태 집합을 알아내는 메커니즘이 존재할 것이다. 이에 관해서는 추후 여유가 생기면 다시 알아보도록 하자.

 

 

 

 

 

 

본 글은 아래 링크의 내용을 참고하여 학습한 내용을 나름대로 정리한 글임을 밝힙니다.

https://rinae.dev/posts/getting-closure-on-react-hooks-summary