[React 공식문서 정독] React Effect 탐구: 의존성 관리 및 올바른 사용법

김진서·2025년 5월 15일

우아한테크코스 7기

목록 보기
42/56
post-thumbnail

Effect의 기본적인 역할부터 시작하여, 불필요한 Effect를 피하는 방법, Effect를 올바르게 작성하는 방법 (의존성 및 클린업), 그리고 의존성 관련 일반적인 문제 해결 및 실험적인 기능(Effect 이벤트)까지 다룬다.

1. Effect란 무엇이며 언제 사용해야 할까? 🚀

  • Effect의 정의와 목적: Effect는 컴포넌트 렌더링 이후 특정 코드를 실행하여 React 외부 시스템(브라우저 DOM, 네트워크, 서드파티 위젯 등)과 컴포넌트를 동기화하는 데 사용되는 React의 탈출구(escape hatch)이다. 이는 렌더링 자체에 의해 발생하는 부수 효과를 처리하기 위함이다.

  • 이벤트 핸들러와 Effect의 차이점

    • 이벤트 핸들러: 특정 사용자 상호작용(예: 버튼 클릭)에 대한 응답으로 실행된다. 이벤트 핸들러 내부의 로직은 비반응형(Non-Reactive)이다. 즉, 읽는 값이 변경되더라도 같은 상호작용이 반복되지 않는 한 재실행되지 않는다. 제품 구매 요청과 같이 특정 시점에 한 번 발생해야 하는 로직에 적합하다.
    • Effect: 특정 이벤트가 아닌 렌더링 결과에 의해 직접 발생하며, 동기화가 필요할 때마다 실행된다. Effect 내부의 로직은 반응형(Reactive)이다. Effect가 읽는 반응형 값(props, state 등)이 변경되면 React는 새로운 값으로 Effect를 다시 실행하여 외부 시스템과의 동기화를 유지한다. 채팅 서버 연결과 같이 컴포넌트가 화면에 보이는 상태와 외부 시스템을 지속적으로 일치시켜야 하는 로직에 사용된다.

2. Effect가 필요하지 않은 경우: 대체 솔루션 탐색 🧭

  • 불필요한 Effect를 제거하는 이유: 코드를 따라가기 더 쉽게 만들고, 실행 속도를 빠르게 하며, 에러 발생 가능성을 줄이기 위함이다. Effect는 주로 React 외부 시스템(브라우저 DOM, 네트워크 등)과 컴포넌트를 동기화하는 데 사용된다. 만약 외부 시스템이 관여하지 않고 단순히 다른 state나 props에 따라 컴포넌트의 state를 업데이트하려는 경우 Effect가 필요 없을 수 있다.

  • 렌더링을 위해 데이터를 변환하는 경우

    • 컴포넌트 최상위 레벨에서 계산: props나 state가 변경될 때마다 해당 코드가 자동으로 다시 실행.
    • 비용이 많이 드는 계산 캐싱: 계산이 느리다면 useMemo Hook을 사용하여 해당 계산 결과를 캐시할 수 있다. useMemo는 의존성이 변경되지 않는 한 이전 결과를 재사용.
  • 사용자 이벤트를 처리하는 경우

    • 해당 로직을 이벤트 핸들러로 옮기기: 이벤트 핸들러는 특정 사용자 상호작용(예: 버튼 클릭)에 대한 응답으로 실행되며, Effect와 달리 로직이 비반응형. Effect는 사용자 상호작용의 원인을 알지 못하기 때문에 이벤트 처리에는 부적합.
    • 이벤트 핸들러 간 로직 공유: 여러 이벤트 핸들러에서 동일한 로직을 사용해야 한다면, 해당 로직을 별도의 함수로 분리하여 이벤트 핸들러에서 호출.
  • props 또는 state에 따라 state를 업데이트하는 경우

    • 중복된 state와 불필요한 Effect 피하기: 대신 렌더링 중에 props나 state를 사용하여 필요한 값을 직접 계산. 이는 불필요하고 비효율적인 재렌더링을 방지.
  • prop 변경 시 state 초기화 또는 조정

    • 렌더링 중에 state 계산 또는 설정하기: (예: 선택된 ID를 저장하고 렌더링 중에 해당 item을 찾기)
    • 다른 key를 사용하여 모든 state 초기화: 이전 렌더링 정보를 사용하여 state를 설정할 수 있지만, key를 사용하여 전체 컴포넌트 트리의 state를 초기화하는 것이 더 명확.
  • 애플리케이션 초기화 로직

    • 컴포넌트 외부에 배치하기: 앱 로드 시 한 번만 실행되어야 하는 로직은 컴포넌트 외부에 배치. 이는 개발 모드에서 Strict Mode에 의해 컴포넌트가 두 번 마운트되더라도 로직이 한 번만 실행됨을 보장.
  • state 변경을 부모 컴포넌트에 알리는 경우

    • 이벤트 핸들러에서 부모의 state 업데이트 함수(props로 전달받은) 호출: 자식 state 업데이트와 부모 state 업데이트가 동일한 이벤트 처리 과정 중에 한 번에 이루어져 효율적.
  • 부모에게 데이터를 전달하는 경우

    • 부모 컴포넌트가 데이터를 가져와 자식에게 prop으로 전달: React에서 데이터 흐름은 기본적으로 부모에서 자식으로 내려옵니다. 자식 컴포넌트가 Effect에서 부모의 state를 업데이트하면 데이터 흐름을 추적하기 어려워집니다. 대신 부모 컴포넌트가 직접 데이터를 가져와 자식에게 prop으로 전달.
  • 외부 저장소 구독

    • useSyncExternalStore Hook 사용하기: 이 Hook은 외부 저장소 구독을 위해 특별히 제작되었으며 Effect보다 에러가 덜 발생.
  • 데이터 가져오기 대체 방안

    • Effect에서 데이터를 가져오는 것은 가능하지만 여러 단점이 있다(서버 렌더링 불가, 네트워크 폭포, 캐싱 부재 등).
    • 프레임워크를 사용하는 경우 해당 프레임워크의 내장 데이터 가져오기 메커니즘을 활용.
    • 프레임워크를 사용하지 않는 경우, React Query, useSWR 등 클라이언트 측 캐시 라이브러리를 사용하거나, 데이터 가져오기 로직을 커스텀 훅으로 추출하는 것을 고려. 이는 더 효율적인 전략으로 전환하기 쉽게 만든다.

