ReactNextCentral

Effect: 동기화

Published on
리액트의 효과(Effect)를 사용하여 컴포넌트를 외부 시스템과 동기화하고, 효과와 이벤트의 차이, 효과를 선언하는 방법, 그리고 효과가 필요하지 않을 때 처리하는 방법에 대해 설명합니다.
Table of Contents

일부 컴포넌트는 외부 시스템과 동기화해야 할 필요가 있습니다. 예를 들어, 리액트 상태에 따라 리액트 이외의 컴포넌트를 제어하거나 서버 연결을 설정하거나 컴포넌트가 화면에 나타날 때 분석 로그를 보내는 등의 작업을 수행할 수 있습니다. 효과를 사용하면 렌더링 이후에 코드를 실행하여 리액트 컴포넌트를 외부 시스템과 동기화할 수 있습니다.

다음 내용을 배우게 됩니다.

  • 효과(Effect)란 무엇인가요?
  • 효과(Effect)와 이벤트(event)의 차이점은 무엇인가요?
  • 컴포넌트에 효과(Effect)를 선언하는 방법은 어떻게 되나요?
  • 효과(Effect)가 필요하지 않을 때는 어떻게 해야 하나요?
  • 왜 개발 중에 효과(Effect)가 두 번 실행되고 그 문제를 어떻게 해결하나요?

효과(Effect)란 무엇이며 이벤트와 어떻게 다른가요?

효과(Effect)에 대해 이야기하기 전에 리액트 컴포넌트 내부의 두 가지 유형의 로직에 대해 알아야 합니다.

  • 렌더링 코드(Rendering code) (UI 설명에서 소개됨)는 컴포넌트의 최상위 수준에 위치합니다. 이곳에서 프로퍼티와 상태를 가져와 변환한 다음, 화면에 표시할 JSX를 반환합니다. 렌더링 코드는 순수해야 합니다. 수학 공식과 마찬가지로 결과를 계산하기만 하고 다른 작업은 수행하지 않아야 합니다.
  • 이벤트 핸들러(Event handlers) (인터랙티비티 추가에서 소개됨)는 컴포넌트 내부에 중첩된 함수로, 계산하는 것이 아니라 작업을 수행합니다. 이벤트 핸들러는 입력 필드를 업데이트하거나 제품을 구매하기 위해 HTTP POST 요청을 제출하거나 사용자를 다른 화면으로 이동시킬 수 있습니다. 이벤트 핸들러는 프로그램 상태를 변경하는 **"부작용(side effects)"**을 포함합니다. 예를 들어, 버튼을 클릭하거나 타이핑과 같은 특정 사용자 동작에 의해 발생합니다.

하지만 이러한 방식만으로는 충분하지 않을 수 있습니다. ChatRoom 컴포넌트를 고려해 보겠습니다. ChatRoom이 화면에 표시될 때마다 채팅 서버에 연결해야 한다고 가정해 보겠습니다. 서버에 연결하는 것은 순수한 계산(pure calculation)이 아니며 (부작용인) **"효과(Effect)"**이므로 렌더링 중에 이루어질 수 없습니다. 그러나 특정 버튼 클릭과 같은 단일한 이벤트가 ChatRoom이 표시되는 원인이 되지 않습니다.

효과(Effect)는 특정 이벤트가 아닌 렌더링 자체에 의해 발생하는 부작용을 지정할 수 있습니다. 채팅에서 메시지를 보내는 것은 사용자가 특정 버튼을 클릭함으로써 직접적으로 발생하는 이벤트입니다. 그러나 서버 연결 설정은 효과(Effect)이므로 컴포넌트가 나타나는데 어떤 상호작용이든 발생할 필요가 있습니다. 효과는 화면이 업데이트된 후에 커밋(commit)이 끝나는 시점에 실행됩니다. 이 시점에서 리액트 컴포넌트를 외부 시스템(네트워크 또는 타사 라이브러리)과 동기화하는 것이 좋습니다.

참고
여기서부터 이 글 전체에서 대문자로 표기된 "효과(Effect)"는 리액트에 대한 특정한 정의를 가리킵니다. 즉, 렌더링에 의해 발생하는 부작용을 말합니다. 프로그래밍의 보다 일반적인 개념을 가리키기 위해 "부작용(side effect)"이라고 말할 것입니다.

효과(Effect)가 필요하지 않을 수도 있습니다

컴포넌트에 효과(Effect)를 바로 추가하려고 서두르지 마세요. 효과는 주로 리액트 코드에서 "탈출하여" 외부 시스템과 동기화하는 데 사용됩니다. 이에는 브라우저 API, 타사 위젯, 네트워크 등이 포함됩니다. 효과가 다른 상태에 기반하여 어떤 상태를 조정하는 경우에는 효과(Effect)가 필요하지 않을 수도 있습니다.

효과(Effect) 작성하기

