React 스터디 3주차 useEffect - 1

Yunes·2023년 8월 17일
1

리액트스터디

목록 보기
6/18
post-thumbnail

서론

useEffect . 정말 많이 들어봤지만 잘 모르는 상황이라 서둘러 찾아보고 싶었던 주제다.

useEffect 는 왜 쓰는 걸까? 렌더링때 어떻게 동작할까? 애초에 effect 라는 건 뭐지?

이번 포스트는 이러한 물음에서 시작했다.

effect 에 대해 먼저 알아보자

렌더링 코드와 이벤트의 비교

어떤 컴포넌트들은 외부 시스템들과 동기화될 필요가 있다. Effect 는 렌더링 이후어떤 코드가 동작하게 해서 리액트 바깥은 몇몇 시스템과 컴포넌트를 동기화할 수 있게 한다.

effect 에 대해 알기전에 리액트 컴포넌트 내의 로직중 2가지 타입 Rendering code 와 Event handler 에 대해 친숙해질 필요가 있다.

Rendering code 는 컴포넌트의 상위 레벨에 존재한다. 상위 레벨이라 함은 props, state 를 다루고 변형하며 보기 원하는 화면에 JSX 를 반환하는 곳을 말한다. 수학 공식처럼 이는 오로지 결과를 계산만 해야 한다.

Event handlers 는 그저 계산만 하는게 아니고 컴포넌트 내에 있는 중첩함수이다. 이벤트 핸들러는 아마도 입력 필드를 갱신, 제품 구매를 요청하는 HTTP POST 제출, 혹은 사용자를 다른 화면으로 navigate 하는 동작을 한다. 이벤트 핸들러는 사용자의 동작에 의한 부수효과를 (side effect 는 프로그램의 상태를 변경한다.) 포함한다.

그런데 이건 충분하지 않다. 서버에 연결하는 것은 단순한 계산으로 표현하기 어렵고 렌더링 도중 발생하지 않는다.

이벤트와 이펙트의 비교

Effect 는 특정 이벤트가 아닌 렌더링 자체로 인해 발생하는 side effect 부수효과를 지정할 수 있다.

예시를 볼까

채팅에서 메세지를 보내는 것은 유저가 특정 버튼을 클릭하는 것으로 바로 발생하는 것이라 이벤트 라고 볼 수 있다.

반면에

서버 연결을 설정하는 것은 어떤 상호작용이 컴포넌트를 나타나게 했든 간에 발생해야 하기 때문에 이펙트 라고 볼 수 있다.

Effect 는 화면 갱신 후 커밋이 끝날때 실행된다. 이 때가 리액트 컴포넌트들이 외부 시스템과 동기화하기 좋은 순간이다.

Effect 를 무작정 추가하지 말자

Effect 는 React 코드에서 벗어나 외부 시스템과 동기화하는데 사용된다는 사실을 잊지 말자. 여기서 말하는 외부 시스템이란 브라우저 API, 서드 파티 위젯, 네트워크 등을 말한다.

만약 Effect 가 그저 몇 가지 상태에 기반하여 다른 상태를 조절하는데 쓰인다면 Effect 가 필요하지 않은 상황이다.

Effect 는 리엑트로부터 한발 떨어져서 외부 시스템과 동기화할 수 있게 해주는데 이런 외부 시스템이 없다면 Effect 가 필요하지 않다.

어떻게 불필요한 Effect 를 제거할 수 있을까

렌더링을 위한 데이터 변형에 Effect 는 필요없다.

예를 들어 리스트를 보이기 전에 필터링을 하고 싶다고 해보자. 리스트가 바뀔때 상태 변수를 갱신할때 Effect 를 사용해야 한다고 생각할 수 있다. 그런데 이건 비효율적이다.

React 는 상태를 변경할때 화면에 보여져야 하는 게 무엇인지 계산하는 컴포넌트 함수를 먼저 호출한다. 그러고 나서 React 는 Effect 를 실행한다.

그래서 Effect 가 즉각적으로 상태를 변경한다면 전체 프로세스가 재시작하게 되는 것이다.

무엇을 렌더링할지 계산하는 컴포넌트 함수 호출 (렌더링) - Effect 실행 (상태 변경) - 전체 재렌더링

