구리

[React] React Hook 파헤쳐보기 - useEffect 본문

React

[React] React Hook 파헤쳐보기 - useEffect

guriguriguri 2024. 11. 8. 22:25

React Hook에 대해 공부하며 정리한 글입니다. 피드백은 언제나 환영입니다.

useEffect란?

공식 문서에 따르면 useEffect란 렌더링 후 특정 코드를 실행해 외부 시스템과 컴포넌트를 동기화하는 hook이라고 한다. effect를 사용해 렌더링 후 특정 코드를 실행해 React 외부의 시스템과 컴포넌트를 동기화할 수 있다고 하는데 여기서 effect는 무엇을 의미할까?

Effect란 무엇일까?

렌더링 자체에 의해 발생하는 부수효과 (side effect)를 의미한다. 리액트의 함수형 컴포넌트는 props, state를 바탕으로 컴포넌트를 렌더링 하는데 컴포넌트의 렌더링과 무관한 연산들이 존재한다면 이것이 side effect가 되는 것이다.

무관한 연산을 컴포넌트 내에서 직접 수행하는 것은 좋지 않은 방식인데 이유는 개발자가 컴포넌트 렌더링을 통제할 수 없기 때문이다.

side effect의 예시는 다음과 같다.

  • 데이터를 가져오기 위해 외부 API 호출
  • setInterval()에 의해 관리되는 타이머 또는 clearInterval()
  • window.addEventListener()을 이용한 이벤트 구독 또는 window.removeEventListener()
  • animation.start()와 같은 서드 파티 애니메이션 라이브러리 API 또는 animation.reset().

[참고 자료]

https://ko.react.dev/reference/react/useEffect

useEffect deps별 동작 방식

많은 글에서 useEffect 는 componentDidMount, componentDidUpdate, componentWillUnmount 라이프사이클을 대신해 사용할 수 있다고 한다. 그러면 useEffect의 case에 따라 클래스형 컴포넌트의 라이프 사이클과 비교해 보면서 알아보자

  1. deps가 빈 배열인 경우

컴포넌트가 처음 렌더링 됐을 때만, setup 함수가 실행된다. 이는 클래스형 컴포넌트의 componentDidMount와 동일하게 동작한다.

function App() {
    const [state, setState] = useState(0);
    useEffect(() => {
        console.log('useEffect');
    }, [])
    return <>{state}</>
}
  1. deps에 props나 state가 있는 경우

컴포넌트 첫 렌더링 후 setup 함수가 실행된다. 그리고 특정 값이 업데이트되면, 컴포넌트가 리렌더링된 후 deps에 변경된 값이 존재하면 setup 함수가 실행된다. 이는 클래스형 컴포넌트의 componentDidMount, componentDidUpdate를 합친 것과 비슷하다.

function App() {
    const [state, setState] = useState(0);
    useEffect(() => {
        console.log('useEffect');
    }, [state]);
    return (
        <>
        {state}
        <button onClick={() => {setState(prev => prev + 1)}}></button>
        </>
    )
}
  1. clean up(정리) 함수가 있는 경우

clean up이란 구독과 같은 이펙트를 되돌리는 역할로 컴포넌트가 언마운트 됐거나 리렌더링 됐을 때 실행되는 함수다. (리렌더링된 경우 clean up 함수 실행 후 set up 함수가 실행된다.) 이는 클래스형의 componentWillUnmount 와 비슷하게 동작한다.

function App() {
    const [state, setState] = useState(0);
    useEffect(() => {
        function stateEvent() {
            console.log(state);
        }
        window.addEventListener('click', stateEvent)
        return () => {
            window.removeEventListener('click', stateEvent)
        }
    }, [state]);
    return (
        <>
        {state}
        <button onClick={() => {setState(prev => prev + 1)}}></button>
        </>
    )
}

위 예시를 보면 두 가지 의문이 들 수 있다. useEffect는 왜 렌더링이 끝난 후 실행되는 걸까? 그리고 useEffect는 단지 클래스형 컴포넌트의 라이프 사이클 메서드 대체용일까?

useEffect는 왜 한꺼번에 렌더링 하지 않는 걸까?

useEffect는 브라우저가 페인트하고 난 후 이펙트를 실행하는데, 이로 인해 대부분의 이펙트가 스크린 업데이트를 가로막지 않기에 앱을 더 빠르게 만들어준다. (만약 이펙트가 렌더링 중에 실행되고 이펙트가 무거운 API를 호출하게 된다면 브라우저를 그리기까지 오랜 시간이 걸릴 수 있다.)

동일한 이유로 클린업 함수도 리렌더링 완료 후(페인트 완료 후) 클린업 함수가 실행된다.

[참고 자료]

https://rinae.dev/posts/a-complete-guide-to-useeffect-ko/

