Synchronizing with Effects

김동현·2026년 3월 16일

Effect와 동기화하기

소개

어떤 컴포넌트들은 외부 시스템과 동기화해야 할 때가 있어요. 예를 들어, React state를 기반으로 React가 아닌 컴포넌트를 제어하고 싶을 수도 있고, 서버 연결을 설정하거나, 컴포넌트가 화면에 나타날 때 분석 로그를 보내고 싶을 수도 있죠. Effect를 사용하면 렌더링 이후에 특정 코드를 실행해서 여러분의 컴포넌트를 React 바깥의 시스템과 동기화할 수 있어요.

이 페이지에서 배울 것들

  • Effect가 뭔지
  • Effect가 이벤트와 어떻게 다른지
  • 컴포넌트에서 Effect를 선언하는 방법
  • 불필요하게 Effect가 다시 실행되는 걸 건너뛰는 방법
  • 개발 환경에서 Effect가 두 번 실행되는 이유와 그걸 어떻게 고치는지

Effect가 뭐고 이벤트와 어떻게 다른 건가요? {/what-are-effects-and-how-are-they-different-from-events/}

Effect에 들어가기 전에, React 컴포넌트 안에 있는 두 가지 종류의 로직에 대해 알아야 해요:

  • 렌더링 코드 (UI 표현하기에서 소개했죠)는 컴포넌트의 최상위 레벨에 위치해요. 여기서 props와 state를 가져다가 변환하고, 화면에 보고 싶은 JSX를 반환하는 거예요. 렌더링 코드는 순수해야 해요. 수학 공식처럼 결과를 계산만 해야 하고, 그 외에 다른 일은 하면 안 돼요.

  • 이벤트 핸들러 (상호작용 추가하기에서 소개했죠)는 컴포넌트 안에 중첩된 함수인데, 단순히 계산만 하는 게 아니라 실제로 뭔가를 하는 함수예요. 이벤트 핸들러는 입력 필드를 업데이트하거나, 제품을 구매하기 위해 HTTP POST 요청을 보내거나, 사용자를 다른 화면으로 이동시킬 수 있어요. 이벤트 핸들러에는 특정 사용자 행동(예를 들어 버튼 클릭이나 타이핑)에 의해 발생하는 "부수 효과(side effects)"(프로그램의 상태를 변경하는 것)가 포함돼요.

때로는 이것만으로 충분하지 않아요. 화면에 보일 때마다 채팅 서버에 연결해야 하는 ChatRoom 컴포넌트를 생각해 보세요. 서버에 연결하는 건 순수한 계산이 아니고 (부수 효과니까) 렌더링 중에 일어날 수 없어요. 하지만 ChatRoom이 표시되게 만드는 클릭 같은 특정 단일 이벤트도 없죠.

Effect를 사용하면 특정 이벤트가 아니라 렌더링 자체에 의해 발생하는 부수 효과를 지정할 수 있어요. 채팅에서 메시지를 보내는 건 이벤트예요. 왜냐하면 사용자가 특정 버튼을 클릭해서 직접적으로 발생하니까요. 하지만 서버 연결을 설정하는 건 Effect예요. 왜냐하면 어떤 상호작용이 컴포넌트를 표시하게 했든 상관없이 일어나야 하니까요. Effect는 화면이 업데이트된 후 커밋의 마지막에 실행돼요. 이 시점이 React 컴포넌트를 외부 시스템(네트워크나 서드파티 라이브러리 같은)과 동기화하기에 좋은 타이밍이에요.

참고

이 글에서 그리고 이후 텍스트에서 대문자로 쓴 "Effect"는 위에서 설명한 React 고유의 정의, 즉 렌더링에 의해 발생하는 부수 효과를 가리켜요. 더 넓은 프로그래밍 개념을 말할 때는 "부수 효과(side effect)"라고 할게요.

Effect가 필요 없을 수도 있어요 {/you-might-not-need-an-effect/}

컴포넌트에 Effect를 서둘러 추가하지 마세요. Effect는 보통 React 코드에서 "빠져나와서" 어떤 외부 시스템과 동기화할 때 사용한다는 걸 기억하세요. 여기에는 브라우저 API, 서드파티 위젯, 네트워크 등이 포함돼요. 만약 여러분의 Effect가 다른 state를 기반으로 어떤 state를 조정하기만 한다면, Effect가 필요 없을 수도 있어요.

Effect 작성하는 방법 {/how-to-write-an-effect/}

Effect를 작성하려면 다음 세 단계를 따라야 해요:

  1. Effect를 선언해요. 기본적으로 Effect는 모든 커밋 후에 실행돼요.
  2. Effect의 의존성을 지정해요. 대부분의 Effect는 매 렌더링마다가 아니라 필요할 때만 다시 실행되어야 해요. 예를 들어, 페이드인 애니메이션은 컴포넌트가 나타날 때만 트리거되어야 하죠. 채팅방에 연결하고 끊는 것도 컴포넌트가 나타나고 사라질 때, 또는 채팅방이 바뀔 때만 일어나야 해요. 의존성을 지정해서 이걸 어떻게 제어하는지 배울 거예요.
  3. 필요하다면 클린업을 추가해요. 어떤 Effect들은 하던 걸 어떻게 멈추거나, 되돌리거나, 정리할지 명시해야 해요. 예를 들어, "연결"에는 "연결 해제"가 필요하고, "구독"에는 "구독 해제"가 필요하고, "fetch"에는 "취소"나 "무시"가 필요하죠. 클린업 함수를 반환해서 이걸 어떻게 하는지 배울 거예요.

각 단계를 자세히 살펴볼게요.

1단계: Effect 선언하기 {/step-1-declare-an-effect/}

컴포넌트에서 Effect를 선언하려면, React에서 useEffect Hook을 import해요:

import { useEffect } from 'react';

그다음 컴포넌트의 최상위 레벨에서 호출하고 Effect 안에 코드를 넣어요:

function MyComponent() {
  useEffect(() => {
    // Code here will run after *every* render
  });
  return <div />;
}

컴포넌트가 렌더링될 때마다, React는 화면을 업데이트하고 그 다음에 useEffect 안의 코드를 실행해요. 다시 말해서, useEffect는 해당 렌더링이 화면에 반영될 때까지 코드 실행을 "지연"시키는 거예요.

Effect를 사용해서 외부 시스템과 동기화하는 방법을 살펴볼게요. <VideoPlayer> React 컴포넌트를 생각해 보세요. isPlaying prop을 전달해서 비디오가 재생 중인지 일시 정지 상태인지 제어할 수 있으면 좋겠죠:

<VideoPlayer isPlaying={isPlaying} />;

