ReactNextCentral

Effect: 필요성 검토

Published on
이 글은 리액트의 Effect 훅을 사용하여 외부 시스템과 동기화할 때 필요성을 검토하고, 비용이 많이 드는 계산을 최적화하며, 이벤트 핸들러와 로직을 공유하는 방법 등에 대해 설명합니다.
Table of Contents

효과는 리액트 패러다임에서 벗어나는 방법입니다. 이를 통해 리액트를 벗어나서 비-리액트 위젯, 네트워크 또는 브라우저 DOM과 같은 외부 시스템과 컴포넌트를 동기화할 수 있습니다. 외부 시스템이 필요하지 않은 경우(예: 일부 props 또는 state가 변경될 때 컴포넌트의 상태를 업데이트하려는 경우), Effect가 필요하지 않을 것입니다. 불필요한 효과를 제거하면 코드를 이해하기 쉽고 실행 속도가 빨라지며 오류가 줄어듭니다.

다음 내용을 학습합니다.

  • 언제 외부 시스템과의 동기화를 위해 Effect를 제거해야 하는지
  • 효과 없이 비용이 많이 드는 계산을 캐시하는 방법
  • 효과 없이 컴포넌트 상태를 재설정하고 조정하는 방법
  • 이벤트 핸들러 간에 로직을 공유하는 방법
  • 어떤 로직을 이벤트 핸들러로 이동해야 하는지
  • 부모 컴포넌트에 변경 사항을 알리는 방법

불필요한 효과를 제거하는 방법

효과가 필요하지 않은 경우에는 일반적으로 두 가지 경우가 있습니다.

  • 렌더링을 위해 데이터를 변환할 필요가 없습니다. 예를 들어, 리스트를 필터링하여 표시하려는 경우가 있습니다. 리스트가 변경될 때마다 상태 변수를 업데이트하는 효과를 작성하려는 유혹에 빠질 수도 있습니다. 그러나 이는 비효율적입니다. 상태를 업데이트할 때 리액트는 먼저 컴포넌트 함수를 호출하여 화면에 어떤 내용이 있는지 계산합니다. 그런 다음 리액트는 이러한 변경 사항을 DOM에 "커밋"하여 화면을 업데이트합니다. 그런 다음 리액트는 효과를 실행합니다. 효과도 즉시 상태를 업데이트하는 경우, 이러한 과정이 처음부터 다시 시작됩니다! 불필요한 렌더링 과정을 피하기 위해 컴포넌트의 최상위 수준에서 모든 데이터를 변환하세요. 이 코드는 props 또는 state가 변경될 때마다 자동으로 다시 실행됩니다.
  • 사용자 이벤트를 처리하기 위해 효과가 필요하지 않습니다. 예를 들어, 사용자가 제품을 구매할 때 /api/buy POST 요청을 보내고 알림을 표시하려는 경우가 있습니다. Buy 버튼의 클릭 이벤트 핸들러에서 사용자가 정확히 무엇을 했는지 알 수 있습니다. 효과가 실행되는 시점에는 사용자가 무엇을 했는지(예: 어떤 버튼을 클릭했는지) 알 수 없습니다. 따라서 일반적으로 사용자 이벤트를 해당하는 이벤트 핸들러에서 처리합니다.

외부 시스템과 동기화하기 위해 효과가 필요합니다. 예를 들어, jQuery 위젯을 리액트 상태와 동기화하는 효과를 작성할 수 있습니다. 또한 효과를 사용하여 데이터를 가져올 수도 있습니다. 예를 들어, 검색 결과를 현재 검색 쿼리와 동기화할 수 있습니다. 하지만 현대의 프레임워크는 컴포넌트에 직접 효과를 작성하는 것보다 더 효율적인 내장 데이터 가져오기 메커니즘을 제공합니다.

올바른 직관력을 얻기 위해 일부 일반적인 구체적인 예제를 살펴보겠습니다!

Props 또는 State에 기반한 상태 업데이트

firstName과 lastName이라는 두 가지 상태 변수가 있는 컴포넌트가 있다고 가정해보겠습니다. firstName과 lastName을 연결하여 fullName을 계산하고 싶습니다. 또한 firstName 또는 lastName이 변경될 때마다 fullName을 업데이트하고 싶습니다. 첫 번째 직감은 fullName 상태 변수를 추가하고 Effect에서 업데이트하는 것일 수 있습니다.

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // 🔴 피하세요: 중복된 상태와 불필요한 효과
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

