한번에 처리하기 & 주문은 와중에도 쌓인다
팀원들이랑 같이 리액트 공식 문서를 읽으며 공부 중인데 모두가 명확한 해석을 못 냈던 문단이 있다.
위 문단인데 명확히 이해해보고 싶어서 batching 에 대해 더 알아보고자 했다. 또한 이에 이어진 궁금증으로 리액트의 렌더링 중 일어난 DOM Event의 처리를 알아보았다.
배칭이란 리액트가 여러 개의 state 업데이트를 하나의 리렌더링 과정에 한번에 처리하는 것을 말한다.
리액트에서 useState
반환 값인 setter로 state를 업데이트하는 것은 컴포넌트의 렌더링을 촉발한다. 그러나 state 업데이트 한 번마다 무조건 렌더링을 수행하게 된다면 연속된 업데이트가 일어날 때 활용하지도 않는 렌더링이 중간 과정에서 수행된다. 이는 자원 면에서 낭비라고 볼 수 있다.
따라서 배칭은 불필요한 렌더링 과정을 줄여서 성능에 큰 이점을 줌과 동시에 일부 변수만 업데이트 된 불완전한 렌더링이 나타나는 것도 막는다.
리액트 17 까지는 리엑트 이벤트 핸들러의 콜 스택에 있는 업데이트만 대상으로 배칭을 수행했다. 핸들러 내부에 있는 코드라도 Promise
나 setTimeout
등으로 비동기 처리되어 task queue로 이동한 코드는 실행될 때 다른 콜스택에서 실행된다. 때문에 해당 코드에서 업데이트가 일어날 경우 업데이트 하나마다 렌더링이 이루어졌다.
// 리액트 17 컴포넌트 내부
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(c => c + 1);
// 두 업데이트는 batch되어 렌더링이 한 번만 이루어 진다.
fetchData().then(() => {
setCount(count + 1);
setCount(c => c + 1);
// Promise 내부의 업데이트이므로 각각 렌더링이 이루어진다.
});
setTimeout(() => {
setCount(count + 1);
setCount(c => c + 1);
// setTimeout 내부의 업데이트도 마찬가지이다.
}, 1000);
}
리액트 17 에서 위 코드는 렌더링이 5번 이루어진다.
또한 리액트 이벤트가 아닌 native 이벤트 핸들러에서도 배칭이 이루어지지 않는다.
리액트 이벤트 & native 이벤트
리액트 이벤트는 태그에 이벤트 설정하듯이 JSX에서 설정해준 이벤트를 말한다. native 이벤트는 바닐라 자바스크립트에서 하던 것처럼
addEventListener
를 이용해 설정해준 이벤트를 말한다.둘을 구분하는 이유는 리액트가 사실 이벤트 위임을 이용하기 때문이다. 리액트 이벤트로 설정한 경우 이는 해당 요소에 직접적으로 붙는 게 아니라 상위 요소에 위임된다. 리액트 16 까지의 경우
document
요소에 위임되고 17부터는 리액트 트리가 렌더되는 root DOM container에 위임된다.
리액트 17 부터는 기존에 배칭이 이루어지지 않던 Promise
, setTimeout
, native 이벤트 핸들러 내부에서도 배칭이 이루어진다. 이를 자동 배칭이라고 한다.
위에 예시로 나왔던 코드의 경우 자동 배칭이 이루어지면 렌더링을 3회만 수행할 수 있다.
왜 이름이 automatic batching?
17에서도 react-dom 라이브러리의
unstable_batchedUpdates
API를 이용해 리액트 이벤트 핸들러 외부의 배칭을 수행할 수 있었다. 18부턴 안써도 자동으로 배칭을 해주니 이름을 저렇게 지은 것으로 보인다.
즉, 자동 배칭은 배칭의 범위를 넓혔다고 볼 수 있다.
그렇다고 다른 이벤트 핸들러 사이에서 실행되는 state 업데이트를 한 렌더링으로 묶지는 않는다.
아마 위에서 언급한 공식 문서의 문단은 18 부터 리뉴얼된 것이라서 자동 배칭을 의식하고 적어둔 게 아닐까 생각한다.
공식 문서에선 예시로 form의 submit 버튼이 첫 번째 클릭 후 disable 되었을 때 두 번째 클릭이 다시 submit 하지 않는 것을 들었다. 두 이벤트 사이 배칭이 일어나지 않기 때문에 첫 번째 이벤트로 인한 렌더링으로 버튼이 disable 됨이 보장되고 다음 클릭 이벤트는 막힌다는 것이다.
여기서 궁금했던 건 리액트가 렌더링 사이 이벤트를 막는 방법이다.
처음에는 리액트의 기능일 거라고 생각하고 검색을 했지만 나오지 않았는데 concurrent 렌더링의 설명에서 힌트를 찾아 추론했다.
click이나 input, change 같은 DOM 이벤트는 발생할 때 등록된 콜백 함수를 Macrotask 큐에 넣는다. Macrotask 큐는 다른 Callback 큐들 중 가장 우선순위가 낮고 이벤트 루프에 따라 콜 스택이 비워져야 실행될 수 있다. 때문에 같이 Macrotask 큐에 들어가는 이벤트 콜백이나 setTimeout
콜백 외의 모든 코드가 완료될 때까지 대기한다.
리액트의 리렌더링은 결국 하나의 함수 실행으로 시작되어 렌더 패스를 따라 이어지는 컴포넌트 함수의 호출일 것이다. 또한 렌더링 과정에서 setTimeout
등의 비동기 API는 사용하지 않는 것이 권장된다. 따라서 리렌더링 과정은 콜 스택에서 진행될 것이다. 혹시나 Promise가 사용되더라도 Microtask 큐에 들어가므로 DOM 이벤트보다는 우선순위가 높다.
즉, DOM 이벤트는 기본적으로 렌더링 사이에 끼어들 수 없다.
Concurrent 렌더링
사실 역으로 이벤트가 리렌더링 중에 끼어드는 기능이 필요했다고 한다.
만약 렌더링 과정이 부하가 커서 시간이 좀 걸린다면 렌더링 완료 전까지 발생한 유저 상호작용 이벤트는 일어나지 않는 것처럼 보이다가 렌더링이 완료된 후 적용된다. 이는 응답성에서 문제가 된다.
느린 렌더링 예시 코드
"Render Slow" 버튼을 누르면Slow
컴포넌트의 key가 변하며 재렌더링되는데 컴포넌트 내부에 부하를 강제로 주어 느린 렌더링이 이루어진다.
"Render Slow" 버튼을 누른 뒤 input을 클릭하고 타이핑하면 렌더링 중에는 input값이 업데이트되지 않다가 완료 후 처리되는 것을 볼 수 있다. Console 창을 열어서onChange
이벤트의 실행을 확인할 수 있다.때문에 리액트 18 에서는 concurrent 렌더링을 도입했다. 이를 사용하면 state 업데이트를 높은 우선순위와 낮은 우선순위로 나누어 배정할 수 있다. 낮은 우선순위의 업데이트는 높은 우선순위의 렌더링이 완료될 때까지 대기한다. 그리고 렌더링 과정에서 높은 우선순위 업데이트를 포함하는 이벤트가 발생할 경우 그동안 수행한 결과물을 버리고 중단된 뒤 높은 우선순위 업데이트가 완료된 후에 처음부터 다시 렌더링된다.
다만, 단일 컴포넌트의 렌더링을 중단할 수 있는 것은 아니다. 중단이 혀용되는 지점을 suspension point라고 하는데 이는 컴포넌트 렌더링 사이에 위치한다. 즉, 렌더 패스로 연결된 컴포넌트가 여러 개일 때 중단이 가능하다.
근본이 함수 실행인 컴포넌트 렌더링 사이에서만 중단해서 이벤트 콜백을 실행할 수 있다는 점이 이벤트 관리가 이벤트 루프 기반이라는 것을 시사한다고 생각한다.
윗 내용을 알아보면서 궁금한 게 하나 더 생겼다. 이벤트 콜백이 등록되는 건 input이 disabled
되기 전인데 렌더링 후에 실행되지 않는다.
concurrent 렌더링 문단의 느린 렌더링 예시 코드를 보면 렌더링이 이루어지는 중에도 이벤트 콜백은 등록되는 걸 확인할 수 있다.
React disabled 예시 코드
위 코드는 느린 렌더링과 함께 input을 disable 하도록 일부 수정했다. 똑같은 방식으로 onChange
실행을 확인하면 이번에는 렌더링 이후 출력이 나타나지 않는다.
input이나 select 등의 일부 DOM 요소에 부여할 수 있는
disabled
속성이 이벤트 발생을 막는 기능만 한다고 생각하고 있었다.
이는 React의 기능이 아닌 disabled
속성에 관한 기본 동작인 것으로 보인다.
JS disabled 예시 코드
위 코드는 React disabled 예시 코드를 Vanilla JS로 비슷하게 구현했다. 같은 방식으로 확인해보면 JS도 마찬가지로 렌더링 이후 출력이 나타나지 않는다. 즉, disabled
속성에 관한 JS의 작업에 큐에 등록된 콜백을 선택적으로 실행하는 기능이 포함되어 있다고 볼 수 있다.
리액트는 내부 최적화 로직이 잘 짜여있어서 그런지 JS랑 다른 차원에서 돌아간다는 오해를 무의식적으로 하고 있었던 것 같다. 결국 리액트도 JS에서 돌아가는 라이브러리고 JS의 동작을 근간으로 하고 있었다.
막상 정리하고 나니 당연한 걸 파고 있었던 것 같다. 🧊
오.. 가장 최근 버전인 리액트 18 까지도 Batching 처리나 Concurrent 렌더링 에 대한 변화도 이뤄져 왔던 것이었군요! 좋은 정보 공유 감사합니다 😊😁