커스텀 VideoPlayer 컴포넌트는 내장 브라우저 <video> 태그를 렌더링해요:

function VideoPlayer({ src, isPlaying }) {
  // TODO: do something with isPlaying
  return <video src={src} />;
}

그런데 브라우저의 <video> 태그에는 isPlaying prop이 없어요. 제어하는 유일한 방법은 DOM 엘리먼트에서 play()pause() 메서드를 수동으로 호출하는 거예요. 비디오가 현재 재생 되어야 하는지를 알려주는 isPlaying prop의 값을 play()pause() 같은 호출과 동기화해야 해요.

먼저 <video> DOM 노드에 대한 ref를 가져와야 해요.

렌더링 중에 play()pause()를 호출하고 싶은 마음이 들 수 있지만, 그건 올바르지 않아요:

// App.js
import { useState, useRef, useEffect } from 'react';

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

  if (isPlaying) {
    ref.current.play();  // Calling these while rendering isn't allowed.
  } else {
    ref.current.pause(); // Also, this crashes.
  }

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
button { display: block; margin-bottom: 20px; }
video { width: 250px; }

이 코드가 올바르지 않은 이유는 렌더링 중에 DOM 노드를 가지고 뭔가를 하려고 하기 때문이에요. React에서 렌더링은 JSX의 순수한 계산이어야 하고 DOM 수정 같은 부수 효과를 포함하면 안 돼요.

게다가, VideoPlayer가 처음 호출될 때는 DOM이 아직 존재하지 않아요! play()pause()를 호출할 DOM 노드가 아직 없는 거예요. 왜냐하면 여러분이 JSX를 반환하기 전까지는 React가 어떤 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 />;
}

DOM 업데이트를 Effect로 감싸면, React가 먼저 화면을 업데이트하게 해요. 그다음에 Effect가 실행되죠.

VideoPlayer 컴포넌트가 렌더링될 때 (처음이든 다시 렌더링이든), 몇 가지 일이 일어나요. 먼저 React가 화면을 업데이트해서 올바른 props를 가진 <video> 태그가 DOM에 있도록 해요. 그다음 React가 Effect를 실행해요. 마지막으로, Effect가 isPlaying 값에 따라 play() 또는 pause()를 호출해요.

Play/Pause를 여러 번 눌러보면서 비디오 플레이어가 isPlaying 값과 동기화된 상태를 유지하는지 확인해 보세요:

// App.js
import { useState, useRef, useEffect } 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 />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
button { display: block; margin-bottom: 20px; }
video { width: 250px; }

이 예시에서 React state와 동기화한 "외부 시스템"은 브라우저 미디어 API였어요. 비슷한 접근 방식을 사용해서 레거시 비React 코드(예: jQuery 플러그인)를 선언적인 React 컴포넌트로 감쌀 수도 있어요.

참고로, 비디오 플레이어를 제어하는 건 실제로는 훨씬 더 복잡해요. play() 호출이 실패할 수도 있고, 사용자가 내장 브라우저 컨트롤을 사용해서 재생하거나 일시 정지할 수도 있고, 등등이 있죠. 이 예시는 매우 단순화되고 불완전한 거예요.

주의할 점

기본적으로 Effect는 매번 렌더링 후에 실행돼요. 그래서 이런 코드는 무한 루프를 만들어요:

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

Effect는 렌더링의 결과로 실행돼요. state를 설정하면 렌더링이 트리거되죠. Effect 안에서 바로 state를 설정하는 건 전원 콘센트에 자기 자신을 꽂는 것과 같아요. Effect가 실행되고, state를 설정하고, 그러면 다시 렌더링이 일어나고, 그러면 Effect가 또 실행되고, 또 state를 설정하고, 또 다시 렌더링이 일어나고... 이런 식으로 끝없이 반복돼요.

Effect는 보통 컴포넌트를 외부 시스템과 동기화해야 해요. 외부 시스템이 없고 다른 state를 기반으로 어떤 state만 조정하고 싶다면, Effect가 필요 없을 수도 있어요.

2단계: Effect 의존성 지정하기 {/step-2-specify-the-effect-dependencies/}

기본적으로 Effect는 매번 렌더링 후에 실행돼요. 하지만 이건 종종 원하는 동작이 아니에요:

  • 때로는 느려요. 외부 시스템과 동기화하는 건 항상 즉각적이지 않으니까, 꼭 필요한 경우가 아니라면 건너뛰고 싶을 수 있어요. 예를 들어, 키를 누를 때마다 채팅 서버에 다시 연결하고 싶지는 않잖아요.
  • 때로는 잘못돼요. 예를 들어, 키를 누를 때마다 컴포넌트 페이드인 애니메이션을 트리거하고 싶지 않잖아요. 애니메이션은 컴포넌트가 처음 나타날 때 딱 한 번만 재생되어야 해요.

이 문제를 보여드리기 위해, 이전 예시에 console.log 호출 몇 개와 부모 컴포넌트의 state를 업데이트하는 텍스트 입력을 추가해 볼게요. 타이핑하면 Effect가 다시 실행되는 걸 확인해 보세요:

// App.js
import { useState, useRef, useEffect } from 'react';

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

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  });

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }

useEffect 호출의 두 번째 인자로 의존성 배열을 지정해서 불필요하게 Effect가 다시 실행되는 걸 건너뛰도록 React에게 알려줄 수 있어요. 위 예시의 14번째 줄에 빈 [] 배열을 추가하는 것부터 시작해 볼게요:

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

그러면 React Hook useEffect has a missing dependency: 'isPlaying'이라는 에러가 보일 거예요:

// App.js
import { useState, useRef, useEffect } from 'react';

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

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, []); // This causes an error

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }

