본문 바로가기

IT

React.StrictMode 에서 useEffect 가 2번씩 호출되는 증상

반응형

공식 문서에 따르면 StrictMode 가 켜져 있으면 첫 번째 실제 설정 전에 추가 개발 전용 설정+정리 주기를 한번 더 실행합니다. 이는 정리 논리가 설정 논리를 "미러링"하고 설정이 수행하는 모든 작업을 중지하거나 실행 취소하는지 확인하는 스트레스 테스트입니다.

참조: https://react.dev/reference/react/useEffect#caveats

 

useEffect – React

The library for web and native user interfaces

react.dev

 

아래 2개의 글을 읽어보고 올바른 해결 방법을 찾아야 합니다.

참조1: https://react.dev/learn/synchronizing-with-effects#step-3-add-cleanup-if-needed

 

Synchronizing with Effects – React

The library for web and native user interfaces

react.dev

참조2: https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development

 

Synchronizing with Effects – React

The library for web and native user interfaces

react.dev

 

정리 로직 추가

채팅룸이라는 컴포넌트가 있다고 합시다. 여기서 우리는 useEffect(..) 를 이용해서 마운트되어 화면에 표시되기 전에 한번만 코드가 실행되도록 합니다. 아래와 같은 형태가 될 겁니다.

useEffect(() => {
  const connection = createConnection();
  connection.connect();
}, []);

 

위 코드를 실행해보면 예상과 다르게 2번 호출되는 것을 확인할 수 있습니다. 왜 이런 일이 발생하는 걸까요?

채팅룸 컴포넌트가 더 큰 앱의 구성요소라고 생각해봅시다. 채팅룸 컴포넌트를 사용하는 화면으로 이동했다가, 사용자가 다른 화면으로 이동한다고 상상하면 어떤 일이 발생할까요? 다시 뒤로 가기를 클릭해서 채팅룸 컴포넌트가 사용되는 화면으로 다시 돌아오면 어떨까요? 이렇게 하면 두 번째 연결이 설정되지만 첫 번째 연결은 절대 끊어지지 않습니다. 사용자가 앱을 탐색하면 할수록 연결이 계속 쌓일 것 입니다.

이런 버그를 빠르게 발견할 수 있도록 개발 시 React 는 초기 마운트 직후에 모든 구성 요소를 한 번 다시 마운트합니다.

연결 중이라는 로그를 두 번 보면 실제 문제를 파악하는 데 도움이 됩니다. 즉, 구성 요소가 마운트 해제될 때 코드가 연결을 닫지 않습니다.

이 문제를 해결하려면 Effect 에서 정리 함수를 반환하세요.

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []);

 

React 는 Effect 가 다시 실행되기 전 매번 정리 함수를 호출하고 구성요소가 마운트 해제(제거됨)될 때 마지막으로 한 번 호출합니다. 정리 기능이 구현되면 어떤 일이 발생하는지 살펴보겠습니다.

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

 

이제 개발 중에는 3개의 로그가 표시됩니다.

1. Connecting...

2. Disconnected.

3. Connecting...

이는 개발 시 올바른 동작입니다. 구성 요소를 다시 마운트함으로써 React 는 다른 곳으로 갔다가 뒤로 이동해도 코드가 손상되지 않는지 확인합니다. 연결이 끊어졌다가 다시 연결하는 일이 정확히 일어나야 합니다. 정리를 잘 구현할 때 Effect 를 한 번 실행하는 것과 실행하고 정리하고 다시 실행하는 것 사이에 사용자가 볼 수 있는 차이가 없어야 합니다. React 가 개발 중 버그가 있는지 코드를 조사하기 때문에 추가 연결/연결 해제 호출 쌍이 있습니다. 이는 정상적인 현상이며, 이를 없애려 노력하지 마세요!!

프로덕션에서는 Connecting... 이 한 번만 인쇄되는 것을 볼 수 있습니다. 구성 요소를 다시 마운트하는 것은 정리가 필요한 Effect 를 찾는 데 도움이 되도록 개발 중에만 발생합니다. 개발 시 StrictMode 를 끌 수 있지만 계속 유지하는 것이 좋습니다. 이를 통해 위와 같은 많은 버그를 찾을 수 있습니다.

