본문 바로가기
FE/React

1. Hooks

by aeyong-dev 2023. 7. 12.

React를 공부할 때면 Hook이라는 말이 꼭 나오곤 한다. 

'아 이런게 존재하는구나~' 정도만 알고 있다가 드디어 공부하게 되었다. 

멋사 과제를 진행하면서 useState()라는 것을 사용한 적 있는데, 이것이 Hook의 한 종류라고 한다. 

나도 모르는 새 Hook을 쓰고있었다니!

아무튼 이 포스팅에서는 Hook에 대해 알아볼 것이다. 

 

useState함수 컴포넌트 내에서 상태 관리를 할 수 있게 해주고, 

useEffect렌더링 직후 작업을 설정합니다.

이런식으로, 기존의 함수 컴포넌트에서 할 수 없던 일들을 하게 해줘요.

 

React 내장 Hooks를 배우고 이후에는 커스텀 Hooks를 만들어 보겠습니다. 


1.1 useState

함수 컴포넌트 내에서 가변적인 상태를 지닐 수 있게 해줍니다. 

State, 즉 상태에 따라 가변적으로 컴포넌트를 조정할 수 있습니다. 

연습을 위해 아래의 코드를 작성하겠습니다. 

import { useState } from "react";

const Counter = () => {
  const [value, setValue] = useState(0);
  return (
    <div>
      <p>
        현재 카운터 값은 <b>{value}</b>입니다.
      </p>
      <button onClick={() => setValue(value + 1)}>+1</button>
      <button onClick={() => setValue(value - 1)}>-1</button>
    </div>
  );
};

export default Counter;

useState는 다음과 같이 사용합니다.

const [상태, 상태를 설정하는 함수] = useState(기본값);

 

저기에서는 value라는 상태를 관리하기 위해 useState를 사용했네요. 

함수 파라미터로 0을 넣어서 value의 기본값을 0으로 초기화했습니다. 

App.js에서 Counter 컴포넌트를 렌더링하고 실행시켜 웹 페이지에서 볼게요.

위와 같은 페이지가 나오는데요, 버튼을 누를 때 마다 카운터의 값이 변하는 것을 확인할 수 있습니다. 

value의 값은 초기에 0으로 설정해주어서 처음에는 0으로 표시됩니다. 

버튼을 누르면 setValue() 함수가 호출되어 value의 값이 바뀌고, 

바뀐 value의 값이 화면에 동적으로 표시되기 시작합니다. 

1.1.1 useState를 여러번 사용하기

useState는 하나의 상태만 관리합니다. 

여러 상태를 관리하고 싶다면, useState를 여러번 사용하면 그만입니다. 

다음 코드를 작성해봅시다. 

함수 이름은 귀찮아서굳이 변경하지 않았습니다.

import { useState } from "react";

const Counter = () => {
  const [name, setName] = useState("");
  const [nickname, setNickname] = useState("");

  const onChangeName = (e) => {
    setName(e.target.value);
  };
  const onChangeNickname = (e) => {
    setNickname(e.target.value);
  };

  return (
    <div>
      <div>
        <input value={name} onChange={onChangeName} />
        <input value={nickname} onChange={onChangeNickname} />
      </div>
      <div>
        <div>
            <b>이름:</b> {name}
        </div>
        <div>
            <b>닉네임:</b> {nickname}
        </div>
      </div>
    </div>
  );
};

export default Counter;

마찬가지로 웹에서 볼까요?

input 안에 이름과 닉네임을 적으면 실시간으로 아래의 텍스트가 동적으로 변합니다. 

이런식으로, 여러 상태를 관리하고 싶다면 useState를 여러번 작성하면 됩니다. 


1.2 useEffect

useEffect컴포넌트가 렌더링 될 때 마다 특정 작업을 수행하도록 설정하는 Hook입니다. 

아까 useState를 연습하면서 쓴 코드에서 useState 아랫줄에 조금만 추가해볼게요.

useEffect(() => {
    console.log("렌더링이 완료되었습니다!");
    console.log({
      name,
      nickname,
    });
  });

처음 렌더링 될 때, name과 nickname이 객체의 형태로 retrun되는군요. 

그리고 타자를 칠 때 마다 같은 형식을 return 해줍니다. 

이런식으로 '컴포넌트가 렌더링 될 때 마다 특정 작업을 수행'해주는 것이 useEffect입니다.

1.2.1 마운트 될 때만 실행하고 싶을 때

useEffect가 화면에 처음 렌더링 될 때만 실행하고, 나머지 경우(업데이트 될 경우)에는 실행되지 않게 하고싶다면?

