리액트 useEffect 사용법: 의존성 배열과 생명주기 이해

리액트 useEffect 사용법: 의존성 배열과 생명주기 이해

useEffect는 컴포넌트가 렌더링된 뒤 실행되어 이벤트 리스너 등록/해제, 타이머, API 호출 등 외부 동기화 작업을 담당합니다. 의존성 배열로 실행 시점을 제어하며 전통적인 componentDidMount·Update·Unmount에 대응합니다. 아래에 의존성 배열의 정확한 동작을 표와 예제로 자세히 알아봅니다.

1. useEffect 기본 형태

import { useEffect } from 'react';

useEffect(() => {
  // 실행 로직 (렌더링 결과가 DOM에 반영된 뒤 실행)
  return () => {
    // 정리(cleanup) 로직 (언마운트 또는 다음 실행 직전에 실행)
  };
}, [/* 의존성 배열 */]);

2. 의존성 배열 깊게 이해하기

의존성 배열은 “이 값들이 바뀌면 이펙트를 다시 실행하라”는 의미입니다. 배열에 무엇을 넣느냐가 실행 타이밍을 결정합니다.

  • 의존성 배열 없음 (useEffect(fn)): 모든 렌더링 후 실행됩니다. (state/props 무엇이 바뀌든 매번)
  • [] (useEffect(fn, [])): 마운트 시 1회만 실행되고, 언마운트 때 cleanup이 1회 실행됩니다.
  • [count] (useEffect(fn, [count])): count 값이 바뀔 때만 실행됩니다. (다른 state가 바뀌어도 count가 그대로면 실행되지 않음)

왜 “[count]에서만 실행”인지 예제로 보기

import React, { useEffect, useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName]   = useState('Kim');

  useEffect(() => {
    console.log('효과 실행! count:', count);
  }, [count]); // count가 바뀔 때만 실행

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>

      <p>name: {name}</p>
      <button onClick={() => setName(n => n === 'Kim' ? 'Lee' : 'Kim')}>이름 토글</button>
    </div>
  );
}

위 컴포넌트에서 name만 바꾸면 리렌더링은 일어나지만, count는 변하지 않았으므로 useEffect(..., [count])실행되지 않습니다.
반대로 count가 바뀌면 실행됩니다. 이것이 “count 값이 바뀔 때만 실행”의 정확한 의미입니다.

의존성 배열 요약표

의존성 표기다시 실행되는 시점cleanup 실행 시점대표 용도
(없음)모든 렌더링 후다음 실행 직전마다간단한 로그, DOM 동기화 실험
[]마운트 시 1회언마운트 시 1회이벤트 리스너 1회 등록/해제, 타이머 시작/정지
[count]count가 바뀔 때각 재실행 직전특정 값 변화에 맞춘 동기화(제목, API 재요청 등)
[id, query]id 또는 query가 바뀔 때각 재실행 직전의존 값에 따른 데이터 페칭
[someFn]someFn참조가 바뀔 때각 재실행 직전함수 의존 시엔 useCallback으로 참조 안정화 권장

참고: 의존성 비교는 얕은 비교(참조 동일성)입니다. 객체/배열/함수는 매 렌더마다 새로 만들면 참조가 달라져 재실행이 빈번해질 수 있으니, 필요 시 useMemo/useCallback을 사용해 참조를 안정화하세요.

3. 예제 1 — 창 크기 추적 (마운트/언마운트 + cleanup)

import React, { useEffect, useState } from 'react';

function WindowSizeWatcher() {
  const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });

  useEffect(() => {
    const onResize = () => setSize({ w: window.innerWidth, h: window.innerHeight });
    window.addEventListener('resize', onResize);
    onResize(); // 최초 동기화
    return () => window.removeEventListener('resize', onResize); // 언마운트 시 정리
  }, []);

  return (
    <section style={{border:'1px solid #ddd', padding:12, borderRadius:8}}>
      <h4>WindowSizeWatcher 예제</h4>
      <p>현재 창 크기</p>
      <div>
        <span>가로: {size.w}px</span>
      </div>
      <div>
        <span>세로: {size.h}px</span>
      </div>
      <hr/>
      <pre style={{background:'#f6f8fa', padding:8}}>
        {JSON.stringify(size, null, 2)}
      </pre>
    </section>
  );
}

