렌더링 관점에서 useEffect 이해하기

우빈·2024년 9월 6일
2
post-thumbnail

평소 리액트를 다룰 때 습관적으로 useEffect를 사용하고, 가끔 무한루프가 발생하면
쩔쩔 매며 deps를 빼고 eslint warning을 disable했던 저에게 이 글을 바칩니다.

이 글에서는...

  • 렌더링 관점에서의 useEffect 동작 원리를 이야기합니다.
  • effect, cleanup이 언제 발생하는지 이야기합니다.
  • exhaustive-deps의 중요성에 대해 이야기합니다.
  • useEffect를 현명하게 사용하는 몇 가지 방법에 대해 이야기합니다.

렌더링 관점에서 useEffect 이해하기

useState를 쓴다고 가정할 때, 보통 렌더링이 어떻게 발생한다고 이해하고 계신가요?
사실 우리가 사용하는 state는 변하지 않는 상수값으로 존재합니다.

props, state는 고유한 값이다

JS에서 함수를 호출할 때 어떤 일이 생기나요?
함수 내에 있는 내용들이 다시 실행되는 식으로 작동합니다. 리액트 컴포넌트도 이와 똑같이 작용합니다.
그저 함수가 다시 호출될 경우(는 즉슨 '컴포넌트가 렌더링된다')에 상태가 업데이트되어 변화되는 것처럼 보이는 것입니다.

// 처음 랜더링 시
function Counter() {
  const count = 0; // useState() 로부터 리턴
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// 클릭하면 함수가 다시 호출된다
function Counter() {
  const count = 1; // useState() 로부터 리턴
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

// 또 한번 클릭하면, 다시 함수가 호출된다
function Counter() {
  const count = 2; // useState() 로부터 리턴
  // ...
  <p>You clicked {count} times</p>;
  // ...
}

우리는 보통 한 컴포넌트 내에서 state값이 바뀐다고 생각하지만,
알고보면 state값은 고유하지만, 렌더링 시점마다 해당 state가 변경되는 원리입니다.

렌더링은 마치 플립북 같습니다. 그림은 각 장마다 고유하게 존재합니다.

handler 또한 고유한 값이다.

  1. 카운터를 2로 증가시킨다
  2. “Show Alert”를 클릭한다
  3. 타임아웃이 실행되기 전에 카운트를 4로 증가시킨다

실행 결과를 맞춰보세요.

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

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }
  
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

정답은 "You clicked on: 2" 입니다.

count = 2가 그려진 종이를 보고 이벤트를 트리거했기에,
추후 state가 변경된다 하더라도 출력 결과는 '2'가 됩니다

해당 개념을 토대로 useEffect를 다시 이해하면, 실행 과정을 더 쉽게 이해할 수 있습니다.

effect와 cleanup은 언제 실행될까?

effect

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

  useEffect(() => {
    document.title = `count: ${count}`;
  });
  
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

위 코드를 실행시켜보면 count값이 바뀔 때마다 useEffect가 실행되어 document의 title도 변경됩니다.

위 플로우를 순서대로 설명하면 다음과 같습니다.

  1. React가 컴포넌트에게 state가 0일 때의 UI를 요청한다
  2. 컴포넌트는 You clicked 0 times를 제공하고, React에게 렌더링이 끝나면
    document.title = count: ${0}을 호출할 것을 요청한다
  3. React는 요청을 받고 브라우저에 UI 업데이트를 요청한다 (렌더링)
  4. 브라우저가 UI를 그린다 (페인팅)
  5. React가 약속했던 document.title = count: ${0}를 실행한다.

useEffect를 사용할 때 ‘DOM에 UI가 업데이트되기 전에 useEffect가 실행된다'
라고 생각하는 경우가 많지만, 실제로는 UI가 그려진 후 effect가 실행됩니다.

저는 렌더링과 UI 업데이트가 같다고 생각했었는데, 이는 정답이 아닙니다.
리액트의 렌더링 및 UI 업데이트 플로우는 다음과 같습니다.

업데이트 감지 → UI 업데이트 요청(렌더링) → 브라우저 페인팅 → effect

NOTE :
useLayoutEffect는 페인팅 이전에 effect가 실행됩니다.
하지만 동기로 작동하기 때문에, data fetching 등 비싼 비용의 작업을 layout effect할 경우
오랫동안 사용자가 빈 화면을 볼 수 있어 이를 신중하게 선택해야합니다.

cleanup

cleanup은 effect가 destroy되고 다음 effect를 실행하기 전에 실행됩니다.

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
  };
});

첫 번째 렌더링에서 id가 10, 두 번째에서 20이라고 가정할 경우 :
1. React가 {id: 20}을 가지고 UI 업데이트를 요청(렌더링)함
2. 브라우저가 UI를 그린다.
3. React는 {id: 10}에 대한 이펙트를 클린업한다.
4. React는 {id: 20}에 대한 이펙트를 실행한다.

저는 이 또한 실제로는 UI를 그리기 전에 effect가 실행된다고 생각했는데,
페인팅이 진행된 다음 클린업이 실행되고 이펙트가 실행됩니다.

결론

해당 실행 과정을 이해하신다면 훨씬 더 유용하고 쉽게 useEffect를 원하는 대로
사용하실 수 있을 겁니다.

exhaustive-deps를 무시하지 마세요

useEffect에는 두 번째 파라미터에 의존성을 넣어줄 수가 있습니다.
하지만 내가 useEffect 내에서 어떠한 의존성을 사용하고 있음에도 불구하고
해당 의존성을 useEffect에게 명시해주지 않으면 warning을 띄웁니다.