이는 불필요하게 복잡합니다. 또한 비효율적입니다. 이전 값이 있는 fullName을 사용하여 전체 렌더링을 수행한 후 업데이트된 값으로 즉시 다시 렌더링합니다. 상태 변수와 효과를 제거하세요.

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ 좋은 예: 렌더링 중 계산됨
  const fullName = firstName + ' ' + lastName;
  // ...
}

기존의 props 또는 state에서 계산할 수 있는 경우 상태에 넣지 마세요. 대신 렌더링 중에 계산하세요. 이렇게 하면 코드가 더 빨라지며(추가적인 "연쇄" 업데이트를 피할 수 있음), 간단해지며(일부 코드를 제거함), 오류가 줄어듭니다(서로 다른 상태 변수가 동기화되지 않아 발생하는 버그를 피할 수 있음). 이 접근 방식이 새로워 보인다면 Thinking in React에서 상태에 어떤 것을 넣어야 하는지 설명합니다.

비용이 많이 드는 계산 캐싱하기

이 컴포넌트는 todos와 filter props를 받아와서 이를 기반으로 필터링하여 visibleTodos를 계산합니다. 결과를 상태로 저장하고 효과에서 업데이트하는 것이 유혹스러울 수 있습니다.

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // 🔴 피하세요: 중복된 상태와 불필요한 효과
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

이전 예제와 마찬가지로 이는 불필요하고 비효율적입니다. 먼저 상태와 효과를 제거하세요.

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ getFilteredTodos()가 느리지 않다면 괜찮습니다.
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

일반적으로 이 코드는 괜찮습니다! 그러나 getFilteredTodos()가 느리거나 할 일이 많은 경우에는 newTodo와 같은 관련 없는 상태 변수가 변경되었을 때 getFilteredTodos()를 다시 계산하고 싶지 않을 수도 있습니다.

useMemo Hook으로 비용이 많이 드는 계산을 캐싱(또는 메모이제이션)할 수 있습니다.

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    // ✅ todos 또는 filter가 변경되지 않는 한 다시 실행되지 않음
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

