Published on

useEffect는 언제 사용해야 하고 언제 사용하지 말아야 할까?

Authors
  • avatar
    Name
    GW

개요

React를 처음 배울 때 useEffect는 보통 다음과 같이 소개됩니다.

컴포넌트가 렌더링된 뒤 특정 코드를 실행할 때 사용하는 Hook

틀린 설명은 아니지만, 이것만으로는 useEffect를 언제 사용해야 하는지 판단하기 어렵습니다.

그러다 보면 상태가 바뀔 때마다 무언가를 처리하기 위해 useEffect를 추가하게 됩니다.

useEffect(() => {
  // 상태가 바뀌었을 때 실행할 코드
}, [state]);

하지만 useEffect는 단순히 렌더링 이후에 코드를 실행하기 위한 도구가 아닙니다.

React에서 Effect의 핵심 역할은 컴포넌트를 React 외부의 시스템과 동기화하는 것입니다.

이번 글에서는 다음 내용을 살펴보겠습니다.

  • useEffect가 어떤 역할을 하는지
  • 어떤 상황에서 사용해야 하는지
  • 계산 가능한 값과 사용자 이벤트에 사용하면 안 되는 이유
  • 의존성 배열과 정리 함수가 필요한 이유
  • React는 왜 Effect를 별도의 단계로 분리했는지

useEffect란 무엇일까?

useEffect는 React 컴포넌트를 외부 시스템과 동기화하기 위한 Hook입니다.

여기서 외부 시스템은 React가 직접 관리하지 않는 대상을 의미합니다.

대표적으로 다음과 같은 것들이 있습니다.

  • 브라우저 이벤트
  • 타이머
  • 네트워크 연결
  • WebSocket
  • 외부 UI 라이브러리
  • DOM API
  • 분석 도구

예를 들어 컴포넌트가 화면에 나타났을 때 브라우저의 resize 이벤트를 구독하고, 사라질 때 구독을 해제해야 한다고 해보겠습니다.

import { useEffect, useState } from 'react';

const WindowWidth = () => {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    const handleResize = () => {
      setWidth(window.innerWidth);
    };

    handleResize();

    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return <p>현재 화면 너비: {width}px</p>;
};

export default WindowWidth;

window의 이벤트 시스템은 React가 관리하는 대상이 아닙니다.

따라서 컴포넌트가 존재하는 동안 이벤트를 구독하고, 컴포넌트가 사라지면 구독을 해제해 두 시스템의 상태를 맞춰야 합니다.

이처럼 React 내부 상태와 외부 시스템의 상태를 연결하는 작업이 useEffect가 필요한 대표적인 상황입니다.

useEffect를 사용해야 하는 경우

useEffect가 필요한지 판단할 때는 다음 질문을 먼저 해보면 좋습니다.

React 밖에 있는 무언가를 현재 props나 state와 동기화해야 하는가?

답이 "예" 라면 Effect가 적절할 가능성이 높습니다.

브라우저 이벤트 구독

useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    console.log(event.key);
  };

  window.addEventListener('keydown', handleKeyDown);

  return () => {
    window.removeEventListener('keydown', handleKeyDown);
  };
}, []);

컴포넌트가 화면에 있는 동안 이벤트를 구독하고, 사라지면 해제해야 하므로 Effect가 필요합니다.

타이머 연결

useEffect(() => {
  const timerId = window.setInterval(() => {
    console.log('1초가 지났습니다.');
  }, 1000);

  return () => {
    window.clearInterval(timerId);
  };
}, []);

브라우저가 관리하는 타이머를 시작하고 중지하는 것도 외부 시스템과의 동기화입니다.

외부 연결 관리

useEffect(() => {
  const connection = createConnection(roomId);

  connection.connect();

  return () => {
    connection.disconnect();
  };
}, [roomId]);

roomId가 바뀌면 기존 연결을 종료하고 새로운 방에 연결해야 합니다.

Effect는 특정 값이 바뀌었을 때 단순히 코드를 실행하는 것이 아니라, 현재 값에 맞춰 외부 연결을 다시 동기화합니다.

계산할 수 있는 값에는 useEffect가 필요하지 않다

다음과 같이 이름과 성을 합쳐 전체 이름을 보여주는 컴포넌트가 있다고 해보겠습니다.

import { useEffect, useState } from 'react';

const Profile = () => {
  const [firstName, setFirstName] = useState('철수');
  const [lastName, setLastName] = useState('김');
  const [fullName, setFullName] = useState('');

  useEffect(() => {
    setFullName(`${lastName}${firstName}`);
  }, [firstName, lastName]);

  return <p>{fullName}</p>;
};

첫 번째 방식에서는 다음 과정이 필요합니다.

firstName 또는 lastName 변경
↓
첫 번째 렌더링
↓
Effect 실행
↓
fullName 상태 변경
↓
두 번째 렌더링

이 코드는 동작하지만 fullName은 이미 firstName과 lastName으로 계산할 수 있습니다.

별도의 상태와 Effect를 만들 필요가 없습니다.

import { useState } from 'react';