효과(Effect)를 작성하려면 다음 세 가지 단계를 따르세요.

  1. 효과(Effect) 선언하기: 기본적으로 효과(Effect)는 매 렌더링 이후에 실행됩니다.
  2. 효과(Effect)의 종속성 지정하기: 대부분의 효과(Effect)는 매 렌더링마다 실행하는 대신 필요할 때만 다시 실행되어야 합니다. 예를 들어, 페이드 인 애니메이션은 컴포넌트가 나타날 때만 트리거되어야 합니다. 채팅방에 연결하거나 연결을 해제하는 작업은 컴포넌트가 나타나고 사라지거나 채팅방이 변경될 때만 발생해야 합니다. 이를 위해 종속성(dependencies)을 지정하여 제어하는 방법을 배우게 될 것입니다.
  3. 필요한 경우 정리 작업 추가하기: 일부 효과(Effect)는 수행하던 작업을 중지하거나 되돌리거나 정리해야 할 수 있습니다. 예를 들어, "연결(connect)"은 "연결 해제(disconnect)"가 필요하고, "구독(subscribe)"은 "구독 해제(unsubscribe)"가 필요하며, "데이터 가져오기(fetch)"는 "취소(cancel)" 또는 "무시(ignore)"가 필요합니다. 이를 위해 정리 함수를 반환하는 방법을 배우게 될 것입니다.

각 단계에 대해 자세히 살펴보겠습니다.

단계 1: 효과(Effect) 선언하기

컴포넌트에서 효과(Effect)를 선언하려면 리액트에서 useEffect Hook를 가져와야 합니다.

import { useEffect } from 'react';

그런 다음, 컴포넌트의 최상위 수준에서 이를 호출하고 효과 내에 코드를 넣습니다.

function MyComponent() {
  useEffect(() => {
    // 이곳의 코드는 *모든* 렌더링 이후에 실행됩니다
  });
  return <div />;
}

컴포넌트가 렌더링될 때마다 리액트는 화면을 업데이트한 후 useEffect 내의 코드를 실행합니다. 다시 말해서, useEffect는 렌더링이 화면에 반영될 때까지 코드의 실행을 "지연"합니다.

이제 외부 시스템과 동기화하기 위해 효과를 사용하는 방법을 살펴보겠습니다. <VideoPlayer>라는 리액트 컴포넌트를 고려해 보겠습니다. 이 컴포넌트에서는 isPlaying prop을 통해 재생 또는 일시 중지 여부를 제어할 수 있는 것이 좋을 것입니다.

<VideoPlayer isPlaying={isPlaying} />;

사용자 정의 VideoPlayer 컴포넌트는 내장 브라우저 <video> 태그를 렌더링합니다.

function VideoPlayer({ src, isPlaying }) {
  // isPlaying에 대한 처리를 여기에 추가하세요
  return <video src={src} />;
}

그러나 브라우저 <video> 태그는 isPlaying prop을 갖고 있지 않습니다. 이를 제어하기 위해 DOM 요소에서 수동으로 play()pause() 메서드를 호출해야 합니다. play() 및 pause()와 같은 호출을 통해 비디오의 재생 여부를 나타내는 isPlaying prop의 값을 동기화해야 합니다.

먼저 <video> DOM 노드에 대한 ref를 얻어야 합니다.

play() 또는 pause()를 렌더링 중에 호출하려고 시도할 수도 있지만, 이는 올바르지 않습니다.

Edit async-dust-i7so0y

이 코드가 올바르지 않은 이유는 렌더링 중에 DOM 노드에 대해 작업을 수행하려고 하기 때문입니다. 리액트에서 렌더링은 JSX의 순수 계산이어야 하며 DOM 수정과 같은 부작용을 포함해서는 안 됩니다.

또한, VideoPlayer가 처음 호출될 때는 아직 DOM이 존재하지 않습니다! JSX를 반환하기 전까지 리액트는 어떤 DOM을 생성해야 할 지 알지 못합니다.

여기서의 해결책은 부작용을 렌더링 계산에서 분리하기 위해 useEffect로 부작용을 래핑하는 것입니다.

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

효과(Effect) 내에서 DOM 업데이트를 래핑함으로써 리액트에게 먼저 화면을 업데이트하도록 할 수 있습니다. 그런 다음 효과가 실행됩니다.

VideoPlayer 컴포넌트가 렌더링될 때(처음 렌더링되거나 다시 렌더링될 때) 몇 가지 일이 발생합니다. 먼저, 리액트는 화면을 업데이트하여 <video> 태그가 올바른 props로 DOM에 있도록 합니다. 그런 다음 리액트는 효과를 실행합니다. 마지막으로, 효과는 isPlaying의 값에 따라 play() 또는 pause()를 호출합니다.

Play/Pause 버튼을 여러 번 누르고 isPlaying 값이 동기화되는 것을 확인해 보세요.

Edit hungry-shape-i7nfiu

이 예제에서 외부 시스템에 대한 동기화 대상은 브라우저 미디어 API입니다. 비슷한 접근 방식을 사용하여 jQuery 플러그인과 같은 레거시 코드를 선언적인 리액트 컴포넌트로 래핑할 수 있습니다.

실제로 비디오 플레이어를 제어하는 것은 훨씬 복잡합니다. play() 호출이 실패할 수 있고, 사용자가 내장된 브라우저 컨트롤을 사용하여 재생 또는 일시 중지할 수도 있습니다. 이 예제는 매우 단순화되고 완전하지 않습니다.

주의하기

기본적으로, 효과(Effect)는 모든 렌더링 이후에 실행됩니다. 다음과 같은 코드는 무한 루프를 생성합니다.

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

