[4회차] (발표 범위) You Might Not Need an Effect - Effect가 필요하지 않은 경우

Sheryl Yun·2023년 10월 20일
0
post-thumbnail

서론

  • Effect는 React의 흐름을 벗어난 동작 구현에 사용
    • 예: 서버와의 네트워크 통신, 브라우저 DOM 직접 건드리기 등
  • React 흐름 안의 동작(props나 state로 연산이 가능한 경우)은 Effect가 불필요

불필요한 Effect 제거하기

Effect가 필요하지 않은 경우: 크게 2가지

1. 렌더링에 쓸 데이터를 가공하는 경우

  • state를 변경하는 Effect는 시점 상으로 불필요
    • 리액트는 state를 변경하면 리렌더링
    • Effect는 렌더링 직후에 실행
  • 성능에도 좋지 않음
    • 만약 Effect 안에 state 변경 로직이 있으면 리렌더링 또 한번 수행

모든 데이터 변환은 컴포넌트 최상위 레벨에서 한다.

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

  • 공통점: 둘 다 렌더링 후에 실행
  • 차이점: 실행 시점에서의 차이
    • Effect는 렌더링 '직후'에 실행 (의존성 배열에 따라 실행 안 될 수도 있음)
    • 이벤트 핸들러는 렌더링 후 '언젠가' 실행 (사용자 상호작용이 있어야 발동)
  • 이벤트 핸들러는 사용자의 '클릭' 이벤트가 발생했다는 걸 인지할 수 있지만 Effect는 모름

Effect를 쓰면 안 되는 경우들: 구체적 예시

1. props나 state로 계산이 가능할 때

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

  // 🔴 Bad: 중복 state 및 불필요한 Effect
  const [fullName, setFullName] = useState('');
  
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}
  • 전체 렌더링 과정에서 fullName에 대한 오래된 값을 사용한 다음, 즉시 업데이트된 값으로 다시 리렌더링
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  
  // ✅ Good: 렌더링 과정 중에 계산
  const fullName = firstName + ' ' + lastName;
  
  // ...
}

2. 고비용 계산을 캐싱할 때

  • 요약: Effect가 아니라 useMemo를 써야 한다. (memoization)
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // 🔴 Bad: 중복 state 및 불필요한 Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
 
  // ✅ getFilteredTodos()가 느리지 않다면 Good
  const visibleTodos = getFilteredTodos(todos, filter);
  
  // ...
}
  • 예: todos가 수천 개의 배열인 경우
  • useMemo로 getFilteredTodos 함수를 감싸고, 의존성 배열에 인수로 들어가는 값들을 넣어준다.
    • 이 인수 값들이 변할 때만 함수를 실행하고, 그 외에는 useMemo가 memoization 해둔 반환 값을 재사용한다.
    • 그리고 값들이 변경되어 함수가 useMemo를 무시하고 다시 실행되면 새로운 반환 값을 또 memoization 해둔 뒤에 나중에 재사용한다.
  • 함수는 렌더링 '중에' 실행 - useMemo도 렌더링 '중에' 실행 (Effect와 실행 시점이 다름)

Tip: 고비용 연산인지 계산하는 법

  • 형태: console.time + 연산 시간을 측정하고 싶은 코드 + console.timeEnd
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
// filter array : 0.15ms
  • 1ms 이상이라면 useMemo로 캐싱하자.
  • 주의할 점: 개발 모드에서는 StrictMode로 인해 2번씩 리렌더링됨 - 빌드 환경(사용자가 실제 쓰는 환경)에서 위 내용 시도

3. prop 변경 시 모든 state가 변경될 때

  • 요약: Effect 대신 key를 활용하자
export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 Bad: prop 변경 시 Effect에서 state 재설정 수행
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}
  • 첫 렌더링 이후 Effect에 의해 한 번 더 렌더링 발생
  • 댓글 UI가 중첩되어 있는 경우 중첩된 하위 댓글 state들도 모두 지워야 함

key를 사용하면 일어나는 일

  • key가 동일한 UI 컴포넌트들 간이라도 서로 간에 state를 공유하지 않는다는 걸 명시
export default function ProfilePage({ userId }) {
  return (
    <Profile
      key={userId}
      userId={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ Good: key가 변하면 이 컴포넌트 및 모든 자식 컴포넌트의 state가 자동으로 재설정됨
  const [comment, setComment] = useState('');
  // ...
}
  • ProfilePage는 각 Profile에 달리는 comment 상태를 알 필요가 없음
  • ProfilePage에 전달되는 prop인 userId가 바뀌면 ProfilePage가 리렌더딩
    • 리액트는 부모가 리렌더링되면 자식도 리렌더링
    • 자식인 Profile이 리렌더링되면서 내부 상태인 comment는 자동으로 초기화 (Effect 불필요)

4. prop 변경 시 일부 state만 변경될 때

  • items이 다른 배열을 받을 때마다 selection을 null로 재설정
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 Bad: prop 변경시 Effect에서 state 조정
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}
  • Effect 안에서의 setSelection(null)호출은 List와 그 자식 컴포넌트를 다시 렌더링하여 이 전체 과정을 재시작하게 됨
  • 해결: 렌더링 '중에' 직접 state를 조정
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: 렌더링 중에 state 조정
  const [prevItems, setPrevItems] = useState(items);
  
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  
  // ...
}
  • 이해하기 어려울 수 있지만, Effect에서 state를 업데이트하는 것보다는 낫다.
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);

  // ✅ Best: 렌더링 중에 모든 값을 계산
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

