본문 바로가기

나야, 리액트 스터디

[week 7] - Effect가 필요하지 않은 경우 알아보기

안녕하세요 ~ YB 김가현입니다.

이번 주는 useEffect에 대해 자세히 알아볼 수 있는 기회였는데요 ..

알면 알수록 어려운 훅인 것 같습니다. 

항상 그래서 언제 사용하는거고, 사용하지 말라는거지 ? 싶었는데 Effect가 필요하지 않은 경우에 대해 상세히 안내해줘서, 예시를 차근차근 이해해보는 시간을 가졌답니다 ! 

 

시자개볼게요 .. 

 

🔎 Effect ?

Effect는 컴포넌트가 렌더링될 때 부수 효과(Side Effect)를 수행하기 위해 사용하는 기능이다.

보통 useEffect 훅을 통해 구현된다.

리액트에서 Effect는 렌더링 자체에 의해 발생되며, 커밋이 끝난 후 화면 업데이트가 이루어지고 나서 실행된다.

 

🔎 useEffect 구조

리액트에서 라이프 사이클은 크게 3단계 (Mount → Update → UnMount)로 이루어지는데,

useEffect 훅을 이용하면 이 라이프사이클 중 원하는 시점을 잡아서, 원하는 로직을 콜백함수에 담아 호출할 수 있다.

useEffect는 컴포넌트가 렌더링된 이후 비동기적으로 사이드 이펙트를 처리한다.

기본문법

import { useEffect } from 'react';

import React, { useEffect } from 'react';

useEffect(() => {
	// 콜백 함수 위치
    // side effects 수행 (ex: 데이터 가져오기, 타이머 설정 등)
    
    return () => {
        // cleanup 로직 (ex: 타이머 정리, 이벤트 리스너 제거 등)
    };

}, [/* 의존성 배열 */]);  // 의존성 배열에 있는 값이 바뀔 때 useEffect가 실행됨

 

콜백 함수는 컴포넌트가 처음 렌더링 될 때, 그리고 의존성 배열이 변할 때마다 실행된다.

콜백 함수가 리턴하는 함수는 다음 렌더링이 일어나기 직전과 컴포넌트가 언마운트될 때 실행되는데 이를 cleanup 함수라고 한다.

 

cleanup 함수는 useEffect 콜백에서의 작업들을 정리하는 역할을 한다 (ex. 연결 해제, 리소스 해제 등 ..)

 

의존성 배열을 설정하는 법은 크게 세 가지로 나눌 수 있다.

첫 번째는 의존성 배열을 전달하지 않는 것이다. 의존성 배열을 생략하면, useEffect는 컴포넌트가 렌더링될 때마다 실행된다.

두 번째는 빈 의존성 배열([])을 전달하는 것이다. 의존성 배열에 아무 값도 넣지 않으면, useEffect는 컴포넌트가 마운트될 때 딱 한 번만 실행된다.

세 번째는, 특정 값을 담아 의존성 배열 전달하는 방식이다. 의존성 배열에 있는 값이 변경될 때마다 useEffect가 실행된다. 이 때 의존성 배열의 원소들 중 하나라도 이전 렌더링 시와 다른 값을 가진다면 콜백 함수를 실행하는데, Object.is 를 사용한다. === 연산자와 유사하지만, 몇 가지 특별한 경우 다른 결과를 반환한다. 객체의 경우 참조가 같아야 같은 객체로 판단한다.

 

 

🤟🏿 주의할 점 …

의존성 배열을 전달하지 않고 useEffect 내에서 state를 변경하면 무한루프가 발생한다.

function App(){
  const [count, setCount] = useState(0);
  useEffect(() => {
    setCount(count + 1);
  });
  return <p>감기조심하세요..</p>;
}

렌더링 → count 업데이트 → state 변경에 따른 리렌더링 → count 업데이트 → … 무한루프

따라서 useEffect를 사용할 때 상태를 직접 변경하는 로직을 작성할 때는 의존성 배열을 올바르게 설정해야 한다

 

