그림 그리는 개발자
  • useEffect 에 대해 알아봅시다
    2023년 07월 25일 22시 37분 04초에 업로드 된 글입니다.
    작성자: 루루개발자

    안녕하세요. 루루개발자입니다.

    이번에는 리액트에서 기본으로 제공해주는 훅 함수 중 하나인 "useEffect" 에 대해 알아보고자 합니다. 아래 예시 코드들은 리액트의 프레임워크인 Next.js 를 기준으로 작성되었으므로 이 점 참고해주세요.

     

    useEffect 란?

    리액트(react) 공식 문서에 의하면, useEffect 는 외부 시스템과 컴포넌트를 동기화 할 수 있게 해주는 리액트 훅(hook) 이라고 정의 되어 있습니다. 여기서 말하는 외부 시스템이란, 리액트에 의해 제어 되지 않는 것들을 말합니다. 리액트에 의해 제어 되지 않는 예시는 다음과 같습니다.

    • API 통신
    • ref 를 이용한 dom 제어
    • window 전역 객체에 이벤트 리스너 할당 또는 해제

    이 밖에도 여러가지가 있겠지만, 대체로 이런 상황일 때 useEffect 를 사용하게 됩니다. useEffect 는 2개의 인자를 받는데 첫번째 인자로는 로직이 작성되어 있는 함수를 받고 두번째 인자로는 여러 상태 값들을 배열로 받아 배열에 주어진 상태 값들의 변화가 감지된 경우 첫번째 인자의 함수가 호출됩니다. 그리고 이러한 useEffect 는 렌더링이 완료 된 이후에 호출됩니다.

     

    useEffect 는 여러 개 작성할 수 있다!

    하나의 컴포넌트(또는 훅)에서는 다음과 같이 useEffect 를 여러개 작성하는 것이 가능합니다.

    "use client"
    
    import { useEffect, useState } from "react";
    
    export default function Page() {
      const [number, setNumber] = useState(0);
    
      useEffect(() => {
        setNumber(prev => prev + 1);
      }, []);
    
      useEffect(() => {
        console.log(`[${performance.now()}] number 변경됨! => ${number}`);
      }, [number]);
    
      useEffect(() => {
        const listner = (event: UIEvent) => {
          console.log(`[${performance.now()}] window size 변경됨!`);
        };
        window.addEventListener('resize', listner);
        return () => {
          window.removeEventListener('resize', listner);
        };
      }, []);
    
      return (
        <>
          {/** 생략.. */}
        </>
      );
    }

    즉, 관심사 별로 useEffect 를 분리 할 수 있습니다.

     

    useEffect 의 로직 함수에서 반환하는 함수는 컴포넌트가 파괴(unmount) 될 때 호출되는 클린업 함수이다!

    아래 예제를 한번 보겠습니다.

    useEffect(() => {
      const listner = (event: UIEvent) => {
        console.log(`[${performance.now()}] window size 변경됨!`);
      };
      window.addEventListener('resize', listner);
      return () => {
        window.removeEventListener('resize', listner);
      };
    }, []);

    useEffect 의 두번째 인자에 빈 배열을 전달하였으니, 첫번째 인자에 전달한 로직 함수는 렌더링이 완료 된 이후 한번만 실행되게 됩니다. 위의 예시는 렌더링이 완료 된 이후 window 전역 객체에 resize 이벤트에 대한 리스너를 할당하는 부분입니다. 그리고 반환되는 함수에서는 window 전역 객체에 할당한 리스너를 다시 제거하는 코드가 작성되어 있습니다. 해당 컴포넌트가 더 이상 사용되지 않을 때 할당해 두었는 이벤트 리스너를 제거해 줄 필요가 있는데 이런 상황일 때 클린업 함수를 활용하면 됩니다. 이 밖에도 만약 웹소켓을 연결한 코드가 있을 때 클린업 함수를 이용해 웹소켓 연결을 끊어주는 코드를 작성할 수도 있습니다.

     

    useEffect 끼리는 순서대로 호출된다!

    useEffect 는 두번 째 인자의 배열에 포함 된 상태 값들의 변화를 감지 후, 렌더링이 완료 된 이후에 로직 함수가 호출 되므로 useState 의 setter 함수를 호출 한 즉시에는 useEffect 의 로직 함수가 호출되지는 않습니다. 즉 비동기로 호출되는 셈인거죠. useEffect 자체는 비동기로 호출 되지만, 위의 경우처럼 useEffect 가 여러개 작성되어 있는 경우에는 제일 먼저 작성 되어 있는 useEffect 부터 제일 마지막에 작성되어 있는 useEffect 순으로 순서대로 호출됩니다. 

     

    커스텀 훅을 사용해 useEffect 들을 뒤로 숨길 수 있다!

    이 처럼 특정 기능을 구현하기 위해 여러 로직이 필요하고 그 만큼 여러개의 useEffect 들을 작성해야 하는 경우가 있을 수 있습니다. 더군다나 해당 기능이 여러 곳에서 반복적으로 호출되어 사용되는 경우라면 이러한 기능을 별도 훅 함수로 만들어서 사용하면 매우 편리합니다. 예를 들어, 아래 코드는 js 의 addEventListener 를 리액트 훅으로 만든 코드입니다.

    import { useEffect, useRef } from "react";
    import { IUseAddEventListener } from "./use-add-event-listener.interface";
    
    export function useAddEventListener<K extends keyof HTMLElementEventMap, T extends keyof WindowEventMap>(props: IUseAddEventListener.Props<K, T>) {
      const {
        domEventRequiredInfo,
        windowEventRequiredInfo,
      } = props;
    
      const savedDomEventRequiredInfoRef = useRef<IUseAddEventListener.DomEventRequiredInfo<K>>();
      const savedWindowEventRequiredInfoRef = useRef<IUseAddEventListener.WindowEventRequiredInfo<T>>();
      
      const fixedCallbackRef = useRef((event: any) => {
        if (savedDomEventRequiredInfoRef.current !== undefined) {
          savedDomEventRequiredInfoRef.current.eventListener(event);
        }
        if (savedWindowEventRequiredInfoRef.current !== undefined) {
          savedWindowEventRequiredInfoRef.current.eventListener(event);
        }
      });
    
      function isTargetRef(value: any): value is { current: HTMLElement } {
        if (typeof value !== 'object') return false;
        if (value.current === undefined) return false;
        return true;
      }
    
      function isTargetSelector(value: any): value is IUseAddEventListener.SelectorString {
        if (typeof value !== 'string') return false;
        if (!value.startsWith(`selector:`)) return false;
        if (value.length <= 10) return false;
        return true;
      }
    
      useEffect(() => {
        const removeEvent = () => {
          if (domEventRequiredInfo !== undefined) {
            const {
              target,
              eventName,
              eventListener,
              options,
            } = domEventRequiredInfo;
    
            if (isTargetRef(target)) {
              target.current.removeEventListener(eventName, fixedCallbackRef.current);
            } else if (isTargetSelector(target)) {
              const element = document.querySelector<HTMLElement>(target.replace(`selector:`, ''));
              element?.removeEventListener(eventName, fixedCallbackRef.current);
            } 
          }
    
          if (savedDomEventRequiredInfoRef.current !== undefined) {
            const {
              target,
              eventName,
              eventListener,
              options,
            } = savedDomEventRequiredInfoRef.current;
    
            if (isTargetRef(target)) {
              target.current.removeEventListener(eventName, fixedCallbackRef.current);
            } else if (isTargetSelector(target)) {
              const element = document.querySelector<HTMLElement>(target.replace(`selector:`, ''));
              element?.removeEventListener(eventName, fixedCallbackRef.current);
            }
          }
        };
        removeEvent();
    
        if (domEventRequiredInfo !== undefined) {
          const {
            target,
            eventName,
            eventListener,
            options,
          } = domEventRequiredInfo;
          if (isTargetRef(target)) {
            target.current.addEventListener(eventName, fixedCallbackRef.current, options);
          } else if (isTargetSelector(target)) {
            const element = document.querySelector<HTMLElement>(target.replace(`selector:`, ''));
            element?.addEventListener(eventName, fixedCallbackRef.current, options);
          } 
        }
    
        return () => {
          removeEvent();
        };
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [domEventRequiredInfo?.target, domEventRequiredInfo?.eventName, domEventRequiredInfo?.eventListener,domEventRequiredInfo?.options]);
    
      useEffect(() => {
        savedDomEventRequiredInfoRef.current = domEventRequiredInfo;
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [domEventRequiredInfo?.target, domEventRequiredInfo?.eventName, domEventRequiredInfo?.eventListener,domEventRequiredInfo?.options]);
    
      useEffect(() => {
        const removeEvent = () => {
          if (windowEventRequiredInfo !== undefined && typeof window !== 'undefined') {
            const {
              eventName,
              eventListener,
              options,
            } = windowEventRequiredInfo;
    
            window.removeEventListener(eventName, fixedCallbackRef.current);
          }
    
          if (savedWindowEventRequiredInfoRef.current !== undefined && typeof window !== 'undefined') {
            const {
              eventName,
              eventListener,
              options,
            } = savedWindowEventRequiredInfoRef.current;
    
            window.removeEventListener(eventName, fixedCallbackRef.current);
          }
        };
        removeEvent();
    
        if (windowEventRequiredInfo !== undefined && typeof window !== 'undefined') {
          const {
            eventName,
            eventListener,
            options,
          } = windowEventRequiredInfo;
          window.addEventListener(eventName, fixedCallbackRef.current, options);
        }
    
        return () => {
          removeEvent();
        };
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [windowEventRequiredInfo?.eventName, windowEventRequiredInfo?.eventListener,windowEventRequiredInfo?.options]);
    
      useEffect(() => {
        savedWindowEventRequiredInfoRef.current = windowEventRequiredInfo;
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [windowEventRequiredInfo?.eventName, windowEventRequiredInfo?.eventListener,windowEventRequiredInfo?.options]);
    
      return {
    
      };
    }

    네, 코드 양도 많고 되게 복잡해 보일 수 있습니다. 위 훅 같은 경우는 부모로부터 이벤트 리스너 할당에 필요한 정보들을 받고 window 전역 객체 또는 특정 element 에 이벤트 리스너를 할당합니다. 할당된 함수는 특정 콜백 함수를 호출하게 되어 있고, 이 특정 콜백 함수는 부모에게서 전달받은 콜백함수가 변경 될 경우 변경 된 콜백함수로 갱신되게 됩니다. 즉 addEventListener 를 사용하더라도 상태값이 갱신되었을 경우 새로운 상태값을 계속 참조하고 있는 갱신된 함수가 호출 될 수 있도록 해주는 기능을 제공하는 훅인 것입니다. 이러한 코드를 필요한 곳에서 일일이 복사 붙여넣기 하며 사용하게 되면 코드 양도 길어질 뿐더러 다른 로직들과 섞여 코드 보기가 힘들어질 것입니다. 그렇기 때문에 이렇게 특정 기능을 위한 부분들을 따로 훅으로 만들게 되면 아래와 같이 사용하는 곳에선 편안(?) 하게 사용이 가능합니다.

    "use client"
    
    import { useAddEventListener } from "@/hooks/use-add-event-listener/use-add-event-listener.hook";
    
    export default function Page() {
      useAddEventListener({
        windowEventRequiredInfo: {
          eventName: 'resize',
          eventListener(event) {
            console.log('@event', event);
            console.log('@window.innerWidth', window.innerWidth);
            console.log('@window.innerHeight', window.innerHeight);
          },
        },
      });
    
      return (
        <>
          {/* 생략.. */}
        </>
      );
    }

    리액트로 개발 하시다보면 이렇게 커스텀 훅을 종종 만들어야 하는 경우가 생기게 됩니다.

     

    useEffect 를 꼭 사용하지 않아도 되는 경우들

    리액트 공식 문서에 의하면 외부 시스템과 연동하는 경우가 아니라면 useEffect 가 필요하지 않을수도 있다고 설명되어 있습니다. 예를 들어 아래와 같은 코드가 있다고 해봅시다.

    "use client"
    
    import { useEffect, useState } from "react";
    
    export default function Page() {
      const [number, setNumber] = useState(0);
      const [description, setDescription] = useState(`현재 숫자는 ${number} 입니다.`);
    
      useEffect(() => {
        setDescription(`현재 숫자는 ${number} 입니다.`);
      }, [number]);
    
      return (
        <>
          <div className="w-full relative">
            { description}
          </div>
          <button
            onClick={() => {
              console.log(`[${performance.now()}] setNumber 호출됨.`);
              setNumber(prev => prev + 1);
            }}>
            number 값을 1 증가시키기
          </button>
        </>
      );
    }

    number 가 변경 될 때마다 description 에 포함된 number 도 변경시키고 싶어서 위와 같이 작성하였다고 합시다. 하지만 이런 경우 변경 된 description 이 최종적으로 렌더링 되는 시점은 number 가 변경되고 렌더링이 완료 된 이후, description 이 변경되고 이후 다시 렌더링이 완료된 이후이게 됩니다. 즉 number 가 바뀌게 되면 렌더링이 두 번 발생하게 됩니다. 물론 위와 같이 작성을 해도 되기는 하겠지만 위 코드는 useEffect 를 사용하지 않고 아래 처럼 작성할 수도 있습니다.

    "use client"
    
    import { useState } from "react";
    
    export default function Page() {
      const [number, setNumber] = useState(0);
      const description = `현재 숫자는 ${number} 입니다.`;
    
      return (
        <>
          <div className="w-full relative">
            { description}
          </div>
          <button
            onClick={() => {
              console.log(`[${performance.now()}] setNumber 호출됨.`);
              setNumber(prev => prev + 1);
            }}>
            number 값을 1 증가시키기
          </button>
        </>
      );
    }

    number 가 변경 되고 렌더링이 진행 될 때 description 도 변경 된 number 를 참조한 값으로 갱신되므로 이 때는 렌더링 한번으로도 이전 코드와 동일한 결과를 얻을 수가 있게 됩니다. 위 코드 에서 만약 number 라는 상태 값이 자주 바뀌는 값이 아니라면 아래와 같이 메모이제이션 시키는 코드로 변경시킬 수도 있습니다.

    "use client"
    
    import { useMemo, useState } from "react";
    
    export default function Page() {
      const [number, setNumber] = useState(0);
      const description = useMemo(() => `현재 숫자는 ${number} 입니다.`, [number]);
    
      return (
        <>
          <div className="w-full relative">
            { description}
          </div>
          <button
            onClick={() => {
              console.log(`[${performance.now()}] setNumber 호출됨.`);
              setNumber(prev => prev + 1);
            }}>
            number 값을 1 증가시키기
          </button>
        </>
      );
    }

    이 처럼 useEffect 를 사용하실 때는 정말 useEffect 가 필요한 부분인지를 다시 생각해보는 것도 좋습니다.

     

     

     

    - 출처 - 

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

     

    댓글