본문으로 건너뛰기

React 19에서 useEffect 안의 setState가 위험한 이유

·15 min read

"Calling setState synchronously within an effect can trigger cascading renders" 경고를 만났을 때, 단순히 effect를 지우는 게 아니라 왜 React가 그렇게 말하는지 이해하고, 어떤 패턴으로 바꿔야 하는지 정리한 글.

다이얼로그 안에서 부모 prop을 따라 내부 state를 동기화하는 코드를 짜다가 React 19가 띄운 경고 한 줄에 멈췄다. 그동안 별 생각 없이 써오던 useEffect + setState 조합이 안티패턴으로 분류된 이유가 궁금해서 렌더링 흐름과 React 내부 동작까지 거슬러 올라가 정리했다. 결론부터 말하면, "effect는 외부 시스템 동기화용 도구"라는 본래 목적을 흐리게 쓰고 있던 셈이었고, React 19가 권장하는 "렌더 중 setState" 패턴이 왜 더 깔끔한지 비로소 납득하게 됐다.


0. TL;DR

  • React 19는 useEffect 안에서 동기적으로 setState를 호출하는 패턴을 안티패턴으로 명시했다.
  • 이유: 렌더 → 커밋 → DOM 페인트 → effect 실행 → 다시 렌더 → 다시 커밋 → 다시 페인트 → 사용자에게 깜빡임이 보일 수 있음.
  • 해결: "Adjusting state when a prop changes" 패턴 (렌더 단계에서 in-render setState).
  • React가 "지금은 렌더 중"이라는 걸 알기 때문에, 같은 렌더 사이클 안에서 함수를 한 번 더 실행해 보정한다 (커밋 없이).

1. 문제 코드

다이얼로그가 열릴 때 부모의 activeTab을 따라 내부 adType state를 동기화하는 흔한 코드:

const [adType, setAdType] = useState<AdParticipationTabKey>(activeTab)
 
useEffect(() => {
  if (open) {
    setAdType(activeTab)
  }
}, [open, activeTab])

겉보기엔 평범하지만 React 19는 다음 경고를 띄운다:

Calling setState synchronously within an effect can trigger cascading renders.

Effects are intended to synchronize state between React and external systems...


2. 왜 이게 안티패턴인가 — 렌더링 흐름으로 보기

2-1. effect 패턴의 실제 동작 시퀀스

T0 [Idle]      부모가 open=true 전달, render 스케줄
T1 [Render]    Dialog() 함수 실행, adType='cps' (이전 값)
T2 [Commit]    DOM에 CPS 검색 필드 그림  ← 사용자 눈에 보임
T3 [Effects]   useEffect 실행 → setAdType('cpa')
T4 [Idle]      새 렌더 스케줄
T5 [Render]    Dialog() 실행, adType='cpa'
T6 [Commit]    DOM을 CPA 검색 필드로 교체  ← 깜빡임
T7 [Effects]   (없음)

2번 렌더 + 2번 커밋 + 2번 페인트.

2-2. 무엇이 잘못되었나

  • effect는 커밋이 끝난 뒤 실행되므로, 보정 전 상태가 잠깐 화면에 보인다.
  • 1프레임이라도 잘못된 UI가 노출되면 사용자는 깜빡임/번쩍임을 인지한다.
  • 모달이 열릴 때 이전 탭의 검색 필드가 잠깐 보였다가 새 탭으로 바뀌는 식의 미묘한 버그가 생긴다.

2-3. effect는 무엇을 위한 도구인가

React 공식 문서가 말하는 effect의 본래 용도는 둘 중 하나다.

  1. 외부 시스템에 React state를 반영 (DOM 직접 조작, third-party 라이브러리 제어)
  2. 외부 시스템 변화 구독 (이벤트 리스너, 타이머, 네트워크 스트림)

→ **"같은 컴포넌트 내부 state를 prop 변화에 맞춰 보정"**은 외부 시스템 동기화가 아니라 **"렌더 일관성 유지"**다. 이건 effect의 일이 아니다.


3. 해결 — "Adjusting state when a prop changes" 패턴

React 공식 가이드: react.dev — Adjusting some state when a prop changes

3-1. 코드

const [adType, setAdType] = useState<AdParticipationTabKey>(activeTab)
const [prevOpen, setPrevOpen] = useState(open)
 
if (open !== prevOpen) {
  setPrevOpen(open)
  if (open) {
    setAdType(activeTab)
  }
}