효과는 렌더링의 결과로 실행됩니다. 상태를 설정하면 렌더링이 발생합니다. 효과 내에서 상태를 즉시 설정하면 전원 플러그를 자기 자신에 연결하는 것과 같습니다. 효과가 실행되고 상태가 설정되면 다시 렌더링이 발생하고, 효과가 다시 실행되고 상태를 다시 설정하면 또 다른 렌더링이 발생하고, 이런 식으로 계속됩니다.

효과는 보통 컴포넌트를 외부 시스템과 동기화하는 데 사용됩니다. 외부 시스템이 없고 다른 상태에 기반하여 일부 상태를 조정하려는 경우에는 효과를 사용하지 않아도 될 수 있습니다.

단계 2: 효과의 종속성 지정하기

기본적으로 효과(Effect)는 모든 렌더링 이후에 실행됩니다. 이는 종종 원하는 동작이 아닐 수 있습니다.

  • 때로는 느릴 수 있습니다. 외부 시스템과 동기화하는 것은 항상 즉시 이루어지지 않으므로 필요한 경우에만 실행하고 싶을 수 있습니다. 예를 들어, 매 타이핑마다 채팅 서버에 다시 연결하고 싶지는 않을 것입니다.
  • 때로는 잘못될 수 있습니다. 예를 들어, 매 타이핑마다 컴포넌트의 페이드 인 애니메이션을 트리거하고 싶지는 않을 것입니다. 애니메이션은 컴포넌트가 처음 나타날 때에만 한 번 재생되어야 합니다.

이 문제를 보여주기 위해 이전 예제에 몇 가지 console.log 호출과 부모 컴포넌트의 상태를 업데이트하는 텍스트 입력란을 추가하였습니다. 타이핑하면 효과가 다시 실행되는 것을 주목하세요:

Edit unruffled-resonance-85rk3b

리액트에게 효과를 불필요하게 다시 실행하지 말라고 지시하려면 useEffect 호출의 두 번째 인자로 종속성 배열(dependency array)을 지정하면 됩니다. 빈 [] 배열을 위의 예제의 14번째 줄에 추가해 보세요:

useEffect(() => {
    // ...
  }, []);

다음과 같은 오류가 발생할 것입니다. 'isPlaying'이라는 리액트 Hook useEffect의 누락된 종속성(dependency)입니다.

Edit cranky-carlos-sxrul5

문제는 효과 내부의 코드가 어떤 작업을 할지 결정하기 위해 isPlaying prop에 의존하지만 이 종속성이 명시적으로 선언되지 않았다는 것입니다. 이 문제를 해결하기 위해 종속성 배열에 isPlaying을 추가하세요:

 useEffect(() => {
    if (isPlaying) { // 여기서 사용됩니다...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...따라서 여기서 선언되어야 합니다!

이제 모든 종속성이 선언되었으므로 오류가 없습니다. 종속성 배열에 [isPlaying]을 지정하면 리액트에게 이전 렌더링 중과 동일한 값이 isPlaying에 있다면 효과를 다시 실행하지 않아도 된다고 알려줍니다. 이 변경으로 인해 입력란에 타이핑해도 효과가 다시 실행되지 않지만, Play/Pause를 누르면 효과가 실행됩니다.

Edit headless-sea-s3ulgu

종속성 배열에는 여러 종속성을 포함시킬 수 있습니다. 리액트는 지정한 모든 종속성이 이전 렌더링과 정확히 동일한 값을 갖는 경우에만 효과를 다시 실행을 건너뛸 것입니다. 리액트는 종속성 값을 Object.is 비교를 사용하여 비교합니다. 자세한 내용은 useEffect 참조 문서를 참조하세요.

주의할 점은 종속성을 "선택"할 수 없다는 것입니다. 종속성으로 지정한 것이 리액트가 코드 내부를 기반으로 예상하는 것과 일치하지 않으면 린트(lint) 오류가 발생합니다. 이를 통해 코드에서 많은 버그를 잡을 수 있습니다. 특정 코드를 다시 실행하지 않으려면 효과 코드 자체를 편집하여 해당 종속성이 "필요하지 않도록" 할 수 있습니다.

주의하기

종속성 배열 없이 동작하는 것과 빈 [] 종속성 배열을 가지고 동작하는 것은 다릅니다.

useEffect(() => {
  // 이것은 모든 렌더링 이후에 실행됩니다.
});

useEffect(() => {
  // 이것은 마운트될 때만 실행됩니다 (컴포넌트가 처음 나타날 때)
}, []);

useEffect(() => {
  // 이것은 마운트될 때 실행되며, a 또는 b가 마지막 렌더링 이후에 변경된 경우에도 실행됩니다.
}, [a, b]);

"마운트"가 무엇을 의미하는지에 대해서는 다음 단계에서 자세히 살펴보겠습니다.

자세히 알아보기: 왜 ref가 종속성 배열에서 생략되었을까요?

이 효과는 ref와 isPlaying을 모두 사용하지만, 종속성으로 선언된 것은 isPlaying뿐입니다.

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying]);

이는 ref 객체가 안정적인 식별성(stable identity)을 갖기 때문입니다. 리액트는 같은 useRef 호출에서 항상 동일한 객체를 얻을 것을 보장하기 때문에(ref에 대해서만) 같은 객체를 얻게 됩니다. 이 객체는 변경되지 않으므로 자체적으로 효과를 다시 실행시키지 않을 것입니다. 따라서 이 객체를 포함시키거나 생략해도 상관없습니다. 포함시키는 것도 괜찮습니다.

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }, [isPlaying, ref]);