한마디로, useEffect가 마운트 될 때만 실행하고 싶다면?

 

그 전에, 마운트가 뭔지 부터 알아보고 갑시다. 

마운트(Mount)란?
React 컴포넌트가 DOM에 삽입되어 화면에 표시되는 과정을 의미합니다.
즉, 컴포넌트의 인스턴스가 생성되고 컴포넌트의 라이프사이클 메서드가 호출되며, 최종적으로 컴포넌트가 화면에 나타나게 됩니다.

React 컴포넌트가 마운트되면 다음과 같은 일련의 단계가 발생합니다:

1. 컴포넌트 인스턴스 생성: React 컴포넌트의 클래스 또는 함수 컴포넌트를 기반으로 컴포넌트의 인스턴스가 생성됩니다.
2. constructor 메서드 호출: 클래스 컴포넌트인 경우, 생성된 컴포넌트 인스턴스의 constructor 메서드가 호출됩니다. constructor 메서드는 초기화 작업을 수행하고 초기 상태를 설정하는 데 사용될 수 있습니다.
3. render 메서드 호출: 생성된 컴포넌트 인스턴스의 render 메서드가 호출됩니다. render 메서드는 JSX 또는 React 요소를 반환하며, 컴포넌트의 렌더링 결과를 나타냅니다.
4. React 요소를 DOM에 삽입: render 메서드가 반환한 React 요소가 실제 DOM에 삽입됩니다. 이 단계에서는 Virtual DOM을 사용하여 최적화된 방식으로 실제 DOM에 변경 사항을 적용합니다.
5. 마운트 이후 라이프사이클 메서드 호출: 컴포넌트가 마운트되면 마운트 이후 라이프사이클 메서드가 순서대로 호출됩니다. 예를 들어, componentDidMount 메서드는 컴포넌트가 실제 DOM에 삽입된 후에 호출됩니다. 이 단계에서는 보통 비동기 작업이나 외부 데이터 요청과 같은 초기화 작업을 수행합니다.

마운트 단계는 컴포넌트의 최초 렌더링이 이루어지는 시점으로, 컴포넌트가 처음으로 화면에 나타나는 시점입니다. 이후에는 컴포넌트의 상태 변경, 프로퍼티 변경 등에 따라 업데이트 또는 언마운트 단계로 이어질 수 있습니다.

그리고 가상 DOM과 실제 DOM에 대해서도.. 

Virtual DOM vs Real DOM

Virtual DOM(Virtual Document Object Model)과 실제 DOM(Document Object Model)은 웹 애플리케이션에서 UI를 표현하고 조작하는 데 사용되는 개념입니다.

실제 DOM은 브라우저가 HTML 문서를 파싱하여 생성하는 웹 페이지의 구조를 나타냅니다. 실제 DOM은 트리 형태로 구성되어 있으며, HTML 요소들과 그 속성, 텍스트 내용 등을 노드로 표현합니다. JavaScript를 사용하여 실제 DOM에 접근하고 조작할 수 있습니다. DOM 요소의 변경이 발생하면 브라우저는 리렌더링을 수행하고 페이지의 레이아웃을 갱신합니다. 실제 DOM은 변경 사항이 있을 때마다 리플로우(reflow)와 리페인트(repaint)를 수행하여 성능상의 비용이 크다는 단점이 있습니다.

Virtual DOM은 실제 DOM의 가벼운 복제본이라고 볼 수 있습니다. React와 같은 라이브러리나 프레임워크에서 사용되는 가상의 DOM 트리입니다. Virtual DOM은 JavaScript 객체로 구성되며, 실제 DOM과 유사한 구조를 가지고 있습니다. React 컴포넌트의 상태나 프로퍼티 변경에 따라 Virtual DOM이 업데이트되고, 이후 실제 DOM과 비교하여 변경 사항을 최소화합니다.

Virtual DOM을 사용하는 이유는 다음과 같습니다:

1. 성능 개선: Virtual DOM은 실제 DOM에 대한 변경 사항을 최소화하고 필요한 부분만 업데이트하여 리렌더링을 최적화합니다. 이를 통해 성능을 향상시킵니다.

2. 간편한 추상화: Virtual DOM은 실제 DOM보다 추상화 수준이 높습니다. React와 같은 라이브러리를 사용하면 개발자는 Virtual DOM에 대한 추상화된 인터페이스를 사용하여 UI를 업데이트할 수 있습니다.

3. 크로스 플랫폼 호환성: Virtual DOM은 브라우저에 종속되지 않으며, React Native와 같은 다른 플랫폼에서도 사용할 수 있습니다. 이는 React를 사용하여 웹 및 모바일 애플리케이션 개발을 일관되게 할 수 있음을 의미합니다.