3-2. 읽는 법

  • "open 값을 따로 기억해두고,"
  • "렌더 도중 부모의 open prop이 그 값과 달라졌으면,"
  • "추적값을 갱신하고,"
  • "닫혔다가 열린 순간에만 광고 유형을 부모 탭으로 동기화한다"

effect 없이도 prop 변화에 반응하는 state 보정이 가능하다.


4. 핵심 원리 — React는 어떻게 "렌더 중"인지 아는가

처음 보면 의문이 생긴다.

렌더 중에 setState를 호출하면 무한 루프 아닌가?

React는 어떻게 "이건 같은 렌더 사이클의 보정"이라고 판단하는가?

4-1. React 내부 상태 머신

React는 항상 다음 중 하나의 페이즈에 있다.

[1] Idle              아무것도 안 함
[2] Rendering         어떤 컴포넌트의 함수를 실행 중
[3] Committing        렌더 결과를 DOM에 반영 중
[4] Effects           useEffect 콜백 실행 중

각 페이즈에서 setState가 호출되면 다르게 처리된다.

4-2. setState 호출 시점별 분기

호출 시점React의 판단결과
Idle (이벤트 핸들러)새 업데이트 → 다음 렌더 스케줄비동기 렌더 1번
Rendering (함수 본문 안)자기 자신의 보정 → 즉시 처리같은 렌더 함수 재실행 (커밋 없음)
Effects (useEffect 안)새 업데이트 → 다음 렌더 스케줄추가 렌더 1번 + 추가 커밋

4-3. React 내부 구현 (단순화)

let currentlyRenderingFiber = null;
 
function renderComponent(component) {
  let attempts = 0;
 
  while (true) {
    didScheduleRenderPhaseUpdate = false;
 
    currentlyRenderingFiber = component;  // ① 이 컴포넌트 렌더 시작
    const result = component();           // ② 함수 본문 실행
    currentlyRenderingFiber = null;       // ③ 렌더 종료
 
    if (!didScheduleRenderPhaseUpdate) {
      return result;                      // ④ 결과 확정
    }
 
    // 렌더 도중 setState 호출됐음 → 새 state로 다시 렌더 (커밋 안 함)
    attempts++;
    if (attempts > 25) throw new Error('Too many re-renders');
  }
}
 
function setState(newValue) {
  if (currentlyRenderingFiber === thisComponent) {
    // 자기 자신을 렌더 중에 보정 → 특별 처리
    didScheduleRenderPhaseUpdate = true;
    pendingUpdate = newValue;
  } else {
    scheduleUpdate(thisComponent, newValue);
  }
}

핵심:

  • React는 currentlyRenderingFiber라는 내부 포인터로 **"지금 어떤 컴포넌트를 렌더 중인지"**를 추적한다.
  • setState가 호출된 순간, React는 이 포인터를 보고 즉시 판단한다.
  • 같은 컴포넌트 → "내 자신 보정" → 함수만 다시 실행 (커밋 없음, DOM 조작 없음, effect 없음, 자식 렌더 없음)
  • if (open !== prevOpen) 조건이 false가 되면 루프 탈출 → 무한 루프 방지

4-4. 수정 후 시퀀스

T0 [Idle]      open=true 전달, render 스케줄
T1 [Render]    Dialog() 실행, currentlyRenderingFiber=Dialog
                ├ if (open !== prevOpen) 진입
                ├ setPrevOpen(true)  ← React: "내 자신 보정?" → 플래그 ON
                └ setAdType('cpa')   ← React: "내 자신 보정?" → 플래그 ON
T2 [Render]    함수 본문 끝났지만 플래그 ON → 다시 실행
                ├ Dialog() 재실행, prevOpen=true, adType='cpa'
                └ if (open !== prevOpen)이 false → 통과
T3 [Render]    함수 본문 끝, 플래그 OFF → 결과 확정
T4 [Commit]    DOM에 CPA 검색 필드 그림 (단 1번)

1번 커밋 + 1번 페인트. 깜빡임 없음.

T2의 재실행은 렌더 단계 안에서만 일어난다. DOM도 안 건드리고, React Devtools에도 새 커밋으로 안 잡힌다.


5. 다른 대안들과의 비교

이 문제를 푸는 다른 방법도 있다.

대안 A: key prop으로 강제 remount

// 부모에서
<Dialog key={activeTab} ... />
  • 가장 React스러운 방법
  • 부모 코드 수정 필요
  • activeTab 바뀔 때마다 dialog 자체가 사라졌다 다시 생김 → 애니메이션/포커스 깨짐

