홍준혁

React Hook 동작원리와 Custom Hook사용이유 본문

React.js

React Hook 동작원리와 Custom Hook사용이유

홍준혁 [Hong-JunHyeok] 2022. 6. 26. 17:42

 

오늘의 주제는 Hook입니다. React 버전 16.8 이후부터 Hook API가 도입되면서 함수형 컴포넌트에서도 상태, 생명주기 등을 사용할 수 있게 되었습니다. Hook API를 사용해서 사용자의 입맛에 맞게 사용되어지는 Custom Hook 또한 있죠. 

정말 편리한 기능입니다. 그래서 오늘은 Hook이 어떻게 동작하는지, 활용은 또 어떻게 하는지 알아보도록 하겠습니다.

 

면접 단골질문 클로저(Closure)를 아시나요?

여러분이 프론트엔드 개발자로써 면접을 봤다면 JS 클로저를 아냐는 질문을 들어봤을 것 같습니다. 

클로저는 실행 컨텍스트 내부의 함수가 외부 실행컨텍스트를 참조할 때 생기는 현상을 의미하는데요, 사실 React Hook은 클로저와 매우 밀접한 관계가 있습니다. 아해의 코드를 보도록 하겠습니다.

function useFoo() {
	let foo = 0;
    
    function getFoo() {
    	return foo;
    }
    
    function setFoo(value) {
    	foo = value
    }
    
    return [getFoo, setFoo];
}

// 사용하는 측

const [getFoo, setFoo] = useFoo();

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

setFoo(1);

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

useState처럼 일종의 Hook 형태로 구현한 useFoo 함수입니다.

위 예제에서는 클로저가 사용되었습니다. getFoo, setFoo가 클로저 함수라고 할 수 있습니다. 왜그런건지 하나하나 살퍄보도록 하겠습니다.

 

실행 컨텍스트는 함수가 실행될 때 생성됩니다. 즉 useFoo()를 한 순간 실행컨텍스트가 생성되는 것이죠. 그런데 useFoo 함수는 함수 두개를 반환합니다. getFoo와 setFoo는 외부 컨텍스트인 useFoo의 foo 변수를 참조하게 됩니다. 

일반적인 경우라면, 실행 컨텍스트에서 함수가 반환되는 경우에 지역변수는 메모리 상에서 사라집니다. 일반적인 경우의 코드를 볼까요?

function normalFn() {
	let trash = 0; // normalFn가 종료되면 사라짐.
    
    return function () {
    	console.log("Hello World");
    }
};

// 사용하는 측

const printHello = normalFn();
printHello();

trash라는 변수는 실행컨텍스트가 종료됨과 동시에 메모리에서 없어집니다. 적어도 우리가 이해하는게 맞다면 이게 원래 맞습니다.

그러면 클로저함수가 반환되면 어떻게 된다는 말일까요? 이에 아까 작성한 코드를 보겠습니다.

 

function useFoo() {
	let foo = 0; // useFoo가 끝나도 foo가 메모리 해제되지 않음.
    
    function getFoo() {
    	return foo;
    }
    
    function setFoo(value) {
    	foo = value
    }
    
    return [getFoo, setFoo];
}

// 사용하는 측

const [getFoo, setFoo] = useFoo();

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

setFoo(1);

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

네, getFoo와 setFoo에서 foo에 여전히 접근할 수 있습니다. 이 말은 메모리상에서 해제되지 않았다는 말로 헤석되는데요.

그 이유는 클로저함수가 외부 컨텍스트의 변수를 참조하니까 가비지 컬렉션 대상이 되지 않기때문입니다. 

쉽게말해서 "누가 쓰고있는데 해제하면 어쩌자는 건데? 계속 살려놔." 로 해석될 수 있습니다.

 

그럼 이게 Hook에서는 어떻게 동작되는걸까요?

직접 리엑트 코드를 구현해보겠습니다. 물론, 조금 추상화를 시켜서 실제 코드랑은 다릅니다.

const React = (function() {
    function useState(initialValue) {
    	let _state = initialValue;
        
        function setState(value) {
        	_state = value;
        }
        
        return [_state, setState];
    }
    
    function render(Component) {
    	return Component();
    }
    
    return { useState, render };
})();

 

React를 (function() {})();로 묶은 이유는 스코프를 만들기 위해서입니다.

그럼 실제 동작은 어떻게 되나요?  

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

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

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

/* 처음 렌더시 */
React.render(Counter);  // 1

/* 사용자가 +1버튼을 누른다면 */

/* state가 변경되어 리렌더링을 했다고 가정합니다. */
React.render(Counter);  // 1

왜 1이 계속 유지될까요? 상태가 변하지 않습니다.

 

Counter 컴포넌트의 리렌더링이라는 것은 Counter함수가 다시 실행되는 것을 의미합니다.

그러니까 아까 만들었던 useState도 다시 실행되게 되겠죠?

useState내부에서 코드를 어떻게 작성했었죠? 

   function useState(initialValue) {
    	let _state = initialValue;
        // ~~~
    }

실행할 때마다 초기값으로 할당해줍니다. 다시한번 곱씹어볼까요?

리렌더링 할때마다 useState의 내부 변수인 _state가 초기값으로 매번 할당되기 때문에 항상 같은 값을 유지한다는 것입니다.

 

이 문제는 어떻게 해결할까요 _state를 내부에 두지 않으면 해결됩니다.