이런 불필요한 렌더링을 피하려면 모든 데이터를 변경하는 것을 컴포넌트의 최상단에 위치시켜야 한다. 그 코드는 props 나 state 가 변경될때 자동적으로 실행될 것이다.

나는 개인적으로 글보다 코드가 더 이해가 잘 된다. 어떻게 동작하는지가 더 명확해 보이기 때문이다.

그럼 여기서 렌더링을 위한 데이터 변형의 비효율적인 예시와 데이터 변경을 컴포넌트 최상단에 위치시킨다는게 무슨 의미인지 확인하자

비효율적인 코드

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

function FilteredList({ list }) {
  const [filteredItems, setFilteredItems] = useState([]);

  useEffect(() => {
    const filteredList = list.filter(item => item.includes('filterKeyword'));
    setFilteredItems(filteredList);
  }, [list]);

  return (
    <div>
      {filteredItems.map((item, index) => (
        <div key={index}>{item}</div>
      ))}
    </div>
  );
}

export default FilteredList;

props 로 전달받은 값이 바뀔때 useEffect 내에서 state 를 변경하는 함수가 실행되어 렌더링 - effect 실행 - 재렌더링 이 되어 불필요한 렌더링이 발생하는 상태이다.

이걸 최상단에 위치시키면

import React from 'react';

function FilteredList({ list }) {
  const filteredList = list.filter(item => item.includes('filterKeyword'));

  return (
    <div>
      {filteredList.map((item, index) => (
        <div key={index}>{item}</div>
      ))}
    </div>
  );
}

export default FilteredList;

애초에 Effect 를 안써도 된다. 이로써 불필요한 렌더링 단계를 건너뛰게 되었다.

유저 이벤트를 다루는데 Effect 는 필요없다.

예를 들어 유저가 제품을 살때 알림을 보고 /api/buy POST 요청을 보내는 상황을 가정해 보자. 제품 구매하기 버튼 클릭 이벤트는 무엇이 발생할지 명확하다. 그런데 이걸 Effect 가 실행될때 하려면? 우리는 유저가 정확히 무엇을 했는지 어떤 버튼을 클릭했는지 알 수 가 없다.

그래서 유저 이벤트는 이에 대응하는 이벤트 핸들러에서 처리해야 한다.

이것도 코드로 알아봐야 진짜 이해할 수 있을 것 같다.

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

function ProductPage() {
  const [notification, setNotification] = useState('');

  useEffect(() => {
    // 이 예시에서는 Effect 내에서 구매 이벤트를 처리하려고 한다.
    // 하지만 Effect는 구체적인 사용자 동작에 대한 정보를 알지 못하기 때문에 권장되지 않는다.
    if (notification === 'buyClicked') {
      fetch('/api/buy', { method: 'POST' })
        .then(response => response.json())
        .then(data => {
          // API 응답을 처리하고 상태를 업데이트.
        });
    }
  }, [notification]);

  const handleBuyClick = () => {
    // 비효율: 상태를 업데이트하여 Effect를 하지만 구체적으로 사용자가 어떤 동작을 했는지 알 수 없다.
    setNotification('buyClicked');
  };

  return (
    <div>
      <button onClick={handleBuyClick}>제품 구매</button>
      ...
    </div>
  );
}

export default ProductPage;

이걸 개선한다면 Effect 대신 이벤트 핸들러에서 처리할 수 있을 것이다.

import React from 'react';

function ProductPage() {
  const handleBuyClick = () => {
    // 효율적: 사용자 이벤트를 직접 이벤트 핸들러에서 처리한다.
    fetch('/api/buy', { method: 'POST' })
      .then(response => response.json())
      .then(data => {
        // API 응답을 처리하고 필요한 경우 알림을 표시한다.
      });
  };

  return (
    <div>
      <button onClick={handleBuyClick}>제품 구매</button>
	...
    </div>
  );
}

export default ProductPage;

다시 말하지만 Effect 는 외부 시스템과 동기화하는데 필요하다.

앞에서 렌더링을 위한 데이터 변형, 유저 이벤트에서 effect 는 필요 없다고 했는데 effect 는 외부 시스템과 동기화할때 필요하다.

외부 시스템과 동기화할때

jQuery 위젯을 React state 와 동기화하는 경우

import React, { useState, useEffect } from 'react';
import $ from 'jquery'; // jQuery 를 가져왔다고 가정한 것이다.