3. Effect 작성하기: 의존성 지정과 클린업 추가 ✍🏻

  • Effect 선언 및 기본 동작 (모든 커밋 후 실행): Effect는 useE아니ffect Hook을 사용하여 컴포넌트 안에 선언한다. 기본적으로 Effect는 컴포넌트의 초기 렌더링(마운트)을 포함하여 모든 렌더링(커밋) 이후에 실행된다. 이는 종종 원치 않는 동작일 수 있으며, 불필요한 실행이나 무한 루프를 유발할 수 있다.

  • Effect의 의존성 지정
    • Effect의 불필요한 재실행을 막기 위해 useEffect 호출의 두 번째 인자로 의존성 배열을 지정.
    • 의존성 배열에 무엇을 넣을지는 개발자가 “선택”하는 것이 아니라, Effect 내부의 코드가 읽는 모든 반응형 값(props, state 및 그로부터 계산된 변수)에 의해 결정된다. React 린터가 이를 검사하여 누락된 의존성이 없는지 확인. 의존성이 코드와 일치하지 않으면 버그가 발생할 위험이 매우 높다.
    • 의존성 배열이 빈 배열([])인 경우, Effect는 어떤 반응형 값에도 의존하지 않음을 의미하며, 컴포넌트가 마운트될 때(화면에 처음 나타날 때) 한 번만 실행된다. 컴포넌트의 props나 State가 변경되어도 Effect가 다시 실행되지 않는다.
    • 의존성 배열이 없는 경우는 모든 렌더링 후에 실행됨을 의미하며, 빈 의존성 배열([])은 마운트될 때만 실행됨을 의미하므로 둘은 다른 동작이다.
  • 클린업 함수 추가
    • 일부 Effect는 수행 중이던 작업을 중지, 취소 또는 정리해야 할 수 있으며, 이를 위해 클린업 함수(cleanup function)를 Effect에서 반환한다. 예를 들어, 연결은 해제가 필요하고, 구독은 구독 취소가 필요하며, 비동기 작업(fetch 등)은 취소 또는 무시 처리가 필요하다.
    • React는 Effect가 다시 실행되기 전마다 클린업 함수를 호출하고, 컴포넌트가 마운트 해제(화면에서 제거)될 때 마지막으로 클린업 함수를 호출한다.
  • 개발 모드에서의 Effect 이중 실행
    • React는 Effect가 다시 마운트된 후에도 제대로 작동하는지 확인하기 위해 개발 모드(Strict Mode 활성화 시)에서 컴포넌트를 명시적으로 한 번 마운트 해제했다가 다시 마운트한다. 이는 클린업이 필요한 Effect를 찾고 경쟁 조건과 같은 버그를 초기에 드러내기 위함이다.
    • 따라서 개발 중에 Effect가 두 번 실행되는 것을 보고 “Effect를 한 번만 실행하는 방법”을 찾는 것이 아니라, “Effect가 다시 마운트된 후에도 작동하도록 고치는 것”에 집중해야 한다. 일반적으로 정답은 클린업 함수를 올바르게 구현하는 것이다. 구독이나 데이터 페칭 같은 작업은 다시 마운트되더라도 연결이 끊어지고 새 연결이 시작되거나, 이전 요청이 무시되도록 클린업이 필요하다.
    • Effect의 이중 실행을 막기 위해 useRef 등을 사용하여 Effect가 한 번만 실행되도록 강제하는 것은 버그를 수정하는 것이 아니라 감추는 잘못된 시도이다. 이는 사용자가 다른 페이지로 이동했다 돌아왔을 때 연결이 누적되거나 예상치 못한 동작을 유발할 수 있다.

