useEffect: 무한 루프 방지와 클린업

Diwoom·2026년 6월 8일

우테코에서의 3번째 원정대 주제는 useEffect: 무한 루프 방지와 클린업이다.
이때까지 useEffect를 사용하긴 했지만 특별한 기준 없이 "이 값이 변하면 이 로직이 실행되어야 하니까 useEffect를 사용하면 되겠다!"라고 생각이 들면 냅다 사용했었다. 하지만 원정대 활동을 통해 useEffect를 깊게 학습해보니 무작정 쓰는 것이 아니라 설계 의도에 맞게끔 사용해야 한다는 것을 알게 되었다.

약 2주 동안 공식문서에 있는 모든 useEffect 문서를 정독하고 크루들과 토론했다.

이 글에서는 useEffect를 언제, 어떻게 써야하는지와 useEffect를 직접 사용해보며 겪은 나만의 팁들을 써볼 것이다.

useEffect는 언제, 왜 써야할까?

useEffect가 등장하기 이전에는 외부 세계와 연결하는 방식이 클래스 컴포넌트 형식으로 존재를 했지만, 코드가 분리되어 있어 응집도가 떨어져 useEffect라는 훅이 등장했다.

관련 문서나 글을 읽다보면 사이드 이펙트라는 말을 자주 접할 것이다. useEffect는 사이드 이펙트를 처리하기 위한 React Hook이다.

왜 사이드 이펙트를 useEffect 내부에 넣어야 할까?
이를 이해하려면 렌더링 사이클에 대해 알아야 한다.

렌더링 사이클

렌더 트리거 → 렌더링 → 커밋 → 브라우저 페인트

각 사이클에 대해 간략하게 설명을 하자면

렌더링 : React가 컴포넌트를 호출
커밋 : 렌더링 결과를 실제 DOM에 반영
페인트 : 브라우저가 변경된 DOM을 화면에 그림

useEffect는 페인트 단계 이후 실행된다. 그 이유는 렌더링을 순수하게 하기 위함이다.
렌더링을 순수하게 한다는 말이 모호하게 느껴질 수도 있다.

순수한 렌더링?

"렌더링이 순수하다"라는 말의 의미는 항상 같은 JSX를 반환해야 한다는 말이다. 쉽게 말해 수학 공식처럼 같은 props에 대해 항상 같은 화면을 그려야 한다는 말이다.

하지만 렌더링 과정에서 사이드 이펙트가 발생하면 결과를 예측할 수 없기 때문에 useEffect 내부에 외부 세계와 연결하는 로직을 넣어서 렌더링의 순수성을 보장한다.

결론은 fetch, DOM 조작, 타이머와 같은 사이드 이펙트를 useEffect 내부에 넣어 렌더링을 순수하게 해야 한다.

이제 useEffect를 왜 써야 하는지 알았으니 어떻게 사용하는지를 알아보도록 하자.

의존성 배열

useEffect(() => {
 // 사이드 이펙트
}, [])

useEffect를 사용할 때 가장 주의해야 하는 것이 바로 의존성 배열이다.
의존성 배열의 값에 따라 useEffect의 호출 방식이 달라진다.

3가지 경우

useEffect(() => {
 // 사이드 이펙트
})
useEffect(() => {
 // 사이드 이펙트
}, [])
useEffect(() => {
 // 사이드 이펙트
}, [a])

위 코드에서 3가지 경우가 있는데 차이를 알아보자.

의존성 배열이 없는 경우 매번 렌더링될 때마다 실행
의존성 배열이 빈 배열인 경우 최초 마운트 시에만 실행
의존성 배열에 변수가 있는 경우 해당 변수의 값이 변경될 때에만 실행

해당 변수의 값이 달라지는 것을 어떻게 판단할까?
바로 Object.is 연산을 통해 값이 달라졌는지를 판단한다.

Object.is 비교 연산

Object.is은 원시값(string, number, boolean 등)의 경우 값을 비교한다.
하지만 객체, 함수, 배열의 경우 값이 같아도 다르다고 판단할 수 있다.
그 이유는 객체, 함수, 배열은 참조 값을 비교하기 때문이다. 객체, 함수, 배열이 새로 생성되면 참조도 달라지는데 따라서 Object.is는 서로 다르다고 판단한다.

함수를 넣어야 하는 경우

함수를 의존성 배열에 넣어야 하는 경우에는 먼저 그 함수가 정말 Effect 내부에 필요한지 확인해야 한다. 단순히 이벤트로 실행되는 로직이라면 이벤트 핸들러 안에서 처리하는 것이 적절하다.