function WidgetComponent() {
  const [widgetValue, setWidgetValue] = useState('');

  useEffect(() => {
    // jQuery 위젯과 React 상태를 동기화
    $('#widget').val(widgetValue);
  }, [widgetValue]);

  const handleWidgetChange = event => {
    setWidgetValue(event.target.value);
  };

  return (
    <div>
      <input id="widget" value={widgetValue} onChange={handleWidgetChange} />
    </div>
  );
}

export default WidgetComponent;

Effect를 사용해서 데이터를 가져올 때

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

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

  useEffect(() => {
    // 데이터를 가져와서 검색 결과를 현재 쿼리와 동기화한다.
    fetch(`/api/search?query=${query}`)
      .then(response => response.json())
      .then(data => {
        setResults(data);
      });
  }, [query]);

  return (
    <div>
      <ul>
        {results.map((result, index) => (
          <li key={index}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchResults;

이처럼 외부 시스템과 동기화할때 Effect 가 필요하다.

state 나 props 변경에 따라 state 를 바꿔줘야 할때

예시를 보자.

firstName 과 lastName 을 상태로 둔 상황에 fullName 을 만들고자 fullName 도 상태에 추가한 상황이다.

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

  // 🔴 Avoid: 장황한 상태와 불필요한 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

필요성보다 너무 복잡하고 비효율적이다. 값이 수정될 때마다 즉시 재렌더링된다.
상태 변수와 Effect 를 제거하면 다음과 같다.

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: 렌더링 동안 계산
  const fullName = firstName + ' ' + lastName;
  // ...
}

현재 존재하는 props 나 상태로 계산될 수 있다면 상태에 넣지 마라. 대신 렌더링중에 계산해라.

복잡한 계산을 캐싱해라

예를 들어 visibleTodos 를 props 를 통해 받은 todos 와 그들을 필터링하는 filter prop 으로 계산하는 컴포넌트를 생각해보자. 이때 계산한 결과를 상태에 저장하고 갱신하는 것을 Effect 에서 하는 것으로 코드를 짤 수 있다.

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

  // 🔴 Avoid: 불필요한 상태와 Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

앞선 예시처럼 불필요하고 비효율적이니 state 와 Effect 를 제거하면 다음과 같다.

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

그런데 getFilteredTodos 가 너무 느리고 많은 양의 todos 를 갖고 있다면 앞선 포스팅 : 재렌더링 최적화 편에서 정리했던 useMemo 훅으로 변수를 메모이제이션해서 계산 결과를 캐시할 수 있다.

useMemo 훅은 재렌더링 사이에 계산의 결과를 캐시해주는 훅이다.

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    // ✅ todos 나 필터가 바뀌지 않는한 재렌더링 되지 않는다.
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

또한 중괄호 내에 리턴문 하나만 있으니 줄여서 중괄호와 return 을 제거해서 쓸 수도 있다.

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ todos 나 필터가 바뀌지 않는한 재렌더링 되지 않는다.
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

prop 이 바뀔때 모든 상태를 리셋하는 경우

예를 들어 프로필 페이지가 userId 를 props 로 전달받을때 입력에 의해 comment 를 가질 수 있고 이걸 state 로 값을 유지하려고 한다. 그런데 한 프로필에서 다른 프로필로 넘어갈때 comment 상태가 리셋되지 않아서 다른 사용자의 프로필에서 comment 를 post 하게 될 수 있는 문제가 발견되어 userId 가 바뀌면 comment 상태를 초기화하도록 코드를 짠게 다음과 같다.

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

  // 🔴 Avoid: prop 가 바뀌는 것에 따라 Effect 에서 state 를 초기화
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

그런데 이것도 비효율적인게 ProfilePage 와 이 페이지의 children 은 state value 를 가지니 첫 렌덜이 이후 재렌더링된다. 또한 부모 컴포넌트가 재렌더링되면 자손 컴포넌트들이 모두 재렌더링되므로 이 과정이 반복된다.

대신 리엑트에게 각 사용자의 프로필이 개념적으로 별개임을 알려주기 위해 외적인 key 를 전달할 수 있다. 다음 예시에선 프로필을 부모와 자식으로 나누어 부모 컴포넌트에서 자식 컴포넌트로 key 어트리뷰트를 전달하도록 해결했다.

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

function Profile({ userId }) {
  // ✅ 이것과 다른 상태는 키가 바뀔때 자동으로 초기화될 것이다.
  const [comment, setComment] = useState('');
  // ...
}

리액트에서 매 렌더링마다 그때그때의 상태는 보존된다. userId 를 키로 Profile 컴포넌트에 전달하면 Profile 컴포넌트가 재렌더링 될때마다 갖는 userId 가 다르기에 리엑트는 Profile 이 별개의 것으로 인식할 수 있고 그 어떤 상태도 공유하지 않게 된다.

prop 이 바뀔때 일부 상태만 변경하는 경우

예를 들어 List 컴포넌트가 items 리스트를 prop 으로 받는 경우 selection 상태에 선택한 item 을 유지한다고 해보자. 이때 items 가 바뀔때 selection 선택한 아이템을 초기화하기 위해 null 로 리셋하려고 useEffect 를 사용했다.

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

  // 🔴 Avoid: Effect 에서 prop 가 바뀜에 따라 state 를 조절
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

이것 역시 이상적이지 않은데 매 번 items 가 바뀌면 selection 상태를 갖는 List 와 자손 컴포넌트들이 렌더링되고 렌더링 이후 Effect 가 실행되는데 이때 selection 이 null 로 바뀌면서 List 와 자손 컴포넌트들이 재렌더링되어 전체 프로세스를 재시작한다.

이 경우엔 Effect 지우고 이전 렌더링으로 부터 정보를 저장하자

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

  // Better: 상태를 렌더링때 제어하자.
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

앞선 예시에선 첫 렌더링 - props 변경 - 재렌더링 - Effect 실행 - 상태 변경 - 재렌더링 이었다면

이 경우 첫 렌더링 - props 변경 - 재렌더링 으로 끝난 셈이다.

물론 이건 예외케이스다

항상 이 방식이 옳다는 것이 아니다. 어떤 방식이든 props 나 다른 상태를 기반으로 상태를 조절하는 것은 데이터 흐름에 대한 이해와 디버깅을 어렵게 한다. 항상 키로 모든 상태를 리셋하거나 모든 것을 렌더링 동안 계산하는 방식을 대신 사용할 수 있음을 확인하자.

예를 들어 앞선 예시에선 selected item 을 저장했지만 그 대신 selected item ID 를 저장할 수도 있다.

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: 렌더링 동안 계산
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

이벤트 핸들러 사이에 로직을 공유하는 경우

상품을 구매하는 페이지에서 구매하기 버튼이나 장바구니 버튼은 결국 상품을 구매하게 한다. 언제든지 사용자가 장바구니에 상품을 넣을때 알림을 받고 싶다면 이 알림을 받는 메서드가 중복이라고 생각되어 Effect 내에 구현하려고 할 수 있다.

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Effect 내에 특정 이벤트 로직
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

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

이 코드는 불필요하고 버그를 유발할 수 있다. 예를들어 화면을 새로고침하면 여전히 product.isInCart 를 만족하는 상황이라 새로고침할때마다 모달이 나타나게 된다. (쇼핑카트에 ~ 상품을 추가했어요~!) 그런데 우리는 버튼을 클릭할때 모달이 나타나게 하고 싶은 것이지 페이지를 새로고침할때 모달을 보고싶은게 아니다.

처음에 언급할때도 Effect 를 쓰지 말아야 하는 경우에 렌더링을 위한 데이터 변환 (props, state 변환시 state 변경)이벤트를 다루는 경우를 언급했었다.

공식문서에서는 Effect 안에 코드를 넣어야 할지 아니면 이벤트 핸들러 내에 넣어야 할지 확신이 들지 않는다면 왜 이 코드를 실행해야 하는지 스스로 생각해 보라고 안내한다.

검증하기

음.. 근데 나는 이때 이해가 되지 않았다. useEffect 는 의존성 배열내에 값이 바뀌면 렌더링 이후 Effect를 실행하는거로 이해했는데 새로고침하면 의존성 배열에 변화가 없는 것 같은데 왜 Effect 가 실행되는거지?

import React, { useState, useEffect } from "react";

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("의존성 배열 x Effect 실행됨");
    return () => {
      console.log("의존성 배열 x 언마운트 전");
    };
  });

  useEffect(() => {
    console.log("빈 의존성 배열 Effect 실행됨");
    return () => {
      console.log("빈 의존성 배열 언마운트 전");
    };
  }, []);

  useEffect(() => {
    console.log("값 있는 의존성 배열 Effect 실행됨");
    return () => {
      console.log("값 있는 의존성 배열 언마운트 전");
    };
  }, [count]);

  const handleIncrement = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <h1>Counter: {count}</h1>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
}