또는 한 줄로 작성된 형태로:

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ todos 또는 filter가 변경되지 않는 한,
  // getFilteredTodos() 다시 실행되지 않음
  const visibleTodos = useMemo(() 
    => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

이를 통해 리액트에게 inner 함수가 todos 또는 filter가 변경되지 않는 한 다시 실행되지 않기를 원한다고 알립니다. 리액트는 초기 렌더링 중에 getFilteredTodos()의 반환 값을 기억합니다. 다음 렌더링에서는 todos 또는 filter가 이전과 동일한지 확인합니다. 이전과 동일한 경우 useMemo는 저장된 마지막 결과를 반환합니다. 그러나 다른 경우에는 리액트가 inner 함수를 다시 호출하고(그 결과를 저장합니다).

useMemo에 감싼 함수는 렌더링 중에 실행되므로 이는 순수한 계산에만 사용할 수 있습니다.

자세히 알아보기: 계산이 비용이 많이 드는지 확인하는 방법

일반적으로 수천 개의 객체를 생성하거나 루프를 돌지 않는 한, 계산이 비용이 많이 드는 것은 아닐 것입니다. 더 확실한 결과를 얻고 싶다면 코드의 실행 시간을 측정하기 위해 콘솔 로그를 추가할 수 있습니다.

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

측정하려는 상호 작용(예: 입력 필드에 입력)을 수행하세요. 그런 다음 콘솔에 filter array: 0.15ms와 같은 로그가 표시됩니다. 전체 기록된 시간이 상당한 양(예: 1ms 이상)으로 누적된다면 해당 계산을 캐싱하는 것이 합리적일 수 있습니다. 실험으로서 해당 계산을 useMemo로 감싸서 상호 작용에 대한 전체 기록된 시간이 감소했는지 확인할 수 있습니다.

console.time('filter array');
const visibleTodos = useMemo(() => {
  return getFilteredTodos(todos, filter); // todos와 filter가 변경되지 않은 경우 건너뜁니다.
}, [todos, filter]);
console.timeEnd('filter array');

useMemo는 첫 번째 렌더링을 더 빠르게 만들어 주지 않습니다. 업데이트에서 불필요한 작업을 건너뛰는 데 도움을 줍니다.

사용자의 컴퓨터가 사용자보다 빠를 가능성이 높기 때문에 성능을 테스트할 때 인위적으로 속도를 늦추는 것이 좋습니다. 예를 들어, Chrome은 CPU Throttling 옵션을 제공합니다.

또한 개발 중 성능을 측정하는 것은 가장 정확한 결과를 제공하지 않습니다. (예를 들어, Strict Mode가 켜져 있는 경우 각 컴포넌트가 한 번이 아닌 두 번 렌더링됩니다.) 가장 정확한 타이밍을 얻으려면 앱을 프로덕션으로 빌드하고 사용자와 같은 기기에서 테스트하세요.

프롭이 변경될 때 모든 상태를 재설정하기

이 ProfilePage 컴포넌트는 userId 프롭을 받습니다. 페이지에는 댓글 입력 필드가 있으며, 해당 값을 저장하기 위해 comment 상태 변수를 사용합니다. 어느 날, 문제가 있다는 것을 알게 됩니다. 프로필에서 다른 프로필로 이동할 때 comment 상태가 재설정되지 않습니다. 결과적으로 실수로 잘못된 사용자 프로필에 댓글을 게시하기 쉽습니다. 이 문제를 해결하기 위해 userId가 변경될 때마다 comment 상태 변수를 지우려고 합니다.

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 피하세요: Effect에서 프롭 변경에 대한 상태 재설정
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

이 방법은 비효율적입니다. ProfilePage와 그 자식들은 처음에 오래된 값으로 렌더링되고 다시 렌더링됩니다. 또한, ProfilePage 내부에 상태가 있는 모든 컴포넌트에서 이 작업을 수행해야 하는 복잡성도 존재합니다. 예를 들어, 댓글 UI가 중첩되어 있다면 중첩된 댓글 상태도 지우고 싶을 것입니다.

대신에 리액트에게 각 사용자 프로필을 개념적으로 다른 프로필로 다루라고 명시적인 키를 부여하여 리액트가 명시적인 키를 부여한 Profile 컴포넌트를 서로 다른 두 개의 컴포넌트로 취급하고 어떤 상태도 공유하지 않도록 할 수 있습니다. 컴포넌트를 두 개로 분리하고 외부 컴포넌트에서 내부 컴포넌트로 key 속성을 전달하세요.

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ 이와 그 아래에 있는 모든 상태는 키가 변경될 때 자동으로 재설정됩니다
  const [comment, setComment] = useState('');
  // ...
}

일반적으로 리액트는 동일한 컴포넌트가 동일한 위치에서 렌더링될 때 상태를 유지합니다. userId를 Profile 컴포넌트에 키로 전달함으로써 리액트에게 다른 userId를 갖는 두 개의 Profile 컴포넌트를 서로 다른 컴포넌트로 취급하고 어떤 상태도 공유하지 않아야 한다고 알려줍니다. 키(여기서는 userId로 설정)가 변경될 때마다 리액트는 DOM을 다시 생성하고 Profile 컴포넌트와 그 모든 자식의 상태를 재설정합니다. 이제 프로필 간에 이동할 때 comment 필드가 자동으로 지워집니다.

이 예제에서는 오직 외부 ProfilePage 컴포넌트만이 프로젝트의 다른 파일에서 내보내지고 보이게 됩니다. ProfilePage를 렌더링하는 컴포넌트는 키를 전달할 필요가 없습니다. 대신 userId를 일반적인 프롭으로 전달합니다. ProfilePage가 내부 Profile 컴포넌트에 키로 전달하는 것은 구현 세부 사항입니다.

프롭이 변경될 때 일부 상태 조정하기

가끔은 프롭 변경에 따라 상태의 일부를 재설정하거나 조정해야 할 수 있습니다.

이 List 컴포넌트는 항목 목록을 프롭으로 받고, 선택된 항목을 selection 상태 변수에 유지합니다. 항목 프롭이 다른 배열을 받을 때마다 선택을 null로 재설정하려고 합니다.

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 피하세요: Effect에서 프롭 변경에 대한 상태 조정
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

이 역시 이상적이지 않습니다. items가 변경될 때마다 List와 그 자식 컴포넌트는 먼저 오래된 선택 값으로 렌더링됩니다. 그런 다음 리액트는 DOM을 업데이트하고 Effects를 실행합니다. 마지막으로 setSelection(null) 호출로 인해 List와 그 자식 컴포넌트가 다시 렌더링되어 이러한 전체 과정이 다시 시작됩니다.

