컴포넌트의 라이프사이클 & Hooks (2)

🤔 Hooks 란?


Hooks 란?

이전 글에 적어둔 내용을 우선 가져와보겠다.

Hooks 란 React v16.8 에 새롭게 도입된 기능으로 함수 컴포넌트에서도 상태 관리를 할 수 있는 useState랜더링 직후 작업을 설정하는 useEffect 등의 기능을 제공하여 기존의 함수 컴포넌트에서 할 수 없었던 작업을 할 수 있게 해주는 기능이다.

단순하게 함수인데 상태 값을 가질 수 있도록 해주는 기능이자 라이프사이클 구현을 할 수 있게 도와주는 기능이다.

Hooks 를 제공해주고 함수 컴포넌트의 인기가 상승한 이유는?

단순하게 생각을 해보면 클래스형 컴포넌트가 문제가 있었거나 불편했기 때문일 것 같은데 이유를 찾아보니 아래와 같은 문제가 있었다고 한다.

  • 컴포넌트간에 로직의 재사용이 불가능하다.
    • render props나 HOC(High order component, 고차 컴포넌트)와 같은 패턴을 통해 문제를 해결할 수 있지만 이러한 패턴은 코드의 추적을 어렵게 만든다.
    • 계속해서 이런 코드를 사용하게 되다보면 많은 레이어들로 둘러쌓인 wrpper hell 을 겪게 된다.
  • 코드가 복잡해진다.
  • this.props 를 활용하기에 this 의 mutable 한 속성으로 인한 문제가 생긴다.

그러면 함수 컴포넌트 + Hooks 는 어떠한 이점이 있을까? 이는 아래와 같다.

  • 선언하기 편하며 메모리 자원을 덜 사용한다. 또한 간결하다.
  • 리랜더링 될 때의 값을 유지한다.
  • props 에 따른 랜더링 결과를 보장받는다.
    • 클래스형 컴포넌트에서는 this.props 를 활용하는데 이 상황에서의 this 는 변경가능하고 조작이 가능하다.
  • props 의 destructuring 을 활용해서 가독성이 보장된다.
  • 컴포넌트로부터 상태 관련 로직을 추상화할 수 있다.
    • 이를 이용해 독립적인 재사용이 가능하다.
    • 코드의 재사용은 가독성을 높이고 유지보수를 용이하게 한다.
  • Hooks 은 계층의 변화 없이 상태 관련 로직을 재사용할 수 있다.
  • 생명주기 메서드를 기반으로 쪼개는 것 보다는 훅을 통해 작은 함수의 묶음으로 컴포넌트를 나누는 방법을 사용할 수 있다.

이러한 장점과 단점으로 인해서 React 공식페이지에도 함수 컴포넌트와 Hooks 를 활용한 함수 컴포넌트를 만들기를 권장한다. 아래는 함수 컴포넌트와 클래스형 컴포넌트의 차이, 그리고 왜 최근에 함수 컴포넌트를 사용하는지에 대한 이야기를 잘 설명해주고 있으니 참고하면 좋다.

함수형 컴포넌트와 클래스, 어떤 차이가 존재할까?


👨🏻‍🏫 함수 컴포넌트의 라이프사이클


이전 글의 정리 부분에 있던 라이프 사이클 그래프랑 약간은 다른 것을 볼 수 있다. 하지만 공통점을 찾아보면 마운팅, 업데이트, 언마운팅 의 과정으로 분리가 된 것을 볼 수 있다. 하나씩 확인을 해보자.

잠깐 멈춰! (Hook 의 규칙)

이전 글에서 useState 를 간단하게 만들어보면서 ‘아 이게 클로저와 전역 array 같은 기능들을 활용해서 만들어진 것일 수도..?’ 라는 생각이 들었을 수 있다.

그렇다면 내부적으로 이 state 들을 다루는 방법이나 순서는 React 에서 설정한 것일 것이다. 이 순서나 사용법을 지키지 않으면 array 내부가 망가질 수 있지 않을까?

