Effect는 리액트의 동기화다?

keemsebeen·2025년 5월 7일

왜 이름에서부터 탈출구(Escape Hatch)일까?

리액트에서 렌더링 되는 코드는 순수함수여야 하고, 입력 값에 따라 동일한 결과를 반환하고 외부에 영향을 미치지 않아야 합니다. (이전 블로그 글 참조) 하지만 실제 프로젝트에서는 순수함수만으로 모든 기능을 구현하기란 사실상 어렵습니다. 예를 들어, API를 호출하거나, DOM을 직접 조작하는거나 다른 라이브러리를 사용 하는 상황에서는 어느정도의 부수효과를 허용할 수 밖에 없기 때문이죠.

이러한 맥락에서 등장한 것이 useEffect가 않을까 생각합니다. 이름에서도 알 수 있듯이, Effect는 부수효과를 일으키는 side-effect에서 따온 것 입니다. useEffect를 쓰는 행위 자체가 곧 프로젝트에서 부수효과를 도입하는 일이 되는 셈인 것이죠. 외부 세계와의 상호작용을 하기 때문에요.

리액트 공식문서에서는 useEffect를 탈출구라는 표현을 사용합니다. 왜 탈출구 일까요? 저는 이 표현이 리액트의 순수성 제약에서 벗어날 수 있는 지점을 의미한다고 해석했습니다. useEffect는 곧 리액트의 선언적인 패러다임에서 벗어나 명령적인 부수 효과를 처리하기 위함이 아닐까? 하는 생각이 들었습니다.

useEffect의 실행 시점과 커밋 단계

React의 렌더링 과정은 크게 렌더(render)와 커밋(commit) 두 단계로 나뉩니다. useEffect는 커밋 단계가 완료된 이후에 실행되는데, 이것이 의미하는 바를 자세히 살펴보겠습니다.

렌더링과 커밋 순서

렌더 단계

컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 말합니다. render() 또는 return 결과와 이전 가상 DOM을 비교하는 과정을 거쳐 변경이 필요한 컴포넌트를 체크하는 단계입니다.type, props ,key 셋 중 하나라도 변경되면 변경이 필요한 컴포넌트로 체크해둡니다.

커밋 단계

렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정을 말한다. 이 단계가 끝나야 비로소 브라우저의 렌더링이 발생한다.

리액트의 렌더링이 일어난다고 무조건 DOM 업데이트가 일어나는 것은 아니다. 렌더링을 수행했으나 커밋단계까지 갈 필요가 없다면 커밋단계는 생략될 수 있습니다.

(렌더 단계에서 변경 사항을 감지할 수 없다면 커밋 단계가 생략되어 브라우저의 DOM 업데이트가 일어나지 않을 수 있습니다.)

useEffect 실행

모든 DOM 변경이 화면에 반영된 후에 실행됩니다. 브라우저가 화면을 그린 후 비동기적으로 실행됩니다. 이는 의도적인 설계로 리액트가 브라우저에게 “화면 업데이트를 먼저 완료하라”고 지시하기 때문입니다.

왜 커밋 단계 이후에 실행될까요?

  1. 사용자 경험 향상 DOM 업데이트를 먼저 완료하여 사용자가 UI 변경을 지체 없이 볼 수 있게 합니다.
  2. DOM 접근의 안정성 Effect 내에서 DOM에 접근할 때, 이미 업데이트 된 DOM을 참조할 수 있습니다.
  3. 렌더링 블로킹 방지 useEffect가 렌더링 과정을 차단하지 않도록 합니다.

컴포넌트 생명주기와는 다른 useEffect 생명주기

컴포넌트는 마운트, 업데이트, 언마운트 3가지 단계를 거칩니다. 하지만 Effect는 동기화를 시작하는 것, 동기화를 중지하는 것 두가지 작업만 가능합니다. 따라서 Effect는 항상 한번에 하나의 시작/중지 사이클에만 집중해야합니다.

구분컴포넌트 생명주기useEffect 생명주기
주요 개념마운트 → 업데이트 → 언마운트동기화 시작 → 동기화 중지
실행 시점렌더링 전후렌더링 후 (커밋 단계 이후)
상태와의 관계상태 변화에 따라 생명주기 메서드 호출의존성 배열에 명시된 값이 변경될 때만 실행

시작과 중지에 집중하는 useEffect

useEffect는 React의 커밋단계가 끝난 후, 즉 브라우저에 실제 DOM 변경이 반영된 뒤에 실행됩니다. 이때, Effect는 다음의 두 가지 작업만 수행할 수 있습니다.

