리액트 기초 강좌 #10 useEffect

22 분 소요

지난 시간에는 폼을 다루는 정석인 controlled component를 배웠습니다. 지금까지 우리가 만든 컴포넌트들은 자기 안에서 모든 일이 시작되고 끝났습니다. 사용자 입력을 받고, state로 보관하고, 화면에 그리는 한 사이클이 컴포넌트 안에 닫혀 있었죠. 이번 시간에는 컴포넌트가 외부 세계와 상호작용해야 할 때 사용하는 도구인 useEffect를 배워봅니다.

Side Effect란

**Side effect(부수 효과)**는 컴포넌트의 핵심 역할인 "props/state로부터 JSX를 만드는 것" 외의 모든 작업을 말합니다.

  • 서버에서 데이터 가져오기 (fetch)
  • 타이머 설정 (setTimeout, setInterval)
  • 브라우저 API 사용 (localStorage, document.title 변경 등)
  • 외부 라이브러리 초기화
  • 이벤트 리스너 등록 (window.addEventListener)

이런 작업들은 모두 렌더링 결과(JSX)를 만드는 것과 별개입니다. 그렇다고 컴포넌트와 무관한 것도 아니죠 — 어떤 데이터를 가져올지, 언제 타이머를 켜고 끌지는 컴포넌트의 props/state에 따라 결정되니까요.

리액트는 이런 작업을 컴포넌트 함수 본문에 직접 쓰지 않고 useEffect 안에 넣어 처리하는 것을 권장합니다.

왜 함수 본문에 직접 쓰면 안 되나?

다음 코드를 보세요.

문제가 있는 코드
function Profile({ userId }) {
  const [user, setUser] = useState(null);
 
  // 🚫 함수 본문에서 직접 fetch
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));
 
  return user ? <p>{user.name}</p> : <p>로딩 중...</p>;
}

문제는 두 가지입니다.

  1. 렌더링 때마다 fetch가 일어남. setUser로 state가 바뀌면 다시 렌더링되고, 또 fetch가 일어나고, 또 setUser, ... 무한 루프가 됩니다.
  2. 렌더링은 빠르고 순수해야 한다는 리액트의 원칙에 어긋납니다.

useEffect는 이런 작업을 렌더링이 끝난 뒤에, 그것도 필요할 때만 실행하도록 분리해주는 장치입니다.

useEffect 기본 사용법

src/Profile.jsx
import { useState, useEffect } from 'react';
 
function Profile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);
 
  return user ? <p>{user.name}</p> : <p>로딩 중...</p>;
}
 
export default Profile;

핵심 모양:

useEffect(() => {
  // 실행할 코드
}, [의존성, 배열]);
  • 첫 번째 인자: effect 함수 (실행할 코드)
  • 두 번째 인자: 의존성 배열 (이 값들이 바뀔 때만 effect를 다시 실행)

리액트는 컴포넌트가 화면에 그려진 뒤 effect 함수를 실행합니다. 그리고 다음 렌더링 시 의존성 배열의 값들이 이전과 같으면 effect를 건너뛰고, 다르면 다시 실행합니다.

의존성 배열의 세 가지 형태

1. 빈 배열 [] — 처음 한 번만

컴포넌트 마운트 시 1회
useEffect(() => {
  console.log('컴포넌트가 처음 화면에 나타났습니다');
}, []);

배열이 비어 있으면 의존하는 값이 없으므로 effect는 처음 한 번만 실행됩니다. 초기 데이터 로딩, 한 번만 실행하면 되는 초기화 작업에 자주 쓰입니다.

2. 의존성 명시 [a, b] — 그 값이 바뀔 때마다

userId가 바뀔 때마다
useEffect(() => {
  fetch(`/api/users/${userId}`).then(/* ... */);
}, [userId]);

userId가 바뀌면 다시 fetch합니다. 동일한 값이 들어오면 다시 실행하지 않으니 효율적입니다. effect 안에서 사용한 모든 props/state는 의존성 배열에 넣어야 한다는 게 기본 규칙입니다 (안 넣으면 오래된 값을 참조하는 버그가 생기기 쉬움).

3. 배열 자체를 안 적음 — 매 렌더링마다