대안 B: 조건부 렌더링

{open && <Dialog ... />}
  • 매번 fresh state로 시작
  • 부모 수정 필요
  • Chakra/Radix 같은 라이브러리의 close 애니메이션이 잘림

대안 C: 렌더 중 보정 (이 글의 패턴)

const [prevOpen, setPrevOpen] = useState(open)
if (open !== prevOpen) {
  setPrevOpen(open)
  if (open) setAdType(activeTab)
}
  • 부모 수정 불필요
  • 컴포넌트 자체는 계속 마운트 → 애니메이션/포커스 정상
  • React 19 권장 패턴
  • 1번 커밋

다이얼로그처럼 닫힘 애니메이션이 중요하고 부모가 여러 곳에서 사용하는 컴포넌트에서는 대안 C가 가장 안전하다.


6. 일반화 — 언제 이 패턴을 쓰는가

다음 조건이 모두 맞으면 이 패턴이 적합하다.

  1. 부모 prop 변화에 따라 자식 내부 state를 갱신해야 함
  2. 매번이 아니라 특정 전환(trigger) 시점에만 갱신
  3. 컴포넌트 자체는 unmount/remount 시키고 싶지 않음

반대로:

상황적절한 도구
부모 prop을 그대로 표시만 하면 됨state 없이 prop 직접 사용
prop 바뀔 때마다 state 전체 리셋key prop
외부 시스템(API, DOM, 타이머) 동기화useEffect
부모 prop 전환 시점에만 내부 state 보정렌더 중 보정 (이 글)

7. 같이 알아두면 좋은 것

7-1. 렌더 중 setState는 같은 컴포넌트만 가능

function Parent() {
  const [, setX] = useState(0)
  return <Child onSomething={() => setX(1)} />  // ← Child 렌더 중에 setX 호출됨
}

이런 식으로 다른 컴포넌트의 setState를 렌더 중에 호출하면 React는 즉시 에러를 던진다 (Cannot update a component while rendering a different component). 같은 컴포넌트 자기 보정만 허용된다.

7-2. useMemo로 대체 가능한 경우는 useMemo가 우선

// 나쁨: state + effect로 derive
const [filtered, setFiltered] = useState([])
useEffect(() => {
  setFiltered(items.filter(...))
}, [items])
 
// 좋음: 그냥 렌더 중에 계산
const filtered = items.filter(...)
 
// 비싼 계산이면 useMemo
const filtered = useMemo(() => items.filter(...), [items])

prop으로 derive 가능한 값은 state로 만들지 말 것. 이 글의 패턴은 state로 가지고 있어야 하지만 prop 전환 시점에 보정이 필요한 경우에만 쓴다.

7-3. 같은 컴포넌트라도 무한 루프 조심

// 무한 루프
const [count, setCount] = useState(0)
setCount(count + 1)  // 매 렌더마다 호출됨
 
// 조건부 호출
if (someCondition) {
  setCount(count + 1)
}

React가 25번 재실행을 시도해도 안정화되지 않으면 Too many re-renders 에러를 던진다. 조건이 언젠가 false가 되도록 보장해야 한다.


8. 정리

이번에 흐름을 따라가면서 정리하고 보니, 그동안 "effect 안에 setState를 넣는다"는 게 단순히 스타일 문제가 아니라 렌더 사이클을 한 번 더 도는 비용을 지불하는 일이었다는 걸 비로소 체감했다.

  • useEffect 안에서 동기 setState를 호출하면 2번 커밋 + 깜빡임.
  • React 19는 이를 안티패턴으로 명시하고 경고한다.
  • 해결책은 렌더 중 setState: React는 "지금 렌더 중인 컴포넌트가 자기 자신에 setState를 호출한다"는 걸 내부 포인터(currentlyRenderingFiber)로 감지해서, 커밋 없이 같은 렌더 사이클에서 함수만 다시 실행한다.
  • 결과: 1번 커밋 + 깜빡임 없음 + effect 의존성 배열 관리 부담 제거.
  • 다이얼로그처럼 마운트 유지가 중요한 컴포넌트에서는 key 리셋이나 조건부 렌더링보다 이 패턴이 적합하다.

앞으로 effect를 쓰기 전에 한 번씩 자문해야겠다는 결론에 이르렀다 — "이게 정말 외부 시스템 동기화인가, 아니면 렌더 일관성 유지인가?" 후자라면 effect는 답이 아니다.


참고 자료