const React = (function() {
	let _state; // 상위 스코프에서 선언하였음
        
    function useState(initialValue) {
    	function setState(value) {
        	_state = value;
        }
        
        return [_state || initialValue, setState];
    }
    
    function render(Component) {
    	return Component();
    }
    
    return { useState, render };
})();

상위 스코프에서 선언하였으므로 useState가 재실행되어도 _state가 매번 초기화되는 문제는 해결됩니다.

/* 처음 렌더시 */
React.render(Counter);  // 1

/* 사용자가 +1버튼을 누른다면 */

/* state가 변경되어 리렌더링을 했다고 가정합니다. */
React.render(Counter);  // 2

그런데 생각해보면 컴포넌트에서 하나의 상태만을 사용하지는 않습니다. 여러 상태를 사용하는 경우에는 어떻게 될까요?

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

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

내부 동작을 생각해보면 외부 _state는 전역적으로 공유하는 값입니다. 그러면 의도한 동작이 되지 않습니다.

이럴 때 배열 자료구조를 사용해서 해결하면 될 것 같습니다. 여러 상태를 배열에 저장하는 것이죠.

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

    function useState(initialValue) {
        const state = _states[idx] || initialValue;
        const currIdx = idx;  // 실행될 당시의 idx값을 기억함.

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

        idx += 1;  // 다음 idx값을 지정함.
        return [state, setState];
    }

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

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

그러면 여러 상태를 저장할 수 있습니다. state가 정말 마법같다고 생각되었는데 useState의 내부 구현을 보니까 별거 없습니다.

하지만 실제 React 내부 구현은 정말 다릅니다. 예를 들어 setState후에 컴포넌트를 자동으로 다시 렌더링하는 코드가 있겠죠.

 

아무튼 클로저를 이용해서 useState를 구현하였다는 개념만 이해하면 좋을 것 같습니다.

 

그럼 처음으로 돌아가

그래서 왜 Hook 쓰는데?

에 대한 질문을 생각해봅시다.

함수 컴포넌트에서 상태와 생명주기를 사용하기 위해서 ~~ 이런 답변말고 왜 Custom Hook을 사용해서 비지니스 로직을 분리하는지에 대해서 생각 해보자구요.

 

프로그래밍에서 중요한 시점중 하나가 중복 재거입니다.

자 그러면 반복되는 상태관련 로직을 Custom Hook로 따로 분리해서 재사용하기 위해서 사용한다는 답을 얻어낼 수 있습니다.

그러면 Hook 나오기 이전에는 그런것이 없었냐? 있었습니다.

HOC (higher order components) 와 render props입니다. <- 이 부분에 있어서는 나중에 블로그 글에 정리하겠습니다.

 

그러면 "왜 굳이 Hook을 사용하는거냐. 솔직히 쓸모없는거 아니냐." 는 제가 말은 불과 몇달전에 했던 말입니다.

제 생각이 짧았습니다. 이 글을 쓴 이유이기도 하구요. 원리를 알고 활용법을 아니 Custom Hook은 정말 편리함 그 자체였습니다.

 

HOC와 render props는 상위 컴포넌트에서 하위 컴포넌트로 props를 전달하는 기술을 사용합니다. 

HOC를 활용한 대표적인 코드가 container presenter 패턴이 있겠네요. Contianer 컴포넌트에서 관련 로직을 작성하고 Presenter에 props로 로직을 넘겨주는 겁니다. 

그럼 Custom Hook은 어떨까요? 각 컴포넌트에서 Hook을 사용하는 입장에서 독립적으로 상태를 생성합니다.

즉, 상위 Wrapper 컴포넌트를 별도로 만들어주지 않아도 된다는 의미입니다. 만약 Wrapper가 많아진다면 Callback Hell 처럼 Wrapper Hell이 될수도 있겠죠?

 

사용하는 예시를 볼까요?

const useDifficultWork = () => {
	const 어마무시하게_복잡한_작업 = () => {
    	for(뭐시기) {
        	if(뭐라면) {
            	const foo = Math.~~; 
            	foo.toString() ~~ 
                ~~~~ // 엄청엄청 복잡복잡
            }
            else {
            	return ~~
            }
        }
    }
    
    return 어마무시하게_복잡한_작업;
}

// 사용하는 측

const Foo = () => {
	const difficultWork = useDifficultWork();
    
    useEffect(() => {
    	difficultWork();
    }, []);
    
    return ~~~
}

 

이렇게 상태관련 로직을 분리하면 사용하는 측에서 엄청 간편하게 사용할 수 있습니다.

Custom Hook과 Hook API (useState, useEffect ~~ 등)의 차이점은 뭘까요?

Custom Hook은 거창한게 아니라 Hook API를 사용하여 재사용 가능한 코드를 작성하면 그것을 custom hook이라고 하는겁니다.

 

이렇게 custom hook을 적절하게 사용하면 선언형 프로그래밍이라는 것을 할 수 있는데요. 그게 뭘까요?

내부 동작을 숨기고 사용하는 측에서는 내부 동작을 믿고 사용할 수 있는것이죠. 

그러면 쉽게 말해서 어떻게 구현할 것인가보다 무엇을 구현할 것인가에 집중할 수 있게 되는것이죠.

 

그럼 Custom Hook을 사용함으로써 얻을 수 있는 장점은 다음과 같습니다.

  1. 클래스 컴포넌트보다 적은 양의 코드로 동일한 로직을 구현할 수 있다.
  2. 코드 양이 적지만 명료함을 잃지 않는다. (useSomething )
  3. 상태관리 로직의 재활용이 가능하다.
Comments