useState로부터 반환되는 설정 함수(set function)들도 안정적인 식별성을 갖기 때문에 종속성 배열에서 생략된 경우가 많이 있습니다. 린터(linter)가 종속성을 생략할 수 있도록 오류 없이 허용한다면, 안전하게 생략해도 됩니다.

항상 안정적인(dependency) 종속성을 생략하는 것은 린터가 해당 객체가 안정적인지 "보"고 있을 때만 동작합니다. 예를 들어, ref가 부모 컴포넌트에서 전달된 경우에는 종속성 배열에 명시해야 합니다. 그러나 이는 좋은 점입니다. 왜냐하면 부모 컴포넌트가 항상 같은 ref를 전달하는지, 또는 여러 ref 중에서 조건에 따라 하나를 전달하는지 알 수 없기 때문입니다. 따라서 효과는 전달되는 ref에 따라 달라질 것입니다.

단계 3: 필요한 경우 정리 코드 추가하기

다른 예를 생각해봅시다. 사용자에게 표시되는 동안 연결을 유지해야 하는 ChatRoom 컴포넌트를 작성하고 있습니다. createConnection() API를 사용할 수 있으며, connect() 및 disconnect() 메서드를 가진 객체를 반환합니다. 컴포넌트가 사용자에게 표시될 때 연결을 유지하는 방법은 무엇일까요?

먼저 효과 로직을 작성해 보겠습니다.

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

매번 다시 렌더링할 때마다 채팅에 연결하는 것은 느릴 수 있으므로 종속성 배열을 추가해보겠습니다.

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

효과 내부의 코드는 어떤 props나 상태를 사용하지 않으므로 종속성 배열은 [] (빈 배열)입니다. 이는 리액트에게 이 코드를 컴포넌트가 "마운트"(화면에 처음 표시될 때)될 때만 실행하도록 지시합니다.

이 코드를 실행해 보겠습니다.

Edit serene-rumple-191rb1

이 효과는 마운트 시에만 실행되므로 콘솔에 "✅ 연결 중..."이 한 번만 표시될 것으로 예상할 수 있습니다. 그러나 콘솔을 확인해 보면 "✅ 연결 중..."이 두 번 표시됩니다. 왜 그럴까요?

ChatRoom 컴포넌트가 많은 다른 화면이 있는 앱의 일부로 사용된다고 상상해보세요. 사용자는 ChatRoom 페이지에서 여정을 시작합니다. 컴포넌트가 마운트되고 connection.connect()를 호출합니다. 그런 다음 사용자가 다른 화면으로 이동, 예를 들어 설정 페이지로 이동한다고 상상해보세요. ChatRoom 컴포넌트가 언마운트됩니다. 마지막으로 사용자가 "뒤로" 버튼을 클릭하고 ChatRoom이 다시 마운트됩니다. 이는 두 번째 연결을 설정할 것입니다 - 하지만 첫 번째 연결은 제거되지 않았습니다! 사용자가 앱을 이동할 때마다 연결이 계속 쌓일 것입니다.

이러한 버그는 철저한 수동 테스트 없이는 쉽게 놓칠 수 있습니다. 이러한 문제를 빠르게 발견할 수 있도록, 리액트는 개발 중에 초기 마운트 후에 모든 컴포넌트를 한 번씩 다시 마운트합니다.

"✅ 연결 중..."이 두 번 로그에 출력되어 실제 문제를 인지할 수 있습니다. 코드가 컴포넌트가 마운트 해제될 때 연결을 닫지 않고 남겨둔 것입니다.

이 문제를 해결하기 위해 효과에서 정리(clean-up) 함수를 반환합니다.

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

리액트는 효과가 다시 실행되기 전에 매번 정리 함수를 호출하며, 컴포넌트가 언마운트(제거)될 때 한 번 호출합니다. 정리 함수가 구현되었을 때 어떻게 동작하는지 살펴보겠습니다.

Edit beautiful-panka-4womvr

개발 환경에서는 콘솔에 세 개의 로그가 출력됩니다.

  1. "✅ 연결 중..."
  2. "❌ 연결이 끊어짐."
  3. "✅ 연결 중..."

이것은 개발 환경에서 올바른 동작입니다. 컴포넌트를 다시 마운트하여 리액트는 사용자가 이동하고 다시 돌아와도 코드가 깨지지 않는지 확인합니다. 연결을 끊고 다시 연결하는 것이 정확히 일어나야 합니다! 정리를 잘 구현했다면, 효과를 한 번 실행하거나 실행하고 정리하고 다시 실행하는 것 사이에는 사용자가 볼 수 있는 차이가 없어야 합니다. 연결/연결 해제 호출 쌍이 추가되는 이유는 리액트가 개발 중에 코드에서 버그를 탐지하기 위해 코드를 확인하고 있기 때문입니다. 이것은 정상적인 동작입니다 - 사라지게 하려고 하지 마세요!

운영 환경에서는 "✅ 연결 중..."이 한 번만 출력됩니다. 컴포넌트를 다시 마운트하는 것은 개발 중에만 발생하며 정리가 필요한 효과를 찾을 수 있도록 도와줍니다. 개발 동작을 해제하려면 Strict Mode를 사용하지 않도록 설정할 수 있지만, 유지하는 것이 좋습니다. 이렇게 하면 위와 같은 많은 버그를 찾을 수 있습니다.

