리액트 Hook과 조금 더 친해지기

Seoyong Lee·2023년 12월 7일
1

개발 공부

목록 보기
17/21
post-thumbnail

개인적으로 리액트의 작동방식은 한 번에 이해하기 어렵다고 생각합니다. 보통 어려운 것들은 참고할 만한 멘탈모델이 없는 경우가 많습니다. 저도 처음 공부할 때는 이상하게 느껴졌지만, 그냥 외웠습니다. 그러나 react.dev를 정독하면서 리액트와 조금 더 가까워지기 시작했고 여러 가지 재미있는 비유들이 떠올랐습니다.

컴포넌트와 렌더링

지금부터 조금 이상한 이야기를 해보겠습니다.

한 강줄기가 있습니다. 강은 줄기를 따라 여러 갈래로 갈라지면서 잔잔한 하류로 이어집니다. 강 가장 위쪽 상류에서부터 배를 타고 하류로 내려오는 사람들이 있다고 생각해 봅시다. 이 사람들은 특이한 노동을 합니다. 바로 강의 상류에서 개발자들이 만든 배의 설계도를 들고 출발해 흘러가는 강 위에서 배를 만들어 잔잔한 바다가 있는 하류에 정박시키는 일입니다. 자칫 설계가 잘못되면 누수가 발생해 배가 침몰할 수도 있는 매우 위험하고 어려운 작업이지만 완벽주의자인 이 사람들은 자신들의 명성을 위해 끝까지 설계도대로 배를 완성하려 노력합니다. 성공하면 보상으로 미지의 외부 세계와 통신할 수 있게 됩니다.

각 비유가 의미하는 것은 무엇일까요?

리액트 컴포넌트를 배라고 생각해봅시다. 우리는 코드라는 설계도를 통해서 컴포넌트라는 배를 그립니다. 배는 여러 개일 수도 있고 서로 연결되어 있을 수도 있습니다. 배의 색과 모양은 스타일링을 통해 마음대로 바꿀 수 있지만 어느 시점에는 고정되어야 합니다. 우리가 만든 배는 렌더링이라는 강물을 따라 흘러가 최종적으로 하류에 도착하면 모든 변화가 완료됩니다.

하나 주의할 점은 배에 조금이라도 변경 사항이 발생하면 배를 부수고 처음부터 다시 만들어야 한다는 점입니다. 조금 이상한 법칙이지만 배를 만드는 리액트라는 일꾼들은 완벽주의자라 그렇다고 합니다. 강의 흐름 속에서 우리는 배 안이나 밖에 무언가(변수)를 둘 수도 있지만 갈고리로 잘 묶어둔 정보만 남고 나머지는 배가 다시 만들어지는 과정에서 모두 사라지게 됩니다.

여기서 생각해 볼 점 중의 하나는 강은 하류 한 방향으로만 흐른다는 점입니다. 상류로 다시 물이 흐르게 되면 배는 하류로 가지 못하고 강 중간에 갇혀 버리게됩니다. 그럼 이제 배를 하나 만들어 보겠습니다.

const Boat = () => {
  const name = "Liberty";
  return (
    <section>
      <div>
        <h1>{name}</h1>
      </div>
    </section>
  );
};

export default Boat;

Liberty 라는 이름의 보트 설계도가 완성되었습니다. 이제 이 설계도를 들고 리액트라는 일꾼에게 맡기면 강을 따라 멋진 보트를 완성합니다.

컴포넌트의 변경

그런데 이때 야망 있는 클라이언트에 의해 보트에 요구 사항이 추가됩니다.

하류로 내려가면 외부인들이 보트를 탈 수 있도록 여닫는 문을 만들어주세요!

일단 위 문장을 리액트식으로 해석해 보겠습니다. 우선 하류로 내려가려면 렌더링을 모두 종료하고 컴포넌트를 완성해야합니다. 두 번째로 컴포넌트에 외부인이 들어올 수 있도록 열리고 닫히는 문을 추가해야 하므로 외부 이벤트를 감지할 방법을 마련해야합니다. 마지막으로 문이라는 ‘상태’가 변경되면 배의 모습은 이전과 달라지기 때문에 변경 시마다 배를 다시 만들어 주어야 합니다.