🔎 불필요한 Effect를 제거하는 방법

불필요하게 Effect를 사용하는 것을 지양해야 한다.

불필요한 Effect를 제거하면 코드를 더 쉽게 따라갈 수 있고, 실행 속도가 빨라지며, 에러 발생 가능성이 줄어든다.

 

✅ Effect가 필요하지 않은 일반적인 경우

 

1. 렌더링을 위해 데이터를 변환하는 경우

ex) 리스트를 표시하기 전에 필터링하고 싶을 때 → 리스트가 변경될 때 state 변수를 업데이트하는 Effect를 작성하면 되지 않을까 ?

이렇게 할 경우, 아래와 같은 프로세스가 수행된다.

  1. React는 먼저 컴포넌트 함수를 호출하여 화면에 표시할 내용을 계산
  2. DOM에 변경 사항 적용
  3. Effect가 실행되어 필터링
  4. Effect에서 다시 상태 업데이트시, 컴포넌트 함수를 다시 호출 → 프로세스가 처음부터 다시 시작

→ 불필요한 렌더링이 발생한다.

 

🤩 해결방안

컴포넌트의 최상위 레벨에서 모든 데이터를 변환하면 된다.

이렇게 하면 상태나 props가 변경될 때마다 해당 코드가 자동으로 실행되어 React가 최적의 방식으로 렌더링을 수행할 수 있고 불필요한 렌더링을 피할 수 있다.

 

2. 사용자 이벤트를 처리하는 경우

ex) 사용자가 제품을 구매할 때 POST 요청을 보내고, 알림을 표시하고 싶을 때

Effect가 실행될 때까지 사용자가 무엇을 했는지 (예: 어떤 버튼을 클릭 했는지) 알 수 없다.

 

🤩 해결방안

일반적으로는 해당되는 이벤트 핸들러에서 사용자 이벤트를 처리한다. 이렇게 하면 사용자가 원하는 행동에 즉시 반응할 수 있다.

 

 

✅ 그럼 Effect는 언제 사용하나요 ..?

외부 시스템과 동기화하거나 데이터를 가져오는 작업에는 Effect가 반드시 필요하다.

(이 작업을 더 효율적으로 하기 위해 Tanstack Query와 같은 라이브러리가 있는 것 !)

 

 

공식 문서 속 구체적인 예시들을 하나하나 이해해보자 … 🙃

 


 

1. Props 또는 state에 따라 state 업데이트하기

아래 코드를 살펴보면, firstName과 lastName이라는 두 개의 state 변수를 두고, 이를 연결하여 fullName을 계산한 뒤 firstName과 lastName이 변할 때마다 fullName을 업데이트 하고 있다.

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const [fullName, setFullName] = useState('');
  
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

이 코드는 효율적일까 ?

위 코드는 필요 이상으로 복잡하다. fullName은 두 state(firstName ,lastName)에서 항상 계산 가능하기 때문에 굳이 별도의 상태로 관리할 필요가 없다. 어차피 firstName, lastName 중 하나가 변경되면 컴포넌트 함수 전체가 다시 호출될 것이고, 새로운 값을 기반으로 fullName도 업데이트 될 것이기 때문이다.

따라서 fullName을 별도의 상태로 관리하지 않고, 렌더링 시 계산된 값으로 처리하면 더 효율적이다.

 

무엇을 state로 관리해야 할까 고민이 된다면, 아래 세 가지 질문을 통해 결정해보기 ! (by 공식문서)

 

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

아래 코드를 살펴보면 visibleTodos라는 state를 만들고, useEffect로 todo나 filter가 변경될 때를 감지하여 visibleTodos를 업데이트 하고 있다.

const TodoList({todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);
};

1번 케이스와 마찬가지로, visibleTodos는 todos, filter로 계산할 수 있으므로 굳이 state로 만들 필요가 없다.

 

🔵 개선된 방법