개발 중에 Effect가 두 번 실행되는 경우 처리 방법

리액트는 마지막 예시에서와 같은 버그를 찾기 위해 개발 중에 컴포넌트를 의도적으로 다시 마운트합니다. 올바른 질문은 "효과를 한 번 실행하는 방법"이 아니라 "내 효과를 다시 마운트 후에도 정상적으로 작동하도록 어떻게 고칠 수 있는지"입니다.

일반적으로 정리(clean-up) 함수를 구현하는 것이 해결 방법입니다. 정리 함수는 효과가 수행한 작업을 중단하거나 되돌리는 역할을 합니다. 일반적인 원칙은 사용자가 효과가 한 번 실행되는 것(프로덕션 환경과 동일)과 설정 → 정리 → 설정 순서(개발 환경에서 발생) 간에 구분할 수 없어야 합니다.

작성할 대부분의 효과는 아래의 일반적인 패턴 중 하나에 해당할 것입니다.

비-리액트 위젯 제어하기

가끔 리액트로 작성되지 않은 UI 위젯을 추가해야 할 때가 있습니다. 예를 들어 페이지에 맵 컴포넌트를 추가한다고 가정해보겠습니다. 이 맵 컴포넌트는 setZoomLevel() 메서드를 갖고 있으며, 리액트 코드의 zoomLevel 상태 변수와 줌 레벨을 동기화하고자 합니다. 효과는 다음과 유사하게 보일 것입니다.

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

이 경우에는 정리가 필요하지 않습니다. 개발 환경에서 리액트는 효과를 두 번 호출하지만, 동일한 값으로 setZoomLevel을 두 번 호출해도 아무런 문제가 없습니다. 약간 느릴 수는 있지만, 프로덕션에서는 필요하지 않은 다시 마운트는 발생하지 않으므로 문제가 되지 않습니다.

일부 API는 연속으로 두 번 호출할 수 없을 수도 있습니다. 예를 들어, 내장된 <dialog> 요소의 showModal 메서드는 두 번 호출하면 예외가 발생합니다. 정리 함수를 구현하고, 대화 상자를 닫도록 만들어보세요:

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

개발 환경에서 효과는 showModal()을 호출한 다음 즉시 close()하고, 그리고 다시 showModal()을 호출합니다. 이는 프로덕션에서 한 번 호출하는 것과 동일한 사용자가 볼 수 있는 동작을 합니다.

이벤트 구독하기

효과가 무언가에 구독하는 경우, 정리 함수는 구독을 해지해야 합니다.

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

개발 환경에서 효과는 addEventListener()를 호출한 다음 즉시 removeEventListener()하고, 그리고 같은 핸들러로 다시 addEventListener()합니다. 따라서 한 번에 한 개의 구독만 활성화됩니다. 이는 프로덕션에서 addEventListener()를 한 번 호출하는 것과 동일한 사용자가 볼 수 있는 동작을 합니다.

애니메이션 트리거하기

효과가 애니메이션을 트리거하는 경우, 정리 함수는 애니메이션을 초기 값으로 재설정해야 합니다.

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // 애니메이션 트리거
  return () => {
    node.style.opacity = 0; // 초기 값으로 재설정
  };
}, []);

개발 환경에서는 불투명도가 1로 설정되고, 그런 다음 0으로, 그리고 다시 1로 설정됩니다. 이는 프로덕션에서 1로 설정하는 것과 동일한 사용자가 볼 수 있는 동작을 합니다. Tweening을 지원하는 타사 애니메이션 라이브러리를 사용하는 경우, 정리 함수는 타임라인을 초기 상태로 재설정해야 합니다.

데이터 가져오기

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

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

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

  startFetching();

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

이미 발생한 네트워크 요청을 "실행 취소"할 수는 없지만, 정리 함수는 더 이상 관련 없는 fetch가 응용 프로그램에 영향을 미치지 않도록 보장해야 합니다. userId가 'Alice'에서 'Bob'으로 변경되면, 정리는 'Bob'이라는 응답이 도착해도 무시하도록 합니다.

개발 환경에서는 네트워크 탭에 두 개의 fetch가 표시됩니다. 이는 아무런 문제가 없습니다. 위의 접근 방식에서 첫 번째 효과는 즉시 정리되어 ignore 변수의 복사본이 true로 설정됩니다. 따라서 추가 요청이 있더라도 if (!ignore) 검사를 통해 상태에 영향을 미치지 않습니다.

프로덕션에서는 하나의 요청만 있을 것입니다. 개발 환경에서 두 번째 요청이 거슬린다면, 가장 좋은 접근 방식은 요청을 중복 처리하고 응답을 컴포넌트 간에 캐시하는 솔루션을 사용하는 것입니다.

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

이는 개발 경험을 개선하는 것뿐 아니라 응용 프로그램이 더 빠르게 느껴지도록 만들어줍니다. 예를 들어, 사용자가 뒤로 가기 버튼을 누를 때 다시로드할 데이터를 기다릴 필요가 없으므로 캐시에 저장됩니다. 이러한 캐시를 직접 구축하거나 효과에서 수동으로 가져오기 대신 여러 가지 대체 방법을 사용할 수 있습니다.

자세히 알아보기: Effects에서 데이터 가져오는 좋은 대안은 무엇인가요?