이렇게 렌더링 이외의 사유로 변경이 발생하면 리액트는 이를 별로 좋아하지 않기 때문에 main(렌더링)에서 벗어난 Side Effect라고 취급합니다.

요구사항은 다시 다음 3가지로 정리해 볼 수 있겠습니다.

  1. 렌더링 완료
  2. 이벤트 감지 및 처리
  3. 상태 변경 이력을 저장하고 변경 발생 시 컴포넌트 리렌더링

먼저 1번의 경우 정상적으로 배를 그렸다면 쉽게 달성할 수 있지만 2번은 외부 이벤트를 다룰 방법이 필요합니다. 이는 클릭 등을 처리해주는 이벤트 핸들러를 이용하면 가능합니다. 3번은 상태라는 변수를 선언하고 이 변수를 이벤트에 맞춰서 바꿔주면 될 것 같습니다.

그럼 이제 위 요구사항을 바탕으로 배를 수정해 보겠습니다.

import { useState } from "react";

const Boat = () => {
  const name = "Liberty";

  let isDoorOpen = false; // 문은 처음엔 닫혀있습니다.

  const handleClickDoor = () => {
    isDoorOpen = true; // 과연 문이 열릴까요?
  };

  return (
    <section>
      <div>
        <h1>{name}</h1>
      </div>
      <button onClick={handleClickDoor}>Door</button>
    </section>
  );
};

export default Boat;

과연 문은 열릴까요? 이렇게 하면 아무 일도 일어나지 않습니다. 왜일까요?

사실 리액트는 자신들의 방식대로 갈고리(Hook)로 배에 묶어두지 않은 어떤 상태가 변경되면 합의된 방식을 지키지 않았다고 생각해서 이를 그냥 무시해 버립니다…. 그렇다면 위 코드를 리액트가 이해하는 방식대로 다시 바꿔보겠습니다.

import { useState } from "react";

const Boat = () => {
  const name = "Liberty";
  const [isDoorOpen, setIsDoorOpen] = useState(false);

  const handleClickDoor = () => {
    setIsDoorOpen(!isDoorOpen);
  };

  return (
    <section>
      <div>
        <h1>{name}</h1>
      </div>
      <button onClick={handleClickDoor}>Door</button>
    </section>
  );
};

export default Boat;

이제 문이 열리고 닫힐 때마다 컴포넌트가 새롭게 그려집니다. 위처럼 리액트는 리액트의 방식으로 무언가를 하기 위해 hook이라는 갈고리를 이용합니다. 컴포넌트 내부에 어떤 상태를 두고 이 상태가 변경될 때마다 컴포넌트를 다시 그리기 위해 사용하는 대표적인 hook이 바로 useState입니다.

Hook의 종류

리액트에서 ‘use-’ 로 시작하는 것들은 hook이라고 부릅니다. 이름이 hook인 이유는 리액트에 따르면 다음과 같습니다.

Hooks are special functions that are only available while React is rendering… They let you “hook into” different React features.
Hook은 리액트가 렌더링되는 동안에만 사용할 수 있는 특수한 기능입니다… 이를 통해서 리액트의 다양한 기능에 “연결할(hook into)” 수 있습니다.

그냥 이름 그대로 연결을 위한 갈고리라고 생각하면 이해하기 쉬울 것 같습니다.

리액트와 같이 사용하기로 약속된 대표적인 hook들은 다음과 같습니다.

  • useState: 컴포넌트 내부적인 상태를 관리하기 위해 사용됩니다.
  • useEffect: 이벤트 이외의 사유로 리렌더링이 필요한 경우 사용됩니다.
  • useRef: 특정 DOM 요소를 렌더링 전후로 계속 참조하기 위해 사용됩니다.
  • useMemo: 계산된 특정 값을 렌더링 전후로 계속 캐싱해두기 위해 사용합니다.
  • useCallback: 특정 함수를 렌더링 전후로 계속 캐싱해두기 위해 사용합니다.