문제는 Effect 안의 코드가 무엇을 할지 결정하기 위해 isPlaying prop에 의존하고 있는데, 이 의존성이 명시적으로 선언되지 않았다는 거예요. 이 문제를 해결하려면 의존성 배열에 isPlaying을 추가하세요:

  useEffect(() => {
    if (isPlaying) { // It's used here...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...so it must be declared here!

이제 모든 의존성이 선언되었으니 에러가 없어요. 의존성 배열에 [isPlaying]을 지정하면, 이전 렌더링 때와 isPlaying이 같으면 Effect 재실행을 건너뛰라고 React에게 알려주는 거예요. 이 변경으로 입력에 타이핑해도 Effect가 다시 실행되지 않지만, Play/Pause를 누르면 실행돼요:

// App.js
import { useState, useRef, useEffect } from 'react';

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

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}
input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }

의존성 배열에는 여러 의존성이 들어갈 수 있어요. React는 지정한 의존성 전부가 이전 렌더링 때와 정확히 같은 값을 가질 때만 Effect 재실행을 건너뛰어요. React는 의존성 값들을 Object.is 비교를 사용해서 비교해요. 자세한 내용은 useEffect 레퍼런스를 참고하세요.

의존성을 "선택"할 수 없다는 걸 주의하세요. Effect 안의 코드를 기반으로 React가 기대하는 것과 지정한 의존성이 맞지 않으면 린트 에러가 나요. 이게 코드의 많은 버그를 잡는 데 도움이 돼요. 어떤 코드가 다시 실행되는 걸 원하지 않는다면, 그 의존성을 "필요로 하지" 않도록 Effect 코드 자체를 편집하세요.

주의할 점

의존성 배열이 없는 것과 [] 의존성 배열이 있는 것은 동작이 달라요:

useEffect(() => {
  // This runs after every render
});

useEffect(() => {
  // This runs only on mount (when the component appears)
}, []);

useEffect(() => {
  // This runs on mount *and also* if either a or b have changed since the last render
}, [a, b]);

"마운트"가 뭘 의미하는지는 다음 단계에서 자세히 볼게요.

심화: 왜 ref가 의존성 배열에서 빠졌나요? {/why-was-the-ref-omitted-from-the-dependency-array/}

이 Effect는 refisPlaying 둘 다 사용하는데, 의존성으로는 isPlaying만 선언했어요:

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

이건 ref 객체가 안정적인 정체성(stable identity)을 가지기 때문이에요: React는 매 렌더링마다 같은 useRef 호출에서 항상 같은 객체를 돌려준다고 보장해요. 절대 변하지 않으니까, 그 자체로 Effect를 다시 실행시키지 않아요. 그래서 포함하든 안 하든 상관없어요. 포함해도 괜찮아요:

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

useState가 반환하는 set 함수들도 안정적인 정체성을 가지고 있어서, 의존성에서 생략되는 걸 자주 볼 수 있어요. 린터가 에러 없이 의존성을 생략하게 해준다면, 그건 안전한 거예요.

항상 안정적인 의존성을 생략하는 건 린터가 해당 객체가 안정적이라는 걸 "볼 수 있을" 때만 동작해요. 예를 들어, ref가 부모 컴포넌트로부터 전달된 거라면, 의존성 배열에 명시해야 해요. 하지만 이건 좋은 일이에요. 왜냐하면 부모 컴포넌트가 항상 같은 ref를 전달하는지, 아니면 여러 ref 중 하나를 조건부로 전달하는지 알 수 없으니까요. 그래서 Effect는 어떤 ref가 전달되는지에 따라 달라질 거예요.

3단계: 필요하면 클린업 추가하기 {/step-3-add-cleanup-if-needed/}

다른 예시를 생각해 볼게요. 화면에 나타날 때 채팅 서버에 연결해야 하는 ChatRoom 컴포넌트를 작성하고 있어요. connect()disconnect() 메서드가 있는 객체를 반환하는 createConnection() API가 주어졌어요. 사용자에게 표시되는 동안 컴포넌트를 어떻게 연결된 상태로 유지하나요?

Effect 로직을 작성하는 것부터 시작해요:

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

매 렌더링 후에 채팅에 연결하면 느리니까, 의존성 배열을 추가해요:

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

Effect 안의 코드가 props나 state를 전혀 사용하지 않으니까, 의존성 배열이 [] (빈 배열)이에요. 이건 React에게 이 코드를 컴포넌트가 "마운트"될 때, 즉 화면에 처음 나타날 때만 실행하라고 알려주는 거예요.

이 코드를 실행해 볼게요:

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

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}
// chat.js
export function createConnection() {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting...');
    },
    disconnect() {
      console.log('❌ Disconnected.');
    }
  };
}
input { display: block; margin-bottom: 20px; }

이 Effect는 마운트 시에만 실행되니까, 콘솔에 "✅ Connecting..."이 한 번만 출력될 거라고 예상할 수 있어요. 하지만 콘솔을 확인해 보면, "✅ Connecting..."이 두 번 출력돼요. 왜 그런 걸까요?

ChatRoom 컴포넌트가 여러 화면이 있는 더 큰 앱의 일부라고 상상해 보세요. 사용자가 ChatRoom 페이지에서 여정을 시작해요. 컴포넌트가 마운트되고 connection.connect()를 호출해요. 그다음 사용자가 다른 화면으로 이동한다고 상상해 보세요—예를 들어 설정 페이지로요. ChatRoom 컴포넌트가 언마운트돼요. 마지막으로 사용자가 뒤로가기를 클릭하면 ChatRoom이 다시 마운트돼요. 이러면 두 번째 연결이 설정되는데—첫 번째 연결은 절대 파괴되지 않았어요! 사용자가 앱을 탐색하면서 연결이 계속 쌓이게 되는 거예요.

이런 버그는 광범위한 수동 테스트 없이는 놓치기 쉬워요. 빠르게 발견할 수 있도록, 개발 환경에서 React는 초기 마운트 직후에 모든 컴포넌트를 한 번 다시 마운트해요.

"✅ Connecting..." 로그가 두 번 보이면 진짜 문제를 알아차리는 데 도움이 돼요: 컴포넌트가 언마운트될 때 연결을 닫지 않고 있다는 거죠.

이 문제를 해결하려면, Effect에서 클린업 함수를 반환하세요:

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

React는 Effect가 다시 실행되기 전에 매번 클린업 함수를 호출하고, 컴포넌트가 언마운트(제거)될 때 마지막으로 한 번 더 호출해요. 클린업 함수가 구현되면 어떻게 되는지 볼게요:

// App.js
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>;
}
// chat.js
export function createConnection() {
  // A real implementation would actually connect to the server
  return {
    connect() {
      console.log('✅ Connecting...');
    },
    disconnect() {
      console.log('❌ Disconnected.');
    }
  };
}
input { display: block; margin-bottom: 20px; }

이제 개발 환경에서 콘솔 로그 세 개가 보여요:

  1. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

이게 개발 환경에서의 올바른 동작이에요. 컴포넌트를 다시 마운트함으로써, React는 다른 곳으로 갔다가 돌아와도 코드가 깨지지 않는지 확인해요. 연결을 끊었다가 다시 연결하는 게 정확히 일어나야 하는 일이에요! 클린업을 잘 구현하면, Effect를 한 번 실행하는 것과 실행하고 → 정리하고 → 다시 실행하는 것 사이에 사용자에게 보이는 차이가 없어야 해요. 개발 환경에서 React가 코드의 버그를 탐지하기 위해 추가적인 연결/해제 호출 쌍이 있는 거예요. 이건 정상이에요—없애려고 하지 마세요!