Effect를 삭제하고 렌더링 중에 상태를 직접 조정하는 방식으로 시작하세요.

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 더 좋음: 렌더링 중에 상태를 조정
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

이렇게 이전 렌더에서의 정보를 저장하는 것은 이해하기 어려울 수 있지만, Effect에서 동일한 상태를 업데이트하는 것보다는 나은 방법입니다. 위의 예제에서는 setSelection이 렌더링 중에 직접 호출됩니다. 리액트는 List가 return 문에서 빠져나간 직후에 List를 즉시 다시 렌더링합니다. 리액트는 List 자식들을 렌더링하거나 DOM을 업데이트하지 않았으므로 이를 통해 자식 컴포넌트가 오래된 선택 값을 렌더링하는 것을 건너뛸 수 있습니다.

렌더링 중에 컴포넌트를 업데이트할 때, 리액트는 반환된 JSX를 버리고 즉시 다시 렌더링을 재시도합니다. 매우 느린 연속 재시도를 피하기 위해 리액트는 렌더링 중에 동일한 컴포넌트의 상태만 업데이트할 수 있습니다. 렌더링 중에 다른 컴포넌트의 상태를 업데이트하면 오류가 발생합니다. items !== prevItems와 같은 조건은 루프를 피하기 위해 필요합니다. 이렇게 상태를 조정할 수는 있지만, DOM을 변경하거나 타임아웃을 설정하는 등 다른 부작용은 이벤트 핸들러나 Effect에서 처리하여 컴포넌트를 순수하게 유지해야 합니다.

이 패턴은 Effect보다 효율적이지만 대부분의 컴포넌트에서는 필요하지 않습니다. 어떤 방식으로든 프롭이나 다른 상태를 기반으로 상태를 조정하는 것은 데이터 흐름을 이해하고 디버깅하기 어렵게 만듭니다. 언제든지 키로 모든 상태를 재설정하거나 렌더링 중에 모든 것을 계산할 수 있는지 확인하세요. 예를 들어, 선택된 항목을 저장하고(재설정하는 대신) 선택된 항목 ID를 저장할 수 있습니다.

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 최상의 방법: 렌더링 중에 모든 것을 계산
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

이제 상태를 "조정"할 필요가 없습니다. 선택된 ID를 가진 항목이 목록에 있는 경우 선택된 상태를 유지합니다. 찾지 못한 경우 렌더링 중에 계산된 selection은 null이 됩니다. 이 동작은 다르지만 대부분의 항목 변경은 선택 상태를 유지합니다.

제품 페이지에는 제품을 구매할 수 있는 두 개의 버튼(Buy와 Checkout)이 있습니다. 제품을 장바구니에 넣을 때마다 알림을 표시하고 싶습니다. 두 버튼의 클릭 핸들러에서 모두 showNotification()을 호출하는 것은 반복적으로 느껴져서 이 로직을 Effect에 넣고 싶을 수도 있습니다.

function ProductPage({ product, addToCart }) {
  // 🔴 피해야 함: 이벤트별 로직을 Effect 내부에 넣지 말 것
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`${product.name}을(를) 장바구니에 추가했습니다!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

이 Effect는 필요하지 않습니다. 또한 버그가 발생할 가능성이 높습니다. 예를 들어, 앱이 페이지 새로고침 사이에 장바구니를 "기억"한다고 가정해 봅시다. 제품을 한 번 장바구니에 추가한 후 페이지를 새로고침하면 알림이 다시 표시됩니다. 그 제품의 페이지를 새로 고침할 때마다 계속 표시됩니다. 이는 product.isInCart가 페이지 로드 시 이미 true로 설정되기 때문에 위의 Effect가 showNotification()을 호출하기 때문입니다.

코드가 Effect에 들어가야 할지 이벤트 핸들러에 들어가야 할지 확신이 없을 때는 이 코드가 실행되어야 하는 이유를 스스로에게 물어보세요. Effect는 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 사용하세요. 이 예제에서 알림은 페이지가 표시되었기 때문이 아니라 사용자가 버튼을 눌렀기 때문에 나타나야 합니다! Effect를 삭제하고 공유 로직을 두 개의 이벤트 핸들러에서 호출하는 함수로 넣어주세요.

function ProductPage({ product, addToCart }) {
  // ✅ 좋은 방법: 이벤트별 로직은 이벤트 핸들러에서 호출됨
  function buyProduct() {
    addToCart(product);
    showNotification(`${product.name}을(를) 장바구니에 추가했습니다!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

이렇게 하면 불필요한 Effect가 제거되고 버그가 수정됩니다.

POST 요청 보내기 

이 Form 컴포넌트는 마운트될 때 분석 이벤트를 보내고, 폼을 작성하고 제출 버튼을 클릭하면 /api/register 엔드포인트로 POST 요청을 보냅니다.

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ 좋은 방법: 컴포넌트가 표시되었을 때 이 로직이 실행되어야 함
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 피해야 함: 이벤트별 로직을 Effect 내부에 넣지 말 것
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}