4. Effect 의존성 심층 이해 🛫

  • 반응형 값의 의미
    • Props와 State는 반응형 값, 컴포넌트 본문에서 Props나 State로부터 계산된 변수도 반응형.
    • 이러한 반응형 값은 다시 렌더링될 때 변경될 수 있으며, 이 경우 Effect를 다시 실행하여 최신 값과 동기화해야 한다.
  • 비반응형 값 (전역, 변경 가능한 값)과 의존성
    • Ref 객체나 useState에서 반환된 set 함수와 같이 안정된 식별성(stable identity)을 가진 값은 변경되지 않으므로 Effect를 다시 실행시키지 않는다. React 린터가 안정적임을 아는 경우 의존성 배열에서 생략해도 안전하다.
    • location.pathname과 같은 변경 가능한 값(mutable values)이나 전역 변수는 React 렌더링 데이터 흐름 외부에서 변경될 수 있으며, 이러한 변경은 컴포넌트를 다시 렌더링하지 않으므로 의존성이 될 수 없다. 대신 useSyncExternalStore를 사용해야 한다.
  • React가 Effect 재동기화를 인식하는 방법 (의존성 비교: Object.is)
    • React는 Effect를 다시 동기화해야 할지 결정하기 위해 Object.is 비교를 사용하여 의존성 배열의 각 값이 이전 렌더링의 값과 정확히 동일한 값을 가지는지 확인한다. 하나라도 다르면 Effect는 다시 실행된다.
  • 린터의 중요성 및 억제 피하기
    • React 린터는 Effect 내부 코드가 읽는 모든 반응형 값이 의존성 목록에 포함되었는지 확인하며, 이는 코드 내의 많은 버그를 잡는 데 도움.
    • 의존성은 Effect 내부 코드에 의해 결정된다. 개발자가 임의로 “선택”하는 것이 아니며, 코드를 변경해야만 의존성을 변경할 수 있다.
    • 린터 오류나 경고가 발생하면 이는 실제 버그를 나타낼 가능성이 매우 높으므로 린터를 억제해서는 안 된다. 린터를 억제하는 대신, 해당 값이 의존성이 될 필요가 없도록 코드 자체를 수정해야 한다.

5. 불필요한 의존성 제거 및 일반적인 문제 해결 전략 ♟️

  • 의존성 무한 루프 수정 방법
    • 무한 루프는 종종 Effect가 State를 업데이트하고, 업데이트된 State가 Effect의 의존성이 되어 Effect를 다시 실행시키는 방식으로 발생한다. 이를 수정하려면 Effect가 State를 불필요하게 업데이트하지 않도록 코드를 변경하거나, 필요한 경우 업데이터 함수를 사용하여 State의 최신 값을 읽되 Effect의 의존성이 되지 않도록 해야 한다. 린터 경고를 억제하는 것은 버그를 숨기는 위험한 시도이다.
  • 의존성을 제거하고자 할 때 해야 할 일
    • 의존성 목록에 있는 값이 변경될 때마다 Effect가 다시 실행되는 것이 합리적인지 질문해야 한다. 그렇지 않은 경우, 해당 값이 의존성이 되지 않도록 Effect 내부 또는 주변 코드를 변경해야 한다.
  • 관련 없는 여러 작업을 수행하는 Effect 분할
    • 하나의 Effect가 country에 따라 도시 목록을 가져오는 것과 city에 따라 지역 목록을 가져오는 것처럼 서로 관련 없는 여러 독립적인 동기화 프로세스를 수행한다면, 이를 여러 개의 개별 Effect로 분할해야 한다. 각 Effect는 자체 의존성 목록을 가지며, 목적에 따라 독립적으로 다시 동기화된다.
  • 다음 state를 계산하기 위해 어떤 state를 읽고 있는 경우 (이전 state 기반 업데이트)
    • Effect가 setCount(count + increment)처럼 현재 State(count, increment)를 읽어 다음 State를 계산하고 State를 업데이트하는 경우, 해당 State 변수가 Effect의 의존성이 되어 불필요한 재연결/재실행을 유발할 수 있다. 이를 피하려면 setMessages(msgs => [...msgs, receivedMessage])처럼 업데이터 함수를 set 함수에 전달해야 한다. 업데이터 함수는 React가 다음 렌더링 시 최신 State 값을 인수로 제공하므로 Effect 자체는 State 변수를 직접 읽을 필요가 없다.
  • 객체와 함수의 의존성 피하기
    • 가능하다면 객체 자체 대신 객체에서 필요한 원시 값만 읽어 의존성으로 사용한다.
    • 함수가 Effect 외부의 props나 State를 읽지 않는다면 컴포넌트 외부로 이동시키거나, Effect 내부에서 선언할 수 있다.
    • 함수가 최신 props나 State를 읽어야 하지만 그 값의 변경에 Effect가 반응하여 다시 동기화되기를 원치 않는 경우, 실험적인 기능인 useEffectEvent Hook을 사용하여 Effect 이벤트로 추출할 수 있습니다.