export default App;

3가지 예시를 담은 코드를 만들어 봤다.

  • 의존성 배열이 없을때
  • 빈 배열을 의존성 배열로 사용할때
  • state 값 하나가 담긴 배열을 의존성 배열로 사용할때

첫 렌더링때 어떻게 동작할까?

Strict Mode 가 있어서 production 이 아닌 local 환경이라서 한번더 실행이 된 것인데 첫 렌더링때 각 useEffect 의 콜백함수가 실행 - 각 useEffect 의 clean up 함수 실행 - 각 useEffect 의 콜백함수 실행

이런 순서로 동작하는 것 같다.

count 값이 바뀌면 재렌더링되겠지?

상태가 바뀌니 빈 배열을 의존성 배열로 사용하는 useEffect 를 제외한 나머지는 재렌더링 될때 cleanup 함수가 실행 된 후 언마운트 되고 재렌더링 된 후 Effect 가 실행 되었다.

상태가 바뀌어 재렌더링 될때도 console.log("값 있는 의존성 배열 Effect 실행됨"); - console.log("값 있는 의존성 배열 언마운트 전"); - console.log("값 있는 의존성 배열 Effect 실행됨"); 순서로 실행될 줄 알았는데
cleanup - Effect 만 실행된다.

그럼 state 는 안바꾸고 새로고침만 할때는 Effect 가 실행될까?