const Profile = () => {
  const [firstName, setFirstName] = useState('철수');
  const [lastName, setLastName] = useState('김');

  const fullName = `${lastName}${firstName}`;

  return <p>{fullName}</p>;
};

두 번째 방식은 렌더링 중에 바로 값을 계산합니다.

firstName 또는 lastName 변경
↓
fullName 계산
↓
렌더링

props나 state로부터 바로 계산할 수 있는 값이라면 Effect가 아니라 렌더링 중에 계산하는 것이 더 단순합니다.

계산 비용이 큰 경우에는 useEffect가 아니라 useMemo를 검토할 수 있습니다.

사용자 이벤트에는 이벤트 핸들러를 사용한다

버튼을 클릭했을 때 요청을 보내야 하는 상황을 살펴보겠습니다.

import { useEffect, useState } from 'react';

const OrderButton = () => {
  const [isOrdered, setIsOrdered] = useState(false);

  const handleOrderClick = () => {
    setIsOrdered(true);
  };

  useEffect(() => {
    if (!isOrdered) {
      return;
    }

    postOrder();
    showNotification('주문이 완료되었습니다.');
  }, [isOrdered]);

  return <button onClick={handleOrderClick}>주문하기</button>;
};

주문 요청이 실행되는 이유는 컴포넌트가 화면에 표시되었기 때문이 아닙니다.

사용자가 버튼을 클릭했기 때문입니다.

따라서 클릭에 대한 로직은 이벤트 핸들러에 직접 작성하는 것이 자연스럽습니다.

const OrderButton = () => {
  const handleOrderClick = () => {
    postOrder();
    showNotification('주문이 완료되었습니다.');
  };

  return <button onClick={handleOrderClick}>주문하기</button>;
};

Effect와 이벤트 핸들러는 실행되는 이유가 다릅니다.

이벤트 핸들러
→ 사용자가 특정 행동을 했기 때문에 실행

Effect
→ 컴포넌트가 화면에 존재하며 외부 시스템과 동기화해야 하므로 실행

특정 클릭, 제출, 입력과 직접 연결된 작업이라면 Effect보다 이벤트 핸들러에 두는 것이 좋습니다.

의존성 배열은 실행 시점을 임의로 고르는 옵션이 아니다

Effect에서 사용하는 props와 state는 의존성 배열에 포함해야 합니다.

useEffect(() => {
  const connection = createConnection(roomId);

  connection.connect();

  return () => {
    connection.disconnect();
  };
}, [roomId]);

roomId가 바뀌면 기존 연결은 더 이상 현재 상태와 맞지 않습니다.

React는 이전 Effect의 정리 함수를 실행한 뒤 새로운 roomId를 사용해 다시 연결합니다.

roomId: 1
→ 1번 방 연결

roomId: 2로 변경
→ 1번 방 연결 해제
→ 2번 방 연결

따라서 의존성 배열은 다음과 같은 의미에 가깝습니다.

이 Effect가 어떤 반응형 값과 동기화되어야 하는가?

Effect 안에서 roomId를 사용하는데 의존성 배열에서 제외하면, Effect는 최신 roomId와 동기화되지 않을 수 있습니다.

린터 경고를 피하기 위해 의존성을 임의로 제거하기보다 Effect의 구조가 적절한지 먼저 검토해야 합니다.

정리 함수가 필요한 이유

Effect가 외부 시스템에 무언가를 등록했다면 대부분 반대 작업도 필요합니다.

  • 연결했다면 연결 해제
  • 구독했다면 구독 해제
  • 타이머를 시작했다면 타이머 종료
  • 이벤트를 등록했다면 이벤트 제거

이 작업은 Effect에서 반환하는 정리 함수에 작성합니다.