6. Effect 이벤트: Effect에서 비반응형 로직 추출 (실험적 API) 🎭

때로는 Effect 내부의 코드가 특정 값(props, State 등)을 읽어야 하지만, 해당 값의 변경에 Effect가 다시 동기화되기를 원치 않는 경우가 있다. 이러한 "비반응형" 로직 부분을 주변의 "반응형" Effect 로직으로부터 분리해야 할 필요가 있다.

  • useEffectEvent 선언하기
    • useEffectEvent로 선언된 함수를 Effect 이벤트라고 한다. Effect 이벤트는 Effect 로직의 일부이지만, 이벤트 핸들러와 매우 유사하게 동작한다. 즉, 그 내부의 코드는 반응형이 아니며, 항상 props와 state의 최신 값을 읽는다. Effect 이벤트는 Effect 내부에서만 호출해야 하며, 다른 컴포넌트나 Hook에 전달해서는 안 된다.
  • Effect 이벤트로 최신 props와 state 읽기
    • Effect에서 값 변경에 따라 반응해야 하는 부분은 Effect 자체의 의존성 목록에 유지하고, 값 변경에 반응하지 않고 최신 값만 읽고 싶은 부분은 Effect 이벤트로 추출한다. Effect 이벤트 내에서 읽는 값(예: isMuted, notificationCount)은 Effect의 의존성이 될 필요가 없다. 또한, 특정 상호작용이나 이벤트에 관련된 데이터(이벤트 페이로드)를 Effect 이벤트에 인수로 전달하여 사용할 수 있다.
  • 린터 억제 피하기
    • useEffectEvent와 같은 방법을 통해 Effect의 의존성 문제를 해결해야 한다. 린터 오류나 경고는 실제 버그를 나타낼 가능성이 높으므로, 린터를 억제하는 것은 버그를 숨기는 위험한 시도이다. 린터를 억제하면 React가 새로운 반응형 의존성에 대해 경고하지 않아 오래된 값(stale values)으로 인한 혼란스러운 버그가 발생할 수 있다. useEffectEvent를 사용하면 린터에 "거짓말"할 필요 없이 코드가 기대한 대로 동작한다.

7. 반응형 Effect의 생명주기 관점 🐦‍🔥

  • 컴포넌트와 다른 Effect의 생명주기

    • Effect는 컴포넌트의 생명주기(마운트, 업데이트, 마운트 해제)와는 다른 자체적인 생명주기를 가진다. 각 Effect는 외부 시스템과의 독립적인 동기화 프로세스를 나타낸다.
  • 각 Effect는 동기화 시작 및 중지 프로세스

    • 각 Effect는 동기화를 시작하고 나중에 동기화를 중지하는 두 가지 작업만 수행.
    • Effect에서 반환하는 클린업 함수는 Effect가 수행하던 동기화 작업을 중지하거나 되돌리는 역할을 한다. 이 함수는 Effect가 다음 실행을 시작하기 전이나 컴포넌트가 마운트 해제될 때 호출된다.
  • Effect가 다시 동기화해야 하는 시기와 그 이유

    • Effect는 컴포넌트가 표시되는 동안 외부 시스템과 동기화 상태를 유지하기 위해, Effect 내부에서 읽는 반응형 값(props, State, 컴포넌트 본문에서 계산된 변수)이 변경될 때 다시 동기화해야 한다. React는 Object.is 비교를 사용하여 의존성 배열의 값이 이전 렌더링과 달라졌는지 확인하고, 다르면 Effect를 다시 실행한다.
  • 각 Effect는 별개의 독립적인 동기화 프로세스

    • 코드 내의 각 Effect는 서로 관련 없는 별개의 독립적인 동기화 프로세스를 나타내야 한다. 이를 통해 각 Effect는 자신의 목적에 따라 독립적으로 다시 동기화된다.
  • 렌더링 결과물에 부착된 Effect와 그 클로저

    • Effect는 각 렌더링 결과물에 부착된다. 각 렌더링의 Effect는 해당 렌더링 시점의 props와 state 값을 캡처(클로저)한다. 따라서 의존성 값이 변경되어 Effect가 다시 실행될 때, React는 이전 렌더링의 Effect를 정리하고 새로운 렌더링의 Effect를 실행하여 최신 값을 사용한다.

