본문으로 건너뛰기
김신건의 로그

[React] useEffect: 동기화의 본질, dep array, cleanup, useEffectEvent

· 수정 · 📖 약 3분 · 1,351자/단어 #react #hook #effect #lifecycle #react19 #frontend
React useEffect, useEffect, useEffectEvent, useLayoutEffect, useInsertionEffect, effect cleanup, effect dependency array, Strict Mode double invoke, race condition useEffect

정의

useEffect(setup, deps?)컴포넌트의 렌더 결과외부 시스템동기화 하는 hook.

  • 외부 시스템: 브라우저 DOM, WebSocket, 구독, 타이머, 외부 라이브러리 (D3, Mapbox 등), 외부 store.
  • 동기화: “렌더가 끝났을 때 외부 상태도 이 컴포넌트 상태와 맞아야 한다” 라는 규칙 적용.

IMPORTANT

useEffect 의 공식 멘탈 모델은 “동기화”. 생명주기 이벤트 가 아니다. 왜 이 effect 가 살아 있어야 하는지외부 시스템 관점 에서 설명할 수 있어야 한다.

라이프사이클의 직관

위 애니메이션은 Spring Bean 의 라이프사이클이지만 컴포넌트 라이프사이클의 일반 직관 을 정확히 보여준다. 생성 → 의존성 주입 → init 콜백 → 사용 → destroy. React 도 mount → effect → re-render (deps 변경시) → cleanup → unmount 의 유사 흐름.

가장 단순한 형태

useEffect(() => {
  // setup: 렌더 후 동작
  const conn = createConnection(url);
  conn.connect();
  return () => {
    // cleanup: 다음 effect 전에, 그리고 unmount 시
    conn.disconnect();
  };
}, [url]);   // dep: 이 값들이 변하면 cleanup + setup 다시

4가지 패턴

flowchart LR
    Q{deps?}
    Q -->|"deps 없음"| EveryRender["매 렌더마다 실행"]
    Q -->|"[] 빈 배열"| Once["mount 시 1회 + unmount 시 cleanup"]
    Q -->|"[dep1, dep2]"| OnChange["mount + dep 변경 시"]
    Q -->|"[state, props]"| Sync["state/props 의존 동기화"]
deps의미흔한 사용
useEffect(fn)매 렌더거의 피해야 한다
useEffect(fn, [])mount 1회구독, 초기 fetch, 외부 라이브러리 init
useEffect(fn, [a, b])a, b 변경 시검색 query 변경 → 새 fetch
useEffect(fn, [state])state 동기화라우터, 외부 store

Cleanup: 왜 이렇게 동작하는가

sequenceDiagram
    autonumber
    participant Render as Render
    participant Effect as Effect setup
    participant Cleanup as Effect cleanup

    Note over Render: 첫 mount
    Render->>Effect: setup() 실행
    Note over Render: prop 변경 → re-render
    Render->>Cleanup: cleanup() (옛 effect)
    Render->>Effect: setup() (새 effect)
    Note over Render: unmount
    Render->>Cleanup: cleanup() (마지막)

IMPORTANT

Cleanup 은 setup 의 거울. setup 에서 시작한 모든 것cleanup 이 멈춰야 한다. 그래야 다음 setup깨끗한 상태 에서 시작.

Cleanup 시각화

위는 AbortController 의 동작 직관. effect 의 cleanup 에서 진행 중인 비동기 를 abort 하는 표준 패턴.

