아무도 말하지 않는 DOM 병목 현상
만 개의 로그 항목을 불러오는 고객 지원 대시보드를 상상해 보세요. 또는 모든 연락처를 하나의 스크롤 가능한 테이블에 표시하려는 CRM을 상상해 보세요. React에서 이를 구축하는 코드는 충분히 무해해 보입니다. 배열을 map으로 돌리고, JSX를 반환하며, 프레임워크가 제 역할을 하도록 맡기면 됩니다. 개발 환경에서 백 개의 행으로 작업할 때는 모든 것이 잘 작동합니다. 하지만 실제 운영 데이터가 들어오면 페이지는 버벅거리기 시작합니다.
브라우저가 게으른 것이 아닙니다. 브라우저는 당신이 요청한 일을 정확히 수행하고 있으며, 바로 그것이 문제입니다. 모든 행은 DOM 노드가 됩니다. 각 노드는 스타일이 지정되고, 레이아웃이 잡히고, 페인팅되며, 메모리에서 추적됩니다. 스크롤할 때 브라우저는 현재 보고 있는 부분만이 아니라 전체 트리의 위치를 다시 계산합니다. 이벤트 리스너가 쌓이고, 메모리 사용량이 급증합니다. 결국 메인 스레드가 과부하되어 인터페이스가 클릭, 키 입력, 심지어 스크롤 자체에도 반응하지 않을 정도로 멈춰버립니다. 기술적인 의미에서 애플리케이션이 충돌(crash)한 것은 아니지만, 앞에 앉아 있는 사용자에게는 경험이 완전히 망가진 것이나 다름없습니다.
이는 브라우저가 모든 요소를 한꺼번에 활성 메모리에 유지하려고 하기 때문에 발생합니다. React는 UI의 가상 설명을 만드는 데는 효율적일지 모르지만, 일단 그 설명이 문서의 실제 노드가 되면 직접 작성한 HTML과 동일한 비용이 발생합니다. 프레임워크 자체에는 탈출구가 없습니다. 리스트를 DOM에 전달하는 방식에 구조적인 변화가 필요합니다.
가상화(Virtualization)의 실제 의미
가상화가 바로 그 구조적 변화입니다. React에게 전체 배열을 렌더링하라고 요청하는 대신, 뷰포트(viewport) 안에 들어갈 수 있는 항목과 위아래의 작은 버퍼(buffer)만 렌더링합니다. 사용자가 스크롤하면 애플리케이션은 화면 밖으로 벗어난 노드를 버리고, 반대쪽 가장자리에서 들어오는 새로운 노드를 생성합니다. 전체 스크롤 가능한 높이가 (보통 하나의 긴 컨테이너 요소나 정교하게 계산된 스페이서를 통해) 유지되기 때문에, 사용자에게는 여전히 하나의 연속된 리스트처럼 느껴집니다. 보이는 항목들은 단순히 데이터셋 위를 미끄러지듯 지나가는 창(window)일 뿐입니다.
영사기 게이트를 통과하는 필름 스트립을 생각해 보세요. 관객은 부드러운 움직임을 보지만, 기계는 현재 위치에 있는 프레임만 비춥니다. 필름 릴의 나머지 부분은 빛이 통과하는 경로가 아니라 공급 및 권취 스풀에 존재합니다. 가상화된 리스트도 같은 방식으로 작동합니다. 데이터셋은 릴이고, 뷰포트는 게이트입니다.
이것은 전통적인 의미의 지연 로딩(lazy loading)이 아닙니다. 지연 로딩은 사용자가 근처로 스크롤할 때까지 데이터 페칭을 미룹니다. 반면 가상화는 이미 데이터를 가지고 있다고 가정하되, 어떤 부분(slice)을 실제 DOM 요소로 승격시킬지 선택적으로 결정합니다. 두 기술은 함께 사용할 수 있지만, 해결하려는 문제는 서로 다릅니다.
차이가 즉각적으로 느껴지는 이유
이점은 네 가지 측면에서 나타나며, 모두 동일한 근본적인 해소책과 연결되어 있습니다. 바로 사용자가 볼 수 없는 것에 비용을 지불하지 않게 된다는 점입니다.
더 빠른 초기 로딩 시간. 브라우저가 페이지를 열 때 15,000개의 행 대신 약 15개의 행만 페인팅합니다. 첫 번째 의미 있는 페인트(first meaningful paint)가 더 빨리 나타납니다. 자바스크립트 엔진이 노드를 생성하고 문서에 부착하는 데 쓰는 시간이 줄어들기 때문에 상호작용 가능 시간(time-to-interactive)도 단축됩니다.
낮은 메모리 사용량. DOM 노드는 비용이 많이 드는 객체입니다. 각 노드는 스타일 규칙, 레이아웃 메트릭, 이벤트 바인딩에 대한 참조를 가집니다. 활성 노드 수를 수십 개로 줄이면 메모리 점유율이 급격히 낮아집니다. 저사양 기기나 장시간 세션에서는 이것만으로도 운영 체제에 의해 탭이 강제 종료되는 것을 방지할 수 있습니다.
부드러운 스크롤 성능. 트리에 노드가 적으면 스크롤 이벤트 중 레이아웃 및 페인트 단계에 소요되는 시간이 줄어듭니다. 컴포지터 스레드(compositor thread)는 숨겨진 콘텐츠의 기하학적 구조를 끊임없이 재계산하지 않고도 움직임을 처리할 수 있습니다. 그 결과 모니터의 주사율에 더 가까운 부드러운 스크롤이 가능해집니다.
안정적인 프레임 속도. 메인 스레드가 더 이상 레이아웃 작업에 허우적거리지 않기 때문에 다른 활동을 위한 여유 공간이 생깁니다. 애니메이션이 매끄럽게 유지되고, 네트워크 응답을 처리할 수 있습니다. 렌더링 경로가 더 이상 병목 현상이 아니므로 새로운 데이터가 들어와도 UI가 멈추지 않습니다.
올바른 구현 방법
React 생태계에서는 react-window나 더 무거운 react-virtualized와 같은 라이브러리가 이 패턴을 위한 메커니즘을 제공합니다. 핵심 아이디어는 일관적입니다. 아이템 렌더러를 정의하고 전체 아이템 개수를 전달하면, 라이브러리가 윈도잉(windowing) 계산을 관리합니다. 하지만 세부적인 구현 사항에서 많은 이들이 어려움을 겪습니다.
첫째, 컨테이너에 정의된 높이가 필요합니다. 만약 리스트가 자식 요소의 크기에 맞춰 확장되는 부모 요소 안에 있다면, 뷰포트 경계가 없기 때문에 가상화(virtualization)가 어떤 항목이 보이는지 계산할 수 없습니다. 리스트의 높이를 고정하거나, 제약 조건이 명확한 flex 컨테이너에 배치해야 합니다.
둘째, 항목의 크기(item sizing)가 매우 중요합니다. 고정 높이 행(fixed-height rows)이 가장 단순한 사례입니다. 라이브러리는 행 높이에 인덱스를 곱하여 각 요소를 정확히 어디에 배치할지 알 수 있습니다. 이미지가 포함된 채팅 메시지나 댓글 스레드와 같이 높이가 가변적인 콘텐츠는 라이브러리가 마운트(mount) 후에 크기를 측정하고 즉석에서 조정하도록 만듭니다. 이 측정 단계가 너무 늦게 발생하면 스크롤 시 화면이 떨리는 현상(scroll jitter)이 발생할 수 있습니다. 데이터가 허용한다면 균일한 높이나 최소 높이를 강제하십시오. 그렇지 않다면 가변 높이 가상화 도구(variable-height virtualizer)를 사용하고 추가적인 복잡성을 감수해야 합니다.
셋째, 오버스캐닝(overscanning)은 유용합니다. 화면에 딱 맞는 만큼만 렌더링하면 사용자가 빠르게 스크롤할 때 빈 흰색 줄이 나타날 수 있습니다. 대부분의 라이브러리는 화면 영역(fold) 위아래로 몇 개의 항목을 더 렌더링할 수 있게 해줍니다. 보통 2~3개 행 정도의 오버스캐닝이면 DOM을 다시 비대하게 만들지 않으면서도 경계선을 숨기기에 충분합니다.
넷째, key prop을 무시하지 마십시오. 가상화된 리스트에서는 스크롤할 때 항목들이 DOM 노드를 재사용합니다. 안정적인 키(stable keys)는 React가 재조정(reconciliation) 과정에서 잘못 추측하여 행 컴포넌트 내부의 상태를 파괴하는 것을 방지합니다. 만약 리스트 행에 input, 토글 또는 확장 가능한 섹션이 포함되어 있다면, 잘못된 키는 데이터 레이어의 버그처럼 보이는 방식으로 UI 상태를 손상시키지만, 실제로는 렌더링 오류입니다.
한 가지 미묘한 함정은 브라우저의 페이지 내 찾기 기능입니다. 숨겨진 항목은 DOM에 존재하지 않기 때문에 브라우저의 검색창에서 찾을 수 없습니다. 사용자가 대규모 리스트 내의 텍스트를 찾기 위해 Ctrl+F에 의존한다면, 문서(document)가 아닌 데이터셋(dataset)을 대상으로 작동하는 커스텀 검색 기능을 구축해야 합니다. 리스트의 시맨틱(semantics)이 주의 깊게 처리되지 않으면 스크린 리더가 문맥을 놓칠 수도 있으므로, 보조 공학 기기(assistive technology)로 테스트하고 동적 로딩을 위한 라이브 리전(live region) 알림 추가를 고려하십시오.
가상화를 건너뛰어야 할 때
가상화는 공짜가 아닙니다. 의존성 무게, 좌표 계산, 제약 조건 오버헤드가 추가됩니다. 리스트 항목이 50개나 100개 정도라면 브라우저가 도움 없이도 충분히 처리할 수 있습니다. 그냥 전체를 렌더링하고 넘어가십시오. 리스트 항목 하나하나가 극도로 복잡한 경우에도 마찬가지입니다. 가상화는 수천 개의 노드를 줄여주지만, 거대한 차트나 비디오 요소를 포함한 단 하나의 노드로부터는 당신을 구해줄 수 없습니다. 항목의 비대함(item bloat)부터 먼저 해결하십시오.
또한 리스트가 스크롤되지 않는 경우에는 가상화를 피하십시오. '다음' 및 '이전' 버튼으로 페이지를 나누고 페이지당 20개의 항목만 보여준다면, 윈도잉(windowing)할 대상이 없습니다. 이 기술은 사용자가 크고 연속적인 시퀀스를 스크롤할 것으로 예상할 때만 제 가치를 발휘합니다.
핵심 요약
가상화는 라이브러리의 선택이라기보다 사고방식에 가깝습니다. 이는 DOM이 무한한 캔버스가 아니라 유한한 자원임을 인정하게 만듭니다. 가상화를 도입하기 전에 Chrome DevTools를 열어 성능 프로필(performance profile)을 기록하고, 레이아웃(layout)이나 페인트(paint) 시간이 실제로 원인인지 확인하십시오. DOM이 병목 현상(bottleneck)임을 확인했다면, 제약 조건을 받아들이십시오. 높이를 고정하고, 키를 관리하며, 적절히 오버스캐닝하고, 접근성을 테스트하십시오. 제대로 구현된 가상화 리스트는 사용 불가능한 데이터 벽을 네이티브 스크롤 뷰처럼 가벼운 무언가로 바꿔줍니다. 브라우저는 더 이상 저항하지 않고, 사용자는 기다릴 필요가 없으며, 앱은 마침내 당신이 의도했던 빠른 인터페이스처럼 동작하게 됩니다.
