에이전트 관련 프로젝트를 제작하다보니 리액트에서 오는 간단한 오류를 발견 할 수 있었다 .
done) 순간 화면에서 통째로 사라지는 버그가 있었다.setState(updater)는 함수를 실행하는 게 아니라 예약하는 것이고, updater의 클로저는 값이 아니라 변수 자체를 캡처한다.streamContents = []라는 청소 코드가 끼어들면서, updater가 실행되는 시점에는 이미 비워진 버퍼를 복제해 커밋하고 있었다.채팅은 SSE로 토큰을 받아 실시간으로 답변을 조립한다. 토큰이 초당 수십 개씩 쏟아지는데 토큰마다 setState를 하면 렌더가 폭발하므로, 흔한 이중 구조를 쓴다.
const create = ({ updateMessages }) => {
// ① React 밖의 조립 버퍼 — 토큰마다 push로 직접 변형
let streamContents: StreamContent[] = [];
// ② 화면 반영은 requestAnimationFrame으로 묶어서, 버퍼의 "복사본"을 setState
const scheduleUIUpdate = () => { ... };
};
cloneStreamContents()로 만든 복사본을 RAF 주기로 반영.이 구조 자체는 정당하다. 문제는 ①→② 복사가 일어나는 "시점"을 잘못 짚으면서 시작된다.
data:{"event":"done"} 이벤트가 도착하는 순간, 방금까지 보이던 답변이 화면에서 통째로 사라진다. 사용자 질문 말풍선만 남는다.본론 전에 개념 두 개만 짚자. 먼저 클로저.
let count = 0;
const fn = () => console.log(count);
count = 99;
fn(); // 0이 아니라 99
fn을 만든 시점의 count는 0이었지만, 출력은 99다. 클로저는 함수를 만든 순간의 값을 박제하는 게 아니라, 그 변수 바인딩 자체를 참조한다. 함수가 실행되는 순간에 변수가 가리키는 것을 읽는다.
"함수를 만든 시점"과 "함수가 실행되는 시점" 사이에 변수가 바뀔 수 있다면, 이 둘의 차이가 곧 버그의 틈이 된다.
setState(updater)는 실행이 아니라 예약이다setMessages((prev) => [...prev, newItem]);
console.log('A');
함수형 업데이트를 넘기면 React가 그 자리에서 실행해줄 것 같지만, 아니다. 이 호출이 하는 일은 updater를 업데이트 큐에 넣고 리렌더를 스케줄링하는 것뿐이다. 위 코드에서 'A'가 updater 본문보다 먼저 실행된다.
dispatch 시점: queue.push(updater); scheduleRender(); ← 여기서 끝
... microtask flush, 스케줄러 순번 대기 ...
render phase: newState = queue.reduce((s, u) => u(s), s) ← 실제 실행은 여기
updater가 prev만 입력으로 받는 순수 함수라면 언제 실행되든 결과가 같아서 이 지연이 문제가 안 된다. 그래서 React 공식 문서가 "updater는 순수해야 한다"고 못박는 것이다. 문제는 updater가 클로저로 바깥의 가변 변수를 읽을 때 — 기초 1과 기초 2가 만나는 지점에서 터진다.
스트림 종료 시 호출되는 complete()의 버그 코드다.
const complete = () => {
...
updateMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = {
...lastMessage,
streamContents: cloneStreamContents(), // ← updater "안"에서 버퍼를 복제
isStreaming: false,
};
return copy;
});
streamContents = []; // ← 다음 턴을 위한 청소
};
작성 의도는 명확하다. "현재 버퍼를 복사해 마지막 메시지에 커밋하고, 버퍼를 청소한다." 코드를 위에서 아래로 읽으면 그렇게 보인다. 하지만 실제 실행 순서는 다르다.
| 시각 | 실행 주체 | 일어나는 일 | streamContents |
|---|---|---|---|
| t0 | complete() | updateMessages(updater) — 큐에 예약만 | [think…, answer…] ✅ |
| t1 | complete() | streamContents = [] — 다음 줄 즉시 실행 | [] |
| t2 | React render phase | updater 실행 → cloneStreamContents() 호출 | 빈 배열을 복제 ❌ |
updater의 클로저는 streamContents라는 변수를 붙잡고 있고(기초 1), 그 실행은 dispatch보다 늦다(기초 2). 그 사이에 낀 streamContents = []가 순서를 뒤집는다. 소스 코드의 줄 순서는 "복사 → 청소"지만, 실행 순서는 "예약 → 청소 → 복사"다.
결과: 마지막 assistant 메시지가 { streamContents: [], isStreaming: false }로 덮어써진다. 렌더러는 콘텐츠가 빈 assistant 메시지를 return null 처리하므로, done 순간 답변·think 블록·피드백 버튼이 한꺼번에 언마운트된다. 스트리밍 중의 RAF 갱신도 같은 lazy 패턴이지만 그 시점엔 아무도 버퍼를 비우지 않으니 멀쩡했다 — 버퍼를 비우는 코드는 complete()에만 있어서, 정확히 done 순간에만 터진 것이다.
이 버그는 100% 재현이 아니라 타이밍성으로 보였다. 여기에도 React 내부 사정이 있다.
React의 dispatchSetState에는 최적화가 하나 있다: 해당 컴포넌트에 대기 중인 업데이트가 하나도 없으면, updater를 dispatch 시점에 즉시(eager) 실행해 새 값을 미리 계산한다(이전 값과 같으면 리렌더 자체를 생략하는 bail-out용). 즉 두 갈래가 존재한다.
스트리밍 중에는 RAF가 매 프레임 setState를 날리고 있으므로, done이 도착한 순간 직전 RAF의 업데이트가 아직 커밋 전이면 lazy 경로(버그), 이미 커밋됐으면 eager 경로(정상)를 탄다. 마지막 토큰과 done 사이에 프레임 경계가 어디에 걸리느냐로 갈리는 레이스였고, 토큰과 done이 촘촘하게 붙어 오는 빠른 답변에서 잘 터진 이유다.
이 최적화가 시사하는 바가 곧 React의 계약이다. React는 updater를 언제든(dispatch 시점이든 render phase든), 몇 번이든(concurrent 렌더 중단·재시작, StrictMode의 의도적 이중 호출) 실행할 수 있다고 가정한다. 그래서 updater는 순수해야 한다. 외부 가변 상태를 읽는 updater는 "실행 시점에 따라 결과가 달라지는" 비순수 함수이고, 그 비순수성이 정확히 실행 경로 차이로 드러난 사례다.
// 수정 후
const finalContents = cloneStreamContents(); // dispatch "전"에 값을 확정
updateMessages((prev) => {
...
copy[copy.length - 1] = {
...lastMessage,
streamContents: finalContents, // 클로저가 붙잡는 게 이제 불변 스냅샷
isStreaming: false,
};
return copy;
});
streamContents = []; // 이제 언제 실행돼도 무관
바꾼 것은 클로저가 캡처하는 대상뿐이다 — "가변 변수"에서 "확정된 불변 값"으로. 이제 updater는 언제, 몇 번 실행돼도 같은 결과를 내는 순수 함수라서 eager/lazy 어느 경로를 타든 동일하고, 프레임 타이밍 레이스가 사라진다. RAF 갱신 경로도 같은 패턴이라 함께 수정했다.
setState(updater)는 예약이다. updater 본문은 dispatch 시점이 아니라 render phase에(또는 eager 최적화로 즉시) 실행된다. 소스 코드의 줄 순서가 실행 순서를 보장하지 않는다.prev 외의 입력이 필요하면, dispatch 전에 값을 확정(스냅샷)해서 클로저에 불변 값으로 넘겨라. StrictMode의 이중 호출은 이런 비순수성을 개발 단계에서 잡기 위한 장치다.