효과 내에서 직접 fetch 호출을 작성하는 것은 데이터를 가져오는 인기있는 방법입니다. 특히 완전한 클라이언트 사이드 앱에서는 자주 사용됩니다. 그러나 이는 매우 수동적인 접근 방식이며 중요한 단점이 있습니다.

  • 효과는 서버에서 실행되지 않습니다. 이는 초기 서버 렌더링된 HTML에는 데이터가 없는 로딩 상태만 포함된다는 것을 의미합니다. 클라이언트 컴퓨터는 모든 JavaScript를 다운로드하고 앱을 렌더링한 후에야 데이터를 로드해야 한다는 것을 알게 됩니다. 이는 매우 효율적이지 않습니다.
  • 효과 내에서 직접 가져오는 것은 "네트워크 폭포"를 만드는 것이 쉽습니다. 부모 컴포넌트를 렌더링하고 일부 데이터를 가져온 다음 자식 컴포넌트를 렌더링하면 그들은 자신의 데이터를 가져오기 시작합니다. 네트워크가 매우 느리다면 이는 모든 데이터를 병렬로 가져오는 것보다 훨씬 느립니다.
  • 효과 내에서 직접 가져오는 것은 일반적으로 데이터를 사전 로드하거나 캐시하지 않는다는 것을 의미합니다. 예를 들어, 컴포넌트가 언마운트되고 다시 마운트되면 데이터를 다시 가져와야 합니다.
  • 사용성이 좋지 않습니다. fetch 호출을 작성할 때 경합 상태와 같은 버그가 발생하지 않도록 작성하는 데 꽤 많은 보일러플레이트 코드가 포함됩니다.

이러한 단점 목록은 리액트에만 해당되는 것은 아닙니다. 어떤 라이브러리를 사용하여도 마운트 시 데이터를 가져오는 경우 동일한 문제가 발생합니다. 데이터 가져오기는 라우팅과 마찬가지로 잘 처리하기 어렵기 때문에 다음과 같은 접근 방식을 권장합니다.

  • 프레임워크를 사용하는 경우 내장된 데이터 가져오기 메커니즘을 사용하세요. 최신 리액트 프레임워크에는 효율적이며 위에서 언급한 문제를 겪지 않는 통합된 데이터 가져오기 메커니즘이 있습니다.
  • 그렇지 않은 경우 클라이언트 측 캐시를 사용하거나 구축하는 것을 고려하세요. 인기있는 오픈 소스 솔루션으로는 리액트 Query, useSWR, 리액트 Router 6.4+ 등이 있습니다. 직접 솔루션을 구축할 수도 있으며, 이 경우 효과를 내부적으로 사용하지만 요청 중복 제거, 응답 캐시, 네트워크 폭포 방지(데이터 사전 로드 또는 데이터 요구 사항을 라우트로 이동)를 위한 로직을 추가해야 합니다.

위의 접근 방식 중 어느 것도 적합하지 않은 경우에는 계속해서 효과 내에서 데이터를 직접 가져오는 것을 사용할 수 있습니다.

Sending analytics

페이지 방문 시 분석 이벤트를 보내는 다음 코드를 고려해보겠습니다.

useEffect(() => {
  logVisit(url); // POST 요청을 보냄
}, [url]);

개발 환경에서는 모든 URL에 대해 logVisit이 두 번 호출되기 때문에 이를 수정하려는 유혹을 느낄 수 있습니다. 우리는 이 코드를 그대로 유지하는 것을 권장합니다. 이전의 예와 마찬가지로 한 번 실행되는 것과 두 번 실행되는 것 사이에는 사용자에게 보여지는 차이가 없습니다. 실제로 logVisit은 개발 환경에서는 아무 작업도 수행하지 않아야 합니다. 개발 컴퓨터에서 생성된 로그가 프로덕션 지표를 왜곡시키지 않기 때문입니다. 컴포넌트는 파일을 저장할 때마다 다시 마운트되므로 개발 중에는 추가 방문을 로그로 기록합니다.

프로덕션 환경에서는 중복된 방문 로그가 없을 것입니다.

보내는 분석 이벤트를 디버깅하기 위해 앱을 스테이징 환경에 배포하거나 일시적으로 Strict Mode와 개발 전용 리마운팅 검사에서 벗어날 수 있습니다. 또한, 효과 대신 라우트 변경 이벤트 핸들러에서 분석을 보낼 수도 있습니다. 더 정밀한 분석을 위해 Intersection Observer를 사용하여 어떤 컴포넌트가 뷰포트에 있는지와 얼마나 오래 보이는지 추적할 수 있습니다.

Not an Effect: 애플리케이션 초기화

일부 로직은 애플리케이션이 시작될 때 한 번만 실행되어야 합니다. 이를 컴포넌트 외부에 배치할 수 있습니다.

if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인합니다.
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

이렇게 하면 해당 로직이 브라우저가 페이지를 로드한 후에 한 번만 실행됨을 보장합니다.

Not an Effect: 제품 구매

때로는 클린업 함수를 작성하더라도 효과가 두 번 실행될 때의 사용자에게 보이는 결과를 방지할 방법이 없을 수도 있습니다. 예를 들어, 제품 구매와 같이 POST 요청을 보내는 효과인 경우 다음과 같습니다.

