useEffect의 세 가지 얼굴: Mount

532025년 10월 07일3

useState의 비동기 업데이트 함정을 이해한 개발자들은, 이제 useEffect라는 더 거대한 산맥을 마주해야 했다. useEffect는 단 하나의 API였지만, 의존성 배열을 어떻게 사용하느냐에 따라 전혀 다른 세 가지 얼굴을 보여주는, 야누스와 같은 존재였다.

댄은 훅 워크숍을 준비하며, useEffect의 첫 번째 얼굴을 어떻게 설명할지 고민했다.
그 첫 번째 얼굴은, 클래스 시절의 componentDidMount와 정확히 같은 역할을 했다.

‘컴포넌트가 처음 화면에 그려진 후, 단 한 번만 실행되는 로직.’

그는 이 개념을 설명하기 위해, 전형적인 데이터 패칭 시나리오를 예로 들었다.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    console.log('Effect is running!');
    fetchUser(userId).then((data) => {
      setUser(data);
    });
  }, []); // 의존성 배열이 비어있다.

  if (!user) {
    return 'Loading...';
  }

  return <h1>{user.name}</h1>;
}

useEffect의 두 번째 인자인 의존성 배열을 보세요.”
댄이 워크숍에서 청중에게 말했다.
“여기에 빈 배열, []을 전달했습니다. 이것은 리액트에게 아주 중요한 신호를 보내는 겁니다.”

“이 빈 배열은 리액트에게 이렇게 말합니다: ‘이 효과 함수는 외부의 어떤 값에도 의존하지 않습니다. 따라서 맨 처음, 컴포넌트가 마운트될 때 딱 한 번만 실행해주시고, 그 후로는 다시는 실행하지 말아 주세요.’”

그의 설명은 명확했다.
빈 의존성 배열은 useEffect를 ‘일회성’으로 만드는 스위치였다.

컴포넌트가 처음 렌더링될 때, 리액트는 useEffect의 함수를 실행한다. 콘솔에는 Effect is running!이 찍히고, fetchUser API 호출이 시작된다.
잠시 후 데이터가 도착하면 setUser가 호출되고, 컴포넌트는 리렌더링되어 로딩 메시지 대신 사용자 이름을 보여준다.

중요한 점은, 이 리렌더링 과정에서 리액트가 useEffect를 다시 들여다본다는 것이었다.
리액트는 의존성 배열 []이 이전 렌더링과 비교했을 때 변하지 않았음을 확인한다. (빈 배열은 언제나 빈 배열이므로). 그리고는 효과 함수를 다시 실행할 필요가 없다고 판단하고, 조용히 넘어간다.

“만약 이 빈 배열을 빼먹으면 어떻게 될까요?”
댄이 질문을 던졌다.
useEffect는 렌더링될 때마다 실행될 겁니다. setUser가 호출되어 리렌더링이 일어나면, useEffect가 또 실행되어 setUser를 또 호출하고… 끝없는 루프에 빠지게 될 수도 있죠.”

이 설명은 개발자들에게 useEffect의 기본 동작 방식과, 그것을 제어하는 의존성 배열의 중요성을 동시에 각인시켰다.

useEffect(fn, [])

이 짧은 코드는 이제 리액트 개발자들 사이에서 componentDidMount를 대체하는 공식적인 관용구(idiom)가 되었다. 초기 데이터 로딩, 외부 라이브러리 초기화, 혹은 컴포넌트 생애 단 한 번만 실행되어야 하는 모든 로직은 이제 이 패턴 안에서 안전하게 처리될 수 있었다.

첫 번째 얼굴은 비교적 이해하기 쉬웠다.
하지만 이제 useEffect는 서서히 자신의 다른 얼굴들을 드러낼 준비를 하고 있었다. 의존성 배열 안에 무언가 ‘값’이 들어가기 시작할 때, useEffect는 훨씬 더 동적이고 강력한 존재로 변모했다. 그 변화는 클래스 시절의 componentDidUpdate가 가졌던 복잡한 문제들을 정면으로 마주하는 것이었다.