useEffect 잘못 쓰고 계신겁니다.

jay·2022년 8월 20일
222

react

목록 보기
9/14
post-thumbnail

안녕하세요, 단테입니다.

리엑트 컴포넌트 작성할 때 useEffect 많이 애용하고 계시죠?

여러분이 카페에서 공부를 하고 있는데 옆에 앉아있는 분이 다음과 같이 질문한다면
컴포넌트 상태 값이 바뀌었을 때 어떤 동작을 해야 하거나 api fetch할 때 어떻게 코드를 작성해요?

몇 초도 안되어서 바로 다음처럼 답변할 것 같지 않으신가요?

useEffect를 사용하세요

그런데 당장의 질문에 대한 답변을 내놓기 위해 별 다른 고민 없이 내뱉은 답변이긴 하지만

useEffect가 정말 질문자의 컴포넌트를 작성하는데 있어 올바른 방법일까요?

잠깐, 읽기 귀찮다면?

영상으로 보기 https://www.youtube.com/watch?v=SrPebT4VBYc

React 18 + Strict Mode

먼저 최신 리엑트 버전에서 기존 방식처럼 useEffect를 사용할 때 예견되는 문제점에 대해 이야기해보겠습니다.

리엑트 18버전 많이 사용하시죠?
create-react-app을 사용하시면 바로 18버전이 적용되기 때문에 무의식적으로 사용하고 계실지도 모르겠습니다.

그런데 사이드 프로젝트가 아니라 상용 제품에 17버전을 사용하고 계신 상황이면, 금년도가 지나기 전에 버전업을 계획하고 계시지 않나요?(계획하고 있어야 하지 않을까요?)

data fetching 함수를 useEffect 내부에서 호출해야 하는 이유는 컴포넌트 생애 주기중 한번만 호출하게 할 수 있기 때문입니다.

그런데 말입니다,
React18 + StrictMode에서는 개발모드에서 data fetching이 최소 두 번이상 일어날 수 있다는 걸 아시나요?

거짓말이 아닙니다. 국내에서는 왜 이 부분에 대해 다루는 콘텐츠가 별로 없는지 모르겠지만 react 18이 정식 출시했을 때부터 해당 주제에 대한 콘텐츠가 해외에서는 꾸준하게 올라오고 있습니다.

어떻게 동작할까요?

다음과 같이 api 서버와 연결하는 코드를 useEffect 내부에 작성한다고 가정해봅시다.
연결을 할때는 Connectiong, 연결을 해제되면 Disconnected이 기록되고 있습니다. 컴포넌트가 렌더링된 후에 기록된 것을 보면 다음과 같이 호출되는데요,

Connecting -> Disconnected -> Connecting

https://beta.reactjs.org/learn/synchronizing-with-effects#

리엑트18은 페이지 이동 이후 다시 돌아왔을때 앱이 망가지는 부분이 없는지 확인하기 위해 개발모드(process.env.NODE_ENV === development)에서 한 컴포넌트를 두번 렌더링합니다.

따라서 useEffect가 두번 호출되어 위와 같이 Connecting이 두 번씩 기록되는 것인데요,

버그 발생을 파악하기 위해 테스트코드를 작성해줘야 합니다.

이에 따라 개발환경에서 BFF(Backend for Frontend) 간 Socket, SSE(Server Sent Event)연결을 할 시 manual testing을 하지 않는 한 왜 발생하는지 알아차리기 어렵습니다.

Strict Mode를 사용하지 않는 것은 좋은 선택이 아닙니다.

Strict Mode를 없애 두번씩 렌더링하는 과정이 일어나지 않게 할 수 있지만 production 환경에서 일어날 수 있는 오류를 리엑트에서 잡아주지 못하므로 항상 Strict Mode에서 개발하는 것이 좋습니다.

Effect가 두 일어나도 유저가 이를 느끼지 못하게 코드를 작성해야 합니다.

어떻게 하면 Effect를 한번만 동작할 수 있게 할까? 는 잘못된 접근 방법입니다. 개발모드에서 컴포넌트가 리렌더링되는 바람에 useEffect가 두 번씩 호출되어도 어떻게 유저가 리렌더링을 느끼지 못하게 할까?가 올바른 접근 방법입니다.

해결방법은 cleanup 함수를 꼭 작성하는 것입니다.
개발모드에서 effect -> cleanup -> effect 와 같은 순서로 리엑트가 컴포넌트를 실행시키기 때문에 cleanup 함수가 필요한 부분에서는 꼭 생략하지 않고 챙겨줘야 합니다.

useEffect 내부에서 setState 함수를 호출하면 cleanup 함수 작성과 무관하게 setState 함수가 두번씩 호출되겠죠? 따라서 이는 올바른 사용 방법이 아닙니다.