동기화 시작 (side effect 시작)
렌더링 이후 외부와 동기화할 작업을 수행합니다. (ex. 이벤트 리스너 등록, 타이머 시작, API 요청 등)

useEffect(() => {
  const id = setInterval(() => {
    console.log('타이머 작동 중...');
  }, 1000);
  
  return () => clearInterval(id);
}, []);

여기서 setInterval 은 외부 브라우저 API와의 동기화를 시작하는 부분입니다.

동기화 중지 (클린업)
이전 Effect가 정리되어야 할 때 실행할 클린업 함수 반환합니다.

useEffect(() => {
  const id = setInterval(() => {
    console.log('타이머 작동 중...');
  }, 1000);
  
  return () => clearInterval(id); // 
}, []);
  • 컴포넌트가 언마운트 될 때 → 컴포넌트가 DOM에서 제거될 때
  • 의존성 배열에 있는 값이 변경될 때 → 새로운 Effect 실행 전 기존 Effect 정리

Effect의 핵심은 동기화 시작과 동기화 중지라는 두가지 단계에만 집중하는 것입니다.

해당 방식의 강점은 개발자가 “이 Effect가 언제 실행되는가?”라는 질문 대신 “이 Effect는 무엇을 동기화하는가?”에 집중하게 된다는 점입니다.

Effect는 렌더링 결과에 따라 고립된다. (Closures)

Effects from each render are isolated from each other.

React의 useEffect는 각 렌더링 결과와 1:1로 매핑되며, 각각의 Effect는 해당 렌더에서 생성된 변수, 상태 등과만 연결됩니다. 렌더링이 일어날 때마다 이전 Effect는 정리(cleanup) 되고, 새로 생성된 Effect가 등록됩니다.

function Text() {
	const [text, setText]= useState('');
	
	useEffect(() => {
	  console.log("현재 text:", text);
	}, [text]);

	return (
		<input value={text} onChange={()=> setText(e.target.value)} />
	)
}

위 코드에서 text는 상태 값입니다. 상태가 a → ab → abc 순으로 업데이트가 된다면, 각각의 렌더링 시점에 대응하는 useEffect를 생성되고, 그 당시의 text값만을 캡쳐(closure)합니다. 이후에 상태가 바뀌더라도 이미 실행중인 Effect는 과거의 상태를 참조합니다.

클로저와의 관계

이 “캡처” 현상은 자바스크립트의 클로저(Closure) 개념과 밀접한 관련이 있습니다.

함수형 컴포넌트는 렌더링될 때마다 새로운 함수 스코프를 생성합니다. 결과적으로 useEffect 내부에서 참조하는 변수는 그 렌더 시점의 변수를 가리키는 클로저에 묶입니다.

즉, 이후 상태가 바뀌어도 해당 Effect는 자신이 렌더링될 당시의 상태에만 접근합니다. 이것이 “Effect는 각 렌더와 격리되어 있다”는 의미입니다.

클린업 함수는 언제 사용해야 옳은건가?

클린업은 앞에서 동기화 중지를 하는데 사용한다고 했습니다. 그렇다면 해당 함수는 항상 필요한 필요조건일까요? 그렇지만은 않습니다. 다음 설명은 클린업 함수가 필요한 경우와 불필요한 경우를 나눠 설명해보겠습니다.

클린업 함수를 사용해야 하는 상황

  1. 구독 취소가 필요한 경우

    useEffect(() => {
      const subscription = dataSource.subscribe();
      
      return () => {
        subscription.unsubscribe();
      };
    }, [dataSource]);

    구독 취소로 메모리 누수를 방지할 수 있습니다.

  2. 타이머나 인터벌을 설정한 경우

    useEffect(() => {
      const timerId = setInterval(() => {
      }, 1000);
      
      return () => {
        clearInterval(timerId);
      };
    }, []);

    타이머 정리로 불필요한 작업을 방지할 수 있습니다.

  3. 이벤트 리스너를 등록한 경우

    useEffect(() => {
      const handleResize = () => {
        setWindowSize(getWindowSize());
      };
      
      window.addEventListener('resize', handleResize);
      
      return () => {
        window.removeEventListener('resize', handleResize);
      };
    }, []);

    이벤트 리스너 제거로 메모리 누수를 방지할 수 있습니다.

  4. 애니메이션 프레임 요청을 한 경우

    useEffect(() => {
      let frameId;
      
      const animate = () => {
        frameId = requestAnimationFrame(animate);
      };
      
      frameId = requestAnimationFrame(animate);
      
      return () => {
        cancelAnimationFrame(frameId);
      };
    }, []);

    애니메이션 프레임 취소를 요청해야 합니다.

  5. 비동기 작업의 결과를 무시해야 하는 경우

    useEffect(() => {
      let isMounted = true;
    
      fetchData().then(data => {
        if (isMounted) {
          setData(data);
        }
      });
    
      return () => {
        isMounted = false;
      };
    }, []);

