background
React10분

React 파헤치기 1단계 - (useEffect, useCallback, useMemo)

2025년 5월 27일

React 파헤치기 1단계

useEffect

useEffect 함수는 리액트 컴포넌트가 렌더링 될 때마다 특정 작업을 실행할 수 있도록 하는 hook임 component 생명 주기에서 mount 시, unmount 시, update 시 특정 작업 처리가 가능한데 이는 클래스형 컴포넌트에서 사용할 수 있었던 생명주기 메소드를 함수형 컴포넌트에서도 사용할 수 있게 된 것이다.

image.png

image.png

image.png

image.png

🔄 브라우저 생명주기 관점에서 본 useEffect

React 렌더링 사이클과 useEffect의 위치

plain
1. 상태 변경 (setState, props 변화 등)
   ↓
2. 컴포넌트 렌더링 (Virtual DOM 생성)
   ↓
3. Reconciliation (Virtual DOM 비교)
   ↓
4. DOM 업데이트 (실제 DOM 변경)
   ↓
5. 브라우저 Layout (Reflow)
   ↓
6. 브라우저 Paint (Repaint)
   ↓
7. 🎯 useEffect 실행 (비동기적으로)

핵심: useEffect는 브라우저의 Paint 이후에 비동기적으로 실행. 이는 사용자 경험을 위해 화면 업데이트를 차단하지 않기 위함

useLayoutEffect vs useEffect 타이밍 비교

javascript
function Component() {
  useLayoutEffect(() => {
    // DOM 업데이트 후, Paint 전에 동기적 실행
    // 브라우저가 화면을 그리기 전에 실행됨
    console.log('useLayoutEffect: DOM 조작 완료, Paint 대기중');
  });

  useEffect(() => {
    // Paint 후에 비동기적 실행
    // 사용자가 이미 화면을 볼 수 있는 상태
    console.log('useEffect: 화면 렌더링 완료 후 실행');
  });

  return <div>Hello World</div>;
}

💡 useEffect의 핵심 개념

1. useEffect는 렌더링의 "결과물"을 처리하는 도구

javascript
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // userId가 변경되어 컴포넌트가 리렌더링 된 후에 실행됨
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]); // 의존성 배열: "userId가 바뀌면 이 effect를 다시 실행해줘"

  return <div>{user?.name}</div>;
}

2. 의존성 배열의 진짜 의미

의존성 배열은 트리거가 아니라 실행 조건

javascript
// ❌ 잘못된 이해: "dependency가 바뀌면 useEffect가 렌더링을 유발한다"
// ✅ 올바른 이해: "렌더링이 일어난 후, dependency가 바뀌었다면 useEffect를 실행한다"

useEffect(() => {
  // 이 코드는 렌더링이 완료된 후에 실행됨
  console.log('렌더링 완료 후 실행');
}, [dependency]);

3. useEffect 내부에서의 상태 업데이트 → 연쇄 렌더링

javascript
function Component() {
  const [count, setCount] = useState(0);
  const [doubled, setDoubled] = useState(0);

  useEffect(() => {
    // count가 변경된 후 렌더링이 완료되면
    // 여기서 doubled 상태를 업데이트
    setDoubled(count * 2); // 새로운 렌더링 사이클 시작!
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubled}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

// 실행 순서:
// 1. 버튼 클릭 → setCount → 첫 번째 렌더링
// 2. useEffect 실행 → setDoubled → 두 번째 렌더링

⚠️ 오해하기 쉬운 포인트들

오해 1: "useEffect가 렌더링을 유발한다"

javascript
// 많은 개발자들의 착각
function BadExample() {
  const [data, setData] = useState(null);

  // "이 useEffect가 컴포넌트를 렌더링시킨다"는 잘못된 생각
  useEffect(() => {
    fetchData().then(setData);
  }, []);

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

정정: useEffect는 렌더링의 결과입니다. 컴포넌트가 마운트되어 첫 렌더링이 완료된 후에 useEffect가 실행되고, 그 안에서 setData가 호출되어 두 번째 렌더링이 발생하는 것

오해 2: "빈 의존성 배열 = componentDidMount"

javascript
// Vue의 onMounted와 같다고 생각하기 쉽지만...
useEffect(() => {
  console.log('마운트 시에만 실행?');
}, []); // 빈 배열

// 실제로는 StrictMode에서 두 번 실행될 수 있음 (개발 모드)
// 또한 의존성이 없을 뿐이지, 여전히 매 렌더링 후에 "조건 검사"는 함

오해 3: "의존성 배열에 모든 변수를 넣어야 한다"

javascript
function Component() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  // name은 effect 내부에서 사용하지 않으므로 의존성에 불필요
  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]); // name을 넣을 필요 없음

  // 하지만 이 경우는 name도 포함해야 함
  useEffect(() => {
    console.log(`${name} clicked ${count} times`);
  }, [name, count]); // 둘 다 사용하므로 둘 다 포함
}

