React 19 tearing — 두 스토어를 같은 틱에 건드리면 생기는 일
이 글은 NGRAS 프로젝트에서 작성되었습니다.
목차
이 글의 코드는 실제 프로덕션 코드를 그대로 옮긴 게 아니다. NGRAS는 보안망 내부에서 개발한 사내 프로젝트라 실제 소스를 그대로 공개할 수 없어서, 사용한 기술과 구현 방식에 대한 기억을 바탕으로 AI가 다시 작성한 예시 코드로 대체했다. 구조와 흐름은 실제와 같지만, 변수명·엔드포인트·세부 구현은 그대로 옮긴 것이 아니다.
한 줄 요약
Zustand 스토어 두 개를 같은 동기 블록에서 연달아 업데이트했더니 화면이 Maximum update depth exceeded로 멈췄다. 원인은 React 19의 useSyncExternalStore가 여러 스토어를 동시에 구독하는 컴포넌트에서 스냅샷 불일치를 감지하고 계속 재렌더로 복구를 시도한 것으로 보이고, 두 번째 스토어 업데이트를 첫 await 이후로 옮기는 것과 한 컴포넌트가 여러 스토어를 동시에 구독하지 않게 하는 것, 두 가지 코드 변경 이후 증상이 재현되지 않았다.
왜 (배경/문제 상황)
NGRAS는 Zustand 스토어 11개를 기능별로 나눠 쓴다. AI 에이전트 채팅 패널(aiAgent 스토어)에서 작업을 새로 시작하면, 이전 작업의 경고를 지우는 testQueue 스토어 업데이트와 현재 작업 ID(currentTaskId)를 갱신하는 aiAgent 스토어 업데이트가 한 함수 안에서 연달아 일어났다. 코드만 보면 평범한 순차 실행인데, 화면에서는 Maximum update depth exceeded 에러와 함께 렌더가 멈추는 증상이 나타났다. 두 스토어를 모두 구독하는 컴포넌트가 있을 때만 재현됐고, 그 컴포넌트 하나만 떼어놓고 보면 이상한 점이 없었다 — 원인이 “무엇을 구독하는가”와 “언제 업데이트하는가”의 조합에 있었기 때문이다.
본문
1. 실제로 확인된 지점 세 곳
같은 계열의 증상이 코드베이스 세 곳에서 발견됐다. 원인은 조금씩 다르지만 전부 “여러 Zustand 스토어를 한 컴포넌트가 같은 렌더 사이클에서 동시에 건드린다”는 조건으로 수렴한다.
① aiAgent 스토어 — 두 스토어를 같은 동기 블록에서 업데이트
// aiAgent/store.ts
// await 이후에 testQueue 스토어 업데이트: aiAgent 스토어의 isStreaming 업데이트와
// 동일 동기 블록에서 두 스토어가 동시 업데이트되면 React 19 useSyncExternalStore
// 동시 렌더링 충돌이 발생하므로 첫 번째 await 이후로 이동한다.
useTestQueueStore.getState().clearPresetWarnings(testId)
set({ currentTaskId: task_id })
② TestQueuePage — 한 컴포넌트가 스토어 두 개를 동시에 구독
두 스토어를 동시에 구독하던 컴포넌트를, 자식 컴포넌트(TestContent)에서 isStreaming 하나만 구독한 뒤 prop으로 내려주는 방식으로 바꿔 다중 구독 자체를 없앴다.
③ ChatPanel — 객체를 반환하는 셀렉터
// ❌ 셀렉터가 매 렌더마다 새 객체를 반환 → 참조가 계속 바뀜
const { isStreaming, currentTaskId } = useAiAgentStore((s) => ({
isStreaming: s.isStreaming,
currentTaskId: s.currentTaskId,
}))
// ✅ 프리미티브 값 두 개로 분리 — 각각 원시 타입이라 실제로 값이 같으면 참조 비교도 통과
const isStreaming = useAiAgentStore((s) => s.isStreaming)
const currentTaskId = useAiAgentStore((s) => s.currentTaskId)
③은 겉으로는 “무한 루프”라는 같은 증상이지만 원인은 다르다 — 객체 리터럴 셀렉터가 매번 새 참조를 반환해서 useSyncExternalStore가 “값이 바뀌었다”고 오판하는 문제다. ①·②와 묶어서 다루는 이유는, 셋 다 “React 19에서 여러 외부 스토어를 한 컴포넌트가 동시에 구독할 때 스냅샷 일관성이 깨진다”는 같은 상위 원인 계열에 속하기 때문이다.
2. 왜 하필 두 스토어를 “같이” 건드릴 때만 터지는가
React 18부터 도입된 useSyncExternalStore는 Zustand 같은 외부 스토어를 구독할 때 쓰는 훅이다. 이 훅은 렌더 중간에 구독 중인 스토어의 스냅샷이 바뀌면 — 특히 여러 외부 스토어를 동시에 구독하는 컴포넌트에서 두 스냅샷 사이에 불일치가 생기면 — 일관성을 맞추기 위해 재렌더를 시도한다. 문제는 그 재렌더 시도 자체가 다시 같은 조건을 만들어내면서, “재렌더 → 불일치 감지 → 재렌더”가 반복되는 루프에 빠질 수 있다는 점이다. useTestQueueStore와 useAiAgentStore를 같은 동기 블록에서 연달아 업데이트하면 이 컴포넌트가 두 스토어를 동시에 구독하는 순간마다 이 조건이 재현됐다.
이 글에서 “tearing”이라는 표현을 쓰는 방식에 대한 안내. NGRAS 코드베이스는 이 증상을 자체적으로 “tearing”으로 진단하고 문서화했다. 다만 React가 정의하는 엄밀한 의미의 tearing(concurrent 렌더링 중 스토어 값의 일관성이 깨지는 현상)과, 여기서 실제로 관찰된
Maximum update depth exceeded무한 재렌더가 정확히 같은 현상인지는 외부적으로 재검증되지 않았다. 이 글은 “이 코드베이스가 이렇게 진단하고 이렇게 고쳤다”까지만 다루고, React 내부 동작을 단정적으로 설명하지는 않는다.
3. 해결 패턴 두 가지
패턴 A — 두 번째 스토어 업데이트를 첫 await 이후로 옮기기. 두 스토어 업데이트를 완전히 동기적인 블록(같은 함수 호출 안에서 await 없이 연달아 실행)에 두지 않고, 그 앞에 실제 비동기 작업(API 호출 등)을 하나 두어 그 이후로 두 업데이트를 옮긴다. NGRAS 코드베이스는 이 변경 이후 증상이 재현되지 않았다고 문서화했다. 다만 await 하나를 거쳤다고 해서 그 뒤에 연달아 실행되는 두 줄이 서로 다른 렌더 사이클로 자동으로 분리되는 건 아니다 — 같은 await 뒤에서 동기적으로 실행되는 코드는 여전히 같은 마이크로태스크 안에서 처리되고, React의 자동 배칭 대상이 될 수 있다. 즉 이 패턴이 실제로 왜 증상을 없앴는지의 정확한 메커니즘은 이 글에서 독립적으로 검증하지 않았고, “이렇게 바꿨더니 재현되지 않았다”는 NGRAS 코드베이스의 경험적 관찰로만 받아들이는 게 안전하다.
패턴 B — 단일 구독점. 한 컴포넌트가 여러 스토어를 동시에 구독하지 않도록, 구독을 가장 필요한 지점 하나로 모으고 나머지는 prop으로 내려준다. 애초에 “여러 스토어를 동시에 구독하는 컴포넌트”가 없으면 스냅샷 불일치가 발생할 자리 자체가 없어진다.
두 패턴 모두 “스토어 자체를 하나로 합친다” 같은 구조적인 해법 대신, 기능별로 스토어를 나눠 쓰는 기존 설계는 유지한 채로 “언제 업데이트하는가”와 “누가 동시에 구독하는가”만 조정한 것이다. 스토어를 다시 합치는 리팩터링은 영향 범위가 컸고, 이 두 패턴만으로 재현된 증상을 전부 잡을 수 있었기 때문이다.
4. 직접 눌러보기 — Before / After 시뮬레이터
아래 데모는 위 ①번 사례(aiAgent 스토어가 testQueue 스토어를 같은 동기 블록에서 건드리는 상황)를 그대로 재현한 것이다. Before를 눌러 실행하면 재렌더 카운터가 폭주하다 멈추고, After로 바꿔서 실행하면 같은 두 스토어 업데이트가 각각 한 번의 렌더로 끝난다.
이 데모는 실제 React/Zustand를 실행하지 않는다. DOM 요소와 타이머,
Math.random()으로 “폭주하는 느낌”을 흉내 낸 개념적 시뮬레이터다.Maximum update depth exceeded에러나 리렌더 횟수는 실제로 발생하는 게 아니라 스토리를 재생하기 위해 연출된 값이다.
📦 useTestQueueStore
📦 useAiAgentStore
⬡ <ChatPanel/> — 두 스토어를 모두 구독
예제
두 패턴을 한 파일에 적용하면 이렇다.
// 패턴 A: await 경계로 분리
async function requestDraft(testId: string) {
const res = await requestDraftApi(testId) // 비동기 경계 — 스케줄러에 양보
useTestQueueStore.getState().clearPresetWarnings(testId)
set({ currentTaskId: res.taskId })
}
// 패턴 B: 단일 구독점 — 부모는 스토어를 구독하지 않고 자식 하나만 구독
function ChatPanel() {
return <TestContent />
}
function TestContent() {
const currentTaskId = useAiAgentStore((s) => s.currentTaskId) // 이 컴포넌트만 구독
const isStreaming = useAiAgentStore((s) => s.isStreaming) // 이 컴포넌트만 구독
// ...
}
주의사항
- 이 글에서 다루는 “4개 버그 수정”이라는 수치는 코드에서 세 곳까지만 직접 확인됐다. 정확한 수정 시점·건수는 별도로 검증하지 않았다.
- “tearing”이라는 용어는 이 코드베이스가 자체적으로 진단하며 쓴 표현이다. React가 정의하는 엄밀한 의미와 정확히 같은 현상인지는 재검증하지 않았으니, “이렇게 진단하고 이렇게 고쳤다”는 사실 이상으로 일반화하지 않는 게 안전하다.
- 같은 문제를 “스토어를 하나로 합친다”로 풀 수도 있지만, 기능별로 스토어를 나눠 쓰는 설계 자체를 바꾸는 건 영향 범위가 훨씬 크다. 업데이트 시점과 구독 지점만 조정하는 게 더 국소적인 해결이었다.
- 같은 증상(무한 재렌더)이라도 원인은 다를 수 있다 — ①·②는 “같은 틱에 여러 스토어 업데이트”, ③은 “셀렉터가 매번 새 객체를 반환”이다. 스택 트레이스만 보고 같은 처방을 쓰기 전에 실제 원인부터 구분해야 한다.