Effect 내부에서 함수가 필요하다면, 함수가 매 렌더링마다 새로 생성되어 Effect가 불필요하게 다시 실행될 수 있다. 이 경우 함수 자체를 Effect 내부로 옮기거나, 필요한 값만 의존성 배열에 넣는 방식으로 해결할 수 있다.

useEffect(() => {
  function func() {}
  
  func();
}, [])

React 19에서는 useEffectEvent를 사용할 수도 있다. useEffectEvent는 글 마지막에서 다뤄보도록 하겠다.

객체를 넣어야 하는 경우

객체를 의존성 배열에 넣어야 하는 경우에는 객체 전체를 넣기보다, Effect에서 실제로 사용하는 프로퍼티만 의존성 배열에 넣는 방식을 선택할 수 있다.

useEffect(() => {
  // 사이드 이펙트
}, [user.name, name.id])

의존성 배열에는 반응형 값!

의존성 배열에는 불필요한 실행을 막기 위해 원시값을 넣어야 한다고 앞서 언급했다.

그렇다면 단순히 원시값만 넣으면 모든 게 해결될까?

예상했듯이 그렇지 않다. 원시값인 반응형 값을 넣어야 한다.

반응형 값 : props, state, 컴포넌트 내부에서 만든 변수

props, state에서 파생된 값을 사용해야 한다면 useEffect 내부에서 선언하고 계산하는 것을 권장한다.
만약 JSX를 그릴 때 사용한다면 컴포넌트 내부에서 선언하고 계산하면 된다.

넣지 않아도 되는 값

필수적으로 넣지 않아도 되는 값도 존재한다.
대표적으로 state setter, 컴포넌트 외부에 선언된 값, useEffect 내부에서 선언한 변수/함수이다.

setter의 경우 React가 setter 함수의 정체성이 안정적이라고 보장하기 때문이다.

무한 루프 방지하기

아래의 권장 사항들을 지킨다면 useEffect를 사용할 때 무한 루프를 방지할 수 있을 것이다.

  1. Effect 안에서 사용하는 값은 의존성 배열에 정확히 넣는다.
  2. 배열, 객체, 함수는 그대로 넣기 전에 구조를 먼저 점검한다.
  3. 파생값은 가능하면 렌더링 중에 계산한다.
  4. 이벤트로 실행되는 로직은 이벤트 핸들러에서 처리한다.
  5. Effect 내부에서 state를 업데이트할 때는 의존성 변화를 조심한다.

이제 클린업으로 넘어가 보자.

클린업(cleanup)

useEffect를 사용할 때 Effect가 남긴 작업을 정리해야 하는 경우가 있다. 이때 사용하는 것이 클린업 함수다.

클린업 : 이전 Effect의 흔적을 지워 새로운 Effect에 영향을 끼치지 않게 하기 위한 장치

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

  return () => {
    // cleanup
    connection.disconnect();
  };
}, [roomId]);

클린업 함수는 다음 상황에서 실행된다.

  1. 컴포넌트가 언마운트될 때
  2. 의존성이 바뀌어 Effect가 다시 실행되기 직전

StrictMode가 켜져 있으면 useEffect가 두 번씩 실행되는 것을 콘솔 탭에서 본 적이 있을 것이다.
그 이유가 바로 클린업이 제대로 작동하는지 확인하기 위함이다.

이전 Effect의 흔적이 남아 있으면 의도와는 다른 화면이 나타날 수 있다.

채팅방 예시

예를 들어, A 채팅방에 들어가자마자 B 채팅방에 들어가는 경우를 생각해보자.
우리는 B 채팅방의 내역과 화면을 기대하지만 A 채팅방의 흔적이 남아서 A 채팅방의 내역 또는 화면이 나타날 수 있다.

레이스 컨디션 예시

또 다른 예시로는 레이스 컨디션이 있다.

A 요청은 오래 걸리고, B 요청은 빠르게 끝난다고 가정해보자.
A 요청을 보낸 뒤 곧바로 B 요청을 보내면 우리는 B 요청의 결과를 기대한다. 하지만 뒤늦게 온 A 요청에 대한 응답이 B 요청의 응답을 덮어씌워버리는 경우가 발생할 수도 있다.

클린업은 Effect가 끝날 때 실행되는 코드가 아니라, 다음 Effect가 실행되기 전 또는 컴포넌트가 사라지기 전에 이전 Effect의 작업을 정리하는 코드라고 볼 수 있다.