프로덕션에서는 "✅ Connecting..."이 한 번만 출력돼요. 컴포넌트 재마운트는 클린업이 필요한 Effect를 찾는 데 도움을 주기 위해 개발 환경에서만 일어나요. Strict Mode를 끄면 개발 환경 동작을 옵트아웃할 수 있지만, 켜두는 걸 권장해요. 위에서 본 것처럼 많은 버그를 찾을 수 있거든요.

개발 환경에서 Effect가 두 번 실행되는 걸 어떻게 처리하나요? {/how-to-handle-the-effect-firing-twice-in-development/}

React는 개발 환경에서 의도적으로 컴포넌트를 재마운트해서 마지막 예시처럼 버그를 찾아요. 올바른 질문은 "Effect를 한 번만 어떻게 실행하나"가 아니라 "재마운트 후에도 작동하도록 Effect를 어떻게 고치나"예요.

보통 답은 클린업 함수를 구현하는 거예요. 클린업 함수는 Effect가 하고 있던 것을 멈추거나 되돌려야 해요. 기본 원칙은 사용자가 Effect가 한 번 실행되는 것(프로덕션에서처럼)과 설정 → 클린업 → 설정 시퀀스(개발 환경에서 보는 것처럼)를 구분할 수 없어야 한다는 거예요.

여러분이 작성할 대부분의 Effect는 아래의 일반적인 패턴 중 하나에 들어맞을 거예요.

주의할 점

Effect가 두 번 실행되는 걸 막기 위해 ref를 사용하지 마세요 {/dont-use-refs-to-prevent-effects-from-firing/}

개발 환경에서 Effect가 두 번 실행되는 걸 방지하기 위한 흔한 함정은 ref를 사용해서 Effect가 한 번 이상 실행되는 걸 막는 거예요. 예를 들어, 위의 버그를 useRef로 "고칠" 수 있어요:

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

이렇게 하면 개발 환경에서 "✅ Connecting..."이 한 번만 보이지만, 버그를 고치지 못해요.

사용자가 다른 곳으로 이동해도 연결이 닫히지 않고, 돌아오면 새 연결이 만들어져요. 사용자가 앱을 탐색하면서 연결이 계속 쌓이게 되는데, "수정" 전과 똑같아요.

버그를 고치려면 Effect를 한 번만 실행하게 만드는 것만으로는 충분하지 않아요. Effect가 재마운트 후에도 작동해야 하고, 그건 위의 해결책처럼 연결이 정리되어야 한다는 뜻이에요.

일반적인 패턴을 어떻게 처리하는지 아래 예시들을 참고하세요.

비React 위젯 제어하기 {/controlling-non-react-widgets/}

가끔 React로 작성되지 않은 UI 위젯을 추가해야 할 때가 있어요. 예를 들어, 페이지에 지도 컴포넌트를 추가한다고 해볼게요. setZoomLevel() 메서드가 있고, 줌 레벨을 React 코드의 zoomLevel state 변수와 동기화된 상태로 유지하고 싶어요. Effect는 이렇게 생길 거예요:

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

이 경우에는 클린업이 필요 없어요. 개발 환경에서 React가 Effect를 두 번 호출하겠지만, 같은 값으로 setZoomLevel을 두 번 호출해도 아무 일도 안 하니까 문제없어요. 약간 더 느릴 수 있지만 프로덕션에서는 불필요하게 재마운트되지 않으니까 상관없어요.

어떤 API는 연속으로 두 번 호출하는 걸 허용하지 않을 수도 있어요. 예를 들어, 내장 <dialog> 엘리먼트의 showModal 메서드는 두 번 호출하면 에러를 던져요. 클린업 함수를 구현해서 다이얼로그를 닫으면 돼요:

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

개발 환경에서 Effect는 showModal()을 호출하고, 바로 close()를 호출하고, 다시 showModal()을 호출해요. 이건 프로덕션에서처럼 showModal()을 한 번 호출하는 것과 사용자에게 보이는 동작이 같아요.

이벤트 구독하기 {/subscribing-to-events/}

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()를 한 번 호출하는 것과 사용자에게 보이는 동작이 같아요.

애니메이션 트리거하기 {/triggering-animations/}

Effect가 뭔가를 애니메이션한다면, 클린업 함수에서 애니메이션을 초기 값으로 리셋해야 해요:

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

개발 환경에서 opacity가 1로 설정되고, 0으로 설정되고, 다시 1로 설정돼요. 이건 프로덕션에서처럼 1로 직접 설정하는 것과 사용자에게 보이는 동작이 같아야 해요. 트위닝을 지원하는 서드파티 애니메이션 라이브러리를 사용한다면, 클린업 함수에서 타임라인을 초기 상태로 리셋해야 해요.

데이터 가져오기 {/fetching-data/}

Effect가 뭔가를 fetch한다면, 클린업 함수에서 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'으로 바뀌면, 클린업이 'Alice' 응답이 'Bob' 이후에 도착하더라도 무시되도록 보장해요.

개발 환경에서는 네트워크 탭에 두 개의 fetch가 보여요. 거기에 아무 문제 없어요. 위의 접근 방식으로, 첫 번째 Effect는 즉시 정리되면서 ignore 변수의 복사본이 true로 설정돼요. 그래서 추가 요청이 있더라도, if (!ignore) 체크 덕분에 state에 영향을 미치지 않아요.

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

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

이건 개발 경험을 개선할 뿐만 아니라, 앱을 더 빠르게 느끼게 만들어 줘요. 예를 들어, 사용자가 뒤로가기 버튼을 눌러도 데이터가 캐시되어 있으니까 다시 로드될 때까지 기다릴 필요가 없어요. 이런 캐시를 직접 만들 수도 있고, Effect에서의 수동 fetch에 대한 많은 대안 중 하나를 사용할 수도 있어요.

심화: Effect에서 데이터 가져오기에 대한 좋은 대안은 뭐가 있나요? {/what-are-good-alternatives-to-data-fetching-in-effects/}