다음은 위 hook들 보다는 자주 사용되지는 않지만 유용한 hook입니다.

  • useContext: 컴포넌트 트리 깊은 곳의 컴포넌트에 context를 전달하기 위해 사용합니다.
  • useReducer: useState와 비슷하지만 reducer를 이용해서 이벤트 핸들러와 분리된 전문적인 처리를 할 수 있게 해줍니다.
  • useLayoutEffect: 브라우저 리페인트 이전에 필요한 처리를 할 수 있도록 해줍니다.

Hook의 실행순서

그렇다면 이러한 hook 들은 어떤 순서로 실행될까요?

  1. useState, useMemo
  • 먼저 useState와 useMemo는 가장 먼저 렌더링 단계에서 실행됩니다. Hook은 내부적으로 호출 순서를 기억해두었다가 리렌더링 시마다 동일한 순서로 작동하도록 설계되어있기 때문에 이 순서를 유지하도록 항상 리액트 함수 최상단에 선언되어야 합니다.
  1. useLayoutEffect
  • useLayoutEffect는 DOM 렌더링 직후 Critical Rendering Path의 리페인트 직전에 동기적으로 실행됩니다. UI 블로킹을 유발할 수 있기 때문에 꼭 필요한 경우에만 사용해야 합니다.
  1. useEffect
  • useEffect는 렌더링 완료 이후 실행됩니다. 이후에 Dependency로 제공된 변수의 변화가 일어난 경우에만 작동합니다.

위처럼 리액트 hook은 여러 개가 선언되어 있더라도 컴포넌트 안에서 자신들의 실행 순서를 정확하게 지킵니다. 만약 실행 시마다 순서가 뒤섞인다면 일관적인 결과를 얻지 못하게 될 수 있습니다. 따라서 리액트는 정책적으로 조건문 안에서의 hook 사용을 금지합니다.

Hook 잘 사용하기

그렇다면 hook을 잘 사용하기 위한 원칙은 무엇이 있을까요? 먼저 다음을 기억하면 좋습니다.

꼭 필요한 경우에만 hook을 쓰고 있는지 고민해보세요

먼저 불필요한 hook은 불필요한 리렌더링을 발생시킨다는 점을 이해해야 합니다. 다음과 같이 어떤 useEffect는 애초에 사용할 필요가 없을 수도 있습니다.

const [state, setState] = useState(false);
const [state2, setState2] = useState(false);

const handleUpdateState = () => {
  setState(true);
};

useEffect(() => {
  if (state) {
    setState2(true);
  }
}, [state]);

useEffect(() => {
  if (state2) {
    // ...
  }
}, [state2]);

위 사례를 보면 여러 가지 hook이 서로 맞물려 불필요한 상태 변경을 발생시키고 있지만 결국 하나로 합칠 수 있습니다. 너무 당연하지만 실제로 실무에서 종종 여러가지 hook이 맞물리면 이런 경우를 놓치는 경우가 생길 수 있습니다. 이러한 부분은 문법적으로는 문제가 없기 때문에 lint를 통해 발견하기 어렵습니다.

또 다른 예는 이벤트 핸들러만으로 처리가 가능한 사례입니다. 만약 어떤 버튼을 클릭하면 POST 요청을 보낸다고 가정하면 POST 요청을 useEffect가 아닌 이벤트 핸들러 안에서 보내도록 하는 것이 좋습니다. 관련 내용은 공식 문서에서도 자세히 다루고 있습니다.

마무리

리액트가 나의 일을 도와주는 사람이라면 리액트의 방식을 존중하고 합의된 방식대로 일을 해주는 것이 동료를 위한 기본적인 도리라고 생각됩니다. 과연 나는 오늘도 동료를 위한 도리를 다했을까요?

가끔 반복적인 업무에 지쳤을 때 여러 가지 재미있는 관점에서 익숙했던 기술을 바라보는 것도 좋을 것 같습니다.

참고

react.dev - State: A Component's Memory
Rudi Yardley - React hooks: not magic, just arrays

0개의 댓글