React는 컴포넌트가 예측 가능하기를 원합니다. 동일한 state와 props를 전달하면 매번 동일한 UI를 그려내야 합니다. 하지만 대부분의 실제 애플리케이션은 그 울타리 안에서만 살아남을 수 없습니다. 외부와 소통해야 하기 때문입니다. 대시보드는 서버로부터 최신 데이터를 가져와야 하고, 채팅 위젯은 메시지를 수신해야 하며, 타이머는 계속 작동해야 합니다. 이러한 작업들은 side effect이며, React의 렌더링 사이클 외부에 존재합니다. useEffect 훅은 컴포넌트 자체는 순수함을 유지할 수 있도록, 이러한 복잡하고 예측 불가능한 작업들을 처리하는 곳입니다.

Side Effects: useEffect 내부에 포함되어야 하는 것들

Side effect란 JSX를 반환하는 것 이외에 외부 세계에 영향을 미치는 모든 것을 의미합니다. React의 렌더링 단계는 순수(pure)해야 합니다. 데이터를 가져오거나, 전역 변수에 값을 쓰거나, DOM에 리스너를 부착하기 시작하면 순수한 영역을 벗어나게 됩니다.

흔한 예시는 다음과 같습니다:

  • API로부터 데이터 가져오기
  • 타이머 또는 인터벌(interval) 설정하기
  • windowdocument에 이벤트 리스너 추가하기
  • 브라우저 탭 제목 업데이트하기
  • WebSockets 연결하기

이러한 작업들은 한 가지 공통점이 있습니다. 컴포넌트의 return 문 내부나 메인 렌더링 로직 중에 수행되어서는 안 된다는 점입니다. setInterval과 같은 브라우저 API를 렌더링 본문(render body) 내에서 직접 호출하려고 하면, 매 렌더링마다 실행되어 중복 타이머를 생성하고 혼란스러운 동작을 일으킵니다. useEffect는 바로 이러한 작업을 격리하고 적절한 시점에 실행하기 위해 존재합니다.

의존성 배열(Dependency Array)을 통한 타이밍 제어

useEffect의 두 번째 인자는 의존성 배열이며, 이는 클래스 컴포넌트에서 넘어온 개발자들이 가장 혼란스러워하는 부분입니다. 이를 React가 현재 렌더링 이후에 이펙트를 건너뛸지 아니면 실행할지를 결정하기 위해 관찰하는 변수들의 집합이라고 생각하세요.

자주 사용하게 될 세 가지 패턴이 있습니다.

의존성 배열이 아예 없는 경우. 배열을 완전히 생략하면, React는 첫 번째 렌더링을 포함하여 모든 렌더링이 끝난 후에 이펙트가 실행되기를 원하는 것으로 간주합니다. 이는 필요한 경우가 거의 없습니다. 만약 이펙트가 네트워크 요청이나 무거운 DOM 작업을 수행한다면, 키 입력 하나하나나 state 변경 때마다 실행되어 성능을 크게 저하시킬 것입니다. 이 패턴은 어떤 prop이나 state가 변경될지 특정할 수 없어서 반드시 재실행해야 하는 경우에만 사용하세요.

빈 배열 []. 이는 컴포넌트가 마운트되고 DOM이 준비된 직후, 이펙트를 딱 한 번만 실행하라는 의미입니다. 초기 데이터를 가져올 때 사용하기 적합한 곳입니다. 예를 들어, 컴포넌트가 사용자 프로필 데이터를 로드한다면, 사용자가 페이지 하단의 폼과 상호작용할 때마다 요청을 보내는 것이 아니라 프로필 페이지가 나타날 때 정확히 한 번만 요청을 보내야 합니다.

특정 변수가 포함된 배열 [count]. 이것은 정밀한 도구입니다. React는 이 의존성들의 현재 값을 지난 렌더링 당시의 값과 비교합니다. 만약 그중 하나라도 변경되었다면 이펙트가 실행됩니다. 목록에 있는 값이 아무것도 변하지 않았다면, React는 이펙트 실행을 건너뜁니다.

브라우저 탭 제목을 state 변수와 동기화하고 있다면, 해당 변수를 의존성 배열에 넣어야 합니다. 그러면 React는 해당 값이 바뀔 때만 제목을 업데이트합니다. 변수를 빠뜨리면 제목은 예전 상태로 남아있게 됩니다. 관련 없는 state 변수를 넣으면, 중요하지 않은 변경사항 때문에 제목을 업데이트하느라 불필요한 사이클을 낭비하게 됩니다.

Cleanup(정리)은 선택이 아닌 필수입니다

어떤 이펙트들은 흔적을 남깁니다. 타이머는 계속 숫자를 세고, 이벤트 리스너는 계속 작동하며, WebSocket은 연결을 유지합니다. 컴포넌트가 언마운트(unmount)되거나, 의존성이 변경되어 이펙트가 다시 실행될 때, React가 이전 이펙트의 잔여물을 자동으로 정리해주지는 않습니다. 그것은 개발자의 몫입니다.

useEffect 내부에서 함수를 반환함으로써 cleanup 함수를 만들 수 있습니다. React는 다음 이펙트를 적용하기 전에, 그리고 컴포넌트가 화면에서 사라질 때 이 cleanup 함수를 호출합니다.

다음과 같은 상황에서 cleanup을 사용해야 합니다:

  • clearInterval 또는 clearTimeout을 사용하여 인터벌이나 타임아웃 해제하기
  • window, document 또는 외부 노드에 추가된 이벤트 리스너 제거하기
  • 데이터 스트림이나 서비스로부터 구독 해제(unsubscribe)하기

이를 소홀히 하면 메모리 누수(memory leak)가 발생합니다. 컴포넌트가 마운트되어 스크롤 리스너를 부착했는데, 언마운트된 후에도 리스너가 남아있는 식입니다. 브라우저는 콜백 함수와 해당 콜백이 참조하는 DOM 노드를 계속 붙잡고 있게 됩니다. 시간이 지나면, 특히 페이지 이동이 빈번한 싱글 페이지 애플리케이션(SPA)에서는 이러한 '유령'들이 쌓여 탭의 속도를 느리게 만듭니다. 해결 방법은 대개 간단합니다. 추가했던 것을 제거하는 함수를 반환하기만 하면 됩니다.

프로덕션 환경으로 배포되는 흔한 실수들

숙련된 개발자조차 더 간단한 방법이 있음에도 useEffect를 사용하곤 합니다. 코드 리뷰 시 경고 신호(red flag)로 간주해야 할 세 가지 패턴을 소개합니다.

무한 루프. 사이클을 끊어주는 조건문(gate condition)이 없다면, useEffect 내부에서 의존성 배열(dependency array)에 포함된 상태 변수를 절대 업데이트하지 마세요. 만약 count를 읽고, 이를 증가시킨 뒤, count를 의존성으로 등록한다면, React는 변경 사항을 감지하여 리렌더링을 수행하고, 이펙트를 다시 실행하고, 또 다시 값을 증가시켜 결국 브라우저를 멈추게 만듭니다.

불필요한 이펙트. 기존의 props나 state로부터 값을 계산하기 위해 useEffect를 사용하지 마세요. 렌더링 중에 직접 유도(derive)할 수 있다면, 그냥 그렇게 하세요. 유도된 값은 컴포넌트 본문 내에 두거나 useMemo를 사용한 메모이제이션된 계산식에 두어야 합니다. 이를 이펙트 안으로 옮기면 아무런 이득 없이 로직이 렌더링 단계와 이펙트 단계로 분산되어 코드를 파악하기 어렵게 만듭니다.

사용자 액션에 부적절한 도구. `use