useEffect(() => {
  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

정리 함수는 컴포넌트가 사라질 때만 실행되는 것이 아닙니다.

의존성이 변경되어 Effect가 다시 실행되기 전에도 이전 Effect를 정리합니다.

Effect 실행
→ 외부 시스템 연결

의존성 변경
→ 이전 연결 정리
→ 새로운 값으로 다시 연결

컴포넌트 제거
→ 마지막 연결 정리

Effect를 단순히 마운트와 언마운트 관점으로만 보기보다 동기화를 시작하고 중단하는 하나의 과정으로 이해하면 더 자연스럽습니다.

왜 React는 Effect를 별도로 분리했을까?

React는 컴포넌트를 렌더링할 때 현재 props, state를 확인하고, 이를 바탕으로 화면에 어떤 UI를 보여줄지 계산합니다.

현재 props와 state
↓
컴포넌트 렌더링
↓
화면에 보여줄 UI 계산

이 과정에서는 같은 props와 state가 주어졌다면 같은 UI를 계산할 수 있어야 하며, 렌더링 과정은 순수하게 유지되어야 합니다.

예를 들어 다음 컴포넌트는 name을 이용해 어떤 UI를 보여줄지만 계산합니다.

const Greeting = ({ name }: { name: string }) => {
  return <p>{name}님, 안녕하세요.</p>;
};

반면 렌더링 도중 React 외부의 값을 직접 변경하면 문제가 달라집니다.

const WindowTitle = ({ title }: { title: string }) => {
  document.title = title;

  return <h1>{title}</h1>;
};

document.title은 React가 관리하는 값이 아니기 때문에 컴포넌트가 렌더링될 때마다 브라우저의 상태를 직접 변경하게 됩니다.

React는 필요에 따라 렌더링을 다시 시도하거나 중간에 중단할 수 있기 때문에, 렌더링 과정에 이러한 작업이 포함되어 있으면 의도하지 않은 동작이 발생할 수 있습니다.

네트워크 요청을 렌더링 중에 실행하는 경우도 마찬가지입니다.

const MyComponent = () => {
  fetch('/api/data');

  return <div>안녕하세요.</div>;
};

렌더링이 다시 실행되면 API 요청도 다시 전송될 수 있습니다.

Render
↓
fetch()
↓
Render 다시 실행
↓
fetch() 또 실행

React가 렌더링을 시작했다가 중간에 중단해도 이미 전송된 API 요청처럼 외부에 발생한 작업을 되돌릴 수는 없습니다.

즉, UI를 계산하는 렌더링 과정에 실행할 때마다 외부에 영향을 주는 작업이 섞여 버린 것입니다.

그래서 React는 렌더링과 Effect의 역할을 분리합니다.

Render
→ 현재 props와 state를 바탕으로 UI 계산

Effect
→ 외부 시스템과 동기화

예를 들어 브라우저의 문서 제목을 title과 동기화해야 한다면 렌더링 중에 직접 변경하지 않고 Effect로 분리할 수 있습니다.

import { useEffect } from 'react';

const WindowTitle = ({ title }: { title: string }) => {
  useEffect(() => {
    document.title = title;
  }, [title]);

  return <h1>{title}</h1>;
};

React는 먼저 title을 바탕으로 UI를 계산하고 화면에 반영한 뒤, Effect를 실행해 브라우저의 문서 제목을 현재 title과 동기화합니다.

이렇게 외부 시스템에 영향을 주는 작업을 Effect로 분리하면 렌더링은 UI를 계산하는 역할에 집중할 수 있습니다.

이 관점에서 보면 useEffect는 단순히 "렌더링 후 코드를 실행하는 Hook" 이 아니라,

React의 순수한 렌더링과 외부 시스템의 동기화를 분리하기 위한 Hook

이라고 이해하는 것이 더 정확합니다.

그래서 Effect는 정말 필요한 경우에만 사용하는 것이 중요합니다.

props와 state로 계산할 수 있는 값이나 사용자 이벤트에서 직접 처리할 수 있는 로직까지 Effect로 작성하면 데이터 흐름이 복잡해지고 불필요한 렌더링이나 Effect 실행이 발생할 수 있습니다.

개발 환경에서 useEffect가 두 번 실행되는 이유

Strict Mode를 사용하는 개발 환경에서는 Effect가 처음 실행될 때 다음과 같은 과정이 추가로 나타날 수 있습니다.

Effect 실행
→ 정리 함수 실행
→ Effect 다시 실행

이는 실제 프로덕션에서 Effect를 항상 두 번 실행한다는 의미가 아닙니다.

React가 개발 환경에서 Effect의 설정과 정리가 올바르게 대응되는지 확인하는 과정입니다.

다음 코드처럼 정리 함수가 없다면 이벤트가 중복 등록될 수 있습니다.

useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);

올바르게 정리하면 추가 실행이 발생해도 활성화된 이벤트는 하나만 유지됩니다.

useEffect(() => {
  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

개발 환경에서 Effect가 두 번 실행되는 것을 막으려고 우회하기보다, 한 번 실행하고 정리한 뒤 다시 실행해도 문제가 없도록 Effect를 작성하는 것이 중요합니다.

정리

useEffect는 렌더링 이후에 아무 코드나 실행하기 위한 Hook이 아닙니다.

React 컴포넌트를 브라우저 API, 네트워크 연결, 타이머, 이벤트처럼 React가 관리하지 않는 외부 시스템과 동기화할 때 사용합니다.

핵심을 정리하면 다음과 같습니다.

  • 외부 시스템과 동기화해야 한다면 useEffect를 사용합니다.
  • props와 state로 계산할 수 있는 값은 렌더링 중에 계산합니다.
  • 사용자 행동으로 실행되는 작업은 이벤트 핸들러에 작성합니다.
  • 의존성 배열은 Effect가 동기화되는 값을 나타냅니다.
  • 외부 시스템을 연결하거나 구독했다면 정리 함수에서 반대 작업을 수행합니다.
  • 개발 환경에서 Effect가 다시 실행되어도 문제가 없도록 설정과 정리를 함께 작성합니다.
  • Effect가 필요하지 않다면 제거하는 것이 데이터 흐름을 더 단순하게 만듭니다.

useEffect가 필요한지 헷갈린다면 먼저 다음 질문을 해보면 됩니다.

외부 시스템과 동기화하기 위해 useEffect가 필요한가?

외부 시스템이 없다면, 대부분은 useEffect 없이 더 단순하게 해결할 수 있습니다.

참고 자료