이제는 좀 알자, React Additional Hooks!

Derhon·2023년 3월 25일
0
post-thumbnail
post-custom-banner

뭐 언제까지 신입이야? 내년에도 신입이야? 후년에도 신입일거니?
저에게 하는 말입니다...

이제는 React가 프론트엔드의 공식으로 자리 잡고 있는 것 같다.
최근 파트타임으로 프론트엔드 개발을 하게 되었고, 이전 버전 코드를 보면서 느낀 점이 아주 많았다.
웹3 관련한 기술을 보며 아직도 난 갈 길이 멀구나... 라는 점, 공부는 끊임이 없다는 것, 마지막으로 리액트와 클린코드의 중요성...

React의 Hooks를 이해하는 것은 곧 논리적이고 간결한 코드의 시작이라고 생각한다.
많은 개발자들이 이것을 개념적으로 알고, 남용이 좋지 않다는 것을 알면서도 구체적으로 언제 사용해야하는지 모르고 있다. (알고있다면 굿)
우선 금주의 기술 블로그는 우리가 잘 알고, 많이 사용하는 useState useEffect가 아닌 나머지 주요 Hooks에 대해 공부할 것이다.
그리고 이후 포스팅에서 아주 펀하고 쿨하고 섹시한 커스텀 훅을 만들어보자.

React Hooks

이 글은 Hooks의 기본 개념을 다루지 않습니다.
Hooks의 개념과 useState useEffect는 공식문서를 참고하세요!

Additional Hooks의 경우 공식문서에서 10개를 확인할 수 있다.
하지만 본 글에서는 기본 Hook 중 하나인 useContext를 포함한 총 4개의 Hooks를 다룰 예정이다.
DOM을 처리하는 useRef는 내용이 많아서 2부로 뺐다!

  • 최적화 관련 Hooks
    • useCallback
    • useMemo
  • 상태 관련 Hooks
    • useReducer
    • useContext

최적화 관련 Hooks

들어가기 앞서, 함수형 컴포넌트 역시 함수라는 사실을 잊어선 안된다.

즉, 컴포넌트를 그려낸다는 것은 본질적으로 함수를 호출한다는 것과 동일한 개념이다.
그리고 우리는 컴포넌트를 그려내는 작업을 렌더링이라고 말한다.
결국 렌더링이 일어날 때 마다, 컴포넌트가 다시 정의되며 내부의 변수가 초기화된다는 것을 기억해야한다.

useCallback

함수의 성능을 최적화하기 위해 사용되는 Hook

공식 문서에는 다음과 같이 정의되어있다.
메모이제이션된 콜백을 반환합니다.

메모이제이션이라는 단어가 낯설다면, 아래의 예제를 먼저 본후 단어의 의미를 살펴보면 된다.

📄 App.tsx

하나의 input box를 만들고, 값이 변하면 콘솔에 해당 값을 찍어주는 간단한 함수를 만들었다.

import { useState } from "react";
import Name from "./components/Name";

function App() {
  const [name, setName] = useState("");

  const onName = () => {
    console.log(name);
  };

  return (
    <div className="App">
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <Name onName={onName} />
    </div>
  );
}

export default App;

📄 Name.tsx

interface Props {
  onName: () => void;
}

const Name = ({ onName }: Props) => {
  onName();
  return <></>;
};

export default Name;

이 경우에, input의 값이 변경되면 그 내역이 즉각적으로 콘솔에 찍힌다.

그렇다면 이번에는 useCallback을 적용해보자.

📄 App.tsx

import { useState, useCallback } from "react";
import Name from "./components/Name";

function App() {
  const [name, setName] = useState("");

  const onName = useCallback(() => {
    console.log(name);
  }, []);

  return (
    <div className="App">
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <Name onName={onName} />
    </div>
  );
}

export default App;

onName이라는 함수를 useCallback을 적용하여 정의하였다.
무언가 useEffect와 생김새가 비슷하다는 것을 알 수 있다.
우선 파라미터로 무엇을 받는지는 차차 살펴보도록 하고, 콘솔창을 확인해보겠다.

분명 값을 입력했는데도, name의 상태를 읽어오지 못하는듯한 모습을 보여준다.
잘못된 현상이 아니라 useCallback의 역할이 바로 이런 것이다!

그래서 useCallback이 뭔데?

쉽게 말해서 함수를 재활용할 수 있도록 도와주는 Hook이다.
앞서 확인했던 첫 번째 예제에서, useState라는 Hook을 통해 생성한 상태가 변경 될 때마다 리렌더링이 발생한다.
그리고 처음 말했던 것 처럼, 리렌더링이 발생할 때 일반적으로 함수는 다시 정의된다.

