useEffect의 설계

242025년 09월 08일4

새로운 퍼즐 조각, useEffect는 아직 미완성이었다. 부수 효과를 ‘시작’하는 방법은 찾았지만, 그것을 ‘정리’하는 방법은 여전히 안갯속이었다. 팀은 componentWillUnmount의 역할을 대체할 방법을 찾아야만 했다.

여러 아이디어가 오고 갔다.
useUnmountEffect라는 별도의 훅을 만들면 어떨까요?”
“아니면 useEffect가 두 개의 함수를 인자로 받는 겁니다. 첫 번째는 실행할 효과, 두 번째는 정리할 효과.”

하지만 이런 방식들은 코드를 다시 흩어놓는 결과를 낳았다. 구독을 시작하는 코드와 해지하는 코드가 물리적으로 분리되는, 클래스 시절의 문제를 되풀이하는 셈이었다. 소피가 추구했던 ‘관심사의 통합’이라는 목표와도 정면으로 배치되었다.

“관련 있는 코드는 함께 있어야 합니다.”
댄이 단호하게 말했다.
“구독을 시작하는 로직 바로 그곳에, 구독을 해지하는 로직도 있어야 합니다.”

그의 말은 팀에 새로운 영감을 주었다.
만약 useEffect에 전달된 함수가, 또 다른 함수를 ‘반환(return)’할 수 있다면 어떨까?

팀은 이 아이디어를 중심으로 useEffect의 동작을 재설계하기 시작했다.

  1. 개발자는 useEffect에 부수 효과를 담은 함수(A)를 전달한다.
  2. 리액트는 렌더링이 끝난 후, 함수 (A)를 실행한다.
  3. 만약 함수 (A)가 또 다른 함수(B)를 반환했다면, 리액트는 이 ‘클린업(cleanup) 함수’ (B)를 따로 저장해 둔다.
  4. 그리고 다음 두 가지 경우 중 하나가 발생하면, 리액트는 저장해 두었던 클린업 함수 (B)를 실행한다.
    • 컴포넌트가 화면에서 사라질 때 (unmount).
    • 다음 렌더링에서 useEffect가 다시 실행되기 직전에, 이전 효과를 정리하기 위해.

이 설계는 놀랍도록 우아했다.
친구의 온라인 상태를 구독하는 예제 코드가 이제 이렇게 바뀔 수 있었다.

useEffect(() => {
  // 1. 효과(Effect) 시작 부분
  console.log('친구 상태 구독을 시작합니다.');
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);

  // 2. 이 효과에 대한 정리(Cleanup) 함수를 반환
  return () => {
    console.log('친구 상태 구독을 해지합니다.');
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
});

구독을 시작하는 코드와 해지하는 코드가 useEffect라는 하나의 블록 안에 완벽하게 함께 있었다. 개발자는 더 이상 componentDidMountcomponentWillUnmount 사이를 오갈 필요가 없었다. 논리적으로 한 묶음인 코드가, 물리적으로도 한 묶음이 된 것이다.

소피의 얼굴에 만족스러운 미소가 번졌다.
이것이 바로 그녀가 원했던 ‘관심사의 통합’이었다.

댄은 이 새로운 useEffect의 힘을 즉시 알아보았다.
이것은 단순히 componentDidMountcomponentWillUnmount를 합친 것 이상이었다.

“잠깐만요, 이건 componentDidUpdate의 문제까지 해결합니다.”
그의 말에 팀원들의 시선이 집중되었다.

클래스 컴포넌트에서는 props.friend.id가 바뀔 때마다 이전 친구의 구독을 해지하고 새로운 친구의 구독을 시작하는 복잡한 코드를 componentDidUpdate에 작성해야 했다.

하지만 새로운 useEffect 모델에서는 그럴 필요가 없었다.
리액트는 다음 렌더링에서 useEffect를 다시 실행하기 전에, 항상 이전 useEffect가 반환했던 클린업 함수를 먼저 실행해준다.

즉, props.friend.id가 바뀌어 리렌더링이 일어나면,

  1. 리액트는 이전 friend.id로 설정되었던 구독을 해지하는 클린업 함수를 먼저 호출한다.
  2. 그런 다음, 새로운 friend.id로 구독을 시작하는 useEffect의 본문을 실행한다.

복잡한 비교 로직 없이도, 리액트는 ‘정리 후 실행’이라는 흐름을 자연스럽게 보장해주었다. 개발자는 그저 ‘무엇을 할지’와 ‘어떻게 정리할지’만 선언하면 되었다.

팀은 환호했다.
useEffectcomponentDidMount, componentDidUpdate, componentWillUnmount라는 세 개의 흩어진 생명주기 메서드의 역할을, 단 하나의 일관되고 우아한 API로 통합해냈다.

두 번째 거대한 벽, ‘복잡한 컴포넌트의 난해함’을 무너뜨릴 강력한 무기가 마침내 완성된 순간이었다.

하지만 그들의 환호는 오래가지 않았다.
한 개발자가 곧바로 새로운 문제를 발견했기 때문이다.
“그런데, 이 useEffect는 렌더링될 때마다 매번 실행되잖아요? 상태가 바뀌기만 해도, 친구 ID는 바뀌지 않았는데 불필요하게 구독과 해지를 반복하게 될 텐데요?”

그들의 새로운 무기는 아직 완벽하지 않았다.
그것을 제어할 ‘방아쇠’가 필요했다.