Virtual DOM은 React에서 가장 널리 알려진 개념이지만, 다른 라이브러리나 프레임워크에서도 유사한 개념을 사용할 수 있습니다. Virtual DOM은 성능 개선과 개발자 경험 향상을 위해 도입된 중요한 기술입니다.

아무튼, 기존의 코드를 다음과 같이 수정해 보자구요.

useEffect(() => {
    console.log("마운트 될 때만 실행됩니다.");
  }, []);

내용을 입력해도 로그가 찍히지 않고, 처음에만 로그가 찍히네요. 

useEffect()함수에서 뒤에 빈 배열을 추가로 넣어주니 마운트 될 때만 실행되는 모습을 확인할 수 있었습니다. 

근데 왜???????? 어떤 원리로?????? 어째서?????? 그냥 받아들이면 됨???

1.2.2 특정 값이 업데이트 될 때만 실행하고 싶을 때

마운트 될 때만 실행하는 것과는 반대로, 업데이트 될 때만 실행하고 싶을 수도 있잖아요?

그건 어떻게 할까요?

바로, useEffect()의 두번째 파라미터로 전달되는 배열 안에 검사하고 싶은 값을 넣어주면 됩니다. 

또 코드를 수정해서 실험해볼게요.

useEffect(() => {
    console.log(name);
  }, [name]);

이름에 값을 입력할 때 마다 로그가 찍히는걸 확인할 수 있습니다. 

1.2.3 뒷정리(cleanup)

useEffect에 대해 정리해볼까요?

  • 기본적으로 렌더링 되고 난 직후부터 실행
  • 두번째 파라미터 배열에 무엇을 넣는지에 따라 실행 조건 바뀜
    • 빈 배열: 마운트 될 때만 실행
    • !빈 배열: 배열에 넣은 값이 업데이트 될 때 실행

만약, 컴포넌트가 언마운트 되기 전, 또는 업데이트 직전에 특정 작업을 수행하고 싶다면?

useEffect에서 뒷정리(cleanup) 함수를 return해줘야 합니다. 

또 함수를 고쳐서 실험해볼게요.

useEffect(() => {
    console.log('effect');
    console.log(name);
    return () => {
        console.log('cleanup');
        console.log(name);
    };
  }, [name]);

그리고, 이번에는 App 컴포넌트에서 useState를 이용하여 상태를 관리하는 코드를 추가해볼게요. 

App.js를 다음과 같이 수정해봅시다. 

import Counter from "./Counter";
import { useState } from "react";

function App() {
  const [visible, setVisible] = useState(false);
  return (
    <div>
      <button onClick={() => setVisible(!visible)}>
        {visible ? "숨기기" : "보이기"}
      </button>
      <hr />
      {visible && <Counter />}
    </div>
  );
}

export default App;

보이기, 숨기기 버튼으로 Counter 컴포넌트를 보이게 또는 안보이게 만들 수 있네요. 

그리고 컴포넌트가 보일 때 effect가 나타나고, 사라질 때 cleanup이 로그에 찍힙니다. 

이제 input에 이름을 적어보고 변화를 관찰해봅시다.

렌더링 될 때 마다 뒷정리 함수가 실행됩니다. 

그리고 업데이트 되기 직전의 상태를 보여줍니다. 

만약 언마운트 될 때만 뒷정리 함수를 호출하고 싶다면, useEffect 두 번째 파라미터에 빈 배열을 넣으면 됩니다. 

아까와 비슷하네요. 


1.3 useReducer

한마디로 정의하자면, useState보다 더 다양한 상황과 상태에 따라 업데이트 하는 Hook입니다. 

useReducer, Reducer를 사용한다는 말인데 Reducer는 뭘까요?

나중에 Redux를 배울 때 자세히 알아보고 지금은 간단히 알아보겠습니다. 

 

Reducer: 현재 상태, 업데이트에 필요한 정보를 담은 action값을 전달받아 새로운 상태를 반환하는 함수

 

Reducer 함수에서 새로운 상태를 만들 때는 반드시 불변성을 지켜줘야 합니다. 