BONUS: ?? 연산자와 || 연산자의 차이

  • ?? 연산자는 좌항이 null이나 undefined이면 우항 반환 (아니면 좌항 반환)
  • || 연산자는 좌항이 falsy값(false, null, 0, NaN, undefined, 빈문자열)인 경우에 우항 반환 (어느 것에도 해당 안 되면 좌항 반환)

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

function ProductPage({ product, addToCart }) {

  // 🔴 Bad: 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 -> false로 변경) 알림 실행
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');
  }
  
  // ...
}
  • 장바구니에 추가(addToCart)하고 알림을 보여주는 것까지 buyProduct 함수로 묶은 다음 각 클릭 이벤트 내에서 실행

Effect인지 이벤트 핸들러인지 구분하는 법

  • '이 코드가 실행되어야 하는 이유(코드가 trigger된 근본적 이유)'를 생각해보기
  • 컴포넌트가 사용자에게 '표시되었기 때문에' 실행되는 코드는 Effect를 사용해야
  • 위 예제의 알림 로직은 '화면이 표시'되었기 때문이 아니라 '사용자가 버튼을 눌렀기 때문에' 실행

6. POST 요청 보낼 때

  • 컴포넌트 마운트 시 분석 이벤트 보내기 ('/analytics/event')
  • 사용자가 버튼 클릭 시 제출 이벤트 발생 ('/api/register')
function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: '컴포넌트가 표시되었기 때문에 로직이 실행되는 경우'에 해당
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Bad: Effect 안에 이벤트 관련 로직 존재
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}
  • 분석 POST 요청은 컴포넌트 마운트로 화면이 표시되었을 때 발생
  • 하지만 제출 후 등록 POST 요청은 사용자가 제출 버튼을 '클릭'했을 때 발생
    • 이벤트 관련 로직은 Effect에서 제거해야
function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ...

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: 이벤트 핸들러 안에서 특정 이벤트 로직 호출
    post('/api/register', { firstName, lastName });
  }
  // ...
}

7. 연쇄 계산

  • Effect를 여러 개 써서 '하나가 실행되면 그걸 가지고 다음을 실행'하는 경우
function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 Bad: 오직 서로를 촉발하기 위해서만 state를 조정하는 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);
    }
  }

  // ...
  • 위 방식은 두 가지 문제 발생
    • Effect 안의 set 함수로 불필요한 리렌더링이 연속적으로 발생
    • 단계가 추가될 경우 코드 변경이 복잡함 (높은 결합도로 인한 '경직되고 취약한' 코드)
  • 해결: 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.');
    }

    // ✅ 이벤트 핸들러에서 다음 state를 모두 계산
    setCard(nextCard);
    
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        
        setRound(round + 1);
        // 동기적으로 round를 판별하려면 useState 대신
        // const nextRound = round + 1 이용
        
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

예외

  • 이벤트 핸들러에서 다음 state를 계산할 수 없는 경우
  • 예: 이전 드롭다운의 선택 값에 따라 다음 드롭다운의 옵션이 달라지는 form
    • 이를 네트워크와 동기화(서버 호출)해야 한다면 Effect 체인이 적절

8. 애플리케이션 초기화 (인증 로직)

  • 토큰이 무효화될 수 있는 인증 로직의 경우 컴포넌트 마운트 시 무조건 한 번만 실행되어야 함
    • useEffect의 빈 배열을 활용해서 구현하고 싶어짐 (그러나..)
function App() {

  // 🔴 Bad: 한 번만 실행되어야 하는 로직이 포함된 Effect
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}
  • 개발 모드의 Strict Mode에 의해 인증 토큰이 무효화되는 등의 문제 발생

Tip: Strict Mode 처리 방법

  • 처리하지 말기 (그대로 유지)
  • 빌드 후(상용 환경)에는 두 번 실행되는 동작 없음 (개발 모드에서만 발생)
  • 참고 링크
  • 로직이 컴포넌트 마운트가 아닌 앱 로드 시 단 한 번만 실행되어야 하는 경우, (App보다 더 높은 곳에) 최상위 변수를 추가하여 앞에 한번 실행되었는지 여부를 추적
let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ 앱 로드당 한 번만 실행됨
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  
  // ...
}
  • 모듈 초기화 중이나 앱 렌더링 전에 실행할 수도 있음