Effect 안에서 fetch 호출을 작성하는 건 특히 완전히 클라이언트 사이드 앱에서 데이터를 가져오는 인기 있는 방법이에요. 하지만 이건 매우 수동적인 접근 방식이고, 상당한 단점이 있어요:

  • Effect는 서버에서 실행되지 않아요. 이건 초기 서버 렌더링된 HTML에 데이터 없이 로딩 상태만 포함된다는 의미예요. 클라이언트 컴퓨터가 모든 JavaScript를 다운로드하고 앱을 렌더링한 다음에야 비로소 데이터를 로드해야 한다는 걸 알게 돼요. 그다지 효율적이지 않죠.
  • Effect에서 직접 fetch하면 "네트워크 워터폴"을 만들기 쉬워요. 부모 컴포넌트를 렌더링하면 데이터를 fetch하고, 자식 컴포넌트를 렌더링하면, 그제서야 자식들이 자기 데이터를 fetch하기 시작해요. 네트워크가 그리 빠르지 않다면, 이건 모든 데이터를 병렬로 fetch하는 것보다 훨씬 느려요.
  • Effect에서 직접 fetch하면 보통 데이터를 사전 로드하거나 캐시하지 않아요. 예를 들어, 컴포넌트가 언마운트됐다가 다시 마운트되면 데이터를 또 fetch해야 해요.
  • 그다지 인체공학적이지 않아요. 경쟁 조건(race condition) 같은 버그가 없는 방식으로 fetch 호출을 작성하려면 상당한 양의 보일러플레이트 코드가 필요해요.

이 단점 목록은 React에만 해당되는 게 아니에요. 어떤 라이브러리든 마운트 시 데이터 fetch에 적용돼요. 라우팅과 마찬가지로 데이터 fetch를 잘 하는 건 쉽지 않으니까, 다음 접근 방식을 추천해요:

  • 프레임워크를 사용한다면, 내장 데이터 fetch 메커니즘을 사용하세요. 최신 React 프레임워크들은 효율적이고 위의 함정들이 없는 통합 데이터 fetch 메커니즘을 가지고 있어요.
  • 그렇지 않다면, 클라이언트 사이드 캐시를 사용하거나 만드는 걸 고려하세요. 인기 있는 오픈 소스 솔루션으로 TanStack Query, useSWR, React Router 6.4+ 등이 있어요. 직접 만들 수도 있는데, 그 경우 내부적으로 Effect를 사용하되 요청 중복 제거, 응답 캐싱, 네트워크 워터폴 방지(데이터 사전 로드 또는 라우트에 데이터 요구사항 호이스팅) 로직을 추가하게 돼요.

이 접근 방식들이 맞지 않는다면 Effect에서 직접 데이터를 fetch하는 걸 계속해도 돼요.

분석 보내기 {/sending-analytics/}

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

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

개발 환경에서 logVisit이 모든 URL에 대해 두 번 호출되니까, 그걸 고치고 싶을 수 있어요. 이 코드를 그대로 유지하는 걸 권장해요. 이전 예시들처럼, 한 번 실행하는 것과 두 번 실행하는 것 사이에 사용자에게 보이는 동작 차이가 없어요. 실용적인 관점에서, logVisit은 개발 환경에서 아무것도 하지 않아야 해요. 왜냐하면 개발 머신의 로그가 프로덕션 지표를 왜곡하는 걸 원하지 않으니까요. 컴포넌트는 파일을 저장할 때마다 재마운트되니까 개발 환경에서는 어차피 추가 방문이 기록돼요.

프로덕션에서는 중복 방문 로그가 없을 거예요.

보내고 있는 분석 이벤트를 디버깅하려면, 앱을 (프로덕션 모드로 실행되는) 스테이징 환경에 배포하거나, Strict Mode와 그 개발 전용 재마운트 체크를 일시적으로 옵트아웃할 수 있어요. Effect 대신 라우트 변경 이벤트 핸들러에서 분석을 보낼 수도 있어요. 더 정밀한 분석을 위해서는 교차 관찰자(intersection observers)가 어떤 컴포넌트가 뷰포트에 있는지, 얼마나 오래 보이는지 추적하는 데 도움이 될 수 있어요.

Effect가 아닌 것: 애플리케이션 초기화 {/not-an-effect-initializing-the-application/}

어떤 로직은 애플리케이션이 시작될 때 한 번만 실행되어야 해요. 컴포넌트 바깥에 두면 돼요:

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

function App() {
  // ...
}

이렇게 하면 그런 로직이 브라우저가 페이지를 로드한 후에 한 번만 실행되는 게 보장돼요.

Effect가 아닌 것: 제품 구매하기 {/not-an-effect-buying-a-product/}

가끔은 클린업 함수를 작성해도 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를 삭제하고 /api/buy 요청을 구매 버튼 이벤트 핸들러로 옮기세요:

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

이건 재마운트가 애플리케이션의 로직을 깨뜨린다면, 보통 기존 버그를 드러내는 거라는 걸 보여줘요. 사용자의 관점에서, 페이지를 방문하는 것과 페이지를 방문하고 → 링크를 클릭하고 → 뒤로가기를 눌러서 다시 보는 것이 다르면 안 돼요. React는 개발 환경에서 컴포넌트를 한 번 재마운트해서 여러분의 컴포넌트가 이 원칙을 지키는지 확인해요.

모든 걸 합쳐보기 {/putting-it-all-together/}

이 플레이그라운드는 Effect가 실제로 어떻게 동작하는지 "감을 잡는" 데 도움이 될 거예요.

이 예시는 setTimeout을 사용해서 Effect가 실행된 후 3초 뒤에 입력 텍스트가 포함된 콘솔 로그를 예약해요. 클린업 함수는 대기 중인 타임아웃을 취소해요. "Mount the component"를 눌러서 시작해 보세요:

// App.js
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초 후에 a라는 로그도 나와요. 앞에서 배웠듯이, 추가적인 스케줄/취소 쌍은 React가 개발 환경에서 컴포넌트를 한 번 재마운트해서 클린업을 잘 구현했는지 확인하기 때문이에요.

이제 입력을 abc로 편집해 보세요. 충분히 빠르게 하면, Schedule "ab" log 바로 뒤에 Cancel "ab" logSchedule "abc" log가 보일 거예요. React는 항상 다음 렌더링의 Effect 전에 이전 렌더링의 Effect를 정리해요. 이래서 입력에 빠르게 타이핑해도 한 번에 하나의 타임아웃만 예약되어 있어요. 입력을 몇 번 편집하면서 Effect가 어떻게 정리되는지 감을 잡아 보세요.

입력에 뭔가 타이핑한 다음 바로 "Unmount the component"를 누르세요. 언마운트가 마지막 렌더링의 Effect를 정리하는 걸 확인하세요. 여기서는 마지막 타임아웃이 실행되기 전에 취소해요.

마지막으로, 위의 컴포넌트를 편집해서 클린업 함수를 주석 처리하면 타임아웃이 취소되지 않게 돼요. abcde를 빠르게 타이핑해 보세요. 3초 후에 뭐가 일어날 것 같나요? 타임아웃 안의 console.log(text)가장 최근 text를 출력해서 abcde 로그 다섯 개를 만들까요? 직관을 확인해 보세요!