useEffect는 생명주기 메서드 대체용인 걸까?

아래의 예시는 웹소켓을 이용해 메세지 상태를 관리하는 클래스형 컴포넌트다.

import React, { Component } from 'react';
import websockets from 'websockets';

class ChatChannel extends Component {
  state = {
    messages: [];
  }

  componentDidMount() {
    this.startListeningToChannel(this.props.channelId);
  }

  componentDidUpdate(prevProps) {
    if (this.props.channelId !== prevProps.channelId) {
      this.stopListeningToChannel(prevProps.channelId);
      this.startListeningToChannel(this.props.channelId);
    }
  }

  componentWillUnmount() {
    this.stopListeningToChannel(this.props.channelId);
  }

  startListeningToChannel(channelId) {
    websockets.listen(
      `channels.${channelId}`,
      message => {
        this.setState(state => {
          return { messages: [...state.messages, message] };
        });
      }
    );
  }

  stopListeningToChannel(channelId) {
    websockets.unlisten(`channels.${channelId}`);
  }

  render() {
    // ...
  }
}

클래스형 컴포넌트는 라이프 사이클을 중심으로 side effect를 모으다 보니 관련 없는 로직들이 섞이거나 상태 관리 코드가 여기저기 분산된다. 여기서 중요한 건 메세지라는 상태 관리가 언제 실행되는지가 아닌 어떤 상태와 동기화되는지가 핵심이다. 위 코드를 useEffect로 변경해 보면 아래와 같다.

import React, { useEffect, useState } from 'react';
import websockets from 'websockets';

function ChatChannel({ channelId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    websockets.listen(
      `channels.${channelId}`,
      message => setMessages(messages => [...messages, message])
    );

    return () => websockets.unlisten(`channels.${channelId}`);
  }, [channelId]);

  // ...
}

useEffect 덕분에 websocket은 컴포넌트 마운트, 언마운트를 신경 쓰지 않아도 된다. 단지 channelId라는 상태에 따라 websocket을 연결/해제하고 message 상태를 관리하는 것을 볼 수 있다. 어떤 상태와 동기화되어야 하는지 관심사 분리를 해주는 것이 useEffect의 핵심이라 볼 수 있다.

[참고 자료]

https://sebastiandedeyne.com/forget-about-component-lifecycles-and-start-thinking-in-effects

useLayoutEffect

useEffect와 비슷한 이름의 Hook이 존재하는데 이는 useLayoutEffect로 어떤 역할을 하는지 간단히 알아본다.

useEffect의 setup 함수는 컴포넌트 렌더링이 끝난 후 (브라우저 페인트가 끝난 후) 실행된다. 그런데 만약 브라우저 페인트가 시작되기 전 레이아웃을 계산해야 한다면 어떻게 할까? useLayoutEffect는 이런 문제를 해결해 준다.

useLayoutEffect는 컴포넌트 렌더링 후 실행되며, 그 이후에 브라우저 paint가 진행된다. 이 작업은 동기적(synchronous)으로 실행되며 paint가 되기 전에 실행되기에 DOM을 조작하는 코드가 존재해도 사용자는 깜빡임 현상을 경험하지 않게 된다.

useLayoutEffect flow

따라서 DOM은 계산됐지만 화면에 반영되기 전에 하고 싶은 작업이 있을 때와 같이 같이 반드시 필요할 때만 사용하는 것이 좋다. 특정 요소에 따라 DOM 요소를 기반으로 한 애니메이션, 스크롤 위치를 제어하는 등 화면에 반영되기 전에 하고 싶은 작업에 useLayoutEffect를 사용한다면 useEffect를 사용했을 때보다 훨씬 더 자연스러운 사용자 경험을 제공할 수 있다.

하지만 useLayoutEffect는 동기적으로 실행되고 내부 코드가 모두 실행된 후 painting 작업을 거친다. 따라서 로직이 무거울 경우, 사용자가 화면을 보는 데까지 시간이 오래 걸릴 수 있기에 React 공식문서에서는 useEffect 사용을 권장한다.

[참고 자료]

https://pubudu2013101.medium.com/what-is-the-real-difference-between-react-useeffect-and-uselayouteffect-51723096dc19

결론

위 내용을 요약하면 다음과 같다.

  • useEffect는 effect가 어떤 상태와 동기화되어야 하는지 관심사를 분리해 주는 Hook으로 단순히 생명주기 메서드 대체용이 아니다.
  • useEffect의 setup은 렌더링 완료 후(브라우저 페인트 완료) 실행되며 clean-up을 통해 effect를 되돌릴 수 있다. 이를 통해 effect는 스크린 업데이트를 막지 않기에 앱을 더 빠르게 만들어줄 수 있다.
  • 브라우저 페인트 전 effect를 사용해야 한다면 useLayoutEffect Hook을 사용하면 된다.