useEffect(() => {
  // 🔴 잘못된 예: 이 효과는 개발 환경에서 두 번 실행되어 코드에 문제가 노출됩니다.
  fetch('/api/buy', { method: 'POST' });
}, []);

제품을 두 번 구매하고 싶지는 않을 것입니다. 그러나 이러한 로직을 효과에 넣어두는 것은 바람직하지 않습니다. 사용자가 다른 페이지로 이동한 후에 뒤로 가기 버튼을 누르면 효과가 다시 실행됩니다. 사용자가 페이지를 방문할 때 제품을 구매하고 싶은 것이 아니라 사용자가 구매 버튼을 클릭할 때 구매하고 싶습니다.

구매는 렌더링에 의해 발생하는 것이 아니라 특정 상호 작용에 의해 발생합니다. 이는 사용자가 버튼을 누를 때만 실행되어야 합니다. 효과를 삭제하고 /api/buy 요청을 구매 버튼의 이벤트 핸들러로 이동시키세요:

 function handleClick() {
    // ✅ 구매는 특정 상호 작용에 의해 발생하기 때문에 이벤트입니다.
    fetch('/api/buy', { method: 'POST' });
  }

이를 통해 컴포넌트의 논리가 렌더링을 중단하면 일반적으로 기존의 버그가 드러나는 것을 알 수 있습니다. 사용자의 관점에서 페이지를 방문하는 것과 방문한 후에 링크를 클릭하고 뒤로 가는 것은 다를 바가 없어야 합니다. 리액트는 컴포넌트가 이 원칙을 따르도록 확인하기 위해 개발 중에 컴포넌트를 한 번 다시 마운트합니다.

모든 것을 함께 사용하기

이 예제에서는 setTimeout을 사용하여 입력 텍스트가 효과 실행 후 3초 후에 콘솔 로그가 표시되도록 예약합니다. 클린업 함수는 대기 중인 타임아웃을 취소합니다. "컴포넌트 마운트"를 눌러 시작하세요:

처음에 세 가지 로그가 표시됩니다. "a" 로그 예약, "a" 로그 취소 및 "a" 로그 다시 예약. 3초 후에 a라는 로그가 표시됩니다. 이전에 배운 대로 추가로 예약/취소 쌍은 리액트가 컴포넌트를 한 번 다시 마운트하여 클린업을 올바르게 구현했는지 확인하기 위한 것입니다.

이제 입력을 abc로 편집하세요. 충분히 빠르게 수행한다면 "ab" 로그 예약이 즉시 나오고 "ab" 로그 취소 및 "abc" 로그 예약이 이어집니다. 리액트는 이전 렌더의 효과를 항상 다음 렌더의 효과보다 먼저 정리합니다. 따라서 빠르게 입력을 하더라도 한 번에 최대 하나의 타임아웃만 예약됩니다. 몇 번 입력을 편집하면서 콘솔을 확인하여 효과가 어떻게 정리되는지 알아보세요.

입력란에 무언가를 입력한 다음 "컴포넌트 마운트 해제"를 눌러보세요. 언마운트는 마지막 렌더의 효과를 정리합니다. 여기서는 타임아웃이 실행되기 전에 마지막 타임아웃이 제거됩니다.

마지막으로, 컴포넌트를 편집하고 클린업 함수를 주석 처리하여 타임아웃이 취소되지 않도록 해보세요. abcde를 빠르게 입력해보세요. 3초 후에 어떤 결과를 기대하시나요? 타임아웃 내에서의 console.log(text)는 가장 최근 텍스트를 출력하고 abcde 로그 5개를 생성할까요? 직접 시도하여 직감을 확인해보세요!

Edit gifted-proskuriakova-p3qogs

3초 후에 a, ab, abc, abcd 및 abcde의 일련의 로그가 표시되어야 합니다. 각 효과는 해당 렌더의 텍스트 값을 "캡처"합니다. 텍스트 상태가 변경되었더라도 'ab' 값으로 렌더링된 효과는 항상 'ab'를 볼 것입니다. 다른 말로, 각 렌더링의 효과는 서로 격리되어 있습니다. 이 작동 방식에 대해 궁금하다면 클로저에 대해 읽어볼 수 있습니다.

자세히 알아보기: 각 렌더링마다 독립된 효과(Effect)가 있습니다.

useEffect는 렌더링 결과에 특정 동작(behavior)을 "부착"하는 것으로 생각할 수 있습니다. 다음은 효과(Effect)의 예입니다.

export default function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to {roomId}!</h1>;
}

사용자가 앱을 탐색하는 과정에서 실제로 무슨 일이 일어나는지 살펴보겠습니다.

초기 렌더링

사용자가 <ChatRoom roomId="general" />를 방문합니다. roomId를 'general'로 대체한 상태로 생각해 보겠습니다.

// 첫 번째 렌더링의 JSX (roomId = "general")
return <h1>Welcome to general!</h1>;

효과(Effect)도 렌더링 결과의 일부입니다. 첫 번째 렌더링의 효과(Effect)는 다음과 같습니다.

// 첫 번째 렌더링의 효과(Effect) (roomId = "general")
() => {
  const connection = createConnection('general');
  connection.connect();
  return () => connection.disconnect();
},
// 첫 번째 렌더링의 종속성(dependencies) (roomId = "general")
['general']

리액트는 이 효과(Effect)를 실행하여 'general' 채팅방에 연결합니다.

같은 종속성(dependencies)으로 다시 렌더링