3초 후에 abcde 로그 다섯 개가 아니라 로그 시퀀스(a, ab, abc, abcd, abcde)가 보일 거예요. 각 Effect는 해당 렌더링에서의 text 값을 "캡처"해요. text state가 변경되었더라도 상관없어요: text = 'ab'인 렌더링의 Effect는 항상 'ab'를 볼 거예요. 다시 말해서, 각 렌더링의 Effect는 서로 격리되어 있어요. 이게 어떻게 동작하는지 궁금하다면 클로저(closures)에 대해 읽어보세요.

심화: 각 렌더링은 자체 Effect를 가져요 {/each-render-has-its-own-effects/}

useEffect를 렌더링 출력에 동작을 "붙이는" 것으로 생각할 수 있어요. 이 Effect를 보세요:

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

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

사용자가 앱을 탐색하면서 정확히 뭐가 일어나는지 살펴볼게요.

초기 렌더링 {/initial-render/}

사용자가 <ChatRoom roomId="general" />을 방문해요. roomId'general'머릿속으로 치환해 볼게요:

  // JSX for the first render (roomId = "general")
  return <h1>Welcome to general!</h1>;

Effect도 렌더링 출력의 일부예요. 첫 번째 렌더링의 Effect는 이렇게 돼요:

  // Effect for the first render (roomId = "general")
  () => {
    const connection = createConnection('general');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the first render (roomId = "general")
  ['general']

React가 이 Effect를 실행하면, 'general' 채팅방에 연결돼요.

같은 의존성으로 다시 렌더링 {/re-render-with-same-dependencies/}

<ChatRoom roomId="general" />이 다시 렌더링된다고 해볼게요. JSX 출력은 같아요:

  // JSX for the second render (roomId = "general")
  return <h1>Welcome to general!</h1>;

React는 렌더링 출력이 변하지 않았다는 걸 보고, DOM을 업데이트하지 않아요.

두 번째 렌더링의 Effect는 이렇게 생겼어요:

  // Effect for the second render (roomId = "general")
  () => {
    const connection = createConnection('general');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the second render (roomId = "general")
  ['general']

React가 두 번째 렌더링의 ['general']을 첫 번째 렌더링의 ['general']과 비교해요. 모든 의존성이 같으니까, React는 두 번째 렌더링의 Effect를 무시해요. 절대 호출되지 않아요.

다른 의존성으로 다시 렌더링 {/re-render-with-different-dependencies/}

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

  // JSX for the third render (roomId = "travel")
  return <h1>Welcome to travel!</h1>;

React가 DOM을 업데이트해서 "Welcome to general""Welcome to travel"로 바꿔요.

세 번째 렌더링의 Effect는 이렇게 생겼어요:

  // Effect for the third render (roomId = "travel")
  () => {
    const connection = createConnection('travel');
    connection.connect();
    return () => connection.disconnect();
  },
  // Dependencies for the third render (roomId = "travel")
  ['travel']

React가 세 번째 렌더링의 ['travel']을 두 번째 렌더링의 ['general']과 비교해요. 의존성 하나가 달라요: Object.is('travel', 'general')false예요. Effect를 건너뛸 수 없어요.

React가 세 번째 렌더링의 Effect를 적용하기 전에, 실제로 실행된 마지막 Effect를 정리해야 해요. 두 번째 렌더링의 Effect는 건너뛰었으니까, React는 첫 번째 렌더링의 Effect를 정리해야 해요. 첫 번째 렌더링으로 올라가 보면, 그 클린업이 createConnection('general')로 만들어진 연결에 대해 disconnect()를 호출하는 걸 볼 수 있어요. 이건 앱을 'general' 채팅방에서 연결 해제하는 거예요.

그 후에 React가 세 번째 렌더링의 Effect를 실행해요. 'travel' 채팅방에 연결돼요.

언마운트 {/unmount/}

마지막으로, 사용자가 다른 곳으로 이동하면 ChatRoom 컴포넌트가 언마운트돼요. React가 마지막 Effect의 클린업 함수를 실행해요. 마지막 Effect는 세 번째 렌더링에서 온 거였어요. 세 번째 렌더링의 클린업은 createConnection('travel') 연결을 파괴해요. 그래서 앱이 'travel' 방에서 연결 해제돼요.

개발 환경 전용 동작 {/development-only-behaviors/}

Strict Mode가 켜져 있으면, React는 마운트 후에 모든 컴포넌트를 한 번 재마운트해요 (state와 DOM은 보존돼요). 이건 클린업이 필요한 Effect를 찾는 데 도움이 되고 경쟁 조건 같은 버그를 일찍 노출해요. 추가로, 개발 환경에서 파일을 저장할 때마다 React가 Effect를 재마운트해요. 이 두 동작은 모두 개발 환경에서만 일어나요.

요약

  • 이벤트와 달리, Effect는 특정 상호작용이 아니라 렌더링 자체에 의해 발생해요.
  • Effect를 사용하면 컴포넌트를 외부 시스템(서드파티 API, 네트워크 등)과 동기화할 수 있어요.
  • 기본적으로 Effect는 매 렌더링 후에 (초기 렌더링 포함해서) 실행돼요.
  • React는 모든 의존성이 마지막 렌더링 때와 같은 값을 가지면 Effect를 건너뛰어요.
  • 의존성을 "선택"할 수 없어요. 의존성은 Effect 안의 코드에 의해 결정돼요.
  • 빈 의존성 배열 ([])은 컴포넌트 "마운팅", 즉 화면에 추가되는 것에 해당해요.
  • Strict Mode에서 React는 (개발 환경에서만!) 컴포넌트를 두 번 마운트해서 Effect를 스트레스 테스트해요.
  • 재마운트 때문에 Effect가 깨진다면, 클린업 함수를 구현해야 해요.
  • React는 Effect가 다음에 실행되기 전에, 그리고 언마운트 시에 클린업 함수를 호출해요.

도전과제

도전 1: 마운트 시 필드에 포커스 주기 {/focus-a-field-on-mount/}

이 예시에서 폼이 <MyInput /> 컴포넌트를 렌더링해요.

입력의 focus() 메서드를 사용해서 MyInput이 화면에 나타날 때 자동으로 포커스되게 만들어 보세요. 이미 주석 처리된 구현이 있는데, 제대로 동작하지 않아요. 왜 동작하지 않는지 알아내고 고쳐보세요. (autoFocus 속성에 대해 알고 있다면, 없는 것으로 가정해 주세요: 같은 기능을 처음부터 다시 구현하는 거예요.)

// MyInput.js
import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  // TODO: This doesn't quite work. Fix it.
  // ref.current.focus()

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
// App.js (hidden)
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [name, setName] = useState('Taylor');
  const [upper, setUpper] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your name:
            <MyInput
              value={name}
              onChange={e => setName(e.target.value)}
            />
          </label>
          <label>
            <input
              type="checkbox"
              checked={upper}
              onChange={e => setUpper(e.target.checked)}
            />
            Make it uppercase
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

솔루션이 동작하는지 확인하려면 "Show form"을 누르고 입력이 포커스를 받는지 (하이라이트되고 커서가 안에 놓이는지) 확인하세요. "Hide form"을 누르고 다시 "Show form"을 누르세요. 입력이 다시 하이라이트되는지 확인하세요.

MyInput은 매 렌더링 후가 아니라 마운트 시에만 포커스되어야 해요. 동작이 맞는지 확인하려면, "Show form"을 누른 다음 "Make it uppercase" 체크박스를 반복적으로 눌러보세요. 체크박스를 클릭해도 위의 입력에 포커스가 가면 안 돼요.

해답

렌더링 중에 ref.current.focus()를 호출하는 건 부수 효과이기 때문에 잘못된 거예요. 부수 효과는 이벤트 핸들러 안에 넣거나 useEffect로 선언해야 해요. 이 경우, 부수 효과가 특정 상호작용이 아니라 컴포넌트가 나타나는 것에 의해 발생하니까, Effect에 넣는 게 맞아요.

실수를 고치려면, ref.current.focus() 호출을 Effect 선언으로 감싸세요. 그리고 이 Effect가 매 렌더링이 아니라 마운트 시에만 실행되도록, 빈 [] 의존성을 추가하세요.

// MyInput.js
import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current.focus();
  }, []);

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
// App.js (hidden)
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [name, setName] = useState('Taylor');
  const [upper, setUpper] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your name:
            <MyInput
              value={name}
              onChange={e => setName(e.target.value)}
            />
          </label>
          <label>
            <input
              type="checkbox"
              checked={upper}
              onChange={e => setUpper(e.target.checked)}
            />
            Make it uppercase
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