불변성(Immutability)이란 데이터의 원본을 변경하지 않고, 그 데이터의 새로운 사본을 만들어 수정하는 것을 말합니다.
리듀서 함수에서 불변성을 지켜야 하는 이유는 다음과 같습니다.
1. 예측 가능한 코드: 불변성을 지키면 데이터 변경이 여러 부분에서 독립적으로 발생하지 않으므로, 애플리케이션 동작을 예측하기 쉽습니다. 이는 디버깅 및 코드의 유지 보수를 도울 것입니다.
2.성능 최적화: React 및 Redux 작동 원리로 인해 상태의 불변성이 지켜질 경우 성능 최적화가 가능합니다. 예를 들어, React는 상태 객체에 대한 얕은 비교(shallow compare)를 수행하여 변경 여부를 감지하고 렌더링을 결정하는데, 불변성이 보장되면 이교가 빠르게 됩니다.
3. 상태 변경 추적: 불변성을 유지하면 어떤 상태 변경이 언제 어떻게 발생했는지 그리고 이전 상태와 새로운 상태가 어떻게 다른지를 쉽게 추적할 수 있습니다. 이를 통해 시간 여행 디버깅과 같은 고급 디버그 도구를 사용 수 있습니다.

리듀서에서 불변성을 유지하기 위해 객체나 배열을 복사하고 복사본에 변경사항을 적용한 후 그사본을 반환하십시오. `Object.assign`, 스프레드 연산자({...}), `Array.prototype.slice`, `Array.prototype.concat` 등의 도구들과 함께 올바른 방법들을 사용하여 원본 객체 및 배열을 수정하지 않도록 주의하세요.

reducer는 다음과 같이 사용해요.

function reducer(state, action){
	return {...};
}

여기서 action은 type으로 'INCREMENT' 를 가지고, 다른 값들이 필요하다면 추가로 가질 수 있습니다. 

하지만 reducer에서 사용하는 action 객체는 type을 꼭 가질 필요는 없습니다. 

객체가 아니라 문자열, 숫자여도 상관없습니다. 

1.3.1 카운터 구현

이전에 useState를 배우면서 만들었던 카운터를 다시 만들어볼게요. 

import { useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { value: state.value + 1 };
    case "DECREMENT":
      return { value: state.value - 1 };
    default:
      return state;
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, { value: 0 });

  return (
    <div>
      <p>
        현재 카운터 값은 <b>{state.value}</b>입니다.
      </p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>+1</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>-1</button>
    </div>
  );
};

export default Counter;

어때요 잘 되죠?

1.3.2 인풋 상태 관리하기 

이름과 닉네임을 적는 컴포넌트를 useReducer를 사용해서 만들어보겠습니다.

useState를 여러개 사용했지만, 이제는 useReducer 하나면 충분할거에요.

import { useReducer } from "react";

function reducer(state, action) {
  return {
    ...state,
    [action.name]: action.value,
  };
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, {
    name: "",
    nickname: "",
  });
  const { name, nickname } = state;
  const onChange = (e) => {
    dispatch(e.target);
  };

  return (
    <div>
      <div>
        <input name="name" value={name} onChange={onChange} />
        <input name="nickname" value={nickname} onChange={onChange} />
      </div>
      <div>
        <div>
          <b>이름:</b> {name}
        </div>
        <div>
          <b>닉네임:</b> {nickname}
        </div>
      </div>
    </div>
  );
};

export default Counter;

 

useReducer에서의 action은 어떠한 값도 사용 가능합니다. 


1.4 useMemo

함수 컴포넌트 내부에서 발생하는 연산을 최적화 할 수 있습니다. 

Average.js파일을 생성하고 다음과 같이 작성해볼게요.

import { useState } from "react";

const getAverage = (numbers) => {
  console.log("평균값 계산 중..");
  if (numbers.length === 0) return 0;
  const sum = numbers.reduce((a, b) => a + b);
  return sum / numbers.length;
};

const Average = () => {
  const [list, setList] = useState([]);
  const [number, setNumber] = useState("");

  const onChange = (e) => {
    setNumber(e.target.value);
  };
  const onInsert = (e) => {
    const nextList = list.concat(parseInt(number));
    setList(nextList);
    setNumber("");
  };

  return (
    <div>
      <input value={number} onChange={onChange} />
      <button onClick={onInsert}>등록</button>
      <ul>
        {list.map((value, index) => (
          <li key={index}>{value}</li>
        ))}
      </ul>
      <div>
        <b>평균값:</b> {getAverage(list)}
      </div>
    </div>
  );
};

export default Average;

App.js에서 이 컴포넌트를 렌더링하고 실행하면 평균값이 잘 나타날 것입니다. 

여기서, 숫자를 등록할 때와 input 내용을 수정할 때 모두 getAverage가 호출되는 것을 console.log를 통해 확인할 수 있어요.

이것을 useMemo를 사용하여 최적화할 수 있습니다. 