const TodoList({todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  
  const visibleTodos = useMemo(() => {
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
};

useMemo를 이용해 계산 결과를 메모이제이션하는 방식이다.

의존성 배열에 포함된 todos, filter가 변경되지 않으면 getFilteredTodos를 실행하지 않는다. getFilteredTodos의 결과를 매번 새로 계산하지 않으므로 불필요한 계산을 줄이는 효율적인 방식이다.

 

3. prop 변경 시 모든 state 초기화

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

위 코드는 userId가 변경될 때마다 comment state 변수를 비우고자 한다.

그러나, useEffect는 렌더링 후에 실행되므로, 컴포넌트는 먼저 comment의 이전 값을 기반으로 렌더링할 것이고 그런 다음 setComment('')로 상태를 초기화하고 다시 렌더링이 트리거되므로 두 번의 렌더링이 발생하는 비효율적인 방식이다. 이와 더불어 만약 ProfilePage가 중첩된 댓글 UI처럼 하위 컴포넌트를 가지고 있다면, 하위 컴포넌트의 comment 상태도 초기화해야 하므로 복잡하다.

 

🔵 key 속성 사용하기

React에서는 컴포넌트의 key 속성이 변경될 때, 해당 컴포넌트를 새로 생성한다는 것을 이용하여 key를 userId로 설정한다. 이렇게 하면 userId가 변경될 때마다 컴포넌트가 완전히 새로 생성되므로 초기 상태가 자동으로 적용됩니다.

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

function Profile({ userId }) {
  const [comment, setComment] = useState('');
  // ...
}

 

 

4. prop이 변경될 때 일부 state 조정하기

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

위 코드는 items를 props로 받아 selection state에 선택된 item을 보관하고, items prop이 변경될 때마다 selection을 null로 재설정 하고 있다. 3번 케이스와 마찬가지로 selection을 null로 만들기 위해 두 번의 리렌더링을 거처야 하는 비효율적인 방식이다.

 

따라서 아래와 같이 리스트가 렌더링 될 때 state를 조정하는 방식으로 수정할 수 있다.

const List = ({items}) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);
  
  const [prevItems, setPrevItems] = useState(items);
  if(items !== prevItems) {
    setPrevItems(itmes);
    setSelection(null);
  }
};

이렇게 해도 되나 ..? 싶었는데 이 패턴이 Effect보다 더 효율적이지만 대부분의 컴포넌트에는 이 패턴이 필요하지 않는다고 한다.

 

따라서, 아래 두 가지가 가능한지 먼저 확인하는 것이 중요하다.

  1. key를 사용하여 모든 state를 초기화
  2. 렌더링 중에 모든 state를 계산할 수 있는가

렌더링 중에 모든 state를 계산하는 방식으로 개선한 코드

(선택된 아이템의 id를 state로 저장해두고, 해당하는 아이템이 있으면 유지하고 없는 경우에는 null로 바꾸기)

const List = ({items}) => {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  
  const selection = items.find(item => item.id === selectedId) ?? null;
};

 

 

 

5. 이벤트 핸들러 간 로직 공유

function ProductPage({ product, addToCart }) {
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);
 
  function handleBuyClick() {
    addToCart(product);
  }
 
  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

productPage에서 product가 변할 때마다 showNotification을 호출하는 방식이다. 카트에 제품을 한 번 추가하고 페이지를 새로 고치면 알림이 다시 표시된다 → 버그 ..

 

실행하고자 하는 로직이 어떤 동작에 의해 실행되는 것인지를 고려해야 한다 !

위 코드의 경우 사용자가 버튼을 눌렀을 때 알림이 떠야 하므로 useEffect가 아니라 두 이벤트 핸들러 내에서 showNotification가 호출되어야 한다.

 

 

 

6. POST 요청 보내기

POST 요청을 보내는 두 가지 경우가 있다. 각각 어떻게 보내야 할까 ?

  1. 마운트 될 때
  2. 폼을 작성하고 Submit 버튼을 클릭했을 때

컴포넌트가 마운트 되었을 때 실행해야할 동작에서는 useEffect에서 처리하는 것이 적합하다. 만약 마운트 되었을 때 일어나야 할 작업을 useEffect 가 아닌 컴포넌트 함수에서 직접 처리한다면, 렌더링 로직과 사이드이펙트 로직이 섞여 복잡해질 수 있기 때문이다.

사용자 입력에 의해 실행되는 동작은 이벤트 핸들러에서 처리하는 것이 좋다. 이벤트 핸들러는 해당 동작과 바로 연결되므로, 실행 시점을 명확히 제어할 수 있기 때문이다.

실행하고자 하는 로직이 특정 상호작용으로 인해 발생하는 것이라면 이벤트 핸들러에, 사용자가 화면에서 컴포넌트를 보는 것이 원인인 경우는 Effect에 두면 된다.

 

 

7. state 변경을 부모 컴포넌트에게 알리기

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);
 
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])
 
  function handleClick() {
    setIsOn(!isOn);
  }
 
  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }
 
 // ...
}

 