도전 2: 조건부로 필드에 포커스 주기 {/focus-a-field-conditionally/}

이 폼은 두 개의 <MyInput /> 컴포넌트를 렌더링해요.

"Show form"을 누르면 두 번째 필드가 자동으로 포커스를 받는 걸 확인하세요. 두 <MyInput /> 컴포넌트 모두 안의 필드에 포커스를 주려고 하기 때문이에요. 두 입력 필드에 대해 연속으로 focus()를 호출하면, 항상 마지막 것이 "이겨요".

첫 번째 필드에 포커스를 주고 싶다고 해볼게요. 첫 번째 MyInput 컴포넌트는 이제 true로 설정된 shouldFocus boolean prop을 받아요. MyInput이 받은 shouldFocus prop이 true일 때만 focus()가 호출되도록 로직을 변경해 보세요.

// MyInput.js
import { useEffect, useRef } from 'react';

export default function MyInput({ shouldFocus, value, onChange }) {
  const ref = useRef(null);

  // TODO: call focus() only if shouldFocus is true.
  useEffect(() => {
    ref.current.focus();
  }, []);

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
// App.js (hidden)
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const [upper, setUpper] = useState(false);
  const name = firstName + ' ' + lastName;
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your first name:
            <MyInput
              value={firstName}
              onChange={e => setFirstName(e.target.value)}
              shouldFocus={true}
            />
          </label>
          <label>
            Enter your last name:
            <MyInput
              value={lastName}
              onChange={e => setLastName(e.target.value)}
              shouldFocus={false}
            />
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

솔루션을 확인하려면 "Show form"과 "Hide form"을 반복적으로 누르세요. 폼이 나타날 때 첫 번째 입력만 포커스를 받아야 해요. 부모 컴포넌트가 첫 번째 입력은 shouldFocus={true}로, 두 번째 입력은 shouldFocus={false}로 렌더링하기 때문이에요. 또한 두 입력 모두 여전히 동작하고 둘 다에 타이핑할 수 있는지 확인하세요.

힌트

Effect를 조건부로 선언할 수는 없지만, Effect 안에 조건부 로직을 포함할 수 있어요.

해답

조건부 로직을 Effect 안에 넣으세요. Effect 안에서 shouldFocus를 사용하니까 의존성으로 명시해야 해요. (이건 어떤 입력의 shouldFocusfalse에서 true로 바뀌면, 마운트 후에 포커스가 간다는 의미예요.)

// MyInput.js
import { useEffect, useRef } from 'react';

export default function MyInput({ shouldFocus, value, onChange }) {
  const ref = useRef(null);

  useEffect(() => {
    if (shouldFocus) {
      ref.current.focus();
    }
  }, [shouldFocus]);

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}
// App.js (hidden)
import { useState } from 'react';
import MyInput from './MyInput.js';

export default function Form() {
  const [show, setShow] = useState(false);
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const [upper, setUpper] = useState(false);
  const name = firstName + ' ' + lastName;
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
      <br />
      <hr />
      {show && (
        <>
          <label>
            Enter your first name:
            <MyInput
              value={firstName}
              onChange={e => setFirstName(e.target.value)}
              shouldFocus={true}
            />
          </label>
          <label>
            Enter your last name:
            <MyInput
              value={lastName}
              onChange={e => setLastName(e.target.value)}
              shouldFocus={false}
            />
          </label>
          <p>Hello, <b>{upper ? name.toUpperCase() : name}</b></p>
        </>
      )}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

도전 3: 두 번 실행되는 인터벌 고치기 {/fix-an-interval-that-fires-twice/}

Counter 컴포넌트는 매 초마다 증가해야 하는 카운터를 표시해요. 마운트 시에 setInterval을 호출해요. 이게 onTick이 매 초마다 실행되게 해요. onTick 함수는 카운터를 증가시키죠.

그런데 초당 한 번이 아니라 두 번 증가해요. 왜 그런 걸까요? 버그의 원인을 찾아서 고쳐보세요.

힌트

setInterval이 인터벌 ID를 반환하는데, 이걸 clearInterval에 전달하면 인터벌을 중지할 수 있다는 걸 기억하세요.

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

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

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    setInterval(onTick, 1000);
  }, []);

  return <h1>{count}</h1>;
}
// App.js (hidden)
import { useState } from 'react';
import Counter from './Counter.js';

export default function Form() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} counter</button>
      <br />
      <hr />
      {show && <Counter />}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

해답

Strict Mode가 켜져 있으면 (이 사이트의 샌드박스처럼), React는 개발 환경에서 각 컴포넌트를 한 번 재마운트해요. 이게 인터벌이 두 번 설정되게 하고, 그래서 매 초마다 카운터가 두 번 증가하는 거예요.

