React LifeCycle과 React Hooks

jwParkDev·2022년 11월 23일
0

React

목록 보기
1/1
post-thumbnail

이번 글에서는 React와 React Router의 Built-in Hooks에 대해서 파헤쳐 보겠습니다.

이 과정에서 나만의 Custom Hook을 제작하는 방법과 이제는 굳이 암기할 필요가 없어진 React LifeCycle에 대해서도 알아보게 될 예정입니다.

React Hook & React Lifecycle


React Hook의 정의와 등장배경

React Hook이란?

먼저 React Hooks란 2018년 React Conference에서 발표된 것으로 Class component에서 뿐만 아니라 Functional component에서도 state를 사용할 수 있도록 하는 기능입니다.

이전부터 React Component는 Class Component(이하 ‘CC’로 지칭하겠습니다) 와 Functional Component(이하 ‘FC’로 지칭하겠습니다)가 있었습니다.
FC가 CC에 비해 보다 심플하게 코드를 작성할 수 있어서 코드의 양도 더 적고,

// Class Component
import React, { Component } from 'react;

export default class Hello extends Component() {
	render() {
		return (
			<div>Hello React!</div>
		)
	}
}
// Functional Component
import React from 'react;

export default function Hello() {
	return (
		<div>Hello React!</div>
	)
}

Babel을 통해 transpile했을 때의 코드의 양도 CC가 훨씬 많아서 FC가 보다 빠른 성능을 보이는데요.

(참고로 Babel은 React에서 사용하게 되는 JSX 문법이나 ES6 등의 최신 문법들을 Vanilla Javascript나 낮은 버전의 JS로 변환하여 브라우저가 읽을 수 있도록 해주는 transpiler입니다)

그럼에도 CC가 state 등 더 많은 기능을 제공하여 CC로 컴포넌트를 생성했었습니다. 여기서 ‘더 많은 기능’과 연결되는 것이 바로 React LifeCycle에 대한 내용인데요. 즉, FC에서는 React Lifecycle와 관련된 메소드들을 사용할 수 없었기에 기능적 제한이 있었습니다.

그렇다면, React Lifecycle이 무엇인지 이에 대해서 한 번 알아봅시다.

React Lifecycle

기본적으로 다양한 라이브러리 및 프레임워크들은 자신만의 Lifecycle을 가지고 있습니다.

여기서 Lifecycle이란 해당 라이브러리 혹은 프레임워크가 동작하는 과정 그 자체를 의미하는데요. 따라서 React를 사용하는 경우 React라는 라이브러리의 Lifecycle에 우리의 코딩 방식을 맞춰야 겠죠?

