useEffect 조금 더 잘 쓰기

se-een·2023년 7월 25일
1

React 탐구하기

목록 보기
6/7
post-thumbnail

useEffect 잘 쓰고 계신가요? 보통 useEffect를 컴포넌트 렌더링 주기에서 수행할 수 없는 일들, 이를테면 API 요청 로직 등을 수행하기 위해서 주로 사용했던 것 같습니다.

useEffect(() => {
  // 셋업 코드
  
  return () => {
    // 클린업 코드
  };
}, [/* 의존성 목록 */]);

기본적인 사용방법은 위 코드로 설명을 대체하겠습니다.

이번 글에서는 공식문서을 보고 학습한 내용을 기반으로 useEffect를 조금 더 잘 사용하는 방법에 대해서 정리 해보겠습니다.

useEffect가 불필요할 때

앞서 useEffect를 사용할 때는 컴포넌트 렌더링 주기에서 수행할 수 없는 외부 API 통신 등과 같은 일들을 처리할 때라고 하였습니다.

useEffect는 꼭 필요한 상황이 아니면 사용을 자제하는 것이 좋은데요. 그 이유는 useEffect 자체가 React 외부의 무언가와 동기화하기 위함이므로 즉, React 패러다임에서 벗어나는 탈출구의 역할이므로 그렇습니다.

그렇기에 useEffect를 남발하게 되면 불필요한 리렌더링이 발생하여 애플리케이션 실행 속도가 느려지거나, 사이드 이펙트로 인해 오류 발생 가능성이 높아지고, 가독성이 떨어질 수 있습니다.

즉, useEffect가 불필요할 때는 다음 두 가지 상황으로 정리해볼 수 있을 것 같습니다.

  • 렌더링을 위해 데이터를 변경할 때
  • 사용자 이벤트를 처리할 때

key 속성 이용하기

prop이 변경되었을 때 state를 다시 초기화 해줘야하는 경우가 있을 수도 있습니다. 다음 예시와 같은 경우죠.

export default function Child({ id }) {
  const [greeting, setGreeting] = useState('hello');

  useEffect(() => {
    setGreeting('hello');
  }, [id]);
  
  // (중략)
  
  return <div>{greeting}</div>;
}

React 훅은 컴포넌트 최상단에서 선언해야하기에 조건문을 걸 수도 없고 이전 id 값을 기억하는 새로운 state를 두어 분기처리하여 초기화 시켜주기에는 불필요하게 복잡해지는 느낌이 있습니다.

따라서 useEffect를 고려해볼만 한데요. 하지만 useEffect 대신 key prop 을 활용하여 동일한 효과를 누릴 수 있습니다.

export default function Parent({ id }) {
  return (
    <Child
      id={id}
      key={id}
    />
  );
}

부모 컴포넌트에서 다음과 같이 id 값을 key prop에 할당해주었습니다.


export default function Child({ id }) {
  const [greeting, setGreeting] = useState('hello');
  
  return <div>{greeting}</div>;
}

React는 key 가 변경될 때마다 DOM을 재생성하고 state 또한 재설정하기 때문에, id 값이 변경된다면 Child 컴포넌트의 greeting 상태 또한 초기화 될 것입니다.

연쇄 계산 대신 조건문 사용하기

다음과 같이 state를 바탕으로 또 다른 state를 조정하는 Effect를 연쇄적으로 사용할수도 있습니다.

export default function App() {
  const [round, setRound] = useState(0);
  const [greeting, setGreeting] = useState('');
  const [count, setCount] = useState(0);
  const [isOver, setIsOver] = useState(false);

  useEffect(() => {
    if (round !== 0) setGreeting((prev) => prev + 'hello,');
  }, [round]);

  useEffect(() => {
    if (greeting.length !== 0) setCount((prev) => prev + 1);
  }, [greeting]);

  useEffect(() => {
    if (count >= 3) setIsOver(true);
  }, [count]);

  const handleStartGame = (btnCount) => {
    if (isOver) {
      setRound(0);
      return;
    }
    setRound(btnCount + 1);
  };

  return (
    <button
      value={round}
      onClick={(e) => {
        handleStartGame(Number(e.target.value));
      }}
    >
      start
    </button>
  );
}

위 코드는 다음과 같은 문제점을 갖고 있습니다.

첫 번째는 매우 비효율적인 작업이라는 점입니다. 아래와 같이 체인의 각 setter를 호출하면서 그 사이에 리렌더링이 발생합니다.

setRound → 렌더링 → setGreeting → 렌더링 → setCount → 렌더링 → ...

즉, 불필요한 리렌더링이 지속해서 발생하게 됩니다.