import React, { useState, useEffect } from "react";

function Counter({ count }) {
  const [localCount, setLocalCount] = useState(count);

  useEffect(() => {
    if (count) {
      console.log("새로고침할때 Effect 가 실행된다.");
      setLocalCount(count);
      return () => {
        console.log("unmount 될때 ");
      };
    }
  }, [count]);

  const handleIncrement = () => {
    setLocalCount(localCount + 1);
  };

  return (
    <div>
      <h1>Counter: {localCount}</h1>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
}

function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <Counter count={3} />
    </div>
  );
}

export default App;

코드를 위와 같이 Counter 가 props 를 전달받는 상황에 새로고침하는 경우를 실행해봤다.

어쩌면 당연할 수도 있는데 첫 렌더링후 Effect 실행 -> unmount 되기전 clean up 실행 -> Strict Mode 에 의해 Effect 다시 실행

이렇게 작동이 되어서 공식문서에서 말하는

It will keep appearing every time you refresh that product’s page.

상품페이지를 새로고침할때마다 모달을 보게 된다는 말이 이해되었다. 나는 dependency array 가 안바뀌었는데 새로고침해도 Effect 가 실행이 안되는게 아닌가 했지만 Effect 는 렌더링 이후 기본적으로 실행된다. 다만 의존성 배열을 추가하면 첫 렌더링 이후에만 Effect 를 실행할 것인지 혹은 재렌더링 이후마다 Effect 를 실행할 것인지 여부를 의존성 배열이 결정하는 것이다.

이미지 출처: 공식문서 react.dev - useEffect

다시 상품 구매페이지로 돌아와서

결국 버튼이 클릭된다는 이벤트 발생할때 모달을 띄우고 싶은건데 새로고침할때 렌더링이 되니 Effect 도 실행되는 것이니 사용자 입장에서 이건 거슬리는 버그다.

그럼 Effect 를 지워버리자

function ProductPage({ product, addToCart }) {
  // ✅ Good: 이벤트로부터 호출된 이벤트 특화 로직
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

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

useEffect 는 알아야 할 것이 매우 많다. 그래서 시리즈를 몇 편으로 나누어 정리할 예정이다.

레퍼런스

docs
react.dev - 당신은 아마 Effect 를 필요로 하지 않을겁니다

profile
미래의 나를 만들어나가는 한 개발자의 블로그입니다.

2개의 댓글

comment-user-thumbnail
2023년 8월 17일

유익한 자료 감사합니다.

1개의 답글