Effect가 필요 없는 상황들

useEffect를 쓰지 말아야 할 대표 패턴 — 렌더 중 계산 가능한 파생 state, props에서 state 초기화, 이벤트 핸들러로 처리할 수 있는 로직, 앱 초기화, 그리고 컴포넌트 간 상태 공유 등을 다룹니다.

· 6 min read · PALDYN Team

지난 글에서 useReducer로 복잡한 상태 로직을 관리하는 방법을 살펴봤다. 이번에는 반대 방향으로, useEffect를 쓰지 말아야 할 상황들을 다룬다. React 팀은 공식 문서에서 “You Might Not Need an Effect”라는 제목으로 이 주제를 상세히 다룰 만큼, 불필요한 Effect 사용이 매우 흔한 실수다.

useEffect의 목적 재확인

useEffectReact 외부 시스템과 동기화하기 위한 훅이다. 네트워크, DOM API, 타이머, WebSocket처럼 React가 직접 제어하지 않는 것들과 연결할 때 쓴다.

반대로, props와 state로 계산 가능한 값을 구하거나, 사용자 이벤트에 반응하는 코드는 Effect가 필요 없다.

useEffect 필요 여부 판단 흐름도

1. 렌더 중 계산 가능한 파생 state

// 잘못된 패턴 — 불필요한 리렌더 추가
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// 올바른 패턴 — 렌더 중 계산
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // 렌더마다 최신 값

Effect를 쓰면 firstName이 바뀔 때 렌더 → Effect → setFullName → 추가 렌더가 발생한다. 직접 계산하면 렌더 한 번에 끝난다.

2. 비용이 큰 계산 — useMemo

// 잘못된 패턴
const [filteredList, setFilteredList] = useState([]);
useEffect(() => {
  setFilteredList(list.filter(item => item.active));
}, [list]);

// 올바른 패턴
const filteredList = useMemo(
  () => list.filter(item => item.active),
  [list]
);

단, 대부분의 경우 useMemo 없이 렌더 중 직접 계산해도 충분히 빠르다. useMemo는 프로파일러로 실제 성능 문제를 확인한 후 추가한다.

3. props 변경 시 state 초기화

// 잘못된 패턴
function List({ items }) {
  const [selection, setSelection] = useState(null);

  useEffect(() => {
    setSelection(null); // items 바뀌면 selection 초기화
  }, [items]);

  // 문제: items 변경 → 렌더 → Effect → setSelection → 추가 렌더
}

// 올바른 패턴 1: 렌더 중 처리
function List({ items }) {
  const [selection, setSelection] = useState(null);
  const [prevItems, setPrevItems] = useState(items);

  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null); // 렌더 중 즉시 업데이트
  }

  // 한 번의 렌더로 처리됨
}

// 올바른 패턴 2: key로 완전 초기화
<List key={userId} items={items} />
// userId 바뀌면 컴포넌트 전체 재생성 → 모든 state 초기화

key를 이용한 방법이 가장 간단하고 명확하다. 컴포넌트를 아예 새로 마운트해서 모든 state를 초기 상태로 돌린다.

4. 이벤트 핸들러에서 처리할 수 있는 로직

// 잘못된 패턴 — 이벤트를 state로 우회
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (submitted) {
    sendForm(data);
    setSubmitted(false);
  }
}, [submitted]);

function handleSubmit(e) {
  e.preventDefault();
  setSubmitted(true); // 왜 Effect를 통해야 할까?
}

// 올바른 패턴 — 이벤트에서 직접 처리
function handleSubmit(e) {
  e.preventDefault();
  sendForm(data); // 직접 호출
}

이벤트 핸들러는 사용자가 언제 어떤 동작을 했는지 이미 알고 있다. Effect를 통해 우회할 이유가 없다.

5. 데이터 변환과 필터링

useEffect 대신 쓸 수 있는 패턴들

// 잘못된 패턴
const [visible, setVisible] = useState([]);
useEffect(() => {
  setVisible(todos.filter(t => !t.done));
}, [todos]);

// 올바른 패턴
const visible = todos.filter(t => !t.done);
// 렌더마다 계산 — 배열 크기가 작으면 비용 거의 없음

6. 앱 초기화는 컴포넌트 밖에서

// 잘못된 패턴 — Strict Mode에서 두 번 실행됨
useEffect(() => {
  checkAuthToken();
  loadConfig();
}, []);

// 올바른 패턴 — 모듈 레벨에서 단 한 번 실행
let initialized = false;
if (!initialized) {
  initialized = true;
  checkAuthToken();
  loadConfig();
}

앱 전체에서 단 한 번만 실행해야 하는 초기화 코드는 컴포넌트 밖에서 실행한다.

7. 부모에게 데이터 보내기

// 잘못된 패턴
function Toggle({ onChange }) {
  const [on, setOn] = useState(false);

  useEffect(() => {
    onChange(on); // Effect로 부모에게 알림
  }, [on]);
}

// 올바른 패턴
function Toggle({ onChange }) {
  const [on, setOn] = useState(false);

  function handleClick() {
    const next = !on;
    setOn(next);
    onChange(next); // 이벤트 핸들러에서 직접 호출
  }
}

이벤트가 발생한 위치(handleClick)에서 부모에게 알리는 것이 자연스럽다.

Effect가 실제로 필요한 경우

  • 브라우저 API와 동기화: document.title, addEventListener, IntersectionObserver
  • 외부 데이터 구독: WebSocket, EventSource, Redux store
  • 네트워크 요청: 컴포넌트가 나타날 때 데이터를 가져와야 할 때
  • DOM 직접 조작: 애니메이션, 포커스 관리, 서드파티 위젯 초기화

지난 글: useReducer — 복잡한 상태 로직을 컴포넌트 밖으로

다음 글: Effect 경쟁 조건 — 오래된 응답 처리


읽어주셔서 감사합니다. 😊