회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
2022년 3월, 봄이 시작되면서 리액트 세계에도 새로운 변화의 바람이 불어왔습니다. 리액트 18버전이 출시되었는데, 이전 버전인 리액트 17이 리액트 16에 비해 크게 달라진 점이 없다는 평을 받았던 것과는 달리, 리액트 18에서는 여러 새로운 기능이 소개되어 많은 개발자들의 관심을 끌었습니다. 동시성 모드와 자동 배칭(Automatic Batching)을 포함한 다양한 실험적이지만 유용한 개발 기능들이 그 중심에 있었습니다.
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
2022년 3월, 봄이 시작되면서 리액트 세계에도 새로운 변화의 바람이 불어왔습니다. 리액트 18버전이 출시되었는데, 이전 버전인 리액트 17이 리액트 16에 비해 크게 달라진 점이 없다는 평을 받았던 것과는 달리, 리액트 18에서는 여러 새로운 기능이 소개되어 많은 개발자들의 관심을 끌었습니다. 동시성 모드와 자동 배칭(Automatic Batching)을 포함한 다양한 실험적이지만 유용한 개발 기능들이 그 중심에 있었습니다.
그중에서도 자동 배칭은 2년이 지난 지금까지 많은 개발자들이 쉽게 접근하고 이해할 수 있는 유용한 기능으로 평가받고 있습니다.
그중에서도 가장 호평받는 부분은 개발자가 특별히 리액트 생태계에 대한 이해를 갖추지 않더라도, 스스로 좋은 성능을 보장하도록 만들어졌다는 점입니다. 메이저(Major) 버전을 올렸을 때, 프로젝트의 버전 호환성을 위해 많은 것들을 변경해야 했던 경험에 비추어보면 상당한 개발 편의성이라고 볼 수 있을 듯합니다.
그러나 리액트 개발팀의 이러한 노력에도, 아직까지 많은 리액트 개발자가 오해하고 있는 부분이 있는데요. 바로 자동 배칭의 정확한 개념을 모른다는 점입니다. 이번 글에서는 리액트 ‘배칭(Batching)’에 관한 전반적인 소개와 배칭과 관련해 알아두어야 할 리액트 렌더링 과정을 함께 설명하고자 합니다.
이 글은 리액트 기초 문법과 상태 값 변경, 이벤트 핸들러를 실행해 UI 상태를 변경하는 등 최소한의 사용 경험을 갖고 계시면 더욱 재밌게 읽을 수 있습니다.
리액트의 배칭과 자동 배칭에 대해 알아보기 전, 여러분은 배칭에 대해 얼마나 알고 있나요? 리액트에서 배칭이란 무엇일까요?
1) UI 레이아웃을 변경하는 작업
2) 리액트의 상태 값을 변경하는 작업
3) 리액트의 상태 값을 일정한 주기로 처리하는 작업
4) 리액트 훅을 순서대로 실행하는 작업
배칭을 이해하기 전, 우리는 리액트가 상태 값 변경에 어떻게 대응하는지 알아야 하는데요. 먼저 리액트의 처리 방식을 살펴보겠습니다.
우선 리액트에서 상태 값이 변하면 어떤 과정을 거치는지 이해할 필요가 있습니다. 버튼을 누르면 숫자를 1만큼 증가하는 컴포넌트가 있다고 가정했을 때, 지금까지 우리는 버튼에 연결한 이벤트 핸들러에서 상태를 변경하면, 변경된 상태가 컴포넌트를 다시 렌더링 하게 만들고 그 결과로 화면에서 증가한 숫자를 볼 수 있었다고 알고 있었습니다. 여기서 리액트는 조금 더 체계적인 단계를 갖추고 있는데요. 이해를 돕기 위해 그림을 준비했으니, 아래 그림과 함께 각 단계를 살펴볼게요.
버스와 버스 노선도를 이용해 리액트의 상태 값 변경을 살펴보겠습니다. 버스엔 운전하는 버스 기사와 승객, 버스 하차 벨이 있습니다. 기사는 버스를 운전하며 여러 정류장을 거칩니다. 정류장에서 멈추기도 하고, 멈췄다가 다시 출발하기도 하는 버스의 총책임자이기도 합니다. 승객은 내리고 싶은 정류장에 내리고 타기도 합니다. 내리고 싶으면 하차 벨을 누르죠.
위 그림에서 버스 기사는 리액트에 해당합니다. 버스 전체를 관장하는 버스 기사처럼 리액트 역시 상태 값을 포함한 모든 기능을 관리하고, 정상적으로 동작하는 데 문제가 없도록 신경 씁니다. 그리고 승객은 이벤트에, 하차 벨은 상태 값의 변화에 해당합니다. 승객이 하차 벨을 누르는 모습은 리액트의 상태 값이 변경되었음을 버스 기사(리액트)에 알려주는 것과 비슷합니다.
이제 리액트의 상태 값 변화에 따른 처리 과정을 보며, 버스 기사와 승객이 서로 어떻게 행동하는지 알아보겠습니다.
상태 값을 변경하면 리액트는 해당 내용을 전달받아 UI를 변경할 준비에 들어가는데, 그 첫 단계가 렌더링입니다. 이 단계에서 리액트는 변경된 상태 값을 중심으로 새로운 가상 돔(Virtual DOM)을 생성합니다.
사용자가 상태 값을 변경하고 리액트가 알아차리는 것은 승객이 하차 벨을 누르면, 버스 기사가 운전석에 있는 계기판을 통해 하차할 승객이 있음을 알게 되는 것과 비슷합니다. 승객이 하차 벨을 누르는 순간 버스 기사가 가장 가까운 정류장에 승객을 내려줄 준비를 하는 것처럼, 리액트 역시 상태 값이 변화했음을 인지하는 순간 상태 값을 UI에 적용할 준비를 합니다. 이때 정류장은 리액트가 갖고 있는 렌더링 주기(Rendering Cycle)라고 볼 수 있습니다.
상태 값이 변하면 리액트는 컴포넌트를 다시 호출(리렌더링)합니다. 하지만 상태 값이 변하는 그 즉시 컴포넌트가 호출되지는 않습니다. 리액트는 렌더링 주기가 언제 컴포넌트를 다시 호출해야 할지 최적의 시간을 계산합니다. 버스가 정류장에 도착하면 잠시 정차하고 문을 열어 승객이 타고 내릴 수 있게 해주는데요. 이는 리액트가 적절한 타이밍에 상태 값이 변경된 컴포넌트를 다시 호출하는 것과 비슷합니다. 오직 버스 기사(리액트)만이 그 권한을 갖고 있고, 언제 버스 정류장에 도착할지(컴포넌트를 호출할지)는 우리가 알 수 없는 것이죠.
컴포넌트가 다시 호출되면, 변경된 상태 값을 바탕으로 화면에 표시할 돔을 다시 그리게 됩니다. 이때 가상 돔을 만들면 리액트는 다음 단계로 진입하는데요. 이 단계는 조정 단계로 렌더링 단계에서 만든 가상 돔을 현재 UI에 적용된 가장 최신 버전의 가상 돔과 비교합니다. 상태 값 변화를 렌더링하고(리액트에 알려주고) 조정하는 일은 리액트에서 사용하고 있는 Fiber의 도움을 받아, 더욱 멋진 성능으로 빠르게 처리됩니다. Fiber는 리액트 16버전에서 등장한 새로운 리액트 내부 아키텍처, 다음에 더 자세히 이야기하는 기회를 가져보겠습니다.
또한 좌석 버스의 경우 버스 앞 유리창과 운전석 내부에 남은 좌석 수가 표시됩니다. 내릴 사람은 내리고 탈 사람은 타면서 버스 안이 잠시 분주해집니다. 어느 정도 시간이 지나면 버스 안의 승객은 몇 명이나 있는지, 어느 자리에 누가 앉았는지, 어떤 옷을 입은 사람들이 타고 있는지 등 내부 전경이 조금 달라질 수도 있죠. 이 과정이 리액트의 조정 단계와 비슷합니다.
버스 승객이 모두 타고 내리면 버스 기사는 문을 닫고 다시 출발합니다. 마찬가지로 가상 돔의 비교가 끝나면 리액트는 다음 단계로 넘어갑니다.
가상 돔의 비교가 끝나고 최종 형태의 가상 돔이 만들어지면, 적용 단계로 진입하게 되는데 이때 리액트는 실제 돔(Actual DOM)에 가상 돔을 적용해, 화면의 변화를 만들어냅니다. 이 과정까지 완료되면 사용자가 비로소 화면상의 변화된 UI를 볼 수 있게 됩니다. 이는 버스 기사가 문을 닫은 후, 다시 출발하는 과정과 비슷합니다.
여기까지 이뤄지면 상태 값의 변화부터 UI의 변경까지 하나의 과정이 모두 종료되고, 리액트는 다시 사용자 인터랙션 이벤트를 기다리게 됩니다. 사용자가 이벤트를 발생시키면 리액트(버스 기사)는 다시 렌더링부터(승객이 하차 벨을 누르는 것부터) 시작할 준비를 하는 것이죠.
위의 버스 기사와 버스 운행에 대한 예시를 이해했다면, 이제 배칭을 이해할 수 있는 충분한 준비가 되었을 겁니다. 이번에는 버스 기사와 버스 이야기에서 배칭이 어느 부분에 해당하는지 알아볼게요.
배칭은 버스가 정류장에 정차하면 승객이 타고 내리는 과정과 비슷합니다. 예를 들어, 여러 명이 내려야 하는데 한 명만 하차했을 때 버스가 바로 출발해 버린다면 어떻게 될까요? 아마 항의 전화가 버스 회사로 빗발칠 거고, 정상적인 운행이 힘들겠죠.
그래서 우리는 한 번에 많은 사람들이 승차하거나, 하차를 모두 완료할 때까지 기다립니다. 리액트의 배칭은 승객이(상태 값 변화) 타고 내리는 과정에서 모든 승객이 전부 탑승하거나 내릴 때까지 기다리는 것과 비슷합니다. 유저 이벤트가 발생하는 시점에 여러 상태 값이 변경되는 코드가 있다면, 리액트는 이 모두를 기다립니다. 단 하나의 상태 값만 변경되었다고 해서 바로 렌더링으로 넘어가지 않습니다. 같은 이벤트 주기에 처리할 수 있는 변경 점은 다 같이 처리하는 것이 성능 최적화 관점에서 더 유리하기 때문입니다.
그렇다면 다시 앞의 질문으로 돌아가, 배칭이 무엇인지 물어본다면 이제는 대답할 수 있겠죠?
1) UI 레이아웃을 변경하는 작업
2) 리액트의 상태 값을 변경하는 작업
3) 리액트의 상태 값을 일정한 주기로 처리하는 작업 (정답!)
4) 리액트 훅을 순서대로 실행하는 작업
이처럼 배칭은 이벤트로 인해 변경되는 변경 점이 하나뿐이든, 여러 개든 일정한 주기에 맞춰 다 같이 처리될 수 있도록 하는 리액트의 내부 기능입니다.
이번에는 실전 예시를 통해 배칭을 살펴보겠습니다. 아래의 코드는 A와 B를 임의의 숫자로 할당해 주는 이벤트를 버튼에 연결해 놓았습니다. 두 수의 합은 MAX인 100을 넘지 못합니다. 각 상태가 변하면 useEffect에서 감지해 로그를 출력해 보고 있습니다.
const MAX = 100;
const getRandomNumber = () => {
// 1부터 99사이의 랜덤 숫자 반환
return Math.floor(Math.random() * (MAX - 1 - 1) + 1);
};
export default function App() {
const [A, setA] = useState(50);
const [B, setB] = useState(50);
const handleClick = () => {
const randomA = getRandomNumber();
setA(randomA);
setB(MAX - randomA);
};
useEffect(() => {
console.log(`A: ${A}, B: ${B}, Total: ${A + B}`);
}, [A, B]);
return (
<div className="App">
<button onClick={handleClick}>재배치</button>
<h4>A: {A}</h4>
<h4>B: {B}</h4>
</div>
);
}
배칭은 단일 또는 여러 상태 값의 변화가 동일한 이벤트 주기에서 처리되도록 해주는 기능을 말합니다. 배칭이 적용될 때는 다음과 같은 값이 출력됩니다.
상태 값 A, B가 동시에 변경되더라도 배칭 덕분에 두 상태 값이 같은 타이밍에 변경되어, 렌더링이 한 번만 발생합니다. 따라서 개발할 때 크게 신경 쓰지 않아도 좋은 성능을 보장할 수 있습니다.
기존 리액트 17버전까지는 배칭에 문제점이 있었는데요. 바로 유저 이벤트에 연결된 함수 내에서 직접적으로 변경된 상태 값만 배칭의 범위 안에 들어왔다는 것이었습니다. 그래서 타이머(setTimeout), 프로미스, Native Event 등은 배칭의 효과를 누릴 수 없었는데, 이러한 점은 성능 최적화와는 거리가 멀었죠.
위의 예시 코드에서 메서드를 하나 추가해 보겠습니다.
const sleep = (t) => {
return new Promise(res => setTimeout(res, t));
}
```
그리고 리액트의 이벤트 함수를 살짝 변경했습니다.
```
const handleClick = async () => {
// Promise를 사용해 Promise 코드로 변경되도록 아래의 코드를 추가합니다.
await sleep(0);
const randomA = getRandomNumber();
setA(randomA);
setB(MAX - randomA);
};
위의 예시처럼 코드를 변경 후 리액트 17버전에서 다시 버튼을 눌러보면, 이번에는 다음과 같은 결과를 확인할 수 있습니다.
첫 번째 변화를 자세히 살펴보면, A와 B는 모두 50에서 시작했습니다. 이때 콘솔에는 A와 B는 모두 50으로, 총합계는 100으로 출력이 되었습니다. 재배치 버튼을 한 번 누르고 나서 콘솔엔 순간 로그가 두 번이 출력되는데요. 이때 각각 A와 B는 87과 50, 총합은 137이 출력되고, 바로 뒤에 출력되는 로그엔 A와 B는 87, 13이 출력되고 총합도 100이 출력되었습니다. 왜 그럴까요?
sleep 함수는 프로미스를 반환하는 함수입니다. 그 내부에서는 다시 타이머를 동작시키고 있습니다. 리액트 17버전까지는 프로미스가 사용되면, 해당 코드는 배칭의 범위에 포함을 시키지 못했습니다.
그래서 A와 B가 각각 87과 13으로 변경이 각각 이뤄졌죠. 상태 값이 변경되면 렌더링 단계로 넘어가는 리액트의 이벤트 주기 특성으로 인해, 조정(Reconciliation)과 적용(Commit)을 거쳐 컴포넌트는 최종적으로 A가 변경될 때 한 번, B가 변경될 때 다시 한번 호출이 되어 총 두 번에 걸쳐 호출됩니다. 결국 하나의 이벤트 함수에서 변경하는 상태 값이 많을수록 성능이 저하될 수 있습니다.
이 점은 다행히 리액트 18버전이 출시되면서 해결되었는데요.createRoot 함수를 사용해 리액트 루트 컴포넌트의 초기화 해주면, 프로미스와 타이머도 같은 주기 내에서 처리될 수 있도록 변경되었습니다.
// 리액트 17버전까지는 이러했습니다.
import { StrictMode } from "react";
import { render } from "react-dom";
import App from "./App";
const rootElement = document.getElementById("root");
render(
<StrictMode>
<App />
</StrictMode>,
rootElement
);
```
```
// 하지만 리액트 18버전에서는 createRoot를 사용합니다.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>
);
차이가 보이시나요? createRoot로 루트 컴포넌트를 생성한 뒤, 루트 컴포넌트의 render 메서드를 사용하는 방식으로 변경되었습니다.
리액트 18버전에서 루트 컴포넌트를 생성하는 방식이 변경되었고, 이에 따라 프로미스와 타이머, Native Event 모두 이제는 리액트가 알아서 같은 주기에서 처리하고, 이벤트를 분류할 수 있게 되었습니다. 마치 버스 기사가 뛰어오는 손님들을 기다려주는 것처럼요. 이러한 기능을 자동 배칭(Automatic Batching)이라고 표현합니다.
마지막으로 한 번 더 정리하면, 리액트는 상태 값이 변경되면 상태 값을 다루는 컴포넌트를 다시 호출하는 렌더링 과정을 거치고, 컴포넌트가 JSX 엘리먼트를 반환하면 그것을 바탕으로 새로운 가상 돔을 생성합니다. 이렇게 만들어진 가상 돔은 HTML에 적용된 가장 이전 버전의 가상 돔과 비교하게 됩니다. 그리고 이 과정을 조정(Reconciliation)이라고 부릅니다.
비교 후 최종적으로 적용할 가상 돔을 꾸리게 되면, 적용(Commit) 단계로 넘어가고 사용자는 UI가 변경되는 것을 느낄 수 있습니다. 이러한 일련의 과정 중 상태 값이 변경되고 적용되는 타이밍에 있어, 리액트는 여러 상태 값이 되도록 한 번에 다 같이 적용할 수 있도록 하는 기능을 갖고 있습니다. 그 기능을 ‘배칭’이라고 표현하죠.
기존 배칭의 단점은 프로미스와 타이머, Native Event로 발생하는 상태 값 변화를 감지하지 못했기에 컴포넌트가 여러 차례 렌더링이 되었는데요. 그래서 18버전부터는 createRoot 함수를 사용해 루트 컴포넌트를 만들어주면, 리액트가 감지할 수 있게 했고 이 기능이 ‘자동 배칭’입니다.
지금까지 리액트 배칭에 대해 여러 예시를 들어 살펴보았는데요. 이번 글을 통해 배칭을 이해하는 데 조금이나마 도움이 되었길 바랍니다.
요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.