내 추론이 맞는지 틀린지는 모르나 React 에서 hook 을 사용할 때 지켜야하는 규칙을 제공한다. 이는 아래와 같으니 hook 을 사용할 때 지키면서 사용할 수 있도록 해야 한다. 이 내용은 [React] Hooks란? 글을 참고했다.

  1. 최상위 에서만 훅을 호출해야 한다. 훅을 호출하는 순서는 항상 같아야 한다.
    • 반복문, 조건문, 중첩된 함수 내에서 hook을 사용하면 안된다.
    • 왜 hook 의 호출 순서가 같아야 하는 걸까?
      • 리액트가 상태값을 구분할 수 있는 유일한 정보는 hook 이 사용된 순서이기 때문이다. 리액트가 훅이 호출된 순서에 의존한다는 것이다.
      • 예시로 반복문 안에서 훅을 호출했을 때 반복문이 true 라면 괜찮겠지만 값이 false 라면 건너뛰게 된다. 이렇게 하면 실행순서가 바뀔 수 있어 오류를 일으킨다.
      • 조건문 혹은 반복문을 사용하고 싶을때는 useEffect 안에 넣어 사용하면 된다.
  2. React 함수 컴포넌트 내에서만 Hook을 호출해야 한다.
    • 일반 JS 함수에서는 훅을 호출하면 안된다.
    • 직접 작성한 custom hook 에서는 사용이 가능하다.

useEffect

이전 클래스 컴포넌트의 하단 블록은 마운팅시의 componentDidMount, 업데이트시의 componentDidUpdate, 언마운팅시의 componentWillUnmonut 이렇게 3개의 라이프 사이클 메서드가 있던 것을 생각하면 이번에는 useEffectuseLayoutEffect 이렇게 두 개의 hook 이 그 역할을 하는 것을 볼 수 있다.

그렇다면 useEffect 는 다음과 같은 기능을 할 수 있다고 정리할 수 있다.

  • 마운팅시 componentDidMount 와 같이 컴포넌트가 웹 브라우저상에 나타난 후 호출이 될 요소를 담을 수 있다.
  • 업데이트시 componentDidUpdate 와 같이 리렌더링이 완료된 후 실행하는 요소들을 담을 수 있다.
  • 언마운팅시 componentWillUnmonut 와 같이 컴포넌트가 DOM 에서 제거될 때 실행하는 요소를 담을 수 있다.

위 상황들을 코드와 함께 알아보도록 한다. 코드와 글은 벨로퍼트와 함께하는 모던 리엑트 를 참고했다.

일단 기본 구조부터 알아보면 다음과 같다.

// 기본 구조 useEffect(function, deps)
useEffect(() => {
  console.log('컴포넌트가 화면에 나타남');
  return () => {
    // cleanup 함수
    console.log('컴포넌트가 화면에서 사라짐');
  };
}, []);

useEffect 를 사용 할 때에는 첫 번째 파라미터에는 함수, 두 번째 파라미터에는 의존값이 들어있는 배열 (deps) 을 넣는다. 만약에 deps 배열을 비우게 된다면, 컴포넌트가 처음 나타날때에’만’ useEffect 에 등록한 함수가 호출된다. (useEffect 는 컴포넌트가 마운트 됬을 때 자동으로 작동한다.)

useEffect 에서는 함수를 반환 할 수 있는데 이를 cleanup 함수라고 부른다.  cleanup 함수는 useEffect 에 대한 뒷정리를 해주는 역할로, deps 가 비어있는 경우에는 컴포넌트가 사라질 때 cleanup 함수가 호출된다.

그러면 useEffect 를 통해 마운팅을 할 때는 어떠한 작업을 주로 해주면 좋을까? 이는 아래와 같다.

  • props 로 받은 값을 컴포넌트의 로컬 상태로 설정
  • 외부 API 요청
  • 라이브러리 사용
  • setInterval 을 통한 반복작업 혹은 setTimeout 을 통한 작업 예약

언마운트를 할 때는 어떠한 작업을 할까? 이는 다음과 같다.

  • setInterval, setTimeout 을 사용하여 등록된 작업들을 clear
  • 라이브러리 인스턴스 제거

업데이트시에는 본인이 원하는 상황을 정하고 알려주면 된다… 라고 하려 했는데 어떻게 하면 업데이트시 마다 동작하게 만들까?

앞서 말했던 deps 에 원하는 값을 넣으면 된다. deps 에 특정 값을 넣어둔다면 해당 값들이 바뀔 때에도 호출이 되게 할 수 있다.

useEffect(() => {
  console.log('user 값이 설정됨');
  console.log(user);
  return () => {
    console.log('user 가 바뀌기 전..');
    console.log(user);
  };
}, [user]);