8. 요약 및 권장 사항 🪀

  • Effect 사용 시점: Effect는 컴포넌트를 네트워크, 브라우저 DOM, 서드파티 라이브러리 등 외부 시스템과 동기화하기 위해 사용한다. 렌더링을 위한 데이터 변환, 사용자 이벤트 처리 (예: 버튼 클릭 시 API 호출), 단순히 다른 State를 기반으로 State 업데이트 등에는 Effect가 필요하지 않을 수 있다.

  • 의존성의 결정: Effect의 의존성은 개발자가 선택하는 것이 아니라, Effect 내부에서 사용되는 모든 반응형 값(props, State, 컴포넌트 본문에서 계산된 변수)에 의해 결정된다. 의존성 목록은 코드를 반영해야 하며, 의존성을 변경하려면 코드를 수정해야 한다.

  • 린터의 중요성: React 린터는 Effect가 읽는 모든 반응형 값이 의존성 목록에 포함되었는지 확인하여 혼란스러운 버그를 방지하는 데 핵심적인 역할을 한다. 린터 오류는 실제 버그를 나타낼 가능성이 높으므로, 린터 경고를 절대 억제해서는 안 된다. 대신 코드를 수정하여 해당 값이 의존성이 될 필요가 없도록 만들어야 한다.

  • Effect의 생명주기: 각 Effect는 컴포넌트의 생명주기(마운트, 업데이트, 마운트 해제)와 별개의 생명주기를 가지며, 외부 시스템과의 독립적인 동기화 프로세스 시작 및 중지를 담당한다. Effect가 반환하는 클린업 함수는 이 동기화를 중지하는 역할을 한다. React는 의존성 값이 변경될 때 Effect의 이전 인스턴스를 정리하고 새 인스턴스를 실행하여 다시 동기화한다. 개발 모드에서는 Effect가 두 번 실행되어 클린업 로직을 테스트하는 데 도움을 준다.

  • 일반적인 문제 해결 패턴:

    • 관련 없는 작업 분할: 하나의 Effect가 서로 독립적인 여러 동기화 작업을 수행하는 경우, 각 작업을 별개의 Effect로 분할한다.
    • 이전 State 기반 업데이트: Effect가 현재 State를 읽어 다음 State를 계산하는 경우 (setCount(count + 1)), State 변수가 의존성이 되어 불필요한 재실행을 유발할 수 있다. 이를 피하려면 setMessages(msgs => [...msgs, receivedMessage])와 같이 업데이터 함수를 set 함수에 전달해야 한다.
    • 객체와 함수의 의존성 회피: 컴포넌트 본문에서 생성된 객체나 함수는 렌더링마다 새로운 식별성을 가질 수 있어 Effect가 의도치 않게 자주 실행될 수 있다. 객체 자체 대신 필요한 원시 값만 읽거나, 함수를 컴포넌트 외부 또는 Effect 내부로 이동시키는 것을 고려한다.
    • Effect 이벤트 사용 (실험적): 값의 변경에 반응하지 않고 Effect에서 최신 값을 읽고 싶을 때, 실험적인 useEffectEvent Hook을 사용하여 비반응형 로직을 Effect 이벤트로 추출한다. Effect 이벤트는 이벤트 핸들러처럼 최신 props와 state를 읽지만 Effect에서 트리거된다. Effect 이벤트는 Effect 내부에서만 호출해야 하며 다른 컴포넌트나 Hook에 전달해서는 안 된다.
profile
PAy IT forwaRD를 실천하는 프론트엔드 개발자.

0개의 댓글