React 19에서 useEffect 안의 setState가 위험한 이유
"Calling setState synchronously within an effect can trigger cascading renders" — 다이얼로그 안에서 부모 prop을 따라 내부 state를 동기화하다가 React 19가 띄운 경고다.
그동안 별 생각 없이 써오던 useEffect + setState 조합이 안티패턴으로 분류된 이유가 궁금해서 렌더링 흐름과 React 내부 동작까지 거슬러 올라갔다. "effect는 외부 시스템 동기화용 도구"라는 본래 목적을 벗어나 쓰고 있었고, React 19가 권장하는 "렌더 중 setState" 패턴이 왜 더 깔끔한지 그제야 납득했다.
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의 본래 용도는 둘 중 하나다.
- 외부 시스템에 React state를 반영 (DOM 직접 조작, third-party 라이브러리 제어)
- 외부 시스템 변화 구독 (이벤트 리스너, 타이머, 네트워크 스트림)
→ **"같은 컴포넌트 내부 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. 일반화 — 언제 이 패턴을 쓰는가
다음 조건이 모두 맞으면 이 패턴이 적합하다.
- 부모 prop 변화에 따라 자식 내부 state를 갱신해야 함
- 매번이 아니라 특정 전환(trigger) 시점에만 갱신
- 컴포넌트 자체는 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는 답이 아니다.