두 번째는 변경에 유연하지 않다는 점입니다. 만일 로직이 변경되어 일부 state를 과거의 값으로 설정하게 되면 조건에 따라 Effect 체인이 촉발될 수 있고 이는 의도치 않은 데이터 변경을 초래할 수 있습니다.

따라서 다음과 같이 불필요한 useEffect를 제거하고, 이벤트 핸들러 내에서 조건문으로 처리하는 것이 효율적이겠습니다.

export default function App() {
  const [round, setRound] = useState(0);
  const [greeting, setGreeting] = useState('');
  const [count, setCount] = useState(0);

  const isOver = count >= 3;

  const handleStartGame = (btnCount) => {
    if (isOver) {
      setRound(0);
      return;
    }

    setRound(btnCount + 1);

    if (btnCount + 1 !== 0) {
      setGreeting((prev) => prev + 'hello,');
      setCount((prev) => prev + 1);
    }
  };

  return (
    <button
      value={round}
      onClick={(e) => {
        handleStartGame(Number(e.target.value));
      }}
    >
      start
    </button>
  );
}

의존성 목록에 둬야할 것

의존성 목록에는 무엇을 두어야할까요?

시간이 지남에 따라 변경될 여지가 있는 것을 의존성 목록에 두면 되겠습니다. 시간이 지남에 따라 변경될 여지가 있다는 것은 다른 말로 해보면 리렌더링 때 변경된다는 것이고, 이는 곧 상태를 의미한다고 볼 수 있겠습니다.

채팅방을 여러곳 들락날락 할 수 있는 애플리케이션을 만들었다고 가정해보겠습니다. A 채팅방에 접속하면 채팅 연결이 활성화 되고, B 채팅방으로 이동하면 A 채팅방과의 연결을 종료 후 B 채팅방과의 연결을 활성화 해야합니다. 해당 상황을 코드로 다음과 같이 표현해볼 수 있겠습니다.

const URL = 'https://somewhere.com/chat';

export default function App({ chattingRoomId }) {
  useEffect(() => {
    const connection = createConnection(URL, chattingRoomId);
    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [chattingRoomId]);

  // (중략)
}

위 코드에서 URL고정되어 있는 값 즉, 상수이기에 useEffect 내부에서 사용이되었다 한들 리렌더링에 따라 그 값이 변하지 않습니다. 따라서 의존성 목록에 있을 필요가 없습니다.

하지만 chattingRoomId사용자가 채팅방을 이동함에 따라 변경되므로 즉, 상태이므로 의존성 목록에 있어야만 합니다.

위에서 가정한 상황을 좀 달리해보겠습니다. 이제 서비스가 커져서 서버를 여러군데 두었고 채팅방 또한 서버에 따라 다르게 분포되어있습니다.

// URL → serverUrl

export default function App({ serverUrl, chattingRoomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, chattingRoomId);
    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [serverUrl, chattingRoomId]);

  // (중략)
}

이제 서버 URL 또한 변경의 여지가 있는 상태값이기 때문에 위 코드와 같이 의존성 목록에 포함되어야만 합니다.

의존성 목록은 선택할 수 있는 것이 아닙니다. 따라서 useEffect 내에서 사용되는 값이 반응형(변경의 여지가 있는) 값이라면 의존성 목록에 포함되어야만 합니다. 만약 잘못된 의존성 목록을 작성했다면 linter가 경고나 에러를 띄울 것입니다.

불필요한 의존성 목록 제거

불필요한 의존성 목록을 제거한다는 것은 useEffect 내부에 작성된 코드에서 반응형 값이 아닌 것이 의존성 목록에 들어갔을 때 제거한다고 볼 수 있겠습니다. 또한 이벤트 핸들러 내부에서 처리 가능한 로직이 불필요하게 useEffect로 작성된 경우도 있겠습니다.

따라서 기본적인 예시보다는 하나의 useEffect 에서 서로 다른 관심사의 일을 진행할 경우에 대해서 알아보겠습니다.

간혹 useEffect 사용을 최소화하기 위해 서로 다른 관심사를 갖는 로직을 다음과 같이 하나의 useEffect 내에 작성하는 경우가 있을 수도 있습니다.

export default function Shop({ category, likes }) {
  useEffect(() => {
    fetch(`/api/get/products?category=${category}`)
      .then((response) => response.json())
      .then((data) => {
        console.log(data);
      });

    if (likes) {
      fetch(`/api/get/likes`)
        .then((response) => response.json())
        .then((data) => {
          console.log(data);
        });
    }
  }, [category, likes]);

  // (중략)
}

위 코드는 하나의 useEffect에서 다음 두 가지의 일을 처리하고 있습니다.

  • category props를 기반으로 products 리스트를 가져오기
  • likes props를 기반으로 likes 리스트를 가져오기