오해 4: "useEffect cleanup = componentWillUnmount"

javascript
function Component() {
  useEffect(() => {
    const interval = setInterval(() => {
      console.log('tick');
    }, 1000);

    // 이 cleanup은 언마운트 시에만 실행되는 게 아님!
    return () => {
      clearInterval(interval);
      console.log('cleanup 실행');
    };
  }, [someDependency]);

  // someDependency가 바뀔 때마다:
  // 1. cleanup 실행 (기존 interval 정리)
  // 2. 새로운 effect 실행 (새로운 interval 생성)
}

🚀 성능 최적화: useEffect vs 메모이제이션

문제 상황: useEffect 남용

javascript
// ❌ 비효율적: 매번 useEffect로 파생 상태 관리
function BadComponent({ items, filter }) {
  const [filteredItems, setFilteredItems] = useState([]);

  useEffect(() => {
    setFilteredItems(items.filter(item => item.type === filter));
  }, [items, filter]); // 의존성 변경 시마다 추가 렌더링 발생

  return (
    <ul>
      {filteredItems.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

해결책: useMemo 활용

javascript
// ✅ 효율적: 렌더링 중에 계산, 불필요한 리렌더링 없음
function GoodComponent({ items, filter }) {
  const filteredItems = useMemo(() => {
    return items.filter(item => item.type === filter);
  }, [items, filter]); // 메모이제이션으로 캐싱

  return (
    <ul>
      {filteredItems.map(item => <li key={item.id}>{item.name}</li>)}
    </ul>
  );
}

복잡한 상호작용에서의 최적화

javascript
function OptimizedComponent({ data, onItemClick }) {
  // 함수 레퍼런스 안정화
  const handleClick = useCallback((id) => {
    onItemClick(id);
  }, [onItemClick]);

  // 비싼 계산 메모이제이션
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      computed: expensiveCalculation(item)
    }));
  }, [data]);

  // useEffect는 정말 필요한 사이드 이펙트만
  useEffect(() => {
    // DOM 조작, API 호출 등 진짜 사이드 이펙트
    analytics.track('data_processed', { count: data.length });
  }, [data.length]);

  return (
    <div>
      {processedData.map(item => (
        <Item
          key={item.id}
          data={item}
          onClick={handleClick} // 안정된 레퍼런스
        />
      ))}
    </div>
  );
}

🎯 실무에서의 useEffect 패턴

1. 데이터 페칭

javascript
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false; // cleanup을 위한 플래그

    setLoading(true);
    setError(null);

    fetchUser(userId)
      .then(userData => {
        if (!cancelled) { // 컴포넌트가 언마운트되지 않았다면
          setUser(userData);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
        }
      })
      .finally(() => {
        if (!cancelled) {
          setLoading(false);
        }
      });

    return () => {
      cancelled = true; // cleanup 시 플래그 설정
    };
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{user?.name}</div>;
}

2. 이벤트 리스너 관리

javascript
function WindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }

    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // 빈 배열: 마운트/언마운트 시에만

  return <div>{windowSize.width} x {windowSize.height}</div>;
}

🤔 언제 useEffect를 사용하고, 언제 피해야 할까?

useEffect를 사용해야 하는 경우

  • 외부 시스템과의 동기화 (API 호출, WebSocket, 타이머)
  • DOM 직접 조작 (포커스, 스크롤 위치 등)
  • 브라우저 API 사용 (localStorage, 이벤트 리스너)
  • 정리가 필요한 리소스 (구독, 타이머, 연결)

useEffect를 피해야 하는 경우

  • 파생 상태 계산 → useMemo 사용
  • 이벤트 핸들러에서 처리 가능한 로직 → 직접 처리
  • 렌더링 중에 계산 가능한 값 → 변수로 선언
javascript
// ❌ useEffect 남용
function BadExample({ items }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(items.length); // 불필요한 useEffect
  }, [items]);

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

// ✅ 올바른 방법
function GoodExample({ items }) {
  const count = items.length; // 렌더링 중 계산
  return <div>Count: {count}</div>;
}

📝 정리

useEffect는 React에서 사이드 이펙트를 처리하는 강력한 도구이지만, 브라우저의 렌더링 사이클을 정확히 이해하고 사용해야 합니다:

  1. useEffect는 렌더링의 결과를 처리하며, 렌더링을 유발하지 않습니다
  2. 의존성 배열은 실행 조건이며, 트리거가 아닙니다
  3. Paint 후 비동기 실행으로 사용자 경험을 보장합니다
  4. 메모이제이션과 함께 사용하여 성능을 최적화해야 합니다
  5. 정말 필요한 사이드 이펙트에만 사용하고, 파생 상태는 렌더링 중 계산하세요

이러한 원리를 이해하면 useEffect를 올바르게 활용하여 효율적이고 안정적인 React 애플리케이션을 만들 수 있습니다.

태그

#React