개발 중 Effect 가 2회 호출되는 것을 어떻게 처리해야 하나요?

React 는 위의 예와 같은 버그를 찾기 위해 개발 중에 의도적으로 구성 요소를 다시 마운트 합니다. 올바른 질문은 "Effect 를 한 번 실행하는 방법"이 아니라 "Effect 를 다시 마운트 한 후 작동하도록 수정하는 방법"입니다.

일반적인 대답은 정리 함수를 구현하는 것 입니다. 정리 함수는 Effect 가 수행하던 모든 작업을 중지하거나 실행 취소해야 합니다. 경험상 사용자는 Effect 가 한 번 실행되는 것(프로덕션에서 처럼)과 설정 -> 정리 -> 설정 순서(개발에서 볼 수 있듯이)를 구별할 수 없어야 합니다.

대부분의 경우 아래의 패턴 중 하나가 적합합니다.

하지말아야 할 것

ref 를 사용하여 의도적으로 Effect 가 한 번만 실행되도록 하지 마십시요.

  const connectionRef = useRef(null);
  useEffect(() => {
    // 🚩 This wont fix the bug!!!
    if (!connectionRef.current) {
      connectionRef.current = createConnection();
      connectionRef.current.connect();
    }
  }, []);

 

이렇게 하면 개발 중에 Connecting... 이 한 번만 표시되지만 버그가 수정되지는 않습니다.

사용자가 다른 곳으로 이동해도 연결은 여전히 닫히지 않으며 뒤로 이동하면 새 연결이 생성됩니다. 사용자가 앱을 탐색할 때 "수정" 전과 마찬가지로 연결이 계속 쌓입니다.

버그를 수정하려면 Effect 가 한 번만 실행하는 것만으로는 충분하지 않습니다. 다시 마운트한 후에 Effect 가 작동해야 합니다. 즉, 정리 함수에서 연결을 정리해야 합니다.

비-React 위젯 컨트롤하기

React 로 쓰여지지 않은 UI 위젯을 사용해야 할 때가 있습니다. 예시에서 처럼 지도 컴포넌트를 추가하는 경우를 가정해봅시다. setZoomLevel() 메서드가 있고 React 코드의 ZoomLevel 상태 변수와 확대/축소 수준을 동기화하고 싶습니다. Effect 는 다음과 유사합니다.

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

 

이런 경우 정리 함수는 필요하지 않습니다. 개발 시 Effect 는 두 번 호출되지만, 같은 값으로 setZoomLevel 을 두 번 호출해도 아무 일도 하지 않기 때문에 이는 문제가 되지 않습니다. 약간 느려질 수 있지만 프로덕션에서 불필요하게 다시 마운트되지 않으므로 문제가 되지 않습니다.

일부 API에서는 연속으로 두 번 호출하는 것을 허용하지 않을 수 있습니다. 예를 들어 내장 <dialog> 요소의 showModal 메서드를 2번 호출하면 오류가 발생합니다. 정리 함수를 구현하고 대화 상자를 닫도록 합니다.

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

 

개발 중에는 Effect 가 showModal() 을 호출한 다음 즉시 close() 를 호출하고 다시 showModal() 을 호출합니다. 이는 프로덕션에서 볼 수 있듯이 showModal() 을 한 번 호출하는 것과 사용자에게 표시되는 동작이 동일합니다.

이벤트 구독

Effect 가 무언가를 구독하는 경우, 정리 함수는 구독을 취소해야 합니다.

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

 

개발 중에는 Effect 는 addEventListener() 를 호출한 다음 즉시 removeEventListener() 를 호출하고 동일한 핸들러를 사용해서 다시 addEventListener() 를 호출합니다. 따라서 한 번에 하나의 활성 구독만 있게 됩니다. 이는 프로덕션에서 addEventListener() 를 한 번 호출하는 것과 사용자에게 표시되는 동작이 동일합니다.

애니메이션 트리거

Effect 가 무언가의 애미네이션을 적용하는 경우 정리 함수는 애니메이션을 초기값으로 재설정해야 합니다.

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);

 