즉 저 작은 컴포넌트 안에서

  1. 값 변경
  2. 이벤트 감지 후 상태 변경
  3. 상태 변경 감지 후 리렌더링
  4. 리렌더링 감지 후 onName 재정의
  5. 재정의된 함수를 자식 컴포넌트에게 다시 전달
  6. 콘솔 출력

의 과정이 발생하고 있었던 것이다.

useCallback은 상태가 변경될 때 함수가 재정의 되는 것을 막아준다.
첫 번째 인자에는 정의할 함수를 작성할 수 있다.
두 번째 인자로 받는 배열에는 의존성을 추가할 수 있다.

추가된 의존성이 변경될 때만 함수가 재정의 되도록 설정할 수 있는 것이다.

그렇다면 아무리 작성해도 콘솔에 공백만 나오던 함수를, 값이 변경 될 때 마다 제대로 콘솔이 찍히게 하려면 useCallback을 이용해서 어떻게 작성할 수 있을까?

import { useState, useCallback } from "react";
import Name from "./components/Name";

function App() {
  const [name, setName] = useState("");

  const onName = useCallback(() => {
    console.log(name);
  }, [name]);

  return (
    <div className="App">
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <Name onName={onName} />
    </div>
  );
}

export default App;

이렇게 배열에 상태를 의존성으로 추가해주면, 값이 변경될 때 정상적으로 콘솔에 찍히는 것을 알 수 있다.

useMemo

변수의 최적화를 위해 사용되는 Hook

기본적인 원리는 useCallback과 동일하다.
특정 변수의 값을 메모이제이션 해놓고, 해당 변수가 재할당되는 시점을 의존성 배열을 통해 제어할 수 있다는 것이다.
가령 아래와 같은 예제를 볼 수 있다.

import { useState, useEffect } from "react";

function App() {
  const [name, setName] = useState("");
  const [dummy, setDummy] = useState(0);

  const myName = {
    name: name,
  };

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

  return (
    <div className="App">
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input
        value={dummy}
        onChange={(e) => setDummy(Number(e.target.value))}
        type="number"
      />
    </div>
  );
}

export default App;

위의 경우에, 분명 dummymyName과 연관이 없는 상태이다.
때문에 dummy 값이 변하든 변하지 않든, useEffect의 콜백함수가 실행되지 않는 것은 자명하다.
결과도 그러할까?

분명 dummy는 의존성에 추가된 값이 아닌데도, 변할 때마다 useEffect의 호출이 콜백된다.
이것은 자바스크립트의 원시타입과 객체타입의 차이점 때문이다.

간단히 설명하자면, 원시타입과 다르게 객체타입은 주소가 할당된 값이다.
그리고 컴포넌트(함수)가 렌더링(호출) 될 때 이 함수(컴포넌트)의 변수(상태)들은 초기화된다.
초기화될 때 원시타입의 변수는 값이 재할당되지만, 객체타입의 변수는 주소가 재할당된다.
결국 개발자 입장에서 내부의 내용물은 같아보여도 실질적으로 담긴 값인 주소가 변경되었기 때문에 useEffect의 콜백이 반복해서 호출되는 것이다.

이러한 상황을 간단히 해결할 수 있는 것이 바로 useMemo이다.

그래서 useMemo를 어떻게 쓸건데?

이제 위 예제에서 작성한 myNameuseMemo를 통해 name이 변경 될 때만 재할당이 일어나도록 작성해보았다.

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

function App() {
  const [name, setName] = useState("");
  const [dummy, setDummy] = useState(0);

  //의존성 배열에 name 추가
  const myName = useMemo(() => {
    return { name: name };
  }, [name]);

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

  return (
    <div className="App">
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input
        value={dummy}
        onChange={(e) => setDummy(Number(e.target.value))}
        type="number"
      />
    </div>
  );
}

export default App;

우리가 기대했던 것처럼, 리렌더링이 발생해도 객체타입의 변수에 할당이 name의 변경 시에만 일어나는 것을 알 수 있다.

useMemo의 주의사항

useMemo로 전달된 함수는 렌더링 중에 실행된다는 것을 기억하세요.
통상적으로 렌더링 중에는 하지 않는 것을 이 함수 내에서 하지 마세요.
예를 들어, 사이드 이펙트(side effects)는 useEffect에서 하는 일이지 useMemo에서 하는 일이 아닙니다.
출처. 공식문서