특정 값이 바뀌었을 때만 연산을 수행하고, 값이 바뀌지 않았다면 이전의 연산 결과를 다시 사용하는 방식이에요.

다음과 같이 코드를 수정해볼게요.

import { useState, useMemo } from "react";

...

  const avg = useMemo(() => getAverage(list), [list]);

...
        <b>평균값:</b> {avg}
...

 

 

이제는 list 배열의 내용이 바뀔 때만 getAverage 함수가 호출되는 것을 확인할 수 있습니다. 


1.5 useCallback

useMemo와 상당히 비슷합니다. 

렌더링 기능을 최적화 할 때 사용하며, 만들어놓은 함수를 재사용할 수 있습니다. 

최적화는 언제 하냐고요?

  • 컴포넌트의 렌더링이 자주 발생할 때
  • 렌더링 해야 할 컴포넌트의 갯수가 많아질 때

이럴 때 최적화를 해주는 것이 좋습니다. 

아까 Average 컴포넌트에서 onChange와 onInsert 함수를 useCallback을 이용하여 최적화 해보겠습니다. 

import { useState, useMemo, useCallback } from "react";

const getAverage = (numbers) => {
  console.log("평균값 계산 중..");
  if (numbers.length === 0) return 0;
  const sum = numbers.reduce((a, b) => a + b);
  return sum / numbers.length;
};

const Average = () => {
  const [list, setList] = useState([]);
  const [number, setNumber] = useState("");

  const onChange = useCallback((e) => {
    setNumber(e.target.value);
  }, []); // 컴포넌트가 처음 렌더링될 때만 함수 생성

  const onInsert = useCallback(() => {
    const nextList = list.concat(parseInt(number));
    setList(nextList);
    setNumber("");
  }, [number, list]); // number 혹은 list가 바뀌었을 때만 함수 생성

  const avg = useMemo(() => getAverage(list), [list]);

  return (
    <div>
      <input value={number} onChange={onChange} />
      <button onClick={onInsert}>등록</button>
      <ul>
        {list.map((value, index) => (
          <li key={index}>{value}</li>
        ))}
      </ul>
      <div>
        <b>평균값:</b> {avg}
      </div>
    </div>
  );
};

export default Average;

useCallback의 첫 파라미터생성하고싶은 함수, 두번째 파라미터배열을 넣습니다. 

이 배열은 '어떤 값이 바뀌었을 때 함수를 새로 생성해야하는지 명시' 해야 합니다. 


1.6 useRef

함수에서 ref를 더 쉽게 사용할 수 있게 해줍니다. 

Average에서 등록 버튼을 눌렀을 때, 포커스가 input으로 넘어가도록 코드를 바꿔볼까요?

import { useState, useMemo, useCallback, useRef } from "react";

...

const Average = () => {
  const [list, setList] = useState([]);
  const [number, setNumber] = useState("");
  const inputEl = useRef(null);

...

  const onInsert = useCallback(() => {
    const nextList = list.concat(parseInt(number));
    setList(nextList);
    setNumber("");
    inputEl.current.focus();
  }, [number, list]); // number 혹은 list가 바뀌었을 때만 함수 생성

  ...

useRef로 ref를 설정하면, useRef로 만든 객체 안의 current값이 실제 엘리먼트를 가리키게 됩니다. 

 

1.6.1 로컬 변수 사용하기

컴포넌트 로컬 변수를 사용할 때도 useRef를 쓸 수 있습니다. 

로컬 변수는 '렌더링과 상관없이 바뀔 수 있는 값'을 말합니다. 

이때, ref의 값이 바뀌어도 컴포넌트가 렌더링되지 않는다는 것에 주의해야합니다. 


1.7 커스텀 Hooks 만들기

자신이 원하는 Hook 또한 만들 수 있습니다. 

이름과 닉네임을 입력받는 코드를 위해 커스텀 Hook을 작성해볼게요.

useInputs.js를 만들어 다음과 같이 작성합니다. 

import { useReducer } from "react";

function reducer(state, action) {
  return {
    ...state,
    [action.name]: action.value,
  };
}

export default function useInputs(initialForm) {
  const [state, dispatch] = useReducer(reducer, initialForm);
  const onChange = (e) => {
    dispatch(e.target);
  };
  return [state, onChange];
}

그리고 아까 코드에서 사용해보겠습니다.

헛소리 미안합니다 ㅎㅎ

훨씬 깔끔하군요. 이런식으로 커스텀 Hook을 사용할 수 있습니다. 

 

Hook에 대해서는 여기까지 하고, 추가적인 내용이 있다면 다시 포스팅 하도록 하겠습니다. 

그럼이만.