컴포넌트가 여전히 마운트된 상태인 경우에만 상태를 업데이트해야합니다. 컴포넌트가 언마운트된 후에 비동기 작업의 결과가 도착하는 경우, 이를 무시하기 위해 클린업함수를 사용합니다.

클린업 함수가 불필요한 경우

  1. 일회성 상태 설정만 필요한 경우
    useEffect(() => {
      setIsLoaded(true);
    }, []);
  2. 일회성 로컬 스토리지 읽기와 같은 부작용이 없는 작업
    useEffect(() => {
      const savedValue = localStorage.getItem('key');
      if (savedValue) {
        setValue(savedValue);
      }
    }, []);
  3. 서버로 일회성 로그 메시지 전송
    useEffect(() => {
      logComponentMount();
    }, []);

클린업 함수를 사용하지 않으면 생기는 문제

  1. 메모리 누수
    useEffect(() => {
      window.addEventListener('resize', handleResize);
    }, []);

clean up 함수 없이 구독이나 이벤트 리스너를 등록하면, 컴포넌트가 언마운트된 후에도 이러한 리소스가 계속 존재하게 됩니다. 이는 메모리 누수로 이어져 애플리케이션 성능 저하와 예기치 않은 동작을 유발할 수 있습니다

  1. 경쟁 상태
    경쟁 상태란 두 개 이상의 비동기 작업이 동시에 실행되고, 그 실행 순서나 타이밍에 따라 결과가 달라질 수 있는 상황을 말합니다. 리액트에서는 주로 다음과 같은 경우에 발생합니다.
    useEffect(() => {
      fetchUserData(userId).then(data => {
        setUserData(data);
      });
    }, [userId]);

만약 사용자가 빠르게 userId를 A → B → C로 변경한다면?

  • C에 대한 요청이 가장 나중에 시작되었지만 가장 먼저 완료될 수 있습니다.
  • 그 후 B, 마지막으로 A에 대한 응답이 도착할 수 있습니다.
  • 결과적으로 화면에는 C가 아닌 A의 데이터가 표시됩니다. (가장 오래된 요청의 결과)

비동기 작업이 완료되기 전에 컴포넌트가 언마운트되거나 의존성이 변경되면, 이전 작업의 결과가 새로운 상태를 덮어쓸 수 있습니다. 이는 경쟁 상태라 불리며, 예측할 수 없는 버그를 초래합니다.
3. 중복 실행 및 리소스 낭비

useEffect(() => {
  const intervalId = setInterval(checkNotifications, 1000);
}, [user]);
클린업 함수 없이 Effect가 여러 번 실행되면, 동일한 리소스(타이머, 구독 등)가 여러 번 생성될 수 있습니다. 이는 성능 저하와 예기치 않은 동작을 유발합니다.
  1. 언마운트된 컴포넌트 상태 업데이트
    useEffect(() => {
      fetch('https://api.example.com/data')
        .then(response => response.json())
        .then(data => {
           setData(data); // 경고: Can't perform a React state update on an unmounted component
        });
    }, []);
    클린업 함수 없이 비동기 작업이 완료되면, 이미 언마운트된 컴포넌트의 상태를 업데이트하려 할 수 있습니다. 이는 리액트 경고를 발생시키고 메모리 누수로 이어질 수 있습니다.

결론적으로 useEffect에서 cleanup 함수는 다음 조건에 해당하는 경우에만 필요합니다.

  1. 지속적인 효과를 설정하는 경우 (구독, 연결, 타이머 등)
  2. 나중에 해제해야 하는 리소스를 생성하는 경우
  3. 이전 실행의 효과가 다음 실행이나 언마운트 후에도 지속되는 경우

이처럼 React에서는 모든 useEffect에 클린업함수가 필요한 것이 아니라, Effect의 특성과 수행하는 작업의 유형에 따라 선택적으로 사용해야 합니다.

useEffectEvent: 클린업 함수의 진화