이전 예제와 동일한 기준을 적용해 봅시다.

분석용 POST 요청은 Effect에 남겨둘 필요가 있습니다. 이는 폼이 표시된 이유 때문에 분석 이벤트를 보내야 하기 때문입니다. (개발 중에는 두 번 호출되지만 여기를 참조하여 이를 처리하는 방법을 확인하세요.)

그러나 /api/register POST 요청은 폼이 표시됨에 따라 발생하는 것이 아닙니다. 이 요청은 특정 시점에만 발생해야 합니다. 바로 사용자가 버튼을 누를 때만 발생해야 합니다. 두 번째 Effect를 삭제하고 해당 POST 요청을 이벤트 핸들러로 이동시켜주세요.

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ 좋은 방법: 컴포넌트가 표시되었을 때 이 로직이 실행되어야 함
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ 좋은 방법: 이벤트별 로직은 이벤트 핸들러에 있음
    post('/api/register', { firstName, lastName });
  }
  // ...
}

이벤트 핸들러 또는 Effect에 로직을 넣어야 할지 선택할 때, 사용자의 관점에서 이 로직이 어떤 종류인지에 대해 답해야 합니다. 이 로직이 특정 상호작용으로 인해 발생하는 경우 이벤트 핸들러에 유지해야 합니다. 사용자가 컴포넌트를 화면에서 볼 때 발생하는 경우 Effect에 유지해야 합니다.

연속된 계산

가끔 다른 상태에 기반하여 각각 상태를 조정하는 연속된 Effects를 연결하고 싶을 수도 있습니다.

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 피해야 함: 각각의 Effect가 서로를 트리거하기 위해 상태를 조정하는 연쇄
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

이 코드에는 두 가지 문제가 있습니다.

첫 번째 문제는 매우 비효율적입니다. 각 set 호출 사이에 컴포넌트(및 하위 컴포넌트)가 다시 렌더링되어야 합니다. 위의 예에서 최악의 경우(setCard → 렌더링 → setGoldCardCount → 렌더링 → setRound → 렌더링 → setIsGameOver → 렌더링) 아래 트리의 세 번의 불필요한 다시 렌더링이 발생합니다.

빠르더라도 코드가 진화함에 따라 "연쇄"가 새로운 요구 사항과 일치하지 않는 경우가 발생합니다. 게임 이동 기록을 확인할 수 있는 방법을 추가한다고 상상해보세요. 각 상태 변수를 과거 값으로 업데이트하여 수행할 것입니다. 그러나 과거 값에서 카드 상태를 설정하면 Effect 체인이 다시 트리거되어 표시하는 데이터가 변경됩니다. 이러한 코드는 종종 유연성이 떨어지고 취약합니다.

이 경우, 렌더링 중에 계산할 수 있는 것은 계산하고, 이벤트 핸들러에서 상태를 조정하는 것이 더 나은 방법입니다.

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ 렌더링 중에 계산할 수 있는 것은 계산함
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    }

    // ✅ 이벤트 핸들러에서 모든 다음 상태를 계산함
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

이것은 훨씬 효율적입니다. 또한 게임 이력을 보는 방법을 구현하면 이제 다른 값들을 조정하는 Effect 체인을 트리거하지 않고도 각 상태 변수를 과거 이동으로 설정할 수 있습니다. 여러 이벤트 핸들러 간에 로직을 재사용해야 하는 경우 함수를 추출하여 해당 핸들러에서 호출할 수 있습니다.

이벤트 핸들러 내부에서 상태는 스냅샷처럼 동작합니다. 예를 들어, setRound(round + 1)를 호출한 후에도 round 변수는 사용자가 버튼을 클릭한 시점의 값을 나타냅니다. 계산에 다음 값을 사용해야 하는 경우 const nextRound = round + 1과 같이 직접 정의해야 합니다.