아래와 같이 useEffect 내부에서 상태 업데이트를 하지 않는다면, development / production의 개발환경과 무관하게 useEffect의 호출횟수에 따른 UI/UX가 달라질 이유가 없습니다.

useEffect(() => {
  function handleScroll(e) {
    console.log(e.clientX, e.clientY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

외부 플러그인을 사용한다 해도 Strict Mode는 useEffect 사용에문제가 되어서는 안됩니다.

리엑트로 작성되지 않은 UI 위젯을 리엑트에서 사용하는 시나리오라고 하더라도 아래와 같이 cleanup 함수를 작성하면 유저는 useEffect가 두번 호출되는 것을 전혀 느낄 수 없습니다.

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

props, state 변경에 따라 또 다른 state를 업데이트해야 할 때

이 때는 useEffect를 사용하면 안됩니다.

만약 이런 코드를 작성하고 있었다면, 여러분은 무의식적으로 불필요한 리렌더링을 발생시키고 있을지도 모릅니다.

상태 변경이 일어나면 리엑트는 돔에 변경된 state를 commit하고 그 이후에 ui를 업데이트 합니다. 상태변경이 두번 일어나면 이 과정이 두번, 상태변경이 세번 일어나면 세번 발생하는겁니다.

ui를 한번 변경하는데 상태 변경이 n 번 일어난다면 불필요한 리렌더링이 최대 n-1번 발생하고 있는 것입니다.

props, state 변경에 따라 데이터를 조립해야 하는 경우

아래 코드에서 todos, filter 둘 중 하나의 데이터만 변경되더라도 ui가 업데이트 되어야 하기 때문에 visibleTodos는 변경되어야 합니다. 이에 따라 useEffect 내부에서 ui 관련 state를 업데이트하는 것은 타당해보입니다.

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

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

하지만 이는 불필요한 리렌더링을 발생시킵니다. ui 업데이트를 위해 setVisibleTodos를 호출해주지 않아도 괜찮아요.

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ This is fine if getFilteredTodos() is not slow.
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

컴포넌트가 리렌더링 될 때마다 ui가 의존하고 있는 visibleTodos가 업데이트 되기 때문에 ui는 최신 데이터 반영을 보장할 수 있습니다.

그런데 getFilteredTodos가 비싼 연산이면 어떨까요?
이 때 useMemo를 사용합니다.

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ Does not re-run getFilteredTodos() unless todos or filter change
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

props 변경에 따라 상태가 리셋되어야 하는 경우

관리자 페이지에서 유저들에 대한 코멘트를 작성한다고 가정합시다.

유저 1에 대한 정보를 작성하다 유저 2로 이동할 때 아래 처럼 comment state를 명시적으로 useEffect 내부에서 리셋해줘야 합니다.

userId 변경 -> 리렌더링 -> comment 변경 -> 리렌더링.
불필요한 리렌더링이 일어나고 있습니다.

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

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

리렌더링을 줄이고 페이지단에서 상태 리셋하는 번거로운 작업을 없애봅시다.

다음 컴포넌트에서 Profile 컴포넌트는 userId가 변경될 때마다 key 값이 변경되기 때문에 comment 상태를 따로 리셋해줄 필요가 없습니다. 훨씬 좋죠?

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

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

props 변경에 따라 특정 상태만 업데이트 해야 하는 경우

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

  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

items가 변경되었을때 selection 상태가 리셋되어야 하는 경우입니다.
items가 변경되면 -> 리렌더링 -> selection 변경 -> 리렌더링
이걸 한번으로 줄여봅시다.

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

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

useState에 items 상태를 담고, prevItems와 items를 비교함으로 인해 리렌더링을 줄였습니다. 첫 렌더링시 if statement를 통해 렌더링 횟수를 줄인 것이죠.

useEffect vs eventHandler - UI rendering

쇼핑몰에서 카트에 물건을 넣었을 때 모달을 통해 정상적으로 물품을 담았는지 유저에게 알려줘야 합니다.

다음은 useEffect를 이용했을 때입니다.
product가 변경되었을 때 모달을 보여주고 있습니다.
모달을 보여줘야 하는 버튼이 여러 개라면, 일일히 각 핸들러에 showNotification을 넣어주는 것 보다 useEffect에 넣어주는 게 바람직하다고 착각할 수 있습니다.

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an 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 상태가 변경되었을 때 모달을 보여주게 작성되어 있습니다.

예를 들어 페이지 전환 시 전역 상태 값인 product.isInCart가 true 라면, 예상하지 못한 버그가 양산될 수 있습니다.

우리는 프론트엔드 개발자입니다.
프론트엔드 개발자는 제품에서 UI를 책임집니다.
우리의 코드가 상태를 변경했을 때 작동하는 것이 의도인지, 유저의 이벤트에 따라 UI를 변경하는 것이 주 목적인지 생각해볼 때,

버그를 양산할 수 있는 가능성을 가진 useEffect보다, 이벤트 핸들러 내부에서 모달을 호출하는 것이 바람직합니다.

When you’re not sure whether some code should be in an Effect or in an event handler, ask yourself why this code needs to run. Use Effects only for code that should run because the component was displayed to the user. https://beta.reactjs.org/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers
useEffect, event handler 둘 중 어디에 코드를 작성해야 할지 모르겠다면, 코드가 언제 실행되어야 하는지 생각해보세요. use effect는 컴포넌트가 렌더링 되는 시점에 유저에게 표현되어야 할 로직을 실행할 때 사용됩니다.

저는 개인적으로
event handler 내부에서 처리할 수 있는 로직은
useEffect가 아닌 event handler 내부에서 처리하는 것이 좋다고 생각합니다.

data fetching은요?

TLDR; data fetching을 해도 괜찮습니다. 다만 race condition을 고려해야합니다.

리엑트18 Strict Mode에서 컴포넌트 렌더링 시 useEffect 내부에서 ajax가 일어난다면
렌더 -> fetch -> 리렌더 -> re-fetch 과정이 일어납니다.

이렇게 단순한 시나리오에서는 괜찮습니다. api 서버 비용이 두 배로 든다는 점이 감안할만 하다면요.

fetch/re-fetch 간 동일한 데이터가 응답된다면 사용자가 UI에 변경점을 느끼지 못하니깐요

하지만 특정 상태나 Props 변경에 따라 뷰가 바뀌는 경우면 어떨까요?

언마운트에서의 setState

다음의 코드는 userId가 변경될 때마다 startFetching이 호출됩니다.

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

컴포넌트가 언마운트 되었을때는 cleanup 함수에 ignore 변수를 true로 만들었어요.
이 덕분에 closure를 사용하는 startFetching 내부에서 setTodos는 언마운트 이후에도 호출되지 않겠네요!

좋습니다. 그런데 매번 이러한 로직을 따로 작성해줘야겠네요?

race condition

한 페이지에서 특정 id 값을 기준으로 모달같은 공통 ui를 그려주는 경우에는 위의 로직이 전혀 도움이 되지 않아요

다음 처럼 startFetching이 테이블 컴포넌트에 있는 Open Modal을 누를때마다 호출된다고 가정해보세요.

id 3의 Open Modal -> close Modal -> id 4의 Open Modal 과 같은 시나리오에서

모달에 그려지는 각 row의 정보가 startFetching에서 불러오는 server state라고 한다면,

id 4가 먼저 응답될지 id 3가 먼저 응답될지 알 수가 없습니다.

유저는 id4의 메타 데이터를 봐야하는데 id 3를 받아보는 상황이 생깁니다. 이렇게 경쟁 조건이 생기는 경우를 race condition이라고 부르며 useEffect에서 setState를 호출하는 것만으로는 이러한 상황을 방지할 수 없습니다.

특정 상태가 변경되었을 때 데이터를 호출하는 상황은 어떤가요?

서버 자원이 동나지 않도록 조심하셔야 됩니다.
어떤 버그 상황에도 대비할 수 있게 useEffect를 자신이 있으신가요?
dependency array 내부에 있는 상태 변경이 여러분이 예측할 수 있는 속도로 변경되는 것을 보장할 수 있으신가요?

저는 별로 권장하고 싶지 않네요. 다른 방법을 알아보시는건 어떤가요?

대안이 뭔가요?

지금 문제점이 뭔가요? 컴포넌트가 여러 번 마운트 -> 언마운트 -> 마운트 되더라도 동일한 api를 호출하는 경우, 이전에 받아온 데이터를 재사용한다면 서버 자원이 부족할 문제는 피할 수 있겠습니다.

잠시만요, 재사용이면 캐시된 데이터를 사용하는 것이네요? 어디서 많이 들어본 것 같지 않나요? 이러한 이유로 react-query, swr등 data fetch 라이브러리를 사용하는 것입니다.

라이브러리를 사용할때는 어떤 문제점에서 비롯된 불편함을 해결하려고 하는지 먼저 공부합시다.
다음 포스팅을 확인해보세요.
단테의 10분만에 react-query 개념 이해하기

the best approach is to use a solution that deduplicates requests and caches their responses between components
최고의 방법은 중복 리퀘스트를 줄이고 여러 컴포넌트에서 캐시된 데이터를 재활용하는 것이다.
->리엑트 공식 문서

오늘은 무분별한 useEffect를 덜어내는 방법에 대해 알아보았습니다.
useEffect는 리엑트 사용에 있어 escape hatch, 즉 문제를 해결할 수 있는 좋은 툴로 사용됩니다. 하지만 어느 순간부터 무분별한 useEffect 사용이 리엑트 개발자를 혼란시키고 있습니다.

useEffect는 버그를 양산할 수 있는 가장 쉬운 장소이기에 안티패턴에 대한 사례가 확실하게 이뤄져야 한다고 생각합니다.

보다 자세한 내용을 알고 싶으시면 리엑트 베타 문서를 확인해보세요.

읽어주셔서 감사합니다!

https://beta.reactjs.org/learn/synchronizing-with-effects#fetching-data
https://beta.reactjs.org/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers

profile
성장을 향한 작은 몸부림의 흔적들

20개의 댓글

comment-user-thumbnail
2022년 8월 22일

삼백만년째 고민하던 문제를 해결해주셨어요 감사합니다 선생님

1개의 답글
comment-user-thumbnail
2022년 8월 22일

많은 배움 얻어가는 글이예요! 강의까지 정말 잘 보고 있습니다 👍

1개의 답글
comment-user-thumbnail
2022년 8월 23일

마침 지금 딱 API 호출 중에 2번 호출되는 현상 때문에 고민이었는데 보게 되어 좋았어요. 결국 StrictMode는 유지한 채로 뒷정리 함수로 문제를 해결했습니다. 좋은 글 감사드립니다 😆

1개의 답글
comment-user-thumbnail
2022년 8월 24일

race condition은 해결하기 위한 좋은 방법이 무엇일까요? 지금 생각으로는 모달창에 props로 넘어온 userId와 서버에서 넘어온 데이터 중 userId 값이 같을 때만 setState를 호출하는 방법이 있을 거 같은데, 혹시 다른 좋은 방법있을까요?
질문과 별개로 좋은글 감사합니다!

1개의 답글
comment-user-thumbnail
2022년 8월 24일

진짜 양질의 글이네요... 좋은 글 감사합니다!

1개의 답글
comment-user-thumbnail
2022년 8월 25일

안녕하세요 좋은글 너무 감사합니다
한가지 질문이 있는데요.
첫 didmount때 query로 데이터를 불러왔었고
버튼을 몇번 눌러서 값을 증가 시키는데 변수의 값이 도달했을 때 다시 refetch를 해야하는 경우가 있는데
그럴때는 useEffect(에서 목표값이 도달하는지 감시중) 말고 어떻게 진행해야할까요?

1개의 답글
comment-user-thumbnail
2022년 8월 25일

안녕하세요! 좋은 글 잘 읽었습니다 :)
글을 읽다 한가지 의문점이 들어 질문 드립니다.

'props 변경에 따라 특정 상태만 업데이트 해야 하는 경우' 에서 if문을 통한 비교 연산을 통해 리렌더링을 줄였다고 말씀해 주셨는데요.

react는 hooks를 순서로 기억하기 때문에 정상 동작을 보장하기 위해 조건문 안에서 hook을 사용하는 것을 규칙으로 금지하는 것으로 알고 있는데, 혹시 이 부분은 해당 코드에서 관련이 없을까요?


공식 문서 원문 중 발췌

'반복문, 조건문 혹은 중첩된 함수 내에서 Hook을 호출하지 마세요. 대신 early return이 실행되기 전에 항상 React 함수의 최상위(at the top level)에서 Hook을 호출해야 합니다. 이 규칙을 따르면 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것이 보장됩니다. 이러한 점은 React가 useState 와 useEffect 가 여러 번 호출되는 중에도 Hook의 상태를 올바르게 유지할 수 있도록 해줍니다.'

출처 : https://ko.reactjs.org/docs/hooks-rules.html

1개의 답글
comment-user-thumbnail
2022년 8월 26일

잘 읽었습니다. 그런데 strict mode는 develop모드에서만 적용되서 결국 deploy시에는 문제가 되지 않는데 굳이 여러개 사용을 지양해야할까요? 카카오에서 만든 리액트에서 카카오맵 fetch하는 예시에서도 useEffect여러개 사용하고 있습니다. cleanup function을 잘 작성해야한다는건 동의합니다ㅎㅎ

1개의 답글
comment-user-thumbnail
2022년 8월 30일

In the above example, strict mode checks will not be run against the Header and Footer components. However, ComponentOne and ComponentTwo, as well as all of their descendants, will have the checks phoodle.

1개의 답글