개발 중에는 불투명도가 1, 0, 다시 1로 설정됩니다. 이는 1로 직접 설정하는 것과 사용자가 볼 수 있는 동작이 동일해야 하며, 이는 프로덕션에서 발생합니다. 트위닝을 지원하는 타사 애니메이션 라이브러리를 사용하는 경우 정리 함수는 타임라인을 초기 상태로 재설정해야 합니다.

데이터 가져오기

Effect 가 무언가를 가져오는 경우 정리 함수는 가져오기를 중단하거나 해당 결과를 무시해야 합니다.

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

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

  startFetching();

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

 

이미 발생한 네트워크 요청은 "실행 취소"할 수 없지만 정리 기능은 더 이상 관련 없는 가져오기가 애플리케이션에 계속 영향을 미치지 않도록 해야 합니다. userId 가 'Alice'에서 'Bob' 으로 변경되면 정리를 통해 'Alice' 응답이 'Bob' 이후에 도착하더라도 무시됩니다.

개발 중에는 네트워크 탭에 두 개의 가져오기가 표시됩니다. 그것은 아무런 문제가 없습니다. 위의 접근 방식을 사용하면 첫 번째 Effect 가 즉시 정리되어 ignore 변수의 복사본이 true 로 설정됩니다. 따라서 추가 요청이 있어도 if(!ignore) 검사 덕분에 상태에 영향을 미치지 않습니다.

프로덕션에서는 요청이 한 번만 있습니다. 개발 중 두 번째 요청이 귀찮은 경우 가장 좋은 접근 방식은 요청을 중복 제거하고 구성 요소 간에 응답을 캐시하는 솔루션을 사용하는 것입니다.