일부 경우에는 이벤트 핸들러에서 다음 상태를 직접 계산할 수 없는 경우도 있습니다. 예를 들어, 이전 드롭다운에서 선택한 값에 따라 다음 드롭다운의 옵션이 의존하는 다중 드롭다운을 가진 양식을 상상해보십시오. 이 경우 네트워크와 동기화하고 있기 때문에 Effect 체인이 적절합니다.

애플리케이션 초기화

일부 로직은 앱을 로드할 때 한 번만 실행되어야 합니다.

상위 수준 컴포넌트의 Effect에 넣는 것이 유혹스러울 수 있습니다.

function App() {
  // 🔴 피해야 함: 한 번만 실행되어야 하는 로직을 가진 Effect
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

하지만 빠른 시간 안에 이것이 개발 환경에서 두 번 실행됩니다. 이로 인해 문제가 발생할 수 있습니다. 예를 들어, 함수가 두 번 호출되지 않도록 설계되지 않았기 때문에 인증 토큰이 무효화될 수 있습니다. 일반적으로 컴포넌트는 다시 마운트될 수 있도록 유연성을 갖추어야 합니다. 이는 최상위 App 컴포넌트를 포함합니다.

실제로 생산 환경에서는 다시 마운트되지 않을 수 있지만 모든 컴포넌트에서 동일한 제약 조건을 따르면 코드를 이동하고 재사용하기가 쉬워집니다. 어떤 로직이 컴포넌트 마운트마다가 아닌 앱 로드마다 실행되어야 한다면, 이미 실행되었는지를 추적하는 최상위 변수를 추가하세요.

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ 앱 로드마다 한 번만 실행됨
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

또는 모듈 초기화 중 및 앱 렌더링 이전에 실행할 수 있습니다.

// 브라우저에서 실행 중인지 확인합니다.
if (typeof window !== 'undefined') { 
  // ✅ 앱 로드마다 한 번만 실행됨
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

컴포넌트를 가져올 때 최상위 레벨의 코드는 컴포넌트가 가져오지 않더라도 한 번 실행됩니다. 임의의 컴포넌트를 가져올 때 지연 또는 예기치 않은 동작을 피하기 위해이 패턴을 남용하지 마십시오. 앱 전체의 초기화 로직을 App.js와 같은 루트 컴포넌트 모듈이나 애플리케이션의 진입점에 유지하세요.

부모 컴포넌트에 상태 변경 알리기

Toggle 컴포넌트를 작성 중이라고 가정해봅시다. 이 컴포넌트는 내부의 isOn 상태를 가지며, true 또는 false일 수 있습니다. 클릭하거나 드래그하여 토글할 수 있는 몇 가지 다른 방법이 있습니다. Toggle 내부 상태가 변경될 때마다 부모 컴포넌트에 알리고 싶으므로 onChange 이벤트를 노출시키고 Effect에서 해당 이벤트를 호출합니다.

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 피해야 함: onChange 핸들러가 너무 늦게 실행됨
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

이전 예제와 마찬가지로 이는 이상적이지 않습니다. Toggle은 먼저 상태를 업데이트하고 리액트가 화면을 업데이트합니다. 그런 다음 리액트가 Effect를 실행하여 부모 컴포넌트에서 전달된 onChange 함수를 호출합니다. 이제 부모 컴포넌트는 자체 상태를 업데이트하면서 다른 렌더링 패스를 시작합니다. 이를 하나의 패스에서 처리하는 것이 좋습니다.

Effect를 삭제하고 대신 동일한 이벤트 핸들러 내에서 두 컴포넌트의 상태를 업데이트하세요.

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ 좋은 방법: 변경 사항을 발생시킨 이벤트에서 모든 업데이트 수행
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

이 접근 방식으로 Toggle 컴포넌트와 부모 컴포넌트가 이벤트 중에 상태를 모두 업데이트합니다. 리액트는 서로 다른 컴포넌트에서의 업데이트를 일괄 처리하여 하나의 렌더링 패스만 발생합니다.

상태를 완전히 제거하고 대신 부모 컴포넌트에서 isOn을 받을 수도 있습니다.

// ✅ 또 다른 좋은 방법: 컴포넌트는 부모에 의해 완전히 제어됨
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }

  // ...
}

상태를 상위로 올리기는 부모 컴포넌트가 Toggle을 완전히 제어할 수 있도록합니다. 이는 부모 컴포넌트에 더 많은 로직을 포함해야 함을 의미하지만, 전체적으로 관리해야 할 상태가 더 적어집니다. 두 가지 다른 상태 변수를 동기화하려고 할 때는 대신 상태를 상위로 올리는 방식을 시도해보세요!