문제점은 서로 관심사가 다른 (관련이 없는) 일을 하나의 useEffect에서 처리하고 있다는 점입니다. likes 가 변했을 때 likes GET 만 진행하고 싶어도 매번 불필요하게 category 를 GET 해오게 됩니다.

따라서 다음과 같이 로직을 두 개의 useEffect로 분리하여 작성하고 각 Effect는 동기화해야하는 props에 반응하도록 하는 것이 좋겠습니다.

export default function Shop({ category, likes }) {
  useEffect(() => {
    fetch(`/api/get/products?category=${category}`)
      .then((response) => response.json())
      .then((data) => {
        console.log(data);
      });
  }, [category]);

  useEffect(() => {
    fetch(`/api/get/likes`)
      .then((response) => response.json())
      .then((data) => {
        console.log(data);
      });
  }, [likes]);

  // (중략)
}

rendering과 mount

이전에 렌더링과 마운트의 관계를 잘 몰라서 언제 마운트가 되는지, 언마운트가 되는지 헷갈릴 때가 있었는데요. 이번 기회에 콘솔에 찍어보며 자세히 알아보겠습니다.

(React.strictMode 는 비활성화 했습니다.)

// disabled strict mode

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

  console.log('rendering');

  useEffect(() => {
    console.log('mount');

    return () => {
      console.log('unmount');
    };
  }, [count]);

  const handleClickBtn = () => {
    setCount((prev) => prev + 1);
  };

  return <button onClick={handleClickBtn}>{count}</button>;
}

위 코드를 실행하면 다음과 같이 렌더링 후 DOM에 마운트 되는 것을 볼 수 있습니다.

만약 버튼을 클릭하면 콘솔에 어떻게 찍힐까요?

리렌더링이 진행된 후에 언마운트가 진행되고 다시 마운트가 되는 것을 볼 수 있습니다. button 태그의 text 값이 계속 증가하기 때문에 React가 리렌더링 후 다시 페인트 (DOM에 렌더링) 하는 작업이 있을 것이라고 예측됩니다. 그렇기에 언마운트를 진행한 뒤 다시 마운트하는 것이 자연스러운 흐름 같다고 생각해볼 수 있겠네요.

그렇다면 다음과 같이 button 태그의 text 값을 click! 이라고 고정한다면 어떨까요?

- return <button onClick={handleClickBtn}>{count}</button>;
+ return <button onClick={handleClickBtn}>click!</button>;

text 값은 변경되지 않기에 리페인트가 되지않아 언마운트 후 다시 마운트가 되지 않을 것이라고 추측해볼 수도 있겠습니다.

하지만 이 역시 리렌더링 후 언마운트, 마운트 모두 진행됩니다. 개인적으로 이 부분이 자주 헷갈렸습니다. 리페인트가 되지 않을테니 언마운트 로직을 작성하지 않아도 되겠지? 하면서 언마운트 로직을 생략하다가 어느 순간부터 같은 API 로직을 등차수열로 요청하는 것을 보면서 디버깅에 애를 먹었던 적이 꽤 있었네요. 🤪

사실 원리는 되게 간단합니다. 개발을 하는 입장에서 React의 리페인팅 등과 같은 내부 상황을 고려하면서 코드를 짜야했다면 코드가 매우 비직관적이게 되었겠죠.

그래서 위에서 살펴보았던 의존성 목록에 집중하시면 되겠습니다. 의존성 목록의 값이 변경되면 리렌더링 → 언마운트 → 마운트 순으로 진행된다고 보시면 되겠습니다. 다음과 같이 의존성 목록이 없다면 최초 렌더링 이후 useEffect 자체에서 언마운트, 마운트 과정이 일어나지 않습니다.

useEffect(() => {
  console.log('mount');

  return () => {
    console.log('unmount');
  };
}, []);

다음과 같이 의존성 목록을 담는 배열을 아예 작성하지 않으면 리렌더링이 발생할 때마다 useEffect 로직이 수행되므로 리렌더링 → 언마운트 → 마운트 과정이 진행됩니다.

useEffect(() => {
  console.log('mount');

  return () => {
    console.log('unmount');
  };
});

즉, 렌더링과 마운트를 한 마디로 정리하자면 다음과 같습니다.

  • 렌더링(rendering) : React에서 컴포넌트를 호출하는 것

  • 마운트(Mount) : React가 렌더링을 완료한 후 Real DOM 을 빌드할 때

이상으로 글을 마치겠습니다. 잘못된 부분이 있다면 지적 부탁드리겠습니다. 🙏

profile
woowacourse 5th FE

0개의 댓글