React 18.3에서 실험적으로 도입된 useEffectEventuseEffect클로저 문제와 과도한 재실행 이슈를 해결하기 위해 설계된 새로운 Hook입니다. Effect 내부에서 항상 최신 상태나 props를 안전하게 참조할 수 있도록 돕는 것이 핵심 목적입니다.

기존 useEffect의 한계

useEffect는 React의 핵심 Hook이지만 몇 가지 제한사항이 있습니다.

function UserStatus({ username }) {
  const [isOnline, setIsOnline] = useState(false);
  
  useEffect(() => {
    console.log(`${username}님이 ${isOnline ? '온라인' : '오프라인'} 상태가 되었습니다.`);
    saveUserStatus(username, isOnline);
    
    // 필요 없는 클린업 함수지만 예시를 위해 포함
    return () => {
      console.log(`${username}의 이전 상태 기록 정리 중...`);
    };
  }, [username, isOnline]); 
  
  return (
    <div>
      <p>{username}님은 현재 {isOnline ? '온라인' : '오프라인'} 상태입니다.</p>
      <button onClick={() => setIsOnline(!isOnline)}>
        상태 변경
      </button>
    </div>
  );
}
  • 렌더링마다 클로저를 생성하며, 내부에서 사용하는 값들이 해당 렌더 시점의 값으로 고정됩니다. (stale value)
  • 의존성 배열에 함수나 객체가 포함될 경우, 자주 재실행 됩니다. (username, isOnline 상태가 변화하면 Effect가 다시 실행됩니다.)
  • 클린업 함수도 이전 클로저에 묶이므로 최신 정보를 참조하기 어렵습니다.

useEffectEvent의 해결책

function UserStatus({ username }) {
  const [isOnline, setIsOnline] = useState(false);
  
  const logStatusChange = useEffectEvent(() => {
    console.log(`${username}님이 ${isOnline ? '온라인' : '오프라인'} 상태가 되었습니다.`);
    saveUserStatus(username, isOnline);
  });
  
  // isOnline 상태가 변경될 때만 실행
  useEffect(() => {
    logStatusChange();
    
    return () => {
      console.log(`${username}의 이전 상태 기록 정리 중...`);
    };
  }, [isOnline]); 
  
  return (
    <div>
      <p>{username}님은 현재 {isOnline ? '온라인' : '오프라인'} 상태입니다.</p>
      <button onClick={() => setIsOnline(!isOnline)}>
        상태 변경
      </button>
    </div>
  );
}

useEffectEvent는 다음과 같은 핵심 차이점을 제공합니다.

  • usename 이 변경되어도 effect는 다시 실행되지 않습니다.
  • logStatusChange는 항상 최신 username, isOnline에 안전하게 접근 가능합니다.
  • 로직을 effect 외부로 분리해 코드 가독성이 향상됩니다.

useEffectEvent의 장점

  • 최신 상태 참조 - 의존성 배열에 포함하지 않고도 항상 최신 상태나 props를 사용할 수 있습니다.
  • 불필요한 재실행 방지 - 모든 값 변경에 effect가 반응하지 않도록 제어할 수 있습니다.
  • 간결한 코드 - 의존성 관리가 간소화되어 코드가 더 깔끔해집니다

stale value란?

오래된 값 또는 렌더링 시점에 고정된 값을 의미합니다.
리액트 컴포넌트는 렌더링될 때마다 함수 전체가 다시 실행됩니다. 이때 렌더링 시점의 변수 값이 클로저에 저장되며, 이후에도 해당 값을 계속 참조하게 되는 문제가 발생할 수 있습니다.

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
	const id = setInterval(() => {
	  console.log(count); // 👈 항상 0만 출력됨
	  setCount(count + 1); // 👈 count는 고정된 0이라 1만 반복됨
	}, 1000);

	return () => clearInterval(id);
  }, []);

    return <div>{count}</div>;
}

콘솔에는 항상 0이 출력됩니다. count 값은 고정된 0이라 setCount(count+1) 값은 항상 1만 반복되게 됩니다.

과정 설명

  1. useEffect는 마운트 시점에 한 번만 실행됩니다 ([]이기 때문).
  2. 이때의 count 값은 0이고, 그 값이 setInterval 클로저 안에 고정됩니다.
  3. 이후 count가 1, 2, 3으로 증가하더라도, 클로저 안의 count는 여전히 0이라 계속 setCount(1)만 호출됩니다.