if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인
  // ✅ 앱 로드당 한 번만 실행됨
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}
  • 이렇게 앱 완전 최상단에 코드를 두는 것 - 권유하는 패턴은 아님
    • 최상위 레벨의 코드는 렌더링은 안 되더라도 일단 한 번은 실행됨
    • 완전한 전역 공간을 쓰는 거라 속도 저하나 예상치 못한 동작 발생 가능
      • 되도록이면 앱 전체 초기화 로직은 App.js나 애플리케이션의 entry 포인트(index.js)에 유지

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

  • Toggle 내부 state가 변경될 때마다 부모 컴포넌트에 알리고 싶어서 onChange 이벤트를 prop으로 받고 Effect에서 이를 호출
    • onChange '이벤트'를 Effect 안에서 호출한 상황
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

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

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

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

  // ...
}
  • 해결: Effect를 삭제하고 이벤트 핸들러 내에서 두 컴포넌트의 state를 업데이트
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
  
    // ✅ Good: 이벤트 발생시 모든 업데이트를 수행
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

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

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

  // ...
}

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

  • 부모의 set 함수만 자식에게 내려보냄
function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  
  // 🔴 Bad: Effect에서 부모에게 데이터 전달
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  
  // ...
}
  • React에서는 데이터가 부모에서 자식으로 단방향으로 흐름
    • 렌더링이 잘못되었을 때 체인을 따라 올라가서 원인 추적 용이 (예측 가능)
  • 그러나 자식에서 Effect를 통해 부모의 state를 변경하면 데이터 흐름 추적하기가 매우 어려워짐
  • 해결: 페치 로직을 부모 컴포넌트에 둬서 결과값을 자식에게 전달
function Parent() {
  const data = useSomeAPI();
  // ...
  
  // ✅ Good: 자식에게 데이터 전달
  return <Child data={data} />;
}

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

11. 외부 스토어 구독하기

  • 컴포넌트가 리액트 state의 외부에 있는 데이터를 구독하는 경우
    • third-party 라이브러리, 브라우저 내장 API 등
  • 아래 코드에서 navigator는 브라우저의 navigator.onLine API를 의미
  • 이러한 데이터는 리액트 흐름 바깥에 있기 때문에 React가 모르는 사이에 변경될 수도 있음
    • 해결: 수동으로 컴포넌트가 해당 데이터를 구독하도록 하기 => 이걸 종종 Effect에서 수행하게 됨 (But 이상적인 방법은 아님)
function useOnlineStatus() {

  // 🔴 Not Ideal: Effect에서 수동으로 store 구독
  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();
  // ...
}
  • 개선: 리액트에서 외부 저장소를 구독하기 위해 특별히 제작된 useSyncExternalStore 훅 사용
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: 빌트인 훅에서 외부 store 구독
  return useSyncExternalStore(
    subscribe, // React는 동일한 함수를 전달하는 한 다시 구독하지 않음
    () => navigator.onLine, // 클라이언트에서 값을 가져오는 방법
    () => true // 서버에서 값을 가져오는 방법
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}
  • 첫 코드처럼 수동으로 동기화하는 것보다 오류 가능성이 적음
  • 일반적으로 위의 useOnlineStatus() 같은 커스텀 훅에 useSyncExternalStore 로직을 작성하여 개별 컴포넌트에서 이 코드를 반복할 필요가 없도록 함

useSyncExternalStore의 상세한 사용법 => 참고 링크

12. 데이터 페칭하기

  • 데이터 페칭을 위해 Effect를 사용한 모습
    • query: 검색을 위해 사용자가 입력한 키워드
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Bad: 클린업 없이 fetch만 수행
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}
  • 페치해야 하는 주된 이유가 타이핑 이벤트가 아니기 때문에 이벤트 핸들러에 넣을 코드는 아님
  • page와 query가 어디에서 오는지는 중요하지 않음
    • 이 컴포넌트가 표시되는 동안 현재의 page, query에 대한 네트워크 데이터와 results 상태의 동기화가 유지되면 됨 => 이것이 Effect를 사용한 이유
  • 다만 위 코드에는 버그가 있음
    • "hello"를 빠르게 입력하면 query가 "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; // flag 변수 추가
    
    fetchResults(query, page).then(json => {
      if (!ignore) {
        setResults(json);
      }
    });
    
    return () => {
      ignore = true; // 클린업 함수에서 flag 변수 변경
    };
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}
  • 결과: Effect가 데이터를 페치할 때 마지막으로 요청된 응답을 제외한 모든 응답이 무시됨

그 외 방법: 페칭 로직을 커스텀 훅으로 추출하기

  • 커스텀 훅으로 추상화했기 때문에 로직 재사용 가능
  • 오류 처리와 콘텐츠 로딩 여부를 추적하기 위한 로직 추가 용이
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;
}

요약

  • 컴포넌트에서 원시 useEffect 호출이 적을수록 애플리케이션을 유지 관리하기가 더 쉬워진다.
  • 리액트 사용 시 useEffect를 최대한 줄일 수 있는 방안을 고려해보자.
profile
데이터 분석가 준비 중입니다 (티스토리에 기록: https://cherylog.tistory.com/)

0개의 댓글