[FE] 렌더링 파이프라인과 이벤트 루프

Part 2. 렌더링 파이프라인과 이벤트 루프, React의 배칭처리

Part 1에서 살펴본 것처럼, 메인 스레드는 무거운 통신 작업을 브라우저의 Web API 네트워크 스레드에 위임하고 본연의 화면 렌더링 작업을 멈춤 없이 계속한다. 그렇다면 Web API가 백그라운드에서 서버 응답을 성공적으로 받아온 이후에는 어떤 일이 벌어질까? 단 하나의 메인 스레드가 렌더링과 비동기 콜백 처리를 모두 통제하는 상황에서, 실행 순서를 결정하는 이벤트 루프의 딥한 원리와 이를 활용한 React의 성능 최적화 방식을 정리했다.

1. 이벤트 루프의 감시와 대기열의 동작 원리

Web API가 네트워크 통신을 마치고 서버로부터 응답 데이터를 받아오면, 그 결과를 처리할 다음 코드들은 즉시 실행되지 못한다. 메인 스레드는 이미 다른 동기 코드를 처리하거나 화면을 그리느라 바쁘기 때문이다. 실행을 기다리는 이 콜백 함수들은 브라우저 내부의 대기열에 차곡차곡 쌓이게 된다.

이때 브라우저의 관제탑인 이벤트 루프가 등장한다. 이벤트 루프는 메인 스레드의 작업 공간인 콜 스택이 비어있는지 1초에도 수천 번씩 감시한다. K8S의 컨트롤러 매니저 같다

메인 스레드가 하던 일을 모두 마치고 콜 스택이 완전히 텅 비는 찰나의 순간, 이벤트 루프는 먼저 들어온 것을 먼저 처리하는 순서대로 대기열에서 기다리던 콜백 함수를 하나씩 꺼내어 메인 스레드로 밀어 넣어 실행시킨다. 즉, 비동기 작업의 완료 시점을 예측할 수 없어도 이벤트 루프가 스레드의 빈틈을 찾아 작업을 스케줄링해 주는 것이다.

2. 프로미스와 UI 렌더링의 절대적인 우선순위

브라우저는 사용자가 부드러운 움직임을 느낄 수 있도록 보통 1초에 60번, 즉 약 16.6ms마다 화면을 지웠다가 새로 그린다. 이를 위해 CSS 스타일을 계산하고, 요소의 위치와 크기를 잡고, 실제 화면에 픽셀을 칠하는 무거운 렌더링 파이프라인을 실행하는데, 이 작업 역시 유일한 일꾼인 메인 스레드가 담당한다.

여기서 핵심은 이벤트 루프가 바라보는 대기열의 종류와 우선순위다. 대기열은 타이머 작업 등이 들어가는 태스크 큐와, 통신 결과물인 프로미스의 후속 작업이 들어가는 마이크로태스크 큐로 나뉜다.

이벤트 루프는 프로미스 전용인 마이크로태스크 큐를 VIP로 취급하여 무조건 최우선으로 처리한다. 결정적으로, 이벤트 루프는 마이크로태스크 큐가 완전히 텅 빌 때까지 렌더링 파이프라인으로 절대 넘어가지 않는다. 왜 화면보다 데이터를 먼저 처리할까? 만약 프로미스 작업이 끝나기 전에 화면을 먼저 렌더링해 버린다면, 찰나의 순간 뒤 프로미스의 결과가 도착했을 때 브라우저는 방금 그린 화면을 폐기하고 렌더링 파이프라인을 두 번 연속으로 실행해야 한다. 이는 심각한 자원 낭비와 화면 깜빡임을 유발한다. 따라서 브라우저는 프로미스에 의한 데이터 변경이나 상태 업데이트가 있다면 화면을 그리기 전에 무조건 다 끝마쳐라, 최종적으로 확정된 데이터만 가지고 화면을 딱 한 번만 깔끔하게 다시 그려주겠다는 확고한 철학을 지키고 있는 것이다.

3. 이 아키텍처가 가지는 치명적인 위험성

이 구조는 효율적이지만 프론트엔드 환경의 치명적인 약점이 되기도 한다. 이벤트 루프는 마이크로태스크 큐가 완전히 비워지기 전까지는 다음 단계인 화면 렌더링으로 절대 넘어가지 않는다고 했다.

만약 개발자가 실수로 프로미스 내부에 10만 개의 데이터를 동기적으로 처리하는 무거운 로직을 작성했거나, 끝없이 자기 자신을 호출하는 프로미스 체인을 만들었다면 어떻게 될까? 메인 스레드는 마이크로태스크 큐에서 작업을 끝없이 꺼내 처리하느라 거기 영원히 갇혀버린다. 결과적으로 렌더링 파이프라인이 실행될 기회를 영영 박탈당하게 되고, 사용자의 화면은 클릭도 스크롤도 되지 않는 하얀 화면 상태로 굳어버리게 된다.

4. React의 배칭: 이벤트 루프를 활용한 최적화

리액트로 작성한 코드를 보면 특정 로직이 끝난 후 실행되는 블록 안에서 로딩 상태 끄기, 검색어 초기화, 카테고리 변경 등 상태를 변경하는 함수가 무려 5번이나 연달아 호출되기도 한다. 만약 상태가 변할 때마다 즉시 메인 스레드가 리렌더링을 지시한다면, 찰나의 순간에 렌더링 파이프라인이 5번이나 실행되어 엄청난 성능 저하가 올 것이다.

React는 이벤트 루프의 특성을 완벽하게 파악하고 이를 활용해 배칭이라는 일괄 처리 최적화를 수행한다. 5개의 상태 변경 함수가 연달아 호출되어도 React는 즉시 화면을 그리지 않는다. 대신 변경될 값들을 내부 메모리 큐에 기록만 해둔다.

그리고 현재 실행 중인 코드 블록의 로직이 모두 끝나고 자바스크립트의 콜 스택이 비워지는 바로 그 시점, 즉 이벤트 루프가 렌더링 파이프라인으로 넘어가기 직전의 타이밍에, 모아두었던 5개의 상태 변경을 하나로 압축하여 단 1번의 렌더링만 발생시킨다.

[ 이벤트 루프의 1회전 순환과 React 배칭의 조화 ]

┌────────────────────────────────────────────────────────────┐
│ 1. 동기 코드 실행 영역                                     │
│   - 메인 로직 실행                                         │
│                                                            │
│ 2. 비동기 VIP 처리 영역                                    │
│   - Promise 결과 처리                                      │
│   - 이 안에서 React의 상태 변경 함수가 5번 연속 호출됨     │
│   - React: 당장 그리지 말고 변경될 상태값만 모아둠         │
│                                                            │
│ 3. 화면 업데이트 영역                                      │
│   - 마이크로태스크 큐가 비워짐                             │
│   - React가 병합된 최종 상태를 렌더링 파이프라인에 전달    │
│   - 브라우저가 단 1번만 렌더링 파이프라인 실행             │
│                                                            │
│ 4. 일반 대기열 영역                                        │
│   - 일반 콜백 1개 꺼내서 실행                              │
└────────────────────────────────────────────────────────────┘

이렇게 프론트엔드의 비동기 처리와 상태 업데이트에는 렌더링 비용을 최소화하기 위해 싱글 스레드의 한계를 브라우저의 멀티 스레드로 극복하고, 이벤트 루프의 우선순위 스케줄링을 이용해 UI 업데이트를 가장 안전하고 저렴하게 처리하려는 과정이 녹여져있다.


© 2022. All rights reserved.

Powered by Hydejack v9.2.1