useEffect(() => {
  const ac = new AbortController();
  fetch(`/api/users/${id}`, { signal: ac.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => ac.abort();   // 새 id 로 바뀌면 옛 요청 취소
}, [id]);

Strict Mode 의 double invoke

React 18+ 의 development 모드에서 effect 가 2 번 실행 된다. cleanup 을 제대로 짰는지 강제로 검사하는 도구.

sequenceDiagram
    participant SM as StrictMode (dev)
    participant Component
    SM->>Component: mount
    Component->>Component: setup()
    SM->>Component: unmount (시뮬레이션)
    Component->>Component: cleanup()
    SM->>Component: remount
    Component->>Component: setup() (2번째)
    Note over Component: 진짜 mount

CAUTION

cleanup 이 비대칭이면 두 번째 setup 후 불일치 상태. 예: setup 에서 전역 카운터 ++, cleanup 에서 안 하면 2 가 남는다. StrictMode 의 의도가 정확히 이런 버그를 잡는 것.

”Effect 가 필요 없는” 패턴

You Might Not Need an Effect 의 핵심:

1. 다른 state 로부터 유도된 값 → useMemo (또는 그냥 계산)

// ❌ Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);

// ✅ 렌더 중 계산
const fullName = firstName + ' ' + lastName;

2. event 응답 로직 → event handler

// ❌ Effect
useEffect(() => {
  if (submitted) {
    post('/api/cart', cart);
    setSubmitted(false);
  }
}, [submitted]);

// ✅ handler
function handleSubmit() {
  post('/api/cart', cart);
}

3. 상위 prop 변경 시 state reset → key prop

// ❌ Effect
useEffect(() => {
  setComment('');
}, [userId]);

// ✅ key reset
<Profile key={userId} userId={userId} />

4. 외부 store 구독 → useSyncExternalStore

// ❌ Effect + 직접 구독
useEffect(() => {
  const unsubscribe = store.subscribe(setData);
  return unsubscribe;
}, []);

// ✅ useSyncExternalStore
const data = useSyncExternalStore(store.subscribe, store.getSnapshot);

진짜 Effect 가 필요한 경우

flowchart TD
    Q{외부 시스템과<br/>동기화?}
    Q -->|예| Yes[Effect 사용]
    Q -->|아니오| Q2{event 응답?}
    Q2 -->|예| Handler[Event Handler]
    Q2 -->|아니오| Q3{유도된 값?}
    Q3 -->|예| Memo[렌더 중 계산]
    Q3 -->|아니오| Q4{key reset?}
    Q4 -->|예| Key[key prop]
    Q4 -->|아니오| Q5{외부 구독?}
    Q5 -->|예| Sync[useSyncExternalStore]
    Q5 -->|아니오| Rare[정말 드물게 Effect]

진짜 Effect 사용처:

  • WebSocket / SSE 연결
  • 외부 라이브러리 (D3, Mapbox, Chart.js) instance 의 attach/detach
  • 브라우저 이벤트 listener (window.scroll, resize)
  • 타이머 (setInterval, setTimeout)
  • document.title 같은 외부 mutation (단, React 19 의 Document Metadata 도 고려)

useEffectEvent (React 19.2)

Effect 안에서 최신 prop/state의존성 없이 읽고 싶을 때.

문제:

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const conn = createConnection(serverUrl, roomId);
    conn.on('connected', () => {
      showNotification('Connected!', theme);   // ❌ theme 가 dep 에 없으면 stale
    });
    conn.connect();
    return () => conn.disconnect();
  }, [roomId, theme]);   // theme 도 넣으면 *theme 바뀔 때마다 재연결*
}

해결 (React 19.2+):

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    // 항상 *최신 theme* 사용. dep array 에 들어가지 *않는다*.
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const conn = createConnection(serverUrl, roomId);
    conn.on('connected', () => onConnected());
    conn.connect();
    return () => conn.disconnect();
  }, [roomId]);   // ✅ theme 는 dep 가 아니다
}

TIP

useEffectEvent남용하면 안 된다. “이건 이벤트인데 effect 안에서 발생한다” 라는 드문 케이스 만. 일반 함수를 dep 에서 빼려고 wrap 하는 건 반패턴.

Effect 의 타이밍

sequenceDiagram
    participant React
    participant DOM
    participant Browser

    React->>React: render() (Virtual DOM 계산)
    React->>DOM: commit (실제 DOM 갱신)
    DOM->>Browser: paint (화면 그림)
    React->>React: useEffect setup (paint 후, 비동기)

useEffect vs useLayoutEffect vs useInsertionEffect

Hook타이밍사용
useEffectpaint 후대부분의 동기화
useLayoutEffectDOM mutation 후, paint 전 (동기)측정 + 즉시 mutation (예: tooltip 위치)
useInsertionEffectDOM mutation 전CSS-in-JS 라이브러리 전용
gantt
    title 렌더 사이클의 effect 타이밍
    dateFormat ms
    axisFormat %S.%L

    section React
    render             :r1, 000, 5ms
    useInsertionEffect :i1, after r1, 1ms
    commit (DOM)       :c1, after i1, 3ms
    useLayoutEffect    :le, after c1, 2ms
    section Browser
    paint              :p1, after le, 8ms
    section React
    useEffect          :e1, after p1, 1ms

CAUTION

useLayoutEffect동기적으로 paint 를 막는다. 비싼 작업 (큰 DOM 측정 등) 을 넣으면 애니메이션 끊김. 반드시 필요한 경우 (FOUC 방지, 즉시 위치 조정) 만 사용.

흔한 함정

1. Race condition (오래된 응답이 새 응답 덮음)

// ❌ id 가 빠르게 변하면 옛 응답이 마지막에 도착
useEffect(() => {
  fetch(`/api/users/${id}`).then(r => r.json()).then(setUser);
}, [id]);