function TodoList() {
  const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
  // ...

 

이렇게 하면 개발 경험이 향상될 뿐만 아니라 애플리케이션이 더 빠르게 느껴질 수도 있습니다. 예를 들어 사용자가 뒤로 가기 버튼을 누르면 일부 데이터는 캐시되기 때문에 다시 로드될 때까지 기다릴 필요가 없습니다. 이러한 캐시를 직접 구축하거나 Effect 에서 수동으로 가져오는 대신 여러 대안 중 하나를 사용할 수 있습니다.

Effect 에서 데이터를 가져오는 가장 좋은 방법은 무엇인가요?

Effect 내에서 데이터 가져오기 호출을 작성하는 것은 특히 완전한 클라이언트 측 앱에서 데이터를 가져오는 데 널리 사용되는 방법입니다. 그러나 이는 매우 수동적인 접근 방식이며 상당한 단점이 있습니다.

  • Effect 는 서버에서 실행되지 않습니다. 즉, 초기 서버 렌더링 HTML에는 데이터가 없는 로드 상태만 포함됩니다. 클라이언트 컴퓨터는 모든 Javascript 를 다운로드하고 앱을 렌더링해야 이제 데이터를 로드할 수 있음을 발견할 수 있습니다. 이는 효과적이지 않습니다.
  • Effect 에서 직접 가져오면 "네트워크 폭포"를 쉽게 만들 수 있습니다. 상위 구성 요소를 렌더링하면 일부 데이터를 가져오고 하위 구성 요소를 렌더링한 다음 데이터를 가져오기 시작합니다. 네트워크 속도가 아주 빠르지 않으면 모든 데이터를 병렬로 가져오는 것보다 훨씬 느립니다.
  • Effect 에서 직접 가져오는 것은 일반적으로 데이터를 미리 로드하거나 캐시하지 않는다는 의미입니다. 예를 들어 구성 요소가 마운트 해제된 후 다시 마운트되면 데이터를 다시 가져와야 합니다.
  • 이는 그다지 친환경적이지 않습니다. 경쟁 조건과 같은 버그가 발생하지 않는 방식으로 가져오기 호출을 작성할 때 관련된 상용구 코드가 꽤 많이 있습니다.

이 단점 목록은 React 에만 국한된 것이 아닙니다. 모든 라이브러리를 사용하여 마운트할 때 데이터를 가져오는데 적용됩니다. 라우팅과 마찬가지로 데이터 가져오기도 잘 수행하기가 쉽지 않으므로 다음 접근 방식을 권장합니다.

  • 프레임워크를 사용하는 경우 내장된 데이터 가져오기 메커니즘을 사용하세요. 최신 React 프레임워크에는 효율적이고 위의 함정을 겪지 않는 통합 데이터 가져오기 메커니즘이 있습니다.
  • 그렇지 않으면 클라이언트 측 캐시를 사용하거나 구축하는 것을 고려하십시오. 인기 있는 오픈 소스 솔루션으로 React Query, useSWR 및 React Router 6.4+ 가 있습니다. 자체 솔루션을 구축할 수도 있습니다. 이 경우 내부적으로 Effect 를 사용하지만 요청 중복 제거, 응답 캐싱 및 네트워크 폭포 방지(데이터를 미리 로드하거나 데이터 요구 사항을 경로에 끌어올리는 방식)를 위한 논리를 추가합니다.

이러한 접근 방식 중 어느 것도 적합하지 않은 경우 계속해서 Effect 에서 직접 데이터를 가져올 수 있습니다.

분석 정보 정송

페이지 방문 시 분석 이벤트를 보내는 다음 코드를 생각해 보세요.

useEffect(() => {
  logVisit(url); // Sends a POST request
}, [url]);

 

개발 중에는 logVisit 이 모든 URL 에 대해서 두 번 호출되므로 이를 수정하려고 할 수 있습니다. 이 코드를 그대로 유지하는 것이 좋습니다. 이전 예제외 마찬가지로 한 번 실행하는 것과 두 번 실행하는 것 사이에 사용자가 볼 수 있는 동작 차이는 없습니다. 실용적인 관점에서 볼 때, 개발 시스템의 로그가 생산 지표를 왜곡하는 것을 원하지 않기 때문에 logVisit 은 개발 중에 아무 것도 수행해서는 안 됩니다. 파일을 저장할 때마다 구성 요소가 다시 마운트되므로 어쨋든 개발 시 추가 방문이 기록됩니다.

프로덕션에서는 중복 방문 로그가 없습니다.

전송 중인 분석 이벤트를 디버깅하려면 앱을 스테이징 환경(프로덕션 모드에서 실행)에 배포하거나 엄격 모드를 일시적으로 해제할 수 있습니다. Effect 대신 경로 변경 이벤트 핸들러에서 분석을 보낼 수도 있습니다. 보다 정확한 분석을 위해 교차 관찰자는 뷰포트에 있는 구성 요소와 해당 구성 요소가 표시되는 기간을 추적하는 데 도움이 될 수도 있습니다.

Effect 아님: 애플리케이션 초기화하기

일부 로직은 애플리케이션이 실행될 때 한 번만 실행되어야 합니다. 구성 요소 외부에 배치할 수 있습니다.

if (typeof window !== 'undefined') { // Check if we're running in the browser.
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

 

이는 브라우저가 페이지를 로드한 후 이러한 로직이 한 번만 실행되도록 보장합니다.

Effect 아님: 제품 구매하기

때로는 정리 함수를 작성하더라도 Effect 를 두 번 실행함으로써 사용자가 볼 수 있는 결과를 방지할 수 있는 방법이 없습니다. 예를 들어 Effect 가 제품 구매와 같은 POST 요청을 보낼 수 있습니다.

useEffect(() => {
  // 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
  fetch('/api/buy', { method: 'POST' });
}, []);

 

제품을 두 번 구매하고 싶지 않습니다. 그러나 이것이 바로 이 로직을 Effect 에 넣지 말아야 하는 이유이기도 합니다. 사용자가 다른 페이지로 이동한 다음 뒤로를 누르면 어떻게 되나요? Effect 는 다시 실행됩니다. 사용자가 페이지를 방문할 때마다 제품을 구매하고 싶지는 않습니다. 사용자가 구매 버튼을 클릭하면 구매하려고 합니다.

구매는 렌더링으로 인해 발생하지 않습니다. 특정 상호작용으로 인해 발생합니다. 사용자가 버튼을 누를 때 실행되어야 합니다. Effect 를 삭제하고 요청을 구매 버튼 이벤트 핸들러로 이동합니다.

  function handleClick() {
    // ✅ Buying is an event because it is caused by a particular interaction.
    fetch('/api/buy', { method: 'POST' });
  }

 

이는 다시 마운트하면 애플리케이션의 논리가 중단되는 경우 일반적으로 기존 버그가 발경된다는 것을 보여줍니다. 사용자의 관점에서 볼 때 페이지를 방문하는 것은 해당 페이지를 방문하고 링크를 클릭한 다음 뒤로를 눌러 페이지를 다시 보는 것과 다르지 않아야 합니다. React 는 개발 중에 구성 요소를 한 번 다시 마운트하여 구성 요소가 이 원칙을 준수하는지 확인합니다.

다 모아서

이 플레이그라운드는 Effect 가 실제로 어떻게 동작하는지 "느끼는" 데 도움이 될 수 있습니다.

이 예시에서는 setTimeout을 사용하여 Effect 가 실행된 후 3초 후에 입력 텍스트가 표시되는 콘솔 로그입니다. 정리 함수는 보류 중인 timeout 을 취소합니다. "Mount the component"를 클릭해서 시작해 보세요.

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Unmount' : 'Mount'} the component
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

 

처음에는 Schedule "a" log, Cancel "a" log, 그리고 Schedule "a" log 3개의 로그가 표시됩니다. 3초 후에는 ⏰ a 로그도 표시됩니다. 앞서 배웠듯이 추가 예약/취소 쌍은 React 가 개발 중에 정리 함수를 잘 구현했는지 확인하기 위해 구성 요소를 한 번 다시 마운트하기 때문입니다.

이제 입력을 편집하여 abc 라고 입력하세요. 충분히 빠르게 수행하면 Schedule "ab" log 가 즉시 표시되고 이어서 Cancel "ab" log 및 Schedule "abc" log 가 표시됩니다. React 는 항상 다음 렌더링의 Effect 전에 이전 렌더링의 Effect 를 정리합니다. 그렇기 때문에 입력을 빠르게 입력하더라도 한 번에 최대 하나의 제한 시간이 예약되어 있습니다. 입력을 몇 번 편집하고 콘솔을 보면서 Effect 가 어떻게 정리되는지 느껴보세요.

입력란에 무언가를 입력한 후 즉시 "Unmount the component" 를 누르십시오. 마운트 해제로 마지막 렌더 효과가 어떻게  정리되는지 확인하세요. 여기서는 실행되기 전에 마지막 timeout 을 지웁니다.

마지막으로 위의 구성 요소를 편집하고 시간 초과가 취소되지 않도록 정리 기능을 주석 처리합니다. "abcde"를 빠르게 입력합니다. 3초 후 어떤 일이 일어날 것으로 예상하시나요? console.log(text) 가 "abcde" 를 5개 인쇄하나요? 당신의 직관을 시험해보세요.

3초 후 5개의 abcde 가 아닌 (a, ab, abc, abcd, abcde) 가 표시됩니다. 각 Effect 는 해당 렌더링에서 텍스트값을 캡쳐합니다. 텍스트 상태가 변경되었는지는 중요하지 않습니다. text = 'ab' 인 렌터링의 효과에는 항상 'ab'가 표시됩니다. 즉, 각 렌더링의 효과는 서로 격리됩니다. 이것이 어떻게 동작하는지 궁금하다면 클로저에 대해서 읽어보세요.

 

요점정리

  • 이벤트와 달리 Effect 는 특정 상호 작용이 아닌 렌더링 자체에 의해 발생합니다.
  • Effect 를 사용하려면 구성 요소를 일부 외부 시스템(타사 API, 네트워크 등)과 동기화 할 수 있습니다.
  • 기본적으로 Effect 는 모든 렌더링(초기 렌더링 포함) 후에 실행됩니다.
  • React 는 모든 종속성이 마지막 렌더링과 동일한 값을 갖는 경우 Effect 를 건너뜁니다.
  • 종속성을 선택할 수 없습니다. 이는 Effect 내부 코드에 의해 결정됩니다.
  • 빈 종속성 배열([])은 'mount' 구성 요소, 즉 화면에 추가되는 구성 요소에 해당합니다.
  • StrictMode 에서 React 는 Effect 의 스트레스 테스트를 위해 컴포넌트를 두 번 마운트 합니다.(개발 중에만!!)
  • 재마운트 때문에 Effect 가 중단되면 정리 기능을 구현해야 합니다.
  • React 는 다음 Effect 가 실행되기 전과 마운트 해제 중에 정리 함수를 호출합니다.
반응형