<ChatRoom roomId="general" />이 다시 렌더링된다고 가정해 보겠습니다. JSX의 출력은 동일합니다.

// 두 번째 렌더링의 JSX (roomId = "general")
return <h1>Welcome to general!</h1>;

리액트는 렌더링 결과가 변경되지 않았으므로 DOM을 업데이트하지 않습니다.

두 번째 렌더링의 효과(Effect)는 다음과 같습니다.

// 두 번째 렌더링의 효과(Effect) (roomId = "general")
() => {
  const connection = createConnection('general');
  connection.connect();
  return () => connection.disconnect();
},
// 두 번째 렌더링의 종속성(dependencies) (roomId = "general")
['general']

리액트는 두 번째 렌더링의 종속성(dependencies)인 ['general']을 첫 번째 렌더링의 종속성(dependencies)인 ['general']과 비교합니다. 모든 종속성(dependencies)이 동일하므로 리액트는 두 번째 렌더링의 효과(Effect)를 무시합니다. 호출되지 않습니다.

다른 종속성(dependencies)으로 다시 렌더링

그런 다음 사용자가 <ChatRoom roomId="travel" />을 방문합니다. 이번에는 컴포넌트가 다른 JSX를 반환합니다.

// 세 번째 렌더링의 JSX (roomId = "travel")
return <h1>Welcome to travel!</h1>;

리액트는 DOM을 업데이트하여 "Welcome to general"을 "Welcome to travel"로 변경합니다.

세 번째 렌더링의 효과(Effect)는 다음과 같습니다.

// 세 번째 렌더링의 효과(Effect) (roomId = "travel")
() => {
  const connection = createConnection('travel');
  connection.connect();
  return () => connection.disconnect();
},
// 세 번째 렌더링의 종속성(dependencies) (roomId = "travel")
['travel']

리액트는 세 번째 렌더링의 종속성(dependencies)인 ['travel']을 두 번째 렌더링의 종속성(dependencies)인 ['general']과 비교합니다. 하나의 종속성(dependency)이 다릅니다. Object.is('travel', 'general')은 false입니다. 효과(Effect)는 건너뛸 수 없습니다.

리액트는 세 번째 렌더링의 효과(Effect)를 적용하기 전에, 실행되었던 마지막 효과(Effect)를 정리(clean-up)해야 합니다. 두 번째 렌더링의 효과(Effect)는 건너뛰었으므로 리액트는 첫 번째 렌더링의 효과(Effect)를 정리(clean-up)해야 합니다. 첫 번째 렌더링로 올라가서, createConnection('general')로 생성된 연결에 대해 disconnect()를 호출하는 것을 볼 수 있습니다. 이로써 앱은 'general' 채팅방과의 연결을 해제합니다.

그 후, 리액트는 세 번째 렌더링의 효과(Effect)를 실행합니다. 'travel' 채팅방에 연결합니다.

언마운트

마지막으로, 사용자가 다른 곳으로 이동하면 ChatRoom 컴포넌트가 언마운트됩니다. 리액트는 마지막 효과(Effect)의 정리(clean-up) 함수를 실행합니다. 마지막 효과(Effect)는 세 번째 렌더링에서 가져온 것입니다. 세 번째 렌더링의 정리(clean-up)는 createConnection('travel')로 생성된 연결을 파괴합니다. 따라서 앱은 'travel' 채팅방과의 연결을 끊습니다.

개발 중에만 발생하는 동작(behaviors)

Strict Mode가 활성화된 경우, 리액트는 각 컴포넌트를 마운트한 후 한 번 더 리마운트합니다(상태와 DOM은 보존됩니다). 이는 정리(clean-up)가 필요한 효과(Effect)를 찾는 데 도움이 되며, 경합 조건과 같은 버그를 초기에 노출시킵니다. 또한, 개발 중에 파일을 저장할 때마다 리액트는 효과(Effect)를 다시 마운트합니다. 이러한 동작은 개발 시에만 발생합니다.

요약

  • 이벤트와 달리 효과(Effect)는 특정 상호작용(interaction)이 아닌 렌더링 자체로 인해 발생합니다.
  • 효과(Effect)를 사용하면 컴포넌트를 외부 시스템(타사 API, 네트워크 등)과 동기화할 수 있습니다.
  • 기본적으로, 효과(Effect)는 모든 렌더링(초기 렌더링 포함) 후에 실행됩니다.
  • 모든 종속성(dependency)의 값이 마지막 렌더링과 동일한 경우, 리액트는 효과(Effect)를 건너뜁니다.
  • 종속성(dependency)를 "선택"할 수 없습니다. 종속성(dependency)는 효과(Effect) 내부의 코드에 의해 결정됩니다.
  • 빈 종속성(dependency) 배열([])은 컴포넌트의 "마운트"를 의미합니다. 즉, 화면에 추가됩니다.
  • Strict Mode에서는 리액트가 컴포넌트를 두 번 마운트합니다(개발 환경에서만!). 이는 효과(Effect)를 스트레스 테스트하기 위한 것입니다.
  • 효과(Effect)가 리마운트로 인해 중단된 경우, 정리(clean-up) 함수를 구현해야 합니다.
  • 리액트는 효과(Effect)가 다음에 실행되기 전에 정리(clean-up) 함수를 호출하며, 언마운트할 때도 호출합니다.