(🤚여기서 잠깐! React가 왜 Framework가 아닌 Library인지 궁금하시죠?
그렇다면 옆의 링크를 참고해주세요! → Is React a Library or a Framework? Here's Why it Matters)

Component를 중심으로 웹 어플리케이션을 구축하는 React에서는 MountingUpdating, 그리고 Unmounting 3가지 축으로 Lifecycle이 이루어져 있습니다.

하나의 Component가 생성되고 처음 실행되어 UI에 보여줄 때를 Mounting이라고 하고,
(즉, React Component 객체가 DOM에 실제로 삽입되기 전까지의 과정)
state나 props를 통해 데이터가 변경된 경우 Re-rendering을 할 때를 Updating이라고 하며,
해당 Component가 DOM에서 제거되어 더이상 UI에 보여주지 않을 때를 Unmounting이라고 합니다.

React에서는 생명주기 각 시점에서 어떠한 동작들이 이루어질 수 있도록 Lifecycle Method들이 존재합니다.

즉, Mounting 시점에서는 어떠한 동작들이 이루어지고, Updaing 시점에서는 또다른 어떠한 동작들이 이루어지게 개발자가 설계할 수 있도록 각 시점에서의 호출 Method들이 존재하며, 그 메소드 안에 해당 동작들에 대한 코드를 작성하게 되는 것이죠.

그렇다면, Lifecycle Method에는 어떠한 것들이 있는지 볼까요?

위 그림에서 알 수 있듯이, 5가지의 대표적인 Lifecycle Method가 존재합니다.

물론 이외에도 getDerivedStateFromProps()shouldComponentUpdate() 등 다양한 method들이 존재하나, CC에서 주로 사용하는 5가지 method를 중심으로 설명하겠습니다.

  1. Mounting
    • constructor Component의 생성자 메서드로 React.Component의 인스턴스 객체가 만들어지면 가장 먼저 실행되며, state 및 다른 value들의 초기값 설정 및 이벤트 처리 메서드의 바인딩이 이루어지게 됩니다. 부모 Component로부터 전달받는 props를 parameter로 갖고, method body에서는 super(props)를 가장 먼저 호출해야 하며, 또한 Constructor 메서드가 호출되면서 부모 Component에서 정의된 method들을 상속받게 됩니다. Class Component 내에서 단 하나만 존재하며, 만약 작성하지 않는 경우 기본 생성자(default constructor)가 자동적으로 들어가고, 파생된 Class Component인 경우에는 Parent Class Component의 Constructor를 기본 생성자로 합니다.
      // W3School에서의 예시
      class Header extends React.Component {
        constructor(props) {
      		// props를 정의
          super(props);
      
      		// this.state에 객체를 할당하여 지역 state를 초기화
          this.state = {favoritecolor: "red"};
      
      		// 인스턴스에 이벤트 처리 메서드를 바인딩
      		this.handleClick = this.handleClick.bind(this);
        }
      	// 생략...
      }
    • render Component를 rendering하는 메서드로 Class Component에서 반드시 구현되어야 하며, 메서드 Body에서는 state와 props를 활용하여 보통 JSX로 작성된 React Element를 반환합니다. render methodPure Function(순수함수)이어야 하므로 Component의 state를 변경시키지 않고, 호출될 때마다 동일한 결과를 반환해야 하며, 브라우저와 직접적으로 상호작용 하지 않도록 해야 합니다. 따라서 사용자의 조작에 따라 브라우저와 상호작용하고, 그에 따라 state나 props가 변경되는 작업에 있어서는 아래의 componentDidMount 혹은 다른 Lifecycle method에서 구현해야 하며, 특히 API 호출의 경우 또한 Component가 모두 구성된 직후 시점에 호출되는 componentDidMount에서 수행하도록 하는 것이 효과적입니다.
      // W3School에서의 예시
      class Header extends React.Component {
        render() {
          return (
            <h1>This is the content of the Header component</h1>
          );
        }
      }
    • componentDidMount Component가 처음으로 rendering된 이후(즉, mount된 직후)에 호출되는 메서드로 보통 외부에서 API를 통해 데이터를 불러와야 하는 네트워크 요청이나, DOM을 사용해야 하는 외부 라이브러리를 연동하거나, DOM의 속성을 읽거나 직접 변경하는 작업들을 해당 메서드 내에서 처리하게 됩니다. componentDidMount 메서드는 render를 다시 호출하지 않으므로, setState를 통해 다시 한 번 출력해주도록 해야 합니다.
      // W3School에서의 예시
      class Header extends React.Component {
        constructor(props) {
          super(props);
          this.state = {favoritecolor: "red"};
        }
      
        componentDidMount() {
          setTimeout(() => {
            this.setState({favoritecolor: "yellow"})
          }, 1000);
      
      		// AJAX
      		axios({
      			method: 'get',
      			url: 'www.naver.com',
      		})
      		.then(res => console.log(res));
        }
      	// 생략 ...
      }
  2. Updating
    • render Mounting에서의 render 메서드와 동일합니다.
    • componentDidUpdate state나 props의 변동 등에 따라 Component의 re-rendering을 마치고(즉, 화면에 우리가 원하는 변화가 모두 반영되고 난 후에) 호출되는 메서드로 최초 렌더링에서는 호출되지 않습니다. Component가 갱신되었을 때 DOM을 조작하기 위한 작업들을 이 메서드 내에 구현하게 되며, 이전과 현재의 props를 비교하여 네트워크 요청을 보내는 작업도 이 메서드에서 이루어지면 됩니다. 한 가지 유의해야 할 점은 componentDidUpdate()에서 setState()를 즉시 호출할 수도 있지만, 조건문으로 감싸지 않으면 무한 반복이 발생할 수 있다는 것입니다.
      // W3School에서의 예시
      class Header extends React.Component {
        constructor(props) {
          super(props);
          this.state = {favoritecolor: "red"};
        }
        componentDidMount() {
          setTimeout(() => {
            this.setState({favoritecolor: "yellow"})
          }, 1000)
        }
      
        componentDidUpdate() {
      		//** Component가 갱신되었을 때 DOM을 조작하기 위한 작업
          document.getElementById("mydiv").innerHTML =
          "The updated favorite is " + this.state.favoritecolor;
        }
      
        render() {
          return (
            <div>
            <h1>My Favorite Color is {this.state.favoritecolor}</h1>
            <div id="mydiv"></div>
            </div>
          );
        }
      }
      
      ReactDOM.render(<Header />, document.getElementById('root'));
  3. Unmounting
    • componentWillUnmount Component가 마운트 해제되어 제거되기 직전에 호출되는 메서드로 타이머 제거, 네트워크 요청 취소, DOM에 직접 등록했던 이벤트(메서드) 제거, 외부 라이브러리 해제 등 필요한 모든 정리 작업을 이 메서드 내에서 구현하게 됩니다. 이제 컴포넌트는 다시 렌더링되지 않으므로, componentWillUnmount() 내에서 setState()를 호출하면 안된다는 점에 유의해야 합니다.
      // W3School에서의 예시
      class Container extends React.Component {
        constructor(props) {
          super(props);
          this.state = {show: true};
        }
        delHeader = () => {
          this.setState({show: false});
        }
        render() {
          let myheader;
          if (this.state.show) {
            myheader = <Child />;
          };
          return (
            <div>
            {myheader}
            <button type="button" onClick={this.delHeader}>Delete Header</button>
            </div>
          );
        }
      }
      
      class Child extends React.Component {
        componentWillUnmount() {
          alert("The component named Header is about to be unmounted.");
        }
        render() {
          return (
            <h1>Hello World!</h1>
          );
        }
      }
      
      ReactDOM.render(<Container />, document.getElementById('root'));

정리해보면, Class Component 내에서 위에서 다룬 5가지 메서드들은 작성 순서와 관계 없이, 컴포넌트가 실행될 때 아래의 순서대로 작동하게 됩니다.

constructor -> render -> componentDidMount -> (ajax 완료) -> render -> (setState)
-> componentDidUpdate -> render -> componentWillUnmount

이러한 흐름을 React Lifecycle이라고 지칭합니다.

앞서 말했듯이, 에러를 catch하기 위한 componentDidCatch 등 5가지 이외의 Lifecycle method들도 존재합니다. 만약 모든 Lifecycle Method에 대해서 알고 싶다!? 그렇다면 아래 링크들을 참고해주세요!

위와 같이 React 라이브러리를 사용하며 Class Component로 개발을 하는 경우 StateReact Lifecycle을 적절히 활용하여 각 시점에 적절한 작업들이 수행되도록 설계할 수 있었고, 보다 빠르고 간결한 Functional Component에서는 이러한 기능이 없었기에 CC로의 개발이 주를 이루었습니다.

그러나 앞서 말했던 FC에 비해서 CC가 가지고 있는 여러 불편한 점들과 문제점들을 해결하기 위해서 FC에는 없던 CC에서의 기능들을 FC에서도 사용할 수 있도록 하여 사단취장의 움직임이 일었고, 그 결과 React 16.8 버전에서 탄생한 것이 React Hooks입니다.

Functional Component에서도 state를 사용할 수 있도록 도입된 Hook이 바로 useStateuseReducer이며, Lifecycle method와 유사한 기능으로 도입된 Hook이 바로 useEffect인 것이죠.

React Hook의 규칙과 종류

Class Component와 Function Component의 Lifecycle

React Hooks에 대해서 구체적으로 보기에 앞서 CC에서의 Lifecycle과 FC에서의 Lifecycle 간의 차이에 대해서 먼저 간략하게 짚고 넘어가겠습니다.

CC에서는 Lifecycle이 Component에 중심이 맞춰져 있어 컴포넌트 별로 mount → update → unmount가 순회하게 되며, componentDidMount, componentDidUpdate, componentWillUnmount를 컴포넌트 당 한 번씩만 사용됩니다.

반면, FC에서는 특정 데이터에 대해 Lifecycle이 진행되므로 각 데이터별로 mount → update → unmount가 순회하게 되며, useEffect가 데이터의 개수에 따라 여러 번 사용됩니다.

Class Component에서 Lifecycle을 사용하는 코드와 동일한 로직의 Functional Component에서 React Hooks를 활용하여 Lifecycle을 사용하는 코드를 비교해봅시다.

// Class Component
componentDidMount() {
	// 컴포넌트가 마운트되면 updateLists 함수를 호출
	this.updateLists(this.props.id);
}
componentDidUpdate(prevProps) {
	if (prevProps.id !== this.props.id) {
		// updateLists 함수를 호출할 때 사용되는 id가 달라지면 다시 호출
		this.updateLists(this.props.id)
	}
}
// updateLists 함수 정의
updateLists = (id) => {
	fetchLists(id)
	.then(lists => this.setState({lists}))
}
// Functional Component
useEffect(()=>{
	fetchLists(id)
	.then(repos => setRepos(repos));
}, [id])

동일한 로직을 구현했음에도 코드의 복잡도나 양에 있어서 CC보다 FC를 활용하면, 훨씬 간단하게 처리할 수 있음을 볼 수 있습니다.

자, 이제 React Hooks에 대해서 알아보도록 할까요!?

React Hook 규칙

React Official Document에 따르면, React Hook을 사용할 때는 2가지 규칙을 반드시 준수해야 한다고 합니다. 그 2가지 규칙은 아래와 같습니다.

  1. React Function 안에서만 Hook을 호출해야 합니다.

    즉, Vanilla Javascript에서 React Hook을 호출하지 말아야 하고, React Functional Component 혹은 Custom Hook 안에서만 호출해야 함을 의미합니다.

    (아래에서 살펴보겠지만, Custom Hook이란 use로 시작하는 React Function입니다.)

  2. 최상위(at the Top Level)에서만 Hook을 호출해야 합니다.

    즉, 반복문이나 조건문, 혹은 중첩된 함수 내에서 Hook을 호출하지 말아야 합니다.

    이는 Hook은 conditional하게 사용되지 않아야 한다는 부차적인 규칙과도 align 됩니다.
    (이와 관련해서는 stackoverflow 글을 참고해주세요 → Calling a React hook conditionally )

React Hook 종류

  1. useStateuseReducer

    useStateuseReducer 모두 Functional Component에서 사용자 인터렉션에 따라 동적으로 관리되는 값 즉, 상태(state)를 관리할 수 있도록 해주는 Hook이며, 상태값이 바뀔 때마다 Component가 re-rendering 됩니다.

    (기억해야 할 점은 re-rendering될 때, state의 값이 변하지 않는다는 것입니다. - 인과관계 고려!)

    먼저 useState Hook은 current state와 state를 update할 수 있는 함수로 이루어져 있으며, 선언 시 인자로 초기값을 넣어주고, 배열 구조분해할당을 활용하게 됩니다.

    import { useState } from 'react';
    
    const [ state, setState ] = useState(initialValue);

    한편, useReducer Hook은 useState와 유사하나, state를 변경하는 로직을 따로 분리하여 reducer 함수 내부에 구현한다는 차이점이 있습니다.

    import { useReducer } from 'react;
    const [ state, dispatch ] = useReducer(reducer, initialValue);
    
    function reducer(state, action) {
    	switch(action) {
    		case action1:
    			return {...};
    		case action2:
    			return {...};
    	}
    }

    결국 useState의 경우 state를 변경하는 로직을 Event handler function에서 setState를 활용하여 직접 구현해하는 반면, useReducer의 경우 state를 변경하는 로직이 reducer에 구현되어 있어 Event handler function에서는 dispatch를 통해 action type만 전달하게 됩니다.

    전자의 경우 매번 구현해야 하는 번거로움과 동시에 자율성이 있고, 후자의 경우 재사용성은 높지만 자율성은 떨어집니다.

    실제 button을 눌러서 숫자를 증감시킬 수 있는 간단한 어플리케이션을 구현하는 방식을 통해 useState와 useReducer를 비교해 봅시다.

    // useState 사용
    import React, { useState } from "react";
    
    export default function App() {
    	// state를 선언
      const [count, setCount] = useState(0);
    
    	// 각 event handler function에서 state 변경 로직을 직접 구현
      function down() {
        setCount(count - 1);
      }
      function reset() {
        setCount(0);
      }
      function up() {
        setCount(count + 1);
      }
    
      return (
        <div>
          <input type="button" value="-" onClick={down} />
          <input type="button" value="0" onClick={reset} />
          <input type="button" value="+" onClick={up} />
          <span>{count}</span>
        </div>
      );
    }
    import React, { useReducer } from "react";
    
    export default function App() {
    	// reducer 함수를 구현 -> state 변경 로직이 담겨져 잇음
      function countReducer(oldCount, action) {
        if (action === "UP") {
          return oldCount + 1
        } else if (action === "DOWN") {
          return oldCount - 1
        } else if (action === "RESET") {
          return 0
        }
      }
    	// state를 선언
      const [count, countDispatch] = useReducer(countReducer, 0);
    
    	// 각 event handler function에서는 action type만을 dispatch에 전달
      function down() {
        countDispatch("DOWN")
      }
      function reset() {
        countDispatch("RESET")
      }
      function up() {
        countDispatch("UP")
      }
    
      return (
        // 생략 ...
      );
    }

    만약 아래와 같이 초기값을 가져올 때, 굉장히 무거운 작업을 해야하는 경우에는 Component가 re-rendering되면, useState 함수가 다시 실행되기 때문에 이를 계속해서 불러오게 되어 매우 비효율적일 수 있습니다.

    물론 re-rendering에 따라 useState가 다시 호출되더라도 무시되긴 하나, 초기값에 복잡한 계산 등이 들어 있는 경우 그러한 계산은 수행됨을 의미합니다.

    import { useState } from 'react';
    
    const heavyWork= () => {
    	console.log('heavy work');
    	return ['홍길동', '김민수'];
    }
    
    export default function App() {
    	const [names, setNames] = useState(heavyWork());
    	const [input, setInput] = useState('');
    
    	const handleInputChange = (e) => {
    		setInput(e.target.value);
    	}
    
    	const handleUpload= () => {
    		setNames(prevState => {
    			console.log(`이전 state: ${prevState}`);
    			return [input, ...prevState]
    		})
    	}
    	
    	return (
    		<>
    			<input type='text' value={input} onChange={handleInputChange} />
    			<button>Upload</button>
    			{names.map((el, idx) => {
    				return <p key={idx}>{el}</p>
    			})}
    		</>
    	)
    }
    

    위 코드를 실행해보면, 처음 Component가 rendering 될 때 뿐만 아니라 input 창에 값을 입력할 때, 그리고 upload를 할 때 모두 heavywork가 불러와 짐을 확인할 수 있습니다.

    처음 Component가 rendering 되면서 초기값이 불러와질 때에만 heavywork가 불러와지도록 할 수 있는 방법은 **state를 선언할 때 바로 값을 넣어주는 것이 아니라 callback function의 형태로 넣어주는 것**입니다.

    	const [names, setNames] = useState(**() => heavyWork()**);
  2. useEffect

    Functional Component 내에서 rendering 과정에서는 구현할 수 없는 네트워크 통신과 같은 side Effect를 수행할 수 있도록 해주는 Hook입니다.

    useEffect는 기본적으로 2개의 인자를 받으며, 첫 번째 인자는 rendering 이후에 실행할 callback function이고, 두 번째 인자는 rendering 이후 선택적으로 useEffect를 호출할 수 있도록 해주는 Dependency Array입니다.

    useEffect( effect callback func, dependency array)

    기본적으로 Dependency Array가 들어가는 두 번째 인자가 주어지지 않는 경우 매 rendering 이후에 effects를 실행하게 됩니다.

    useEffect(()=>{
    	// Effects
    });

    이러한 useEffect Hook은 Class Component에서 사용되는 Lifecycle method인 componentDidMount 나 componentDidUpdatecomponentWillUnmount와 유사한 기능을 수행하나, 하나로 통합된 것이라고 생각하면 됩니다.

    • 초기 구성 (Mounting, putting inserting elements into the DOM)
      useEffect(()=>{
      	console.log("we have run the useEffect")
      });
      해당 component가 실행되면, useEffect의 콜백 함수가 바로 실행됩니다.(Mount)
    • 데이터 변경 (Updating, involves methods for updating components in the DOM)
      useEffect(()=>{
      	console.log("hidden changed!");
      }, [hidden])
      위 코드는 hidden이라는 state가 있을 때, hidden의 값이 변화하는 것에 따라서 라이프사이클을 정한 예시입니다. 즉, 컴포넌트가 첫 랜더링될 때 한 번 실행되고, 그 이후 hidden이 바뀔 때마다 실행되겠죠. 이는 곧 useEffect가 클래스형 컴포넌트에서 componentDidMount와 componentDidUpdate가 합쳐진 형태임을 보여줍니다.
    • 컴포넌트 해제 (Unmounting, removing a component from the DOM)
      useEffect(()=>{
      	console.log("hidden changed!");
      return (
      	console.log("hidden이 바뀔 예정입니다.");
      )
      }, [hidden])
      함수형 컴포넌트에서는 componentWillUnmount의 역할을 **useEffect 안의 return**이 담당하고 있습니다. 따라서 return문에서 clean up 작업들이 수행되도록 하겠죠? 결국 State(Data)의 Lifecycle이 useEffect 하나로 합쳐진 형태로 구현된다고 생각하면 됩니다.
    • 기타 Case
      • Class Component에서 componentDidMount의 기능만 수행하도록 하려면(즉, Component가 처음 rendering될 때에만 Effects가 실행되도록 하려면), Dependency Array를 빈 배열로 해주면 됩니다.
        useEffect(()=>{
        	console.log("hidden changed!");
        }, [])
      • Class Component에서 componentDidUpdate의 기능만 수행하도록 하려면, 아래에서 배울 useRef Hook을 사용해서 구현할 수 있습니다.
        const mountRef = useRef(false);
        useEffect(() => {
          if (mountRef.current) {
            console.log('updated!');
          } else {
            mountRef.current = true;
          }
        });
      • 만약 여러 개의 state에 대해서 각각의 Effects를 실행되도록 하려면, 각 state에 대해 useEffect를 적용해주면 됩니다.
        useEffect(() => {
          console.log('hidden changed');
        }, [hidden]); 
        
        useEffect(() => {
          console.log('shown changed');
        }, [shown]);
  3. useRef

    re-rendering과 관련 없는 데이터를 component에 저장하고자 하는 경우 혹은 DOM 요소에 직접 접근을 하고자 하는 경우에 사용되는 Hook입니다. 각각에 대해 나눠서 보도록 하죠.

    • re-rendering과 관련 없는 데이터를 component에 저장 및 관리 Component 별로 데이터를 저장하고자 하는 경우 해당 데이터가 re-rendering과 관련이 있다면 useState Hook을 이용하여 state로 관리하게 되는데요. 그렇다면, re-rendering과 관련 없는 데이터들은 어떻게 관리할 수 있을까요? 단순하게는 state로 선언하지 않고, 그냥 변수에 데이터를 할당하면 될 것 같은 느낌쓰가 듭니다. 이러한 방식으로는 Component 바깥에 변수를 선언하여 데이터를 관리하는 방법과 Component 안에 변수를 선언하여 데이터를 관리하는 2가지 방법이 있을 수 있습니다. 하지만, 전자의 경우 해당 Component를 여러 곳에서 사용하게 되면,
      // ZeroCho Blog 예시
      // 본 예시에서 사용되고 있는 useCallback은 일단 무시해주세요
      import React, { useCallback } from 'react';
      
      let data = 0;
      const Basic = () => {
        const onClick = useCallback(() => {
          data++;
        }, [data]);
        return <div onClick={onClick}>Basic</div>;
      };
      
      export default Basic;
      <Basic />
      <Basic />
      해당 Component들이 모두 데이터를 공유하게 되어 예상치 못한 결과를 초래할 수 있습니다.
      (물론 Component간 공유 데이터를 두고자하는 경우에는 이렇게 관리할 수 있겠죠?) 위 예시에서 ‘Basic’을 각각 한 번씩 클릭하게 되면, data는 2가 됩니다. 후자의 경우에는 공유되지 않는 데이터를 저장할 수는 있으나, Component가 re-rendering 될 때마다 값이 초기화되어 이 또한 예상치 못한 결과를 초래할 수 있습니다.
      // ZeroCho Blog 예시
      import React, { useCallback } from 'react';
      const Basic = () => {
        let data = 0;
        const onClick = useCallback(() => {
          data++;
        }, [data]);
        return <div onClick={onClick}>Basic</div>;
      };
      export default Basic;
      위 예시에서 Component가 re-rendering 될 때마다 let data = 0;이 다시 실행되어 data가 0으로 초기화 되겠죠? 그렇다면 re-rendering과 관련 없이 한 번 만들어진 Component에서 이전 data를 유지하고 싶은 경우 어떻게 해야 할까요? 이를 해결해주는 것이 바로 useRef Hook입니다.
      // ZeroCho Blog 예시
      import React, { useCallback, useRef } from 'react';
      const Basic = () => {
        const dataRef = useRef(0);
        const onClick = useCallback(() => {
          dataRef.current++;
        }, []);
        return <div onClick={onClick}>Basic</div>;
      };
      export default Basic;
      위와 같이 useRef로 생성한 데이터는 re-rendering 여부와 상관없이 값이 유지되며, 선언할 때 초기값을 인자로 넣어주게 됩니다. (물론 component가 unmount 될 때까지 유지되겠죠?) 또한, 그 값을 바꾸더라도 state와 달리 re-rendering이 발생되지 않습니다. 한 가지 유의해야 할 점은 useRef로 선언한 변수에는 { current : value }의 형태로 값이 담겨지기 때문에 변수의 값을 가져올 때는 '.current'로 접근해야 한다는 것입니다. 위의 예시에서는 dataRef.current가 되겠죠? 또한 객체 형태로 담겨져 있기 때문에 언제든 그 값을 변경할 수 있습니다.
      const ref = useRef('hi');  // { current : 'hi' }
      ref.current = 'hello';     // { current : 'hello' }
      ref.current = 'nice';      // { current : 'nice' }
    • DOM 요소에 직접 접근 JavaScript 를 사용 할 때, 특정 DOM 을 선택해야 하는 경우 getElementById 나querySelector 같은 DOM Selector 함수를 사용해서 DOM의 Reference를 가져오고, 이를 통해 DOM Element에 직접 접근하여 우리가 원하는 조작들을 할 수 있었습니다. 기본적으로 React 애플리케이션을 만들 때 DOM을 직접 조작하는 것은 지양해야 합니다. React에서는 virtual DOM을 거쳐 Real DOM을 구현하게 되는데, DOM을 직접 조작하는 것은 Virtual DOM을 거치지 않기 때문에 React의 지향점에 반하기 때문입니다. 하지만, 개발을 하다보면 DOM을 직접 건드려야하는 상황이 발생하기도 하기도 하죠. 예를 들어, 아래와 같이 DOM 엘리먼트의 주소값을 활용해야 하는 경우 특히 그러합니다.
      • focus

      • text selection

      • media playback

      • 애니메이션 적용

      • d3.js, greensock 등 DOM 기반 라이브러리 활용

        React에서는 이런 예외적인 상황에서 useRef Hook으로 DOM 노드, 엘리먼트, 그리고 React 컴포넌트 주소값을 참조할 수 있습니다.

        // 주소값을 활용하는 예시
        const 주소값을_담는_그릇 = useRef( 참조자료형 )
        // 이제 주소값을_담는_그릇 변수에 어떤 주소값이든 담을 수 있습니다.
        return (
            <div>
              <input **ref={주소값을_담는_그릇}** type="text" />
                {/* React에서 사용 가능한 ref라는 속성에 주소값을_담는_그릇을 값으로 할당하면*/}
                {/* 주소값을_담는_그릇 변수에는 **input DOM 엘리먼트의 주소가 담깁**니다. */}
                {/* 향후 다른 컴포넌트에서 input DOM 엘리먼트를 활용할 수 있습니다. */}
            </div>);

        이 주소값은 컴포넌트가 re-render 되더라도 바뀌지 않으므로 이 특성을 활용하여 아래와 같은 제한된 상황에서 **useRef** 를 활용할 수 있겠죠?

        // codestate 예시
        function TextInputWithFocusButton() {
          **const inputEl = useRef(null)**;
          const onButtonClick = () => {
            **inputEl.current.focus();**
          };
          return (
            <>
              <input **ref={inputEl}** type="text" />
              <button onClick={onButtonClick}>Focus the input</button>
            </>);
        }

        React 공식 문서에서도 기재되어 있는 바와 같이, 몇몇 예외적인 상황을 제외한 대부분의 경우 기본 React 문법을 벗어나 useRef 를 남용하는 것은 부적절합니다.

        앞서 말했듯이, DOM 요소에 직접 접근을 하는 것은 React의 특징이자 장점인 선언형 프로그래밍 원칙과 배치되기 때문에 조심해서 사용해야 합니다!

  4. useContext (with Context API)

    React에서의 Data flow는 부모 컴포넌트에서 자식 컴포넌트로 props를 전달해주는 단방향 흐름이죠.

    이때 특정 부모 컴포넌트에서 관리되는 데이터를 여러 손자 컴포넌트에서 사용해야 하는 경우 중간에 위치한 컴포넌트들에도 의미없이 props를 전달해줘야 하는 문제가 발생할 수 있습니다. 이러한 현상을 props drilling이라고 하는데요.

    이러한 props drilling 현상은 코드의 가독성을 저해하고, 유지보수도 힘들게 하며, state 변경 시 props 전달 과정에서 불필요하게 관여된 컴포넌트들 또한 리렌덜링이 발생함에 따라 웹 성능에 악영향을 줄 수 있습니다.

    이를 해결하기 위해 등장한 React built-in Hook이 바로 useContext Hook입니다. 즉, 전역적으로 데이터가 관리되고, 여러 하위 컴포넌트들에서 사용되어야 할 때, 활용할 수 있는 Hook입니다. (물론 현 시점에서 전역 상태를 관리하기 위한 도구로 Redux, RTX, MobX, Recoil 등을 보다 많이 사용하긴 합니다만, 저러한 라이브러리들 역시 Context API를 기반으로 구현되어 있습니다.)

    단, React 공식 문서에서 언급되고 있는 바와 같이, Context를 사용하는 경우 사용된 Component를 재사용하기 어려워질 수 있수 있기 때문에 props drilling 만을 피하기 위한 목적이라는 Component Composition의 사용을 먼저 고려해보는 것이 좋다고 합니다. 물론 동일한 데이터를 tree 안에서 nesting level이 다른 여러 Component들에서 사용해야하는 경우에는 Context를 사용해야 겠죠?

    Dark Mode/Light Mode가 구현된 웹 페이지의 코드를 보며 useContext와 Context API를 어떻게 사용할 수 있는지 알아봅시다.

    (기본적으로 아래의 isDark라는 state는 웹 페이지의 전체적인 테마를 결정짓는 것으로 전역적으로 관리되어야 하는 데이터임을 알 수 있습니다.)

    • React props만을 이용하여 구현
      import { useState } from 'react;
      
      // 부모 컴포넌트
      function App() {
      	const [isDark, setIsDark] = useState(false);
      
      	return <Page isDark={isDark} setIsDark={setIsDark} />
      }
      
      // 자식 컴포넌트
      const Page = ({ isDark, setIsDark }) => {
      	return (
      		<div>
      			<Header isDark={isDark} />
      			<Content isDark={isDark} />			
      			<Footer isDark={isDark} setIsDark={setIsDark} />
      		</div>
      	)
      }
      
      // 손자 컴포넌트 - 1
      const Header = ({ isDark }) => {
      	return (
      		<header styles={{
      			background-color : isDark ? 'black' : 'lightgray',
      			color : isDark ? 'white' : 'black',
      		}}>
      			<h1>Welcome Our Web!</h1>
      		</header>
      	)
      }
      
      // 손자 컴포넌트 - 2
      const Content = ({ isDark }) => {
      	return (
      		<header styles={{
      			background-color : isDark ? 'black' : 'white',
      			color : isDark ? 'white' : 'black',
      		}}>
      			<h1>좋은 하루 되세요</h1>
      		</header>
      	)
      }
      
      // 손자 컴포넌트 - 3
      const Header = ({ isDark, setIsDark }) => {
      	const ModeButtonClick = () => {
      		setIsDark(!isDark);
      	}
      	return (
      		<header styles={{
      			background-color : isDark ? 'black' : 'lightgray',
      		}}>
      			<button 
      				onClick={ModeButtonClick}
      			>{isDark ? 'Light Mode' : 'Dark Mode'}</button>
      		</header>
      	)
      }
      위 예시에서 자식 컴포넌트에서는 isDark라는 state가 사용되지 않음에도, 손자 컴포넌트들에게 전달해주기 위해 props를 받고 props를 내려주고 있습니다. → 이러한 중간 컴포넌트가 10개 20개라고 한다면 이를 관리하기 쉽지 않겠죠?
    • Context API와 useContext Hook을 사용하여 구현
      import { useState, createContext, useContext } from 'react';
      
      // 부모 컴포넌트
      function App() {
      	const [isDark, setIsDark] = useState(false);
      	const ThemeContext = createContext(null);
      	
      	return (
      		<ThemeContext.Provider value={{ isDark, setIsDark }} >**
      			<Page />
      		</ThemeContext.Provider>
      	)
      }
      
      // 자식 컴포넌트
      const Page = () => {
      	return (
      		<div>
      			<Header />
      			<Content />			
      			<Footer />
      		</div>
      	)
      }
      
      // 손자 컴포넌트 - 1
      const Header = () => {
      	const { isDark } = useContext(ThemeContext);
      	return (
      		<header styles={{
      			background-color : isDark ? 'black' : 'lightgray',
      			color : isDark ? 'white' : 'black',
      		}}>
      			<h1>Welcome Our Web!</h1>
      		</header>
      	)
      }
      
      // 손자 컴포넌트 - 2
      const Content = () => {
      	const { isDark } = useContext(ThemeContext);
      	return (
      		<header styles={{
      			background-color : isDark ? 'black' : 'white',
      			color : isDark ? 'white' : 'black',
      		}}>
      			<h1>좋은 하루 되세요</h1>
      		</header>
      	)
      }
      
      // 손자 컴포넌트 - 3
      const Header = () => {
      	const { isDark, setIsDark  } = useContext(ThemeContext);
      	const ModeButtonClick = () => {
      		setIsDark(!isDark);
      	}
      	return (
      		<header styles={{
      			background-color : isDark ? 'black' : 'lightgray',
      		}}>
      			<button 
      				onClick={ModeButtonClick}
      			>{isDark ? 'Light Mode' : 'Dark Mode'}</button>
      		</header>
      	)
      }
      • Context API를 통해 React에서 Context를 만들기 위해서는 먼저 react에서 createContext를 import 해와서 선언 및 할당해주면 되고, 이때 인자로 들어가는 값은 예외적인 초기값으로 Context 사용 목적상 초기값을 사용하는 경우는 거의 없습니다.
      • Context를 통해 관리할 데이터를 사용해야하는 컴포넌트의 상위 컴포넌트를 context.provider라는 element로 감싸줘야 하며, 이때 value attribute에 넣는 것이 바로 Context로 관리할 데이터입니다. (여러 개인 경우 위와 같이 객체 형태로 넣어주면 됩니다!)
      • 사용하고자 하는 Component에서 선언된 Context를 가져온 후(여러 파일에 작성된 경우 context를 import 해와야 합니다!) useContext의 인자로 넣어서 선언해주면, Context에서 관리되고 있는 데이터들을 해당 Component에서 사용할 수 있게 됩니다.

    useContext와 Context API는 Context를 만들고(createContext), 데이터를 집어 넣고(Provider), 이를 사용하고자 하는 Component에서 꺼내어 사용(useState)하면 되듯이 매우 간단하게 공동으로 사용되는 데이터를 관리하기 쉽습니다. 또한 Redux 등의 라이브러리와 달리 전역적이지 않은 상태 데이터를 다루기에도 용이하죠.

    하지만 Context API는 React Component이므로 wrapper hell이 발생(Provider의 중첩)하게 되는 단점도 존재합니다.

  5. useCallbackuseMemo

    useCallbackuseMemo는 모두 Component 성능을 최적화하기 위해 사용되는 Hook들입니다.

    캐싱(또는 Memoization)과 관련된 Hook으로 useCallback은 함수를, useMemo는 값(함수 포함)을 캐싱하여 불필요한 re-rendering을 최소화 할 수 있도록 해줍니다.

    먼저 useCallback Hook에 대해서 보도록 합시다.

    이를 사용하지 않을 때의 코드를 보면,

    // ZeroCho Blog 예시
    import React, { useState } from 'react';
    
    const Basic = () => {
      const [hidden, setHidden] = useState(false);
      
    	return (
        <div>
          <button onClick={() => setHidden(true)}>숨기기</button>
        </div>
      );
    };
    
    export default Basic;

    button의 onClick 부분에 들어가있는 () ⇒ setHidden(true) 함수가 Basic 함수가 다시 실행될 때마다 새롭게 생성됩니다.

    button의 onClick props로 해당 함수를 넣은 상태에서, 해당 함수가 새롭게 생성되면 props가 바뀌는 것이므로 button 엘리먼트도 re-rendering되는 현상이 발생하는 것이죠.

    따라서 정말 새로 생성되어야 할 함수가 아니라면, 새로 생성되지 않도록 막아줄 필요가 있는데요. useCallback Hook을 통해 이를 방지할 수 있습니다.

    // ZeroCho Blog 예시
    import React, { useState, useCallback } from 'react';
    
    const Basic = () => {
      const [hidden, setHidden] = useState(false);
    	const ButtonOnClickHandler = useCallback(() => {
    		setHidden(true);
    	}, []);
      
    	return (
        <div>
          <button onClick={**ButtonOnClickHandler**}>숨기기</button>
        </div>
      );
    };
    
    export default Basic;

    위와 같이 useCallback으로 해당 함수를 감싸주면, Basic Component가 재실행되어도 useCallback은 과거의 값을 가져오므로 button element가 re-rendering되지 않습니다.

    한 가지 유의할 점은 useCallback에 두 번째 인자로 dependecy array가 들어가며, 해당 배열에 특정한 값을 넣으면, 그 값이 변화할 때만 캐싱된 함수를 새로운 함수로 바뀌도록 할 수 있다는 것입니다.

    결론적으로 함수를 캐싱하여 component를 최적화 하고자 한다면, return 내부에 JSX에 넣는 함수들은 대부분 return문 바깥으로 뺴서 useCallback으로 감싸주면 됩니다.

    다음으로 useMemo를 보도록 하죠. 앞서 말했듯이, useCallback함수를 캐싱하기 위한 Hook이라면, useMemo함수를 포함한 값들을 캐싱하기 위한 Hook입니다.

    각각을 사용하는 형식을 비교해보면 아래와 같습니다.

    useCallback(() => {
    	// 내용
    }, []);
    
    useMemo(() => value, []);

    둘 다 함수를 첫 번째 인자로 받고, dependency array를 두 번째 인자로 받고 있는데요.

    useCallback의 경우 첫 번째 인자로 들어가는 함수 자체를 캐싱하는 반면,

    useMemo의 경우 첫 번째 인자로 들어가는 함수에서 반환되는 value를 캐싱합니다.

    useCallback에서도 보았듯이, deps array 내부의 값이 달라지면, 기존에 캐싱된 것을 버리고 새로운 값을 생성하게 됩니다.

    로또 추첨 번호를 계산하고 보여주는 어플리케이션을 예로 들어 useMemo Hook을 이해해봅시다.

    // ZeroCho Blog 예시
    // 로또 추첨 번호를 계산하는 getWinNumbers 함수
    function getWinNumbers() {
      console.log('getWinNumbers');
      const candidate = Array(45).fill().map((v, i) => i + 1);
      const shuffle = [];
      while (candidate.length > 0) {
        shuffle.push(candidate.splice(Math.floor(Math.random() * candidate.length), 1)[0]); 
      }
      const bonusNumber = shuffle[shuffle.length - 1];
      const winNumbers = shuffle.slice(0, 6).sort((p, c) => p - c);
      return [...winNumbers, bonusNumber];
    }
    
    // ZeroCho Blog 예시
    const Basic = () => {
      const [lottoNumbers, setLottoNumbers] = useState(getWinNumbers());
    
      return <div>{lottoNumbers.join(',')}</div>;
    };

    위 예시에서 Basic Component가 re-rendering 될 때마다 getWinNumbers가 다시 호출되게 됩니다. (물론 useState는 re-rendering에 따라 다시 호출되어도 무시하므로 state나 화면에는 아무런 영향이 없습니다.)

    만약 getWinNumbers 함수가 위의 예시에서 보다 훨씬 복잡하고 무거운 연산을 하는 함수라면, 화면에 바뀌는 것은 없지만, re-rendering 될 때마다 계속 함수가 실행되어 성능을 저하시킬 수 있습니다.

    특히 자식 컴포넌트는 부모 컴포넌트가 re-rendering 되면 따라서 re-rendering 되므로 특정 컴포넌트에 있어 re-rendering control right이 없는 경우에 위와 같은 문제가 극대화 될 수 있죠.

    이럴 때 useMemo를 사용하여 getWinNumbers 함수의 결과값을 캐싱함으로써 문제를 해결할 수 있습니다.

    // ZeroCho Blog 예시
    const Basic = () => {
      const cachedNumbers = useMemo(() => getWinNumbers(), []);
      const [lottoNumbers, setLottoNumbers] = useState(cachedNumbers);
    
      return <div>{lottoNumbers.join(',')}</div>;
    };

    이처럼 무거운 연산을 필요로 하는 함수는 useMemo로 캐싱해주면, 불필요한 연산을 줄임으로써 component 최적화를 도모할 수 있습니다.

    단, useCallbackuseMemo를 무분별하게 남용하면 안됩니다. 왜냐하면 캐싱한 다는 것은 함수나 값을 재활용하기 위해 메모리를 소비해서 저장해둔다는 것을 의미하므로 불필요한 값들까지 모두 메모이제이션을 하게 되면 오히려 성능이 악화될 수 있기 때문입니다.

이외에도 useRef로 만든 reference를 상위 컴포넌트로 전달할 수 있도록 해주는 forwardRef,
useRef로 만든 래퍼런스의 상태에 따라, 실행할 함수를 정의 할 수 있도록 해주는 useImperativeHandle, 모든 DOM 변경 후 브라우저가 화면을 그리기(render)전에 실행되는 기능을 정할 수 있도록 해주는 useLayoutEffect Hook, 그리고 사용자 정의 Hook의 디버깅을 도와주는useDebugValue가 있습니다.


다음 포스팅에서는 React에서 Custom Hook을 만드는 방법을 포함하여 React Router Hook에 대해서 알아보도록 하겠습니다.

0개의 댓글