// ✅ cleanup 플래그 또는 AbortController
useEffect(() => {
  let alive = true;
  fetch(`/api/users/${id}`).then(r => r.json()).then(u => {
    if (alive) setUser(u);
  });
  return () => { alive = false; };
}, [id]);

2. 무한 루프 (effect 안에서 setState)

// ❌ 무한 루프
useEffect(() => {
  setUser({ ...user, lastSeen: Date.now() });
}, [user]);   // user 가 deps 에 있고 effect 가 user 변경

유도된 값이면 렌더 중 계산. 외부 시스템 동기화 라면 어떤 조건 으로 막을지 명시.

3. Stale closure

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);   // 처음 mount 시점의 count 만 영원히
  }, 1000);
  return () => clearInterval(id);
}, []);

→ deps 에 count 추가, 또는 useEffectEvent / setState 함수형 갱신 으로.

4. 의존성을 수동으로 빠뜨림

useEffect(() => {
  fetchPosts(query);
  // eslint-disable-next-line   ❌
}, []);

해결의 시작이 아니라 끝 이다. 의존성이 진짜 필요 없음 을 증명하거나, useEffectEvent 또는 useRef 로 의도 명시.

Event Loop 와의 관계

useEffect 는 paint 후 마이크로태스크 큐 에서 실행. 위 두 애니메이션은 언제 어떤 작업이 실행되는지 의 일반 직관. effect cleanup 과 setup순서왜 그렇게 정해졌는지 의 배경.

React 19.2 의 변화

기능의미
useEffectEvent 정식dep array 분리
<Activity>hidden 모드effect unmount 시뮬레이션 (배경 렌더)
ESLint 새 규칙set-state-in-effect, refseffect 안의 흔한 버그 자동 검출
React Compiler 1.0effect 안의 reference 자동 memoize → dep array stable

김신건의 현장 메모

  • Effect 의 80% 는 필요 없는 경우. 첫 코드 리뷰 라운드에서 “이거 진짜 외부 시스템과 동기화 인가?” 를 물어보면 거의 반 이상제거됨.
  • AbortControllerfetch + effect기본 패턴. 옛 axios.cancel 같은 라이브러리별 API 보다 web 표준 이 깔끔.
  • useEffectEvent알림 전송 / 로깅 같은 side effect 하나만 분리할 때 가장 빛난다. 비즈니스 로직 을 wrap 하는 용도로 남용하면 추후 디버깅이 더 어렵다.
  • React Compiler 1.0 이 잡힌 뒤 useEffect 의 의존성 누락 버그컴파일 타임에 잡힘. 런타임 버그가 빌드 에러로 옮겨감.

관련 위키

참고

이 글의 용어 (6개)
[Frontend] React Compiler 1.0: 자동 memoization 의 새 표준frameworks
정의 React Compiler 는 React 컴포넌트와 hook 의 불필요한 재계산 을 빌드 타임에 자동으로 잡아주는 optimizing compiler. 손으로 / / 를 박…
[Javascript] async/awaitjavascript
정의 / 는 기반 비동기 코드를 마치 동기 코드처럼 쓸 수 있게 해주는 ES2017 의 문법 설탕. 본질은 Promise 그 자체, 문법만 다르다. 전체 동작 메커니즘은 글 참조…
[React] Component Composition: Props, Children, Render Prop, HOC, Compoundframeworks
정의 Component Composition 은 작은 컴포넌트를 조합 해 큰 UI 를 만드는 React 의 핵심 빌드 블록. 객체지향의 상속 (inheritance) 대신 Com…
[React] Lifecycle: Mount / Update / Unmount, StrictMode, Activity, RSCframeworks
정의 React Lifecycle 은 컴포넌트의 생성 → 갱신 → 제거 의 흐름. 함수 컴포넌트에서는 클래스의 lifecycle method 가 사라지고 render + effe…
[React] useMemo / useCallback / memo: 자동 시대의 수동 도구frameworks
정의 세 가지 도구는 동일한 목표 (불필요한 재계산 / 재생성 / 재렌더 방지) 를 서로 다른 위치 에서 잡는다. - : 값 의 메모이제이션. deps 가 같으면 같은 값 반환.…
[Voice AI] Agent 패턴: Supervisor, Handoff, ReAct, HITLai
정의 복잡한 음성 에이전트 = 여러 sub-agent + 조정 패턴. 단일 LLM 으로 처리하기 어려운 흐름. [!IMPORTANT] 각 sub-agent 내부는 VAD → ST…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기