setTimeout(0)으로 React와 Canvas 타이밍 맞추기
setState 직후에 toDataURL()을 호출하면 안 되는 이유
개요
Vue로 만든 v1에서는 네이티브 Canvas API로 직접 그리고, canvas.toDataURL()로 캔버스를 캡처해서 서버에 저장하는 방식이었다. toDataURL()은 캔버스에 현재 그려진 픽셀을 그 순간 PNG로 변환해서 data:image/png;base64,... 형태의 문자열로 돌려주는 메서드다. 서버에서 이미지를 가져오는 게 아니라 캔버스의 스크린샷을 찍는 것이다. React로 새로 만드는 v2에서는 스트로크 데이터를 JSON으로 저장하는 방식으로 바꾸려 했는데, 기존에 PNG로 저장된 드로잉도 보여줘야 했기 때문에 두 포맷을 모두 지원해야 했다.
v2에서는 react-konva를 사용했다. Konva는 Canvas를 다루는 바닐라 JS 라이브러리다. React 없이 Konva를 쓰면 명령적으로 하나하나 지시해야 한다.
// Konva만 사용 -> 명령적
const line = new Konva.Line({
points: [0, 0, 100, 100],
stroke: "red",
});
layer.add(line);
layer.draw();
react-konva는 이걸 React 컴포넌트로 감싸서, 선언적으로 쓸 수 있게 해준다.
// react-konva 사용 -> 선언적
<Line points={[0, 0, 100, 100]} stroke="red" />
react-konva가 내부적으로 new Konva.Line() 생성, props 변경 시 line.points() 호출, 컴포넌트 제거 시 line.destroy() 같은 명령적 작업을 대신 처리한다. 개발자는 원하는 결과만 선언하면 된다.
다만 stageRef.current.toDataURL()처럼 ref로 Konva에 직접 접근하면, react-konva의 선언적 흐름을 거치지 않고 Canvas를 즉시 읽는 명령적 호출이 된다. 그리기/Undo/Redo가 일어날 때마다 현재 드로잉 데이터를 상위 컴포넌트에 전달하는 구조였고, v1 하위 호환을 위해 stage.toDataURL()로 PNG 캡처하는 경로도 남아 있었다.
여기서 이상한 버그를 만났다. Undo를 누르면 캔버스에서는 스트로크가 사라지는데, 캡처된 이미지에는 여전히 남아 있었다. 한참을 헤매다가 setTimeout(0) 한 줄로 해결했는데, 왜 이게 되는지 이해하기까지가 더 오래 걸렸다.
선언적 vs 명령적
이 버그의 근본 원인은 React와 Canvas가 서로 다른 모델로 동작한다는 데 있다.
선언적(Declarative)이란 "무엇을 원하는지"만 말하고 과정은 맡기는 것이다. React가 이 방식이다. setStrokes([A, B])를 호출하면 "스트로크가 A, B인 상태를 원해"라고 선언할 뿐, 실제로 DOM에서 어떤 노드를 지우고 어떤 노드를 추가할지는 React가 결정한다. 호출 시점에는 아무 일도 일어나지 않는다.
명령적(Imperative)이란 "어떻게 할지"를 하나하나 지시하는 것이다. Canvas가 이 방식이다. ctx.clearRect()로 지우고, ctx.beginPath()로 선을 시작하고, ctx.stroke()로 그린다. stage.toDataURL()을 호출하면 그 순간 캔버스에 그려진 픽셀을 바로 돌려준다. 호출한 시점의 상태가 곧 결과다.
문제는 이 두 모델을 한 함수 안에서 섞어 쓸 때 생긴다. setStrokes(선언적)와 toDataURL()(명령적)을 순서대로 호출하면, 선언적 세계에서는 아직 "예약"만 된 상태인데 명령적 세계에서는 "지금 당장" 읽어버린다. 둘 다 선언적이었으면 React가 배치 처리를 알아서 해주고, 둘 다 명령적이었으면 실행 순서가 보장된다. 두 모델이 섞이는 순간 타이밍 간극이 생기고, 따라서 이 타이밍에 버그가 터지게 된 것이다.
상태 변경 != 화면 반영
이걸 더 구체적으로 이해하려면 React의 렌더링 파이프라인을 알아야 한다. React 공식 문서에서는 이걸 레스토랑에 비유한다. React는 웨이터, 컴포넌트는 요리사, DOM은 테이블이다.
setState를 호출하는 건 웨이터에게 주문을 넣는 것이다. 주문이 들어갔다고 요리가 바로 나오지는 않는다. React는 주문을 받아서 주방(컴포넌트)에 전달하고, 요리(렌더링 결과)를 받아서 테이블(DOM)에 서빙한다. 이 과정이 세 단계로 나뉜다.
- Trigger — 상태가 변경되어 렌더링이 예약된다.
- Render — 컴포넌트 함수를 호출해서 새로운 UI를 계산한다. 이 단계에서는 DOM을 건드리지 않는다.
- Commit — 계산된 변경 사항을 실제 DOM에 반영한다.
Commit 이후에도 끝이 아니다. Konva를 쓰는 경우, React가 Commit에서 하는 일은 Konva 컴포넌트(<Line>, <Rect> 등)에 새 props를 전달하는 것이다. 그러면 Konva가 내부적으로 캔버스에 다시 그린다. 그 다음에야 브라우저가 Paint를 수행해서 화면에 픽셀을 찍는다.
이미지 출처: React 공식 문서 — Render and Commit
전체 흐름을 정리하면 이렇다.
toDataURL()로 정확한 결과를 얻으려면 최소한 Konva가 캔버스를 다시 그린 이후에 호출해야 한다. setState를 호출한 시점(Trigger)과 Konva가 캔버스를 업데이트하는 시점 사이에 간격이 있고, 이 간격 안에서 Canvas를 읽으면 이전 상태가 읽힌다.
화면에서는 사라졌는데 캡처에는 남아 있다
드로잉 에디터에서 스트로크를 그리거나 Undo/Redo를 할 때마다 현재 캔버스를 이미지로 캡처해서 상위 컴포넌트에 전달해야 했다. Konva의 stage.toDataURL()을 호출하면 캔버스를 PNG data URL로 변환할 수 있다.
const finishStroke = useCallback(() => {
addToHistory(strokes);
if (stageRef.current && onDrawingChange) {
const dataURL = stageRef.current.toDataURL();
onDrawingChange(dataURL);
}
}, [strokes, addToHistory, onDrawingChange]);
Undo를 누르면 화면에서는 스트로크가 사라지는데, onDrawingChange로 전달되는 이미지에는 아직 남아 있었다. 처음에는 히스토리 로직이 잘못된 줄 알고 한참 디버깅했다.
로그 찍기
히스토리를 아무리 살펴봐도 문제가 없었다. 혹시나 해서 toDataURL() 직전에 상태를 찍어봤다.
console.log("현재 strokes:", strokes.length); // Undo 후: 2개
const dataURL = stageRef.current.toDataURL();
// dataURL 이미지에는 3개짜리 그림이 찍혀 있음
strokes는 분명 2개인데, 캡처된 이미지에는 3개가 그려져 있었다. 상태(state)와 실제 캔버스에 그려진 그림이 다른 것이다.
setStrokes로 상태를 바꿨지만, 그건 Trigger일 뿐이었다. React가 Render → Commit을 거쳐 Konva에 새 props를 전달하고, Konva가 캔버스에 다시 그리는 것은 그 이후에 일어난다. toDataURL()은 Trigger 직후에 호출되고 있었으니, 캔버스에는 아직 이전 상태가 그려져 있는 것이다.
일단 다음 틱으로
원인은 알겠는데, 어떻게 고칠지가 막막했다. React의 Commit이 끝난 후에 toDataURL()을 호출해야 하는데, "Commit이 끝났다"를 직접 알 수 있는 방법이 마땅치 않았다.
그러다 "일단 다음 이벤트 루프 틱으로 밀어보자"는 생각에 setTimeout(0)을 넣어봤다.
const finishStroke = useCallback(() => {
addToHistory(strokes);
setTimeout(() => {
if (stageRef.current && onDrawingChange) {
const dataURL = stageRef.current.toDataURL();
onDrawingChange(dataURL);
}
}, 0);
}, [strokes, addToHistory, onDrawingChange]);
이렇게 바꾸니 Undo를 눌렀을 때 캡처된 이미지도 정확하게 나왔다. 되긴 됐는데, 왜 되는 건지 정확하게 알지 못해 찜찜했다.
이벤트 루프
setTimeout(fn, 0)은 "0밀리초 후에 실행"이 아니다. 브라우저 이벤트 루프는 세 단계로 돌아간다.
- 콜 스택 — 현재 실행 중인 코드를 끝까지 처리한다.
- 마이크로태스크 큐 —
Promise.then, React 배치 처리 등이 실행된다. - 태스크 큐(macrotask) —
setTimeout콜백 등이 실행된다.
setTimeout(fn, 0)의 콜백은 3번 태스크 큐에 들어간다. 0ms라고 해도 1번(현재 코드)과 2번(마이크로태스크)이 전부 끝난 후에야 실행된다. React의 배치 처리는 2번에서 일어나기 때문에, 3번에 있는 setTimeout 콜백이 실행되는 시점에는 React가 이미 Render → Commit을 마친 후다.
핵심은 React의 배치 처리가 콜 스택이 비워진 직후에 마이크로태스크로 실행된다는 것이다. setTimeout(0)의 콜백은 태스크 큐(macrotask)에 있기 때문에, 마이크로태스크인 React 배치 처리가 끝난 후에야 실행된다. 그래서 toDataURL()이 호출되는 시점에는 캔버스가 이미 최신 상태다.
같은 패턴이 여기저기에
finishStroke에서 해결하고 나니, 같은 문제가 undo, redo, addStroke에도 전부 있다는 걸 발견했다. "상태를 바꾸고 캔버스를 캡처하는" 모든 곳에서 동일한 타이밍 이슈가 있었다.
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setStrokes(history[newIndex].strokes);
setHistoryIndex(newIndex);
setTimeout(() => {
if (stageRef.current && onDrawingChange) {
const dataURL = stageRef.current.toDataURL();
onDrawingChange(dataURL);
}
}, 0);
}
}, [historyIndex, history, onDrawingChange]);
결국 모든 캡처 코드에 setTimeout(0)을 넣었다.
다른 방법
useEffect
"상태 변경 후에 뭔가를 실행하고 싶다"면 보통 useEffect를 떠올린다.
useEffect(() => {
if (stageRef.current && onDrawingChange) {
const dataURL = stageRef.current.toDataURL();
onDrawingChange(dataURL);
}
}, [strokes]);
strokes가 바뀔 때마다 캡처하는 방식이다. 동작은 하지만 두 가지 문제가 있었다.
첫째, useEffect의 실행 시점이 애매하다. useEffect는 Commit 이후 브라우저가 Paint를 마친 다음에 비동기적으로 실행된다. React에서는 이 단계를 Passive Effects라고 부른다. 화면 업데이트를 차단하지 않기 위해 Paint 이후로 미루는 것이다. Mark Erikson의 설명에 따르면 "React가 짧은 timeout을 설정하고, 그것이 만료되면 모든 useEffect를 실행한다." 이 시점에 Konva가 실제로 캔버스에 픽셀을 찍었는지는 보장되지 않는다.
둘째, strokes는 마우스 드래그 중에도 updateLastStroke로 계속 바뀐다. 그리는 중간에 매 프레임마다 toDataURL()이 호출되면 불필요한 캡처가 반복된다. 성능을 직접 측정하지는 않았지만, 확정되지 않은 스트로크를 매번 캡처할 이유는 없었다. setTimeout(0)은 finishStroke 안에서만 호출되기 때문에, 스트로크가 확정된 시점에서만 캡처가 일어난다.
flushSync
나중에 찾아보니 react-dom에 flushSync라는 API가 있었다. 상태 업데이트를 즉시 동기적으로 Commit까지 수행한다. "배치하지 말고 지금 당장 DOM에 반영하라"는 명령이다.
import { flushSync } from "react-dom";
flushSync(() => {
setStrokes(newStrokes);
});
// 이 시점에서 DOM은 이미 업데이트됨
const dataURL = stageRef.current.toDataURL();
당시에는 이런 API가 있는지 몰랐다. 나중에 찾아보니 React 공식 문서에서도 "마지막 수단(last resort)"으로 소개하고 있었다. 배치 최적화를 무력화하기 때문에 성능에 영향을 줄 수 있고, 드로잉처럼 빈번한 상태 업데이트가 일어나는 곳에서는 부담이 된다고 생각했다.
requestAnimationFrame
requestAnimationFrame은 브라우저가 다음 화면을 그리기 직전에 콜백을 실행한다. 이후 창 크기 계산 같은 곳에서는 requestAnimationFrame으로 바꿨다.
requestAnimationFrame(() => {
calculateVideoSize();
});
하지만 toDataURL() 캡처 용도에서는 setTimeout(0)을 유지했다. requestAnimationFrame은 다음 Paint 직전에 실행되는데, 이 시점에 React의 Commit은 완료되었지만 react-konva가 내부적으로 batchDraw를 통해 캔버스 드로잉을 비동기 처리하는 경우, 우리가 등록한 콜백이 Konva의 실제 캔버스 드로잉보다 먼저 실행될 수 있기 때문이다.
React 18에서 달라진 점
한 가지 주의할 점이 있다. React 18에서는 Automatic Batching이 도입됐다. React 17에서는 이벤트 핸들러 내부에서만 배치가 적용됐지만, React 18에서는 setTimeout, Promise.then 안의 setState도 배치된다.
이 프로젝트는 React 18을 사용하고 있었고, setTimeout(0) 안에서 setState를 호출하는 경우는 없었기 때문에 문제가 되지 않았다. 하지만 만약 setTimeout(0) 콜백 안에서 상태를 업데이트하는 패턴을 쓴다면 React 18에서 의도와 다르게 배치될 수 있다.
정리
React는 선언적이다. "이 상태일 때 이렇게 보여라"고 선언하면 React가 알아서 그린다. Canvas는 명령적이다. "지금 캔버스에 뭐가 그려져 있지?"를 물으면 그 즉시의 상태를 돌려준다.
이 두 세계를 함께 쓰면 반드시 타이밍 문제를 만난다. setState는 주문이고, 캔버스에 그려지는 건 서빙이다. 주문 직후에 테이블을 보면 아직 이전 요리가 놓여 있다.
setTimeout(0)은 우아한 해결책이 아니다. 하지만 "React가 서빙을 끝낼 때까지 한 틱 기다린다"는 의도를 명확하게 표현한다. 처음에는 왜 되는지도 모르고 넣었지만, 렌더링 파이프라인을 따져보고 나서야 이게 적절한 해결이라는 확신이 생겼다.
돌아보면 이 버그를 찾는 데 오래 걸린 이유가 하나 더 있다. DrawingCanvas 컴포넌트 하나에 히스토리 관리, 이미지 캡처, 스케일 계산이 전부 들어 있었다. 상태를 바꾸는 코드와 캔버스를 읽는 코드가 같은 함수 안에 붙어 있으니, 둘 사이의 타이밍 간극을 의심하기가 어려웠다. 역할이 분리되어 있었다면 "캡처는 상태 반영 이후에 해야 한다"는 걸 구조적으로 더 빨리 알아챘을 것이다.