하지만 React의 동작이 버그의 원인은 아니에요: 버그는 이미 코드에 존재해요. React의 동작은 버그를 더 눈에 띄게 만드는 거예요. 진짜 원인은 이 Effect가 프로세스를 시작하지만 정리하는 방법을 제공하지 않는다는 거예요.

이 코드를 고치려면, setInterval이 반환하는 인터벌 ID를 저장하고, clearInterval로 클린업 함수를 구현하세요:

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

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

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    const intervalId = setInterval(onTick, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <h1>{count}</h1>;
}
// App.js (hidden)
import { useState } from 'react';
import Counter from './Counter.js';

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} counter</button>
      <br />
      <hr />
      {show && <Counter />}
    </>
  );
}
label {
  display: block;
  margin-top: 20px;
  margin-bottom: 20px;
}

body {
  min-height: 150px;
}

개발 환경에서 React는 여전히 컴포넌트를 한 번 재마운트해서 클린업을 잘 구현했는지 확인해요. 그래서 setInterval 호출이 있고, 바로 clearInterval이 오고, 다시 setInterval이 와요. 프로덕션에서는 setInterval 호출이 하나만 있어요. 두 경우 모두 사용자에게 보이는 동작은 같아요: 카운터가 초당 한 번 증가해요.

도전 4: Effect 안의 데이터 가져오기 고치기 {/fix-fetching-inside-an-effect/}

이 컴포넌트는 선택된 사람의 약력을 보여줘요. 마운트 시와 person이 바뀔 때마다 비동기 함수 fetchBio(person)를 호출해서 약력을 로드해요. 이 비동기 함수는 결국 문자열로 resolve되는 Promise를 반환해요. 가져오기가 끝나면 setBio를 호출해서 그 문자열을 선택 상자 아래에 표시해요.

// App.js
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);

  useEffect(() => {
    setBio(null);
    fetchBio(person).then(result => {
      setBio(result);
    });
  }, [person]);

  return (
    <>
      <select value={person} onChange={e => {
        setPerson(e.target.value);
      }}>
        <option value="Alice">Alice</option>
        <option value="Bob">Bob</option>
        <option value="Taylor">Taylor</option>
      </select>
      <hr />
      <p><i>{bio ?? 'Loading...'}</i></p>
    </>
  );
}
// api.js (hidden)
export async function fetchBio(person) {
  const delay = person === 'Bob' ? 2000 : 200;
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('This is ' + person + ''s bio.');
    }, delay);
  })
}

이 코드에 버그가 있어요. "Alice"를 선택하는 것부터 시작하세요. 그다음 "Bob"을 선택하고 바로 그 직후에 "Taylor"를 선택하세요. 충분히 빠르게 하면 버그를 알아차릴 거예요: Taylor가 선택되어 있는데, 아래 단락에 "This is Bob's bio."라고 나와요.

왜 이런 일이 일어나나요? 이 Effect 안의 버그를 고쳐보세요.

힌트

Effect가 비동기적으로 뭔가를 fetch한다면, 보통 클린업이 필요해요.

해답

버그를 트리거하려면 다음 순서대로 일어나야 해요:

  • 'Bob'을 선택하면 fetchBio('Bob')이 트리거돼요
  • 'Taylor'를 선택하면 fetchBio('Taylor')가 트리거돼요
  • 'Taylor' 가져오기가 'Bob' 가져오기 보다 먼저 완료돼요
  • 'Taylor' 렌더링의 Effect가 setBio('This is Taylor's bio')를 호출해요
  • 'Bob' 가져오기가 완료돼요
  • 'Bob' 렌더링의 Effect가 setBio('This is Bob's bio')를 호출해요

이래서 Taylor가 선택되었는데도 Bob의 약력이 보이는 거예요. 이런 버그를 경쟁 조건(race condition)이라고 해요. 두 비동기 작업이 서로 "경주"하고 있고, 예상치 못한 순서로 도착할 수 있기 때문이에요.

이 경쟁 조건을 고치려면 클린업 함수를 추가하세요:

// App.js
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';

export default function Page() {
  const [person, setPerson] = useState('Alice');
  const [bio, setBio] = useState(null);
  useEffect(() => {
    let ignore = false;
    setBio(null);
    fetchBio(person).then(result => {
      if (!ignore) {
        setBio(result);
      }
    });
    return () => {
      ignore = true;
    }
  }, [person]);

  return (
    <>
      <select value={person} onChange={e => {
        setPerson(e.target.value);
      }}>
        <option value="Alice">Alice</option>
        <option value="Bob">Bob</option>
        <option value="Taylor">Taylor</option>
      </select>
      <hr />
      <p><i>{bio ?? 'Loading...'}</i></p>
    </>
  );
}
// api.js (hidden)
export async function fetchBio(person) {
  const delay = person === 'Bob' ? 2000 : 200;
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('This is ' + person + ''s bio.');
    }, delay);
  })
}

각 렌더링의 Effect는 자체 ignore 변수를 가져요. 처음에 ignore 변수는 false로 설정돼요. 하지만 Effect가 정리되면 (다른 사람을 선택할 때처럼), 그 ignore 변수가 true가 돼요. 그래서 이제 요청이 어떤 순서로 완료되든 상관없어요. 마지막 사람의 Effect만 ignorefalse이니까, setBio(result)를 호출하게 돼요. 이전 Effect들은 정리되었으니까, if (!ignore) 체크가 setBio 호출을 막아줘요:

  • 'Bob'을 선택하면 fetchBio('Bob')이 트리거돼요
  • 'Taylor'를 선택하면 fetchBio('Taylor')가 트리거되고 이전 (Bob의) Effect를 정리해요
  • 'Taylor' 가져오기가 'Bob' 가져오기 보다 먼저 완료돼요
  • 'Taylor' 렌더링의 Effect가 setBio('This is Taylor's bio')를 호출해요
  • 'Bob' 가져오기가 완료돼요
  • 'Bob' 렌더링의 Effect는 ignore 플래그가 true로 설정되었으니까 아무것도 하지 않아요

오래된 API 호출의 결과를 무시하는 것 외에도, AbortController를 사용해서 더 이상 필요 없는 요청을 취소할 수도 있어요. 하지만 이것만으로는 경쟁 조건에 대한 보호가 충분하지 않아요. fetch 이후에 더 많은 비동기 단계가 체인될 수 있으니까, ignore 같은 명시적 플래그를 사용하는 게 이런 유형의 문제를 고치는 가장 신뢰할 수 있는 방법이에요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글