위 코드에서는 setIsOn이 실행되면서 Toggle 컴포넌트가 리렌더링되고, useEffect 가 호출되면서 onChagne 호출하면서 부모 컴포넌트 리렌더링한다. 이는 두 번의 렌더링 패스가 일어나므로, 비효율적이다. 모든 동작을 한 번의 패스로 처리할 수 있다.

 

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);
    }
  }
 
  // ...
}

 

 

8. 부모에게 데이터 전달하기

자식 컴포넌트에서 API fetch를 수행한 뒤 부모의 state에 저장해야 하는 경우라면 ?

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

위와 같이 자식 컴포넌트가 Effect를 활용해 부모 컴포넌트로 state를 업데이트하는 경우 데이터 흐름을 추적하기가 어려워진다.

따라서 자식과 부모 모두 동일한 데이터가 필요한 경우라면 부모에서 데이터를 fetch 후 자식에게 내려주는 방식이 더 효율적이다 !

function Parent() {
  const data = useSomeAPI();
  // ...
  return <Child data={data} />;
}
 
function Child({ data }) {
  // ...
}

 

 

9. 외부 저장소 구독하기

React state가 아닌 외부 저장소의 데이터를 구독하는 경우라면 ?

function useOnlineStatus() {
  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();
  // ...
}

 

처음 state는 true로 설정되며, 브라우저에서 해당 데이터 저장소의 값이 변경될 때마다 컴포넌트는 해당 state를 업데이트한다.

보통 이 경우 Effect를 사용하는 것이 일반적이지만, 외부 저장소를 구독하기 위해 특별히 제작된 Hook인 useSyncExternalStore 를 사용할 수도 있다.

 

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}
 
function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe, // 동일한 함수를 전달하는 한 React는 다시 구독하지 않습니다.
    () => navigator.onLine, // 클라이언트에서 값을 얻는 방법
    () => true // 서버에서 값을 얻는 방법
  );
}
 
function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

이 방식은 Effect를 사용해 React state에 수동으로 동기화하는 것보다 에러가 덜 발생한다.

 

 

10. 데이터 가져오기(fetch)

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);
  }
  // ...
}

위 코드처럼 input의 onChange에 의한 이벤트라면 최종적으로 어떤 데이터가 도착할지는 알 수 없다. 

hello를 빠르게 입력한다고 가정하면, "h", "he", "hel", ... , "hello” 에 대한 fetch가 각각 진행될텐데, 반드시 hello가 가장 마지막에 도착한다고는 보장할 수 없다. 이렇게 되면 잘못된 검색 결과를 표시할 수 있는데, 이를 경쟁 조건이라고 한다. 서로 다른 두 요청이 서로 “경쟁”하여 예상과 다른 순서로 도착하는 것을 의미한다.

 

따라서 오래된 응답을 무시하는 정리 함수를 추가해야 한다.

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가 데이터를 가져올 때 마지막으로 요청된 응답을 제외한 모든 응답이 무시된다.

 

 

 

 


useEffect를 야무지게 쓰는 것 정말 어렵네요 ~~ @.@