사이드 이펙트란, 컴포넌트가 화면에 렌더링 된 이후에 비동기로 처리되어야하는 부수적인 효과들을 말한다.
즉, useMemo는 렌더링 중에 실행되므로, 렌더링 되지 않는 값은 useMemo가 아닌 useEffect에서 사용하라는 이야기이다.
사실 불가능한 것은 아니지만, Hook의 성격과도 다르고 궁극적으로 오히려 독이 될 수 있다.

모든 최적화에는 비용이 따른다

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때,이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다.
동적 계획법의 핵심이 되는 기술이다.

useCallback은 작성한 deps를 기준으로 함수 객체를 메모이제이션 한다.
때문에 거대한 계산식이 존재하는 경우, useCallback은 효과적인 솔루션이 될 수 있다.
하지만 일반적인 경우, 객체를 메모이제이션하고 deps값을 비교하는 작업 때문에 역설적인 효과를 갖고올 수 있다는 점을 명심해야한다.

useMemo는 어플리케이션의 성능을 위해 변수의 값을 메모리에 메모이제이션 한다.
일련의 복잡한 로직으로 인해 변수를 메모리에 저장하고 비교해서 할당하는 과정이 필요하다면 useMemo는 효과적인 솔루션이 될 수 있다.
하지만 일반적인 변수를 useMemo를 통해 메모리에 저장한다면, 이것은 메모리 낭비로 이어질 수 있다.

즉, 남용은 좋지 않다는 점!

이 이야기는 후에 조금 더 구체적으로 다루겠다.

상태 관련 Hooks

지역 상태를 관리하는 강력한 Hook인 useState가 있지만, 상태를 지역에서 분리하거나 관리해야할 상태가 많은 경우에 사용할 수 있는 useReducer도 있고 기본으로 제공되는 상태관리 라이브러리인 useContext도 있다.

어떤 경우에 각 Hook을 사용하면 좋을지 살펴보겠다.

useReducer (typescript)

input과 같은 텍스트 입력 요소에서 단 하나의 값만 처리하는 경우는 드물다.
당장 회원 가입 페이지만 보더라도 입력할 요소가 많이 있는 것을 확인할 수 있다.
이런 경우에 요소 하나하나에 useState를 이용하여 상태값을 부여하는 것은 비효율적이며, 지저분한 코드로 가는 지름길이 될 수 있다.

이때 사용할 수 있는 Hook인 useReducer가 존재한다.
사실 이름에서 알 수 있듯이, 사용법이 Redux와 굉장히 유사하다. 실제로 공식문서에서도 언급하고 있다.

useState의 대체 함수입니다.
(state, action) => newState의 형태로 reducer를 받고 dispatch 메서드와 짝의 형태로 현재 state를 반환합니다.
(Redux에 익숙하다면 이것이 어떻게 동작하는지 여러분은 이미 알고 있을 것입니다.)

📄 전체 코드

import { useReducer } from "react";

//input으로 받을 값 세가지
type Student = {
  name?: string;
  school?: string;
  description?: string;
};

//값을 받아서 저장할 객체의 타입
type State = {
  students: Student;
};

//값을 받아서 저장할 객체의 초기 값
const initialState: State = {
  students: { name: "", school: "", description: "" },
};

//무슨 값을 입력했는지 나누어주는 ENUM
enum Input {
  Name = "Name",
  School = "School",
  Description = "Description",
}

//useReducer dispatch의 파라미터 인터페이스
interface Props {
  type: Input;
  payload: Student;
}

//어떤 값이 들어왔을 때 상태를 어떻게 처리할 것인가?
const reducer = (state: State, action: Props): State => {
  //action.type은 Input enum.
  //즉, 세 종류의 타입이 있을 것임
  switch (action.type) {
    case Input.Name:
      //name으로 들어왔으니까, 기존 객체는 스프레드.
      //name만 변경해서 할당.
      //아래 두개도 마찬가지
      return { students: { ...state.students, name: action.payload.name } };
    case Input.School:
      return { students: { ...state.students, school: action.payload.school } };
    case Input.Description:
      return {
        students: {
          ...state.students,
          description: action.payload.description,
        },
      };
  }
};

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      <input
    	//dispatch의 파라미터는 Props
        onChange={(e) =>
          dispatch({ type: Input.Name, payload: { name: e.target.value } })
        }
      />
      <input
        onChange={(e) =>
          dispatch({ type: Input.School, payload: { school: e.target.value } })
        }
      />
      <input
        onChange={(e) =>
          dispatch({
            type: Input.Description,
            payload: { description: e.target.value },
          })
        }
      />
      <button onClick={() => console.log(state)}>확인</button>
    </div>
  );
};