매 렌더링마다 (거의 안 씀)
useEffect(() => {
  console.log('렌더링됨');
});

의존성 배열을 아예 빼면 매 렌더링마다 effect가 실행됩니다. 거의 쓸 일이 없고, 보통은 의도치 않은 무한 루프의 원인이 되니 의식적으로 사용하지 않는 편이 좋습니다.

의존성 배열은 잊거나 빠뜨리기 쉬운 부분이라, 리액트 ESLint 플러그인의 react-hooks/exhaustive-deps 규칙이 빠진 의존성을 자동으로 잡아줍니다. Vite의 기본 ESLint 설정에 포함돼 있어서 코드 작성 중에 경고가 뜰 거예요. 경고를 무시하지 말고 그대로 따라가면 대부분 정확합니다.

Cleanup 함수

effect가 등록한 리소스(타이머, 이벤트 리스너, 구독 등)는 정리가 필요할 때가 많습니다. 컴포넌트가 화면에서 사라지거나, 의존성이 바뀌어 effect가 다시 실행되기 직전에 이전 effect의 정리 작업을 해야 하죠.

useEffect의 effect 함수가 함수를 반환하면, 그 함수를 리액트가 cleanup 시점에 호출해줍니다.

src/Clock.jsx
import { useState, useEffect } from 'react';
 
function Clock() {
  const [time, setTime] = useState(new Date());
 
  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);
 
    return () => clearInterval(id);  // cleanup
  }, []);
 
  return <p>{time.toLocaleTimeString()}</p>;
}
 
export default Clock;

이 컴포넌트가 사라질 때 리액트가 반환된 함수를 호출해 clearInterval로 타이머를 정리합니다. cleanup이 없으면 컴포넌트가 사라진 뒤에도 타이머가 살아 돌아다녀 메모리 누수와 알 수 없는 버그를 일으킵니다.

의존성이 바뀔 때도 cleanup이 호출됩니다

userId 변경 시 이전 fetch 무시
useEffect(() => {
  let cancelled = false;
 
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!cancelled) setUser(data);
    });
 
  return () => {
    cancelled = true;
  };
}, [userId]);
  • userId1일 때 fetch 시작
  • 응답이 오기 전에 사용자가 다른 페이지로 이동해 userId2로 바뀜
  • 리액트가 이전 effect의 cleanup(cancelled = true)을 먼저 실행
  • 새 effect가 실행되어 2에 대한 fetch 시작
  • 늦게 도착한 1번 응답은 cancelled === true라 무시됨

이런 race condition 처리가 cleanup의 또 하나의 흔한 용도입니다.

흔한 패턴들

데이터 가져오기

src/UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    setLoading(true);
    setError(null);
 
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('요청 실패');
        return res.json();
      })
      .then(data => setUser(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);
 
  if (loading) return <p>불러오는 중...</p>;
  if (error) return <p>에러: {error}</p>;
  if (!user) return null;
  return <p>{user.name}</p>;
}

loading/error/data 세 state로 비동기 요청의 모든 상태를 표현하는 패턴이 정석입니다. Promise.finally()에서 로딩을 끄면 성공이든 실패든 일관되게 처리됩니다.

노트

실무에서는 직접 useEffect + fetch를 짜는 것보다 TanStack Query 같은 데이터 페칭 라이브러리를 쓰는 경우가 많습니다. 캐싱, 재시도, 백그라운드 동기화 등을 알아서 처리해주거든요. 다만 그 라이브러리들도 결국 useEffect로 만들어진 것이라 동작 원리를 이해하는 데 도움이 됩니다.

이벤트 리스너 등록

src/WindowSize.jsx
function WindowSize() {
  const [width, setWidth] = useState(window.innerWidth);
 
  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
 
  return <p>창 너비: {width}px</p>;
}

addEventListenerremoveEventListener는 짝이 맞아야 하므로 cleanup이 필수입니다.

document.title 같은 외부 상태 동기화

문서 제목을 카운트와 동기화
function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    document.title = `카운트: ${count}`;
  }, [count]);
 
  // ...
}

document.title은 리액트가 관리하는 영역 밖이므로, 우리 state와 동기화하려면 effect가 필요합니다.