타이머, 이벤트 리스너 cleanup

cleanup을 사용해야 하는 대표적인 예시로 타이머와 이벤트 리스너, fetch 요청이 있다. fetch 요청을 제외한 타이머와 이벤트 리스너의 클린업 방식을 살펴보자.

타이머

useEffect(() => {
  const timerId = setInterval(() => {
    setCount((prev) => prev + 1);
  }, 1000);

  return () => {
    clearInterval(timerId);
  };
}, []);

컴포넌트가 사라질 때 기존 타이머를 제거하지 않으면, 화면에서 사라진 뒤에서 타이머가 게속 실행될 수 있다.

이벤트 리스너

useEffect(() => {
  const handleResize = () => {
    setWidth(window.innerWidth);
  };

  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

이벤트 리스너를 제거하지 않으면 컴포넌트가 사라진 뒤에도 리스너가 남아 있을 수 있고, 같은 리스너가 중복 등록될 수 있다.

fetch 요청 cleanup

fetch 요청의 cleanup을 구현하는 방식은 2가지로 볼 수 있다.ignore 플래그, AbortController이다.

ignore 플래그

ignore 플래그는 이미 보낸 요청을 취소하지는 않는다. 대신 이전 요청의 응답이 나중에 도착하더라도 state를 업데이트하지 못하게 막는다.

useEffect(() => { 
  let ignore = false;
  fetchUser(userId).then((data) => { 
    if (!ignore) { setUser(data); } 
  }); 
  
  return () => {
    ignore = true;
  };
}, [userId]);

AbortController

두 번째는 AbortController를 사용하는 방식이다. 이 방식은 요청 자체를 취소할 수 있다. 컴포넌트가 언마운트되거나 의존성이 바뀌면 이전 요청을 중단한다.

useEffect(() => {
  const abortController = new AbortController();
  
  fetch(`/api/users/${userId}`, {
    signal: abortController.signal,
  })
    .then( //성공 시 로직 )
    .catch((error) => {
      if(error.name === 'AbortError') {
        return;
      }
    })
    
  return () => {
    abortController.abort();
  }
}, [userId]);

개인적으로는 ignore 플래그보다 AbortController를 선호하는 편이다.

ignore 플래그는 요청 자체를 취소하지 않고, 응답이 도착했을 때 state 업데이트만 막는 방식이다. 반면 AbortController는 진행 중인 요청을 중단할 수 있다.

물론 이미 서버에 도착한 요청의 처리까지 항상 취소되는 것은 아니지만, 클라이언트 입장에서는 불필요한 응답 처리와 네트워크 사용을 줄일 수 있다. 그래서 단순히 결과 반영만 막는 ignore 플래그보다 요청 자체를 중단하는 AbortController 방식이 더 직관적이라고 느꼈다.

번외: useEffectEvent

React 19에서는 useEffectEvent를 사용할 수 있다. 다만 useEffectEvent는 의존성 배열에서 값을 억지로 제거하기 위한 도구가 아니다. Effect 안에서 실행되지만, 특정 값의 변경에 반응해서 Effect를 다시 실행할 필요는 없는 로직을 분리할 때 사용한다.

useEffectEvent는 최신 값을 읽고 싶지만 그 값의 변경 때문에 Effect를 다시 실행하고 싶지 않을 때 사용한다.

채팅방 예시

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);

    connection.on('connected', () => {
      showNotification('연결되었습니다!', theme);
    });

    connection.connect();

    return () => connection.disconnect();
  }, [roomId, theme]);
}

이 코드에서 roomId가 바뀌면 채팅방을 다시 연결한다. 그런데 theme이 바뀔 때도 채팅방을 다시 연결한다. 하지만 showNotification은 최신 theme을 읽고 있다. 이럴 때 useEffectEvent를 사용하면 된다.

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('연결되었습니다!', theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);

    connection.on('connected', () => {
      onConnected();
    });

    connection.connect();

    return () => connection.disconnect();
  }, [roomId]);
}

이렇게 함으로써 showNotification은 최신 roomId를 읽고, theme이 변경된다고 해서 채팅방이 다시 연결되지 않는다.

useEffect 내부에 함수를 사용해야 하는 경우, Effect 내부에서 함수를 선언해서 사용하거나 useEffectEvent를 사용해서 불필요한 Effect 재실행을 막는 걸 선호한다.

profile
개발자 주의 : 필력 안좋음

0개의 댓글