👉 이렇게 최신 상태를 참조하지 못하고 고정된 값만 참조하는 현상을 stale value라고 부릅니다.

해당 경우의 해결법은 두가지가 있습니다.

  1. 상태 업데이터 함수 사용

    setCount(prev => prev + 1); 
  2. useRef로 최신 값 저장

    const countRef = useRef(count);
    
    useEffect(() => {
      countRef.current = count;
    }, [count]);
  3. useEffectEvent사용

React에서 === 가 아닌 Object.is 로 비교하는 이유

리액트에서는 상태나 props의 변화를 감지할 때 === 연산자 대신 Object.is() 를 사용합니다. 왜일까요?

=== 연산자와 Object.is() 의 주요 차이점

  1. NaN 값 비교
    NaN === NaN *// false - NaN은 자기 자신과도 동등하지 않음.*
    Object.is(NaN,NaN) *// true - NaN을 자기 자신과 동등하게 처리*
  2. +0와 -0 비교
    +0 === -0  *// true - 두 값을 같다고 판단*
    Object.is(+0, -0)  *// false - 두 값을 다르다고 판단*

리액트에서 Object.is()를 사용하는 이유

  1. 더 정밀한 비교

    React는 Object.is()를 사용하여 이전 상태와 새 상태를 비교합니다. 이는 useStateuseReducer 같은 훅에서 상태 업데이트 시 특히 중요합니다.

    function Counter() {
      const [count, setCount] = useState(0);
      
      function handleReset() {
        setCount(NaN);
      }
      
      return (
        <div>
          Count: {count}
          <button onClick={() => setCount(count + 1)}>Increment</button>
          <button onClick={handleReset}>Reset</button>
        </div>
      );
    }

    count가 NaN일 경우, 서로 다른 값으로 인지하고 리렌더링을 발생시킬 것 입니다. 하지만 Object.is(NaN, NaN)는 true이므로 리렌더링을 유도하지 않습니다.

  2. 부동 소수점 계산의 정확성

    function TemperatureConverter() {
      const [celsius, setCelsius] = useState(0);
      
      return (
        <div>
          <input 
            value={celsius}
            onChange={e => setCelsius(Number(e.target.value<))}
          />
          <p>화씨: {celsius * 9/5 + 32}</p>
        </div>
      );
    }

    물리학이나 공학 계산에서는 +0과 -0의 구분이 의미가 있을 수 있습니다. 이를 같다고 해버리는 순간 물리학이나 공학 관련 관계자들은 잘못된 예측 결과에 당황스러울 것입니다. Object.is(+0, -0)false이므로 상태 변화로 인식할 수 있습니다.

  3. 리액트의 내부 구현 일관성

    function shallowEqual(objA: mixed, objB: mixed): boolean {
      if (Object.is(objA, objB)) {
        return true;
      }
    
      if (
        typeof objA !== 'object' ||
        objA === null ||
        typeof objB !== 'object' ||
        objB === null
      ) {
        return false;
      }
    
      const keysA = Object.keys(objA);
      const keysB = Object.keys(objB);
    
      if (keysA.length !== keysB.length) {
        return false;
      }
    
      // Test for A's keys different from B.
      for (let i = 0; i < keysA.length; i++) {
        const currentKey = keysA[i];
        if (
          !hasOwnProperty.call(objB, currentKey) ||
          !is(objA[currentKey], objB[currentKey])
        ) {
          return false;
        }
      }
    
      return true;
    }
    
    export default shallowEqual;

    React에서는 shallowEqual 함수를 사용해 객체의 얕은 비교를 수행합니다. 이 함수는 내부적으로 Object.is()를 기반으로 합니다.

  • 다만 Object.is()는 ES6부터 도입됐기에 리액트 코드를 보면 이전 브라우저를 위해 폴리필을 포함하고 있습니다.

깃허브 오픈소스

    function is(x: any, y: any) {
      return (
        (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
      );
    }
    
    const objectIs: (x: any, y: any) => boolean =
      typeof Object.is === 'function' ? Object.is : is;
    
    export default objectIs;

+0 vs -0, NaN vs NaN처럼 ===로 비교할 수 없는 엣지 케이스를 보완하고 Object.is를 안전하게 사용할 수 있도록 리액트에서 구현한 코드입니다.

(++ 내용 추가 예정입니다)

profile
프론트엔드 개발자 김세빈입니다. 👩🏻‍💻

1개의 댓글

comment-user-thumbnail
2025년 5월 7일

역시 세라

답글 달기