export default App;

공통된 상태입력이 자주 있는 경우, 상태에 대한 기본 정보 값을 하나의 파일로 분리한다면 상태를 여러번 선언할 필요없이 재사용이 가능하다는 장점이 있다.
하지만 실제로 하나의 상태를 선언하기까지 useState보다 많은 개발자 리소스가 필요하기 때문에, 이것이 필요한 상황을 적절하게 선정하는 과정이 중요할 것이다.

useContext (typescript)

흔히 상태관리라고 불리는, props-drilling을 피하기 위한 방법을 React에서도 기본적으로 제공한다.
그리고 그 방법이 바로 useCotext이다.

React로 개발을 하다보면, 특정 컴포넌트에서 제작한 상태를 하위 컴포넌트로 몇 단계씩 전달해야하는 경우가 있다.
로직을 살짝만 삐끗해도, 상-하위 컴포넌트를 넘어서 수평관계에 있는 타 컴포넌트에게 전달해야하는 일이 생길 수 있다.
이렇게 특정 컴포넌트에서 생성한 상태를, 타 컴포넌트까지 반복적인 props로 전달하는 과정을 props-drilling이라 부른다.
다 개발하고 그런 로직을 발견하면...😭

React에서 useContext를 사용하면, 여러 컴포넌트에서 공유할 수 있는 데이터를 전역적으로 관리할 수 있다.

구체적인 사용순서는 아래와 같다.

  1. useContext를 사용하기 위해서는 먼저 createContext를 사용하여 Context 객체를 생성한다.
  2. createContext는 기본값을 인자로 받는다.
    • 기본값은 해당 Context를 사용하는 컴포넌트들이 받아올 값이 없을 때 기본적으로 사용할 값이다.
  3. 해당 Context 객체에 useContext를 통해 접근할 수 있다.

📄 StudentContext.tsx

Context 객체를 생성한다.

import { createContext } from "react";

const StudentContext = createContext({
  name: "",
  setName: (value: string) => {},
});

export default StudentContext;

📄 Context1.tsx

Context 객체에 값을 전달한다.

import React from "react";
import StudentContext from "./StudentContext";

const Context1 = () => {
  return (
    <StudentContext.Consumer>
      {(state) => (
        <div>
          <input onChange={(e) => state.setName(e.target.value)} />
        </div>
      )}
    </StudentContext.Consumer>
  );
};

export default Context1;

📄 Context2.tsx

Context 객체에 값을 표시한다.

import React, { useContext } from "react";
import StudentContext from "./StudentContext";

const Context2 = () => {
  const student = useContext(StudentContext);
  return (
    <div>
      <button onClick={() => console.log(student)}>정보 출력</button>
    </div>
  );
};

export default Context2;

📄 App.tsx

Context객체를 사용하는 컴포넌트를 래핑한다.

import React, { useState } from "react";
import Context1 from "./components/Context1";
import Context2 from "./components/Context2";
import StudentContext from "./components/StudentContext";

const App = () => {
  const [name, setName] = useState("");

  return (
    <div>
      <StudentContext.Provider value={{ name: name, setName: setName }}>
        <Context1 />
        <Context2 />
      </StudentContext.Provider>
    </div>
  );
};

export default App;

이렇게, useContext를 사용하면 props-drilling 없이 특정한 상태 값을 자유롭게 사용할 수 있다.
하지만 사실 RTKRecoil등 충분히 훌륭한 상태관리 라이브러리가 존재하고, 불필요한 리렌더링을 방지하기 위해 useMemouseCallback을 이용해야하는 등의 단점이 존재한다.
웬만하면 Recoil 쓰자 제발...

마치며

나름 React로 개발한지도 꽤 오래 되었는데, 본질적인 내용에서는 아직도 많이 부족하다고 느껴진다.
솔직히 깊은 내용 공부하고 실습하는거... 개발보다는 재미없지만, 그래도 꼭꼭 해야하는 부분이라고 생각한다.
그리고 정말 펀쿨섹한 커스텀 훅을 만들어보기 위해서, React의 기본 훅을 조금 더 깊게 공부할 필요가 있다.
내가 여태 만든 훅들은 바보훅이라고 생각한다.
아직 useRef도 안봤다!
다음 글에서는 오늘 다루지 못한 내용을 잔뜩 정리해야겠다.

profile
🧑‍🚀 이사했어요 ⮕ https://99uulog.tistory.com/
post-custom-banner

0개의 댓글