useEffect 안에서 사용하는 상태나, props 가 있다면, useEffect 의 deps 에 넣어주어야 한다. 그렇게 하는게, 규칙이다. 만약 useEffect 안에서 사용하는 상태나 props 를 deps 에 넣지 않게 된다면 useEffect 에 등록한 함수가 실행 될 때 최신 props / 상태를 가르키지 않게 된다.

한 가지 궁금한 상황이 있다. 만약 deps 에 빈 어레이도 안넣어주면 어떠한 일이 일어날까? 이러한 경우에는 컴포넌트가 리렌더링 될 때마다 호출이 된다.

useLayoutEffect

useEffect 는 들어봤지만 useLayoutEffect 는 이번 학습을 통해서 처음 듣게 되었다. 아마 대부분의 나와 같은 찍먹 입문자들은 동일하게 처음 보는 것일 거다. 일단 모양을 보면서 대략 유추를 해보자.

useLayoutEffect(() => {
  effect;
  return () => {
    cleanup;
  };
}, [input]);

뭐지 싶을 수 있다. 맞다 useEffect 와 동일해보인다. 그러면 어떠한 차이가 있을까?

일단 위에서 이야기했듯 useEffect 는 componentDidMount, componentDidUpdate, componentWillUnmonut 의 역할을 한다고 했다. 이 라이프 사이클 메서드의 특징은 DOM 의 레이아웃 배치와 페인트가 끝난 후 호출이 이루어진다. useEffect 또한 마운트, 업데이트, 언마운트 작업이 끝난 이후에 이루어진다. 이러한 상황속 만약 상태 값이 useEffect 에 의존한다면 사용자 경험에 불편함을 줄 수 있다. 예를 통해 봐보자.

import { useEffect, useState } from 'react';

function App() {
  const [age, setAge] = useState(0);
  const [name, setName] = useState('');

  useEffect(() => {
    setAge(25);
    setName('지훈');
  }, []);

  return (
    <>
      <div className="App">{`그의 이름은 ${name} 이며, 나이는 ${age}살 입니다.`}</div>
    </>
  );
}

export default App;

이러한 코드가 있다고 했을 때 처음 DOM 의 레이아웃 배치와 페인트가 이루어졌을 때‘그의 이름은 이며, 나이는 살 입니다.’ 라는 글이 화면에 출력될 것이다. 물론 잠깐의 순간이겠지만 해당 상태가 유지되고, useEffect 이후에 ‘그의 이름은 지훈 이며, 나이는 25살 입니다.’ 가 될 것이다.

위의 예제에서는 저 딜레이가 크지 않아서 불편한 부분이 적지만 useEffect 내부에서 다루는 state 가 많으면 그 딜레이는 더 커질 것이다. 이러한 불편을 해결하기 위해서 나온 것이 useLayoutEffect 이다.

useLayoutEffect 는 브라우저가 DOM 을 그리기 전에 이펙트를 수행하고 이후 작업을 이어서 한다. 정리하면 아래와 같다.

  • useEffect 의 이펙트는 DOM이 화면에 그려진 이후에 호출된다.
  • useLayoutEffect 의 이펙트는 DOM이 화면에 그려지기 전에 호출된다.
  • 따라서 렌더링할 상태가 이펙트 내에서 초기화되어야 할 경우, 사용자 경험을 위해 useLayoutEffect 를 활용하면 된다.

useState

Hooks 하면 떠오르는 대표 hook 이자 가장 기본적인 hook 이다. 말 그대로 state, 동적인 값을 사용한다고 생각하면 된다.

간단한 사용법이나 예제는 다른 글에서도 너무 잘 나와있으며 해당 부분을 정리하기에는 의미가 크게 없다고 생각이 된다. 혹여나 기초적인 사용법을 알고 싶으신 분이 있다면 벨로퍼트님 블로그 를 참고하면 큰 도움이 될 것이다.

이번에 다룰 내용은 학습을 하면서 처음 알게된 내용을 다루려고 하는데 먼저 출처는 [ React ] useState는 어떻게 동작할까 이다.

  • useState 가 어떻게 상태를 변경시키는가
  • 어떻게 컴포넌트 함수가 변경 시킨 값으로 렌더링을 진행하는가