localStorage 동기화

설정 값을 localStorage에 저장
function Settings() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
  });
 
  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);
 
  // ...
}

useState의 초기값에 함수를 전달하면 처음 마운트 시에만 실행됩니다 (localStorage.getItem이 매 렌더링마다 호출되는 걸 막음). 이후 theme이 바뀔 때마다 effect가 새 값을 저장합니다.

흔한 실수

1. 의존성 빠뜨리기

버그 — userId가 누락
useEffect(() => {
  fetch(`/api/users/${userId}`).then(/* ... */);
}, []);

빈 배열로 두면 첫 렌더링의 userId로만 fetch하고, 이후 userId가 바뀌어도 다시 가져오지 않습니다. ESLint가 잡아주니 경고를 따르세요.

2. effect 안에서 무한히 setState

무한 루프
useEffect(() => {
  setCount(count + 1);  // 🚫 의존성 [count] 안에서 count를 변경
}, [count]);

state를 바꾸면 다시 렌더링되고, 의존성이 바뀌었으니 effect가 다시 실행되고, 또 state가 바뀌고 ... 끝없는 루프입니다. effect는 외부 세계를 변경하는 역할이지, 자기 자신의 state를 끊임없이 바꾸기 위한 도구가 아닙니다.

3. useEffect를 너무 많이 사용하기

리액트 공식 문서가 강조하는 점입니다 — 계산으로 끝낼 수 있는 일은 useEffect로 처리하지 마세요. 예를 들어 "이름 + 성"을 합친 값이 필요하다고 useEffect로 state를 만드는 건 과한 일입니다. 그냥 함수 본문에서 변수로 계산하면 됩니다.

잘못된 예 — 굳이 effect로
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
올바른 예 — 그냥 변수
const fullName = `${firstName} ${lastName}`;

useEffect가 정말 필요한지는 "이 작업이 외부 세계(서버, 타이머, DOM API ...)와 관련 있는가?"로 판단하면 됩니다. 그게 아니면 거의 useEffect는 필요 없습니다.

직접 해보기

간단한 시계 + 페이지 제목 동기화 컴포넌트를 만들어봅니다.

src/ClockTitle.jsx:

src/ClockTitle.jsx
import { useState, useEffect } from 'react';
 
function ClockTitle() {
  const [time, setTime] = useState(new Date());
 
  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []);
 
  useEffect(() => {
    document.title = `현재 시각: ${time.toLocaleTimeString()}`;
  }, [time]);
 
  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>{time.toLocaleTimeString()}</h2>
      <p>브라우저 탭 제목도 같이 변하는지 확인해보세요.</p>
    </div>
  );
}
 
export default ClockTitle;

src/App.jsx:

src/App.jsx
import ClockTitle from './ClockTitle';
 
function App() {
  return <ClockTitle />;
}
 
export default App;

저장하면 시계가 1초마다 갱신되고, 브라우저 탭의 제목도 같이 바뀝니다. 두 개의 useEffect가 각자 다른 일을 하면서 협력하는 모습을 볼 수 있어요. 첫 번째는 1초마다 시간을 업데이트하고, 두 번째는 시간이 바뀔 때마다 탭 제목을 동기화합니다.

마무리

이번 글에서는 컴포넌트가 외부 세계와 상호작용하는 도구인 useEffect를 배웠습니다. 핵심 정리:

  • useEffect(fn, deps)deps가 바뀔 때마다 fn 실행
  • [] = 처음 한 번, [a] = a가 바뀔 때, 생략 = 매 렌더링
  • 함수를 반환하면 cleanup으로 사용 (타이머/리스너/구독 정리)
  • effect 안에서 사용한 props/state는 의존성에 모두 포함
  • 단순 계산은 effect로 처리하지 말고 그냥 변수로

지금까지 우리가 다룬 모든 컴포넌트는 자기 자신의 state를 가지고 있었습니다. 그런데 두 개의 형제 컴포넌트가 같은 state를 공유해야 하는 상황이라면 어떻게 해야 할까요? 다음 글인 "리액트 기초 강좌 #11 상태 끌어올리기"에서는 이런 경우 사용하는 핵심 패턴인 lifting state up을 배워보겠습니다.