부모에게 데이터 전달하기

이 Child 컴포넌트는 데이터를 가져오고, 그 다음에 Effect를 통해 해당 데이터를 Parent 컴포넌트에 전달합니다.

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 피해야 함: Effect에서 데이터를 부모에게 전달
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

리액트에서 데이터는 부모 컴포넌트에서 자식 컴포넌트로 흐릅니다. 화면에 잘못된 내용이 보인다면, 잘못된 프롭을 전달하거나 잘못된 상태를 가진 컴포넌트를 찾을 때까지 컴포넌트 체인을 따라 올라가면서 정보의 출처를 추적할 수 있습니다. 자식 컴포넌트가 Effect에서 부모 컴포넌트의 상태를 업데이트하면 데이터 흐름을 추적하기가 매우 어려워집니다. 자식과 부모 모두 동일한 데이터가 필요한 경우, 부모 컴포넌트가 해당 데이터를 가져오고 자식에게 전달하도록 하세요.

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ 좋은 방법: 데이터를 자식에게 전달
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

이 방법은 더 간단하며 데이터 흐름을 예측 가능하게 유지합니다. 데이터는 부모로부터 자식으로 흐릅니다.

외부 저장소 구독하기

가끔은 컴포넌트가 리액트 상태가 아닌 외부 데이터를 구독해야 할 수도 있습니다. 이 데이터는 타사 라이브러리나 내장된 브라우저 API에서 가져올 수 있습니다. 이러한 데이터는 리액트의 지식 없이 변경될 수 있으므로 컴포넌트를 수동으로 구독해야 합니다. 이는 주로 Effect를 사용하여 수행됩니다. 예를 들어:

function useOnlineStatus() {
  // 좋지 않은 방법: Effect에서 수동으로 저장소 구독
  const [isOnline, setIsOnline] = useState(true);
  useEffect(() => {
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState();

    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []);
  return isOnline;
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

여기서 컴포넌트는 외부 데이터 저장소(이 경우 브라우저 navigator.onLine API)를 구독합니다. 이 API는 서버에 존재하지 않으므로(초기 HTML에 사용할 수 없음), 초기 상태는 true로 설정됩니다. 브라우저에서 해당 데이터 저장소의 값이 변경될 때마다 컴포넌트는 상태를 업데이트합니다.

이를 위해 주로 Effects를 사용하지만, 리액트에는 대신 외부 저장소에 구독하는 데 사용할 수 있는 목적에 특화된 Hook이 있습니다. Effect를 삭제하고 useSyncExternalStore를 호출하여 대체하세요.

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ 좋은 방법: 내장된 Hook을 사용하여 외부 저장소에 구독
  return useSyncExternalStore(
    subscribe, // 리액트는 동일한 함수를 전달하는 한 다시 구독하지 않음
    () => navigator.onLine, // 클라이언트에서 값을 얻는 방법
    () => true // 서버에서 값을 얻는 방법
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

이 접근 방식은 Effect를 사용하여 가변 데이터를 수동으로 리액트 상태와 동기화하는 것보다 오류가 적습니다. 일반적으로 위와 같이 useOnlineStatus()와 같은 사용자 정의 Hook을 작성하여 개별 컴포넌트에서 이 코드를 반복하지 않도록 할 것입니다. 리액트 컴포넌트에서 외부 저장소에 대한 구독에 대해 더 알아보세요.

데이터 가져오기

많은 앱에서는 데이터 가져오기를 시작하기 위해 Effects를 사용합니다. 다음과 같이 데이터를 가져오는 Effect를 작성하는 것이 일반적입니다.

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 피해야 함: 정리 로직 없이 가져오기
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

이러한 가져오기를 이벤트 핸들러로 옮길 필요는 없습니다.

이전에는 로직을 이벤트 핸들러에 넣어야 한다는 것과 상반되는 것처럼 보일 수 있습니다! 그러나 "typing" 이벤트가 가져오기의 주요 이유가 아닙니다. 검색 입력란은 URL에서 미리 채워질 수 있으며, 사용자는 입력란을 건드리지 않고 뒤로 가기와 앞으로 가기를 할 수 있습니다.

page와 query가 어디에서 오는지는 중요하지 않습니다. 이 컴포넌트가 보이는 동안 현재 페이지와 쿼리에 대한 네트워크 데이터와 결과를 동기화하려고 합니다. 이것이 바로 Effect인 이유입니다.

그러나 위의 코드에는 버그가 있습니다. "hello"를 빠르게 타이핑한다고 가정해보세요. 그러면 쿼리는 "h"에서 "he", "hel", "hell", "hello"로 변경됩니다. 이렇게 되면 별도의 가져오기가 시작되지만 응답의 도착 순서에 대한 보장이 없습니다. 예를 들어, "hell" 응답이 "hello" 응답보다 나중에 도착할 수 있습니다. 마지막으로 setResults()를 호출하므로 잘못된 검색 결과가 표시됩니다. 이것을 "경쟁 상태(race condition)"라고 합니다. 두 가지 다른 요청이 서로 "경쟁"하여 예상과 다른 순서로 도착한 것입니다.

경쟁 상태를 해결하기 위해 정리 함수를 추가하여 쓸모 없는 응답을 무시해야 합니다.

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);
  useEffect(() => {
    let ignore = false;
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query, page]);

  function handleNextPageClick()

 {
    setPage(page + 1);
  }
  // ...
}

이렇게 함으로써 Effect가 데이터를 가져올 때, 마지막으로 요청한 것을 제외한 모든 응답이 무시되도록 보장합니다.

경쟁 상태 처리는 데이터 가져오기를 구현하는 데 있어서의 어려움 중 하나일 뿐입니다. 이전 화면을 즉시 볼 수 있도록 응답을 캐싱하고, 서버에서 데이터를 가져오는 방법(초기 서버 렌더링된 HTML에 가져온 콘텐츠가 표시되도록) 및 네트워크 폭포를 피하는 방법을 생각해볼 수도 있습니다.

이러한 문제는 리액트뿐만 아니라 모든 UI 라이브러리에 적용됩니다. 이를 해결하는 것은 쉽지 않으며, 이러한 문제에 대한 효율적인 기본 데이터 가져오기 메커니즘이 내장된 프레임워크를 사용하는 것이 더 좋습니다.

프레임워크를 사용하지 않거나(직접 만들고 싶지 않은 경우) Effects에서 데이터 가져오기를 더 편리하게 하려면 이와 같이 사용자 정의 Hook으로 가져오기 로직을 추출하는 것을 고려하세요.

function SearchResults({ query }) {
  const [page, setPage] = useState(1);
  const params = new URLSearchParams({ query, page });
  const results = useData(`/api/search?${params}`);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

function useData(url) {
  const [data, setData] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(url)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setData(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [url]);
  return data;
}

또한, 오류 처리 및 콘텐츠 로딩 여부 추적에 대한 일부 로직을 추가하는 것이 좋습니다. 이러한 로직을 직접 만들거나 리액트 생태계에 이미 많은 솔루션이 있습니다. 이것만으로는 프레임워크의 내장된 데이터 가져오기 메커니즘만큼 효율적이지는 않지만, 데이터 가져오기 로직을 사용자 정의 Hook으로 이동시키면 나중에 효율적인 데이터 가져오기 전략을 채택하기가 더 쉬워집니다.

일반적으로 Effects를 작성해야 할 때는 가능한 경우 useData와 같이 더 선언적이고 목적에 맞는 API를 가진 사용자 정의 Hook으로 기능을 추출할 수 있는지 살펴보세요. 컴포넌트에 있는 원시 useEffect 호출이 적을수록 응용 프로그램을 유지 관리하기 쉬워집니다.

요약

  • 렌더링 중에 계산할 수 있는 것은 Effect가 필요하지 않습니다.
  • 비싼 계산을 캐시하려면 useMemo를 사용하세요.
  • 전체 컴포넌트 트리의 상태를 재설정하려면 다른 key를 전달하세요.
  • 특정 비트의 상태를 프롭 변경에 응답하여 재설정하려면 렌더링 중에 설정하세요.
  • 컴포넌트가 "표시"되었을 때 실행되는 코드는 Effects에 있어야 하고, 나머지는 이벤트에 있어야 합니다.
  • 여러 컴포넌트의 상태를 업데이트해야 할 때는 이벤트 중에 수행하는 것이 더 좋습니다.
  • 여러 상태 변수의 동기화가 필요할 때는 상태를 위로 끌어올리는 것을 고려하세요.
  • Effects로 데이터를 가져올 수 있지만 경쟁 상태를 피하기 위해 정리를 구현해야 합니다.