이 두 가지를 다뤄볼 것이며 출처에서 나온 내용을 간단하게 다뤄보려 한다. (정말 정리를 잘하신 글이기 때문에 한번씩 보는 것을 추천드린다.)

useState 파해치기

vscode 나 다른 IDE 를 사용해서 useState 가 선언된 곳을 찾아보면 node_modules/react/cjs/react.development.js 인 것을 확인할 수 있다.

내부의 내용을 확인하면 대충 dispatcher 라는 인스턴스를 생성하고, 우리가 입력한 초기 값을 dispatcher.useState 에 전달한 후 반환값을 보내준다. 그러면 resolveDispatcher 는 어디에 있는지 찾아보자.

동일한 파일에 존재하며 useState 위를 보면 찾을 수 있다. 보면 이 또한 ReactCurrentDispatcher 의 current 속성을 통해서 dispatcher 를 만들고 예외처리를 하는 걸 볼 수 있다.

귀찮을 수 있지만 한 번 더 위로 가본다.

이것이 ReactCurrentDispatcher 로 전역으로 선언된 것을 볼 수 있다. 이 객체에 있는 current 속성에 (지금은 null 이지만) dispatcher 가 담길 예정인 것 같다. 더 이상 따라올라갈 코드가 없으니 여기서 중단한다.

위에서 뒤져본 결과로 알 수 있는 것은 아래와 같다.

  • useState를 포함한 hooks는 react 모듈에 선언되어있는 함수이고,
  • 실행 될 때 마다 dispatcher를 선언하고 useState 메소드 실행해서 그 값을 반환한다.
  • 할당부를 거슬러 올라가니 dispatcher는 전역 변수 ReactCurrentDispatcher로부터 가져온다

이는 앞 글에서 잠깐 설명한 클로저 개념이랑 유사하다고 생각하면 된다.

setState 함수가 상태를 변경시키는 방법

각 상황별로 확인을 해본다. 이 내용은 출처에 있는 내용을 거의 그대로 사용했다.

  • 웹이 로딩되고 최초로 컴포넌트 함수가 호출

    • 컴포넌트는 인수로 초기 값을 전달하며 useState 를 호출한다.
    • useState는 실행될 때 마다 초기값을 전달받지만, 내부적으로 _value값이 undefined 인지 확인해서, 최초의 호출에만 초기값을 _value 에 할당하고, 이후 초기 값은 사용되지 않는다.
    • 이후 _value 와 그 값을 재할당하는 setState 함수를 배열에 담아 반환한다.
  • setState 호출

    • 전달 받은 값을 react 모듈 상단의 _value 에 할당한다.
    • 이후 컴포넌트 리렌더링을 trigger 한다.
  • setState가 실행되어 리렌더링이 발생

    • 위에서 리렌더링 과정에서 해당 컴포넌트 함수가 실행되고, 새로운 jsx 를 반환한다고 정리했다. setState가 리렌더링을 트리거하며 컴포넌트 함수가 두 번째로 실행되었을 때
      • 다시 초기 값을 useState 에 전달하며 호출한다.
      • useState는 내부적으로 _value 값을 확인하고, undefined 가 아닌 값이 할당되어 있기 때문에 초기값 할당문을 실행하지 않는다.
      • 이후 useState가 현재 시점의 _value 와 setState 를 반환한다. (이 시점에서 _value는 위에서 할당한 값이다.)
      • 두 번째 실행된 컴포넌트 함수 내부에서 useState 가 반환한 값을 비구조화 할당으로 추출해 변수에 할당한다.

즉, setState 함수는 자신과 함께 반환된 변수를 변경시키는게 아니라, 다음 useState가 반환할 react 모듈의 _value 를 변경시키고, 컴포넌트를 리렌더링 시키는 역할을 한다. 이후 변경된 값은 useState가 가져온다.


🧐 짧은 소감


클래스형 컴포넌트에 대한 이해를 가지고 이를 함수 컴포넌트의 라이프 사이클과 비교하면서 하니 이해가 더욱 쉬운 부분이 있었다.
또한 좋은 글을 발견해서 useState 에 대한 이해도가 조금은 높아진 것 같아서 무언가 뿌듯했고 좋은 글 작성해주신 DD 님께 감사하다. 남은 Hooks 라이프 사이클 내용은 다음 글에서 이어서 하기로 한다.