export default WindowSizeWatcher;
리액트 useEffect 창 크기 추적 예제 실행 화면

4. 예제 2 — 생명주기 대응: Mount/Update/Unmount 로그

import React, { useEffect, useState } from 'react';

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

  // componentDidMount / componentWillUnmount
  useEffect(() => {
    console.log('mounted');
    return () => console.log('unmounted');
  }, []);

  // componentDidUpdate(count)
  useEffect(() => {
    console.log('updated: count =', count);
  }, [count]);

  return (
    <section style={{border:'1px solid #ddd', padding:12, borderRadius:8}}>
      <h4>LifecycleLogDemo 예제</h4>
      <p>카운트 값: {count}</p>
      <div>
        <button onClick={() => setCount(c => c + 1)}>+1</button>
      </div>
      <hr/>
      <pre style={{background:'#f6f8fa', padding:8}}>
        {JSON.stringify({ count }, null, 2)}
      </pre>
    </section>
  );
}

export default LifecycleLogDemo;
리액트 useEffect 생명주기 대응 예제 콘솔 로그 화면

5. 예제 3 — API 데이터 가져오기 (요청 취소 포함)

import React, { useEffect, useState } from 'react';

function UsersFetcher() {
  const [users, setUsers]   = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError]     = useState('');

  useEffect(() => {
    const ctrl = new AbortController();

    async function fetchUsers() {
      try {
        setLoading(true);
        setError('');
        const res = await fetch('https://jsonplaceholder.typicode.com/users', {
          signal: ctrl.signal,
        });
        if (!res.ok) throw new Error('네트워크 오류');
        const data = await res.json();
        setUsers(data);
      } catch (err) {
        if (err.name !== 'AbortError') setError(err.message ?? '알 수 없는 오류');
      } finally {
        setLoading(false);
      }
    }

    fetchUsers();
    return () => ctrl.abort(); // 언마운트/재요청 시 이전 요청 중단
  }, []);

  return (
    <section style={{border:'1px solid #ddd', padding:12, borderRadius:8}}>
      <h4>UsersFetcher 예제</h4>

      {loading ? (
        <p>로딩 중...</p>
      ) : error ? (
        <p style={{color:'crimson'}}>오류: {error}</p>
      ) : (
        <ul>
          {users.map(u => (<li key={u.id}>{u.name} ({u.email})</li>))}
        </ul>
      )}

      <hr/>
      <pre style={{background:'#f6f8fa', padding:8}}>
        {JSON.stringify(
          { loading, error: Boolean(error), usersCount: users.length, sample: users[0] ?? null },
          null,
          2
        )}
      </pre>
    </section>
  );
}

export default UsersFetcher;
리액트 useEffect로 API 데이터 가져오기 예제 실행 화면

6. 자주 하는 실수 & 베스트 프랙티스

  • 의존성 누락: 오래된 값(스테일 클로저) 사용 위험. ESLint react-hooks/exhaustive-deps 규칙을 켜고 경고를 해결하세요.
  • 비동기 함수를 직접 전달: useEffect(async () => ...)는 권장되지 않습니다. 내부에 async 함수를 선언해 호출하세요.
  • 객체/함수 의존성 튐: 매 렌더마다 새로 만들어지면 재실행이 잦아집니다. useMemo/useCallback으로 참조를 고정하세요.
  • cleanup 누락: 이벤트/타이머 해제 잊으면 메모리 누수·중복 동작 발생.

참고 링크

함께 보면 좋은 게시글

위로 스크롤