이 warning이 단순 정적인 시스템상 React가 요구사항을 읽지 못해 발생한다고 생각하고
이를 eslitn-disable하거나 무시하는 경우가 많습니다.

하지만 해당 warning이 떴을 경우 내가 useEffect를 올바르게 사용하고 있나 고민해보아야 합니다.

컴포넌트에 있는 모든 값 중 이펙트에 사용되는 값은 반드시 deps에 존재해야 합니다.

그렇지 않다는건, useEffect가 필요하지 않은 곳에서 useEffect를 사용하고 있을 수도 있겠죠.
그렇다면 어떻게 이를 바꿀 수 있을까요?

useEffect 현명하게 사용하기

1. 불필요한 useEffect는 걷어내기

props나 state에 의존하는 state를 업데이트해야할 때

const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
  setVisibleTodos(getFilteredTodos(todos, filter));
}, [props.todos, props.filter]);

props 변경 → 렌더링 → 페인팅 → props에 따른 effect 실행 → visibleTodos 렌더링

가끔 어떠한 props나 state에 다른 state가 의존해있어서, 의존당하는 값이 변경되었을 때
useEffect에 해당 deps를 넣고 의존중인 state를 변경하는 경우가 있습니다.

이 경우, useState를 사용하지 않아도 됩니다.

const visibleTodos = getFilteredTodos(props.todos, props.filter);

props 변경 → props에 의존하는 상수가 다시 그려짐 → 렌더링 → 페인팅

위에서 언급했던 것처럼, props나 state가 변경되면 리액트는 '새로운 그림'을 그립니다.
그렇기에 '새로운 그림'에서 업데이트된 props나 state에 따라 의존하는 값이 상수여도
그림은 새로 그려지기에 불필요한 리렌더링 없이 업데이트가 진행되는 것입니다.

2. 하나의 useEffect는 하나의 기능만 완수하기

useEffect도 우리가 useCallback, useMemo를 쓸 때처럼 사용해야 합니다.
하나의 useEffect에 여러가지의 deps를 넣어두고 여러가지 일을 실행시키게 하는 건
코드의 가독성을 떨어뜨릴 뿐만 아니라 불필요한 리렌더링을 불러일으키고 있을 수도 있습니다.

사용 중인 deps에서 처리하는 일이 분리 가능한 경우, useEffect를 분리하는 걸 추천드립니다.
useEffect를 분리하고 나면 1번의 경우를 찾을 수도 있습니다. (제가 리팩토링할 때 그랬습니다)

3. 사용하고 있는 deps에 대해서만 명시하기

useEffect(() => {
  const [recentHistory] = historyList.filter((history) => ...);
  if(recentHistory ...)
}, [historyList])

실제로 제가 짰던 코드인데, 이 useEffect에서는 historyList를 필터링한
recentHistory 값에만 의존합니다.

하지만 실제로는 historyList에 의존하고, historyList를 내부에서 파싱해서 사용합니다.

const [lastHistory] = historyList.filter((history) => ...);
useEffect(()=> {
  if(lastHistory ...)
}, [lastHistory])

저는 useEffect 외부로 해당 변수를 걷어내서 해결하였습니다.

4. 복잡한 로직을 트리거하는 useEffect에 이름 달아주기

가끔 useEffect가 여러 개 쓰이거나, 함수 내용이 많은 경우 어떤 기능을 하는
effect인지 쉽게 알기 어렵습니다. 이럴 때 제공해주는 함수에 이름을 달아주면
effect의 기능을 훨씬 쉽게 확인할 수 있습니다.

const router = useRouter();

useEffect(
  function 사업자등록번호조회() {
    if (!lastHistory) return;
    const is사업자 = ...
    if (is사업자) {
      router.replace( ... )
    }
  },
  [recentHistory],
);

5. third-party에서의 exhaustive deps 해결책 탐색하기

저는 바로 위 코드처럼 next/router의 router를 사용하고 있었는데, 이렇게 사용하니
useEffect deps에 router를 넣으라고 하더군요. 하지만 넣으면 무한루프가 발생합니다.

제 생각에는 컴포넌트가 리렌더링될 때마다 const router = useRouter()로 선언한
함수의 참조값이 계속 바뀌고 있다고 추측했습니다.

검색해보니 useRouter를 싱글톤으로 선언한 Router가 있어서 해당 모듈을 사용했습니다.
싱글톤 패턴으로 생성된 클래스이기에 렌더링에 따른 참조값이 변경될 일이 없어
useEffect 내에서 해당 클래스를 호출해도 exhaustive-deps warning이 발생하지 않게 됩니다.

import Router from "next/router";

useEffect(()=> {
  Router.replace( ... )
}, [recentHistory])

마무리

렌더링 원리에 대해 이해하고 그 관점에서 useEffect를 바라보니 추후 사용할 때도
조금 더 원하는 방향대로 useEffect를 사용할 수 있겠구나 싶었습니다.

또한 제가 너무 습관적으로 useEffect를 사용하고 있는지 다시 돌아보는 계기가 되었습니다.
만약 useEffect를 사용할 때 exhaustive-deps warn이 발생하면, 무시하지 말고
한번 쯤 다시 고민해보는 시간을 가지시는 것을 추천드립니다.

참고 자료

useEffect 완벽 가이드
useEffect 잘못 쓰고 계신겁니다.

profile
프론트엔드 공부중

0개의 댓글