ceriousdevtech/cerious-scroll는 1억(100,000,000) 개가 넘는 항목을 60FPS로 부드럽게 스크롤하면서도, 데이터가 아무리 많아져도 메모리 사용량은 거의 일정하게(O(1)) 유지하는 고성능 가상 스크롤 라이브러리입니다. 비결은 단순하고 강력합니다 — 화면에 실제로 보이는 ~25~30개의 행만 DOM(브라우저가 그리는 실제 요소)으로 만들고, 스크롤하면 화면 밖으로 나간 요소를 떼서 새로 들어오는 자리에 재활용합니다. 게다가 행 높이가 제각각이어도(채팅 말풍선처럼) 미리 계산할 필요 없이 그리는 순간 즉석에서 재 보는 방식이고, 위치를 픽셀이 아니라 "몇 번째 항목 + 그 안에서 몇 px"로 표현해 정밀도 문제를 피합니다. React·Vue·Angular·바닐라 JS 어디서든 쓸 수 있는 프레임워크 비종속 설계이고, 데이터 그리드·채팅·로그 뷰어·금융 대시보드처럼 "엄청 많은 줄을 끊김 없이 보여줘야 하는" 모든 화면이 타깃입니다. 별은 아직 3개인 신생 프로젝트지만, 가상화 알고리즘 자체를 코드로 공부하기에는 보기 드물게 잘 정리된 교본입니다. (저장소: ceriousdevtech/cerious-scroll · TypeScript · ★3 · MIT)
"100만 권짜리 도서관을 보여주되, 책상 위에는 지금 펼친 30권만 올려 두는 사서."
웹 페이지에서 항목 100만 개를 전부 <div>로 만들면 브라우저는 그 100만 개를 다 메모리에 들고 레이아웃을 계산하다가 멈춰 버립니다. cerious-scroll은 정반대로, 화면(뷰포트)에 실제로 보이는 행 + 위아래 약간의 여유분(버퍼)만 만들어 둡니다. 스크롤로 위로 사라진 행은 DOM에서 떼어 내 '재활용 풀'에 넣고, 아래에서 새로 들어오는 자리에 그 요소를 다시 끼워 씁니다(= 책상 위 30권만 두고 계속 갈아 끼우는 사서).
핵심 특징 셋: ① O(1) 메모리(항목이 100개든 1억 개든 메모리는 ~200KB 베이스라인 근처에서 일정), ② 가변 행 높이 네이티브 지원(미리 계산 없이 그리는 순간 측정), ③ 프레임워크 비종속(엔진은 순수 TypeScript, 어떤 UI 프레임워크에도 얹힘).
좀 더 구체적으로, 실제 코드는 모두 src/ 아래 TypeScript로 깔끔하게 모듈화돼 있습니다(전체 약 5,800줄). 핵심은 네 덩어리입니다 — 위치 계산 엔진(engine/navigation-engine.ts, 289줄), 높이 캐시(core/performance-cache.ts, 338줄), 화면 렌더러(features/viewport-renderer.ts, 621줄), 총괄 클래스(cerious-scroll.ts, 904줄). 여기에 마우스 휠·터치·키보드·리사이즈를 각각 담당하는 컨트롤러들과, 행 배치 방식(`div` 절대배치 vs `table` 플로우)을 갈아 끼우는 전략 패턴이 더해집니다. 테스트도 tests/에 단위·통합으로 잘 갖춰져 있고, jsdom + Playwright로 검증합니다.
이 문서가 파고드는 건 "어떻게 설치하나"가 아니라 그 안의 알고리즘입니다 — "왜 픽셀이 아니라 '항목 번호 + 오프셋'으로 위치를 잡나", "높이를 미리 모르는데 어떻게 100만 행 중 정확한 위치로 점프하나", "스크롤 한 번에 정확히 몇 개의 DOM 노드가 생기고 사라지나", "어떻게 GPU 트랜스폼 없이도 60FPS가 나오나". 프런트엔드 성능 엔지니어링을 코드로 공부하려는 사람에게 작지만 밀도 높은 실전 교본입니다.
"가변 높이 + O(1) 메모리 + 픽셀이 아닌 인덱스 기반 위치"라는 세 가지를 동시에 해결한 설계.
가상 스크롤 라이브러리는 이미 많습니다(react-window, react-virtualized, TanStack Virtual, Clusterize.js 등). 그런데도 cerious-scroll이 눈길을 끄는 이유는, 기존 라이브러리들이 보통 하나씩은 타협하던 지점들을 한꺼번에 정면돌파했기 때문입니다. 다섯 가지로 정리하면:
많은 가상 스크롤러는 모든 행 높이가 같다고 가정하거나(고정 높이), 가변 높이를 쓰려면 전체 높이를 미리 계산하거나 추정치 → 측정 → 보정의 떨리는(jitter) 과정을 거칩니다. cerious-scroll은 "그리는 순간 즉석에서 측정"합니다 — 행을 DOM에 붙이고 offsetHeight로 실제 높이를 재서 그 측정값으로 다음 행 위치를 잡습니다. 추정 높이도, 사후 보정 패스도 없습니다. 채팅 말풍선처럼 길이가 제각각인 콘텐츠에 강합니다.
전통적 방식은 스크롤 위치를 scrollTop = 45,320px 같은 절대 픽셀로 다룹니다. 그러면 "45,320px가 몇 번째 항목이지?"를 매번 찾아야 하고, 가변 높이면 이 탐색이 비싸집니다. cerious-scroll은 위치를 "현재 항목 = 1000, 그 안에서 25px 지점"으로 표현합니다. 그래서 스크롤 한 번은 현재 항목의 높이만 보고 옆 항목으로 넘어가는 O(1) 연산이 됩니다(아래 §4의 scroll() 트레이스 참고).
높이 캐시를 최대 200개로 고정하고, 현재 위치에서 ±100 범위 밖의 측정값은 잘라 냅니다(pruning). DOM 요소 수도 "보이는 행 + 버퍼 10개 + 하단 경계용 최대 50개"로 상한이 있습니다. 그래서 100만 행이든 1,000만 행이든 메모리 그래프가 평평합니다(문서의 벤치마크 표가 이를 보여줍니다).
많은 가상 스크롤러가 transform: translate3d()로 GPU 가속을 씁니다. cerious-scroll의 기본 모드는 순수 DOM 위치(absolute top)만 씁니다 — GPU 레이어 관리·텍스처 업로드가 없어 GPU 자원이 빈약한 기기에서 더 잘 버팁니다. (선택형 table 모드는 예외적으로 <tbody>에 단 하나의 트랜스폼을 씁니다.)
엔진 본체는 어떤 프레임워크에도 의존하지 않는 순수 TypeScript라, 바닐라 JS는 물론 React·Vue·Angular 래퍼로 감싸 쓸 수 있습니다. 더 영리한 건 진짜 HTML <table>/<tr>/<td> 모드를 옵션으로 제공하면서도(컬럼 자동 정렬·고정 헤더) O(1) 가상화를 유지한다는 점입니다 — 100만 행 테이블도 DOM에는 ~25행만 존재합니다.
| 비교 대상 | 성격 | cerious-scroll과의 차이 |
|---|---|---|
| react-window | 리액트용 경량 가상화 | react-window는 React 전용·고정 높이에 최적. 이쪽은 프레임워크 비종속이고 가변 높이를 즉석 측정으로 1급 지원. |
| TanStack Virtual | 헤드리스 가상화 코어 | 철학이 비슷(헤드리스). 차이는 위치 모델 — TanStack은 픽셀/추정 기반이 흔하고, 이쪽은 '인덱스+오프셋' + 측정 기반이라 보정 패스가 없음. |
| Clusterize.js | 오래된 바닐라 가상화 | 저장소가 직접 나란히 벤치마크(comparison-demo)를 둠. Clusterize는 행 묶음(cluster) 교체 방식이라 가변 높이·초대형에서 흔들림. 이쪽은 행 단위 재활용 + O(1). |
"100M+ at 60FPS"·"O(1) memory" 같은 수치는 저장소 문서와 자체 데모 기준입니다. react-window처럼 수만 곳에서 굴러본 도구가 아니므로, 실서비스 도입 전엔 본인 데이터로 직접 측정하는 게 맞습니다. 다만 "알고리즘 학습 교본"으로서의 가치는 별 개수와 무관하게 높습니다.
package.json의 빌드 단계에 javascript-obfuscator가 들어 있어, npm에 올라가는 dist는 읽기 어렵게 변환됩니다. 공부는 반드시 src/의 TypeScript 원본으로 하세요(이 문서도 원본 기준). 상표(™)·CLA·"All rights reserved" 문구가 있으니 상업적 사용 시 LICENSE(MIT)와 함께 확인하세요.
엔진은 수직 가상화 전용입니다. 가로 스크롤은 브라우저 네이티브에 맡기고(요소 안에서 overflow-x), 2D 그리드 가상화는 로드맵상의 미래 과제입니다. "행이 아주 많은 세로 목록"이 스위트스폿입니다.
의존성이 거의 0인 순수 TypeScript 라이브러리. 무엇으로 만들고, 무엇으로 빌드·검증하는지 펼쳐 본다.
이 프로젝트는 웹 서버가 아니라 브라우저에서 도는 클라이언트 라이브러리라, 보통의 백엔드/프론트엔드/인프라 구분 대신 ① 런타임 본체(소스) ② 빌드 파이프라인 ③ 테스트·배포로 나누어 보는 게 자연스럽습니다. 가장 인상적인 점은 런타임 의존성이 사실상 0이라는 것 — 외부 라이브러리 없이 브라우저 표준 API만으로 동작합니다.
TypeScript(약 5,800줄, 본체) — 타입이 붙은 자바스크립트로 모든 로직 작성. / 브라우저 표준 API만 사용 — ResizeObserver·MutationObserver(콘텐츠 변화 감지), requestAnimationFrame(60FPS 스로틀), offsetHeight(즉석 측정), CustomEvent(cerious-viewport-change로 렌더 시점 통지). "무거운 프레임워크 없이, 브라우저가 이미 주는 것만으로" 만든 게 핵심입니다.
| 핵심 모듈 | 역할 |
|---|---|
| cerious-scroll.ts (904줄) | 총괄 클래스 — 모든 모듈을 조립하고 공개 API(생성자·renderViewport·scroll 등)를 노출 |
| engine/navigation-engine.ts (289줄) | 스크롤 수학의 심장 — 델타(px) → '항목+오프셋' 전환, 경계 보정 |
| core/performance-cache.ts (338줄) | 높이 캐시 + O(1) 메모리를 만드는 가지치기(pruning) 로직 |
| features/viewport-renderer.ts (621줄) | 실제 DOM 생성/측정/재활용 — 윈도잉의 본체 |
| features/native-scrollbar.ts (932줄) | 네이티브 스크롤바와 양방향 동기화(가상 위치 ↔ 픽셀) |
| features/row-placement.ts (510줄) | 행 배치 전략 — AbsolutePlacement(div) / TableFlowPlacement(table) |
| 모듈 | 역할 |
|---|---|
| controllers/wheel-controller.ts | 마우스 휠 — 트랙패드/휠 자동 분류, 부드러운 관성(easing) |
| controllers/touch-controller.ts (429줄) | 터치 제스처 — 속도 추적(고정 크기 링버퍼), 모멘텀, 축 잠금 |
| controllers/keyboard-controller.ts | 방향키·PageUp/Down·Home/End 내비게이션 |
| controllers/resize-controller.ts | 창/컨테이너 크기 변화 → 뷰포트 재계산 |
| observers/content-observer.ts | ResizeObserver+MutationObserver로 행 높이가 실제로 바뀔 때만 캐시 무효화 |
빌드는 4단계 체인입니다: compile(tsc로 TS→JS, ES2017 타깃·타입 선언 생성) → bundle(rollup으로 IIFE/UMD 번들) → minify(terser로 압축) → obfuscate(javascript-obfuscator로 난독화). 산출물은 dist/에 ESM·IIFE·min·obfuscated 여러 형태로 나오고, npm·unpkg·jsDelivr로 배포됩니다.
| 도구 | 쓰임 |
|---|---|
| typescript 5.9 | 타입 검사 + 컴파일(tsc) |
| rollup 4 | 모듈 번들러 — 여러 .js를 하나로 |
| terser 5 | JS 압축(공백·변수명 제거) |
| javascript-obfuscator 4 | 난독화(제어흐름 평탄화·문자열 배열 등) |
vitest(테스트 러너) + jsdom(브라우저 없이 DOM 흉내) 조합으로 단위 테스트를, playwright로 실제 브라우저 통합 테스트를 돌립니다(tests/integration/). vitest.config.ts가 environment: 'jsdom'으로 설정돼 있어 offsetHeight 같은 DOM 측정 로직까지 테스트할 수 있습니다. prepack 스크립트가 배포 전 빌드+테스트를 강제해, 깨진 채로 npm에 올라가는 것을 막습니다.
이 프로젝트는 무거운 프레임워크 대신 브라우저가 이미 제공하는 표준(ResizeObserver·rAF·offsetHeight)만으로 고성능을 냅니다. 런타임 의존성이 없으면 번들 크기가 작고, 버전 충돌·보안 취약점·유지보수 부담이 모두 줄어듭니다. "라이브러리를 만들 때 의존성을 얼마나 줄일 수 있는가"의 좋은 본보기입니다.
스크롤 한 번(휠 50px)이 '항목+오프셋' 계산 → 높이 측정 → DOM 재활용 → 화면을 타고 흐른다.
전체 구조의 핵심은 "위치는 인덱스+오프셋으로 들고, 보이는 만큼만 그리되, 그리는 순간 즉석 측정해서 캐시에 쌓고, 캐시는 일정 크기로 잘라 낸다"입니다. 실제 코드(src/)의 흐름을 그림으로 보면:
가장 근본적인 설계 결정입니다. 위치를 scrollTop=45,320px가 아니라 { element: 1000, offset: 25 }("1000번 항목의 25px 지점")으로 들고 다닙니다. 그래서 스크롤은 현재 항목의 높이만 보고 옆으로 한 칸씩 넘어가는 O(1) 연산이 되고, 가변 높이여도 "이 픽셀이 몇 번째 항목이지?"를 매번 탐색할 필요가 없습니다.
// navigation-engine.ts scroll() 의 핵심 루프 (요약)
let element = currentElement;
let offset = scrollOffset + deltaY; // 델타를 오프셋에 더함
while (true) {
const h = getElementHeight(element);
if (offset >= h && element < total - 1) { // 아래로 넘침 → 다음 항목
offset -= h; element++; continue;
}
if (offset < 0 && element > 0) { // 위로 넘침 → 이전 항목
element--; offset = getElementHeight(element) + offset; continue;
}
break; // 더 넘길 게 없으면 종료
}
offset = Math.max(0, Math.min(offset, getElementHeight(element) - 1)); // 범위 고정
src/engine/navigation-engine.ts scroll() — 보통은 while이 0~2번만 돌고 끝납니다(현재 항목 안에서 움직이거나 한두 항목 넘는 게 대부분).
viewport-renderer.ts는 행을 하나 그릴 때마다 즉시 offsetHeight로 재서 그 값으로 다음 행 위치를 잡습니다. "5번 행을 붙이고 → 높이 재고 → 그만큼 아래에 6번 행을 붙이고 → ..." 식으로, 뷰포트가 다 찰 때까지 반복합니다. 추정 높이가 없으니 사후 보정 패스(잘못 놓인 걸 다시 옮기는 과정)가 아예 필요 없습니다. 단, 모든 행이 같은 높이로 판명되면 offsetHeight 읽기를 건너뛰는 최적화(uniform-height hint)가 있습니다.
performance-cache.ts는 측정 높이를 Map에 저장하되 최대 200개로 묶고, 250개를 넘으면 현재 위치 ±100 밖의 항목을 삭제합니다. 그래서 1,000만 행을 끝까지 스크롤해도 캐시는 항상 ~200개. "측정값은 영원히 쌓이는 게 아니라, 현재 보는 곳 주변만 들고 있는다"가 O(1)의 비밀입니다.
// performance-cache.ts _pruneOldCacheEntries() (요약)
if (measuredHeights.size <= 250) return; // 임계 넘을 때만
const keep = 100; // 현재 위치 ±100만 보존
const min = lastAccessedIndex - keep, max = lastAccessedIndex + keep;
for (const [index] of measuredHeights) {
if (index < min || index > max) measuredHeights.delete(index);
}
// 최대 200개 × 약 8바이트 ≈ 1.6KB — 데이터 크기와 무관!
"행이 어떻게 y좌표에 도달하는가"를 RowPlacement 인터페이스로 분리했습니다. AbsolutePlacement(기본)는 각 행을 position:absolute로 두고 top을 직접 씁니다(트랜스폼 0). TableFlowPlacement는 진짜 <table> 안에 행을 흐름대로 넣고 <tbody>에 transform: translateY() 한 번으로 창문 전체를 밀어 컬럼 정렬을 네이티브로 얻습니다. 렌더링 알고리즘(측정·재활용·O(1))은 그대로 두고 'DOM에 쓰는 방식'만 갈아 끼우는 깔끔한 분리입니다.
스크롤바 드래그·터치 같은 고빈도 이벤트를 requestAnimationFrame으로 묶어 초당 최대 60번만 처리합니다(문서상 렌더 70~80% 감소). 또 매 프레임 새 객체를 만드는 대신 Set·배열·결과 객체를 재사용해 GC 압박을 줄입니다(_shouldBeVisibleSet, _scrollResult 등). "빠른 길의 적은 잦은 메모리 할당"이라는 통찰이 코드 전반에 배어 있습니다.
사용자가 마우스 휠을 50px 아래로 굴렸을 때, 코드가 어디를 거쳐 화면이 갱신되는지 단계별로 추적합니다(현재 상태: element=100, offset=30, 뷰포트 높이 600px, 행 높이 ~40~60px 가정).
뷰포트 600px ÷ 행 40px ≈ 보이는 행 15개. 여기에 위아래 버퍼 각 5개(=10개)를 더하면 화면 윈도우는 ~25개. 하단 경계 감지용 최대 50개를 더해도 DOM에 존재하는 행은 많아야 ~75개입니다 — 데이터가 100개든 1억(100,000,000)개든 똑같습니다. 만약 1억 개를 전부 <div>로 만들었다면? 노드 하나에 수백 바이트만 잡아도 수십 GB라 브라우저가 즉사합니다. 그 차이가 O(N)과 O(1)의 차이입니다.
진짜 코드는 전부 src/ 아래. 역할별로 깔끔하게 쪼개진 모듈들을 펼쳐 본다.
읽는 순서를 추천하자면: ① docs/ARCHITECTURE.md(전체 그림과 알고리즘) → ② engine/navigation-engine.ts(위치를 어떻게 계산하는지) → ③ core/performance-cache.ts(메모리를 어떻게 일정하게 유지하는지) → ④ features/viewport-renderer.ts(실제 DOM이 어떻게 재활용되는지) 순입니다. vanilla-js-demo.html로 가장 단순한 사용법을 먼저 보고 들어가면 코드가 훨씬 잘 읽힙니다.
이 저장소의 진짜 가치는 "가상화 알고리즘 + 브라우저 성능 엔지니어링"을 한곳에서 코드로 볼 수 있다는 것.
"긴 목록 전체를 그리지 않고 보이는 부분만 그린다"는 가상화의 핵심을 viewport-renderer.ts가 5단계로 또렷이 구현합니다(위 버퍼 → 보이는 행 증분 → 아래 버퍼 → 하단 경계 → 안 보이는 행 제거). react-window 같은 라이브러리를 그냥 쓰기만 하던 사람이 "그 안에서 무슨 일이 일어나는가"를 코드로 체득하기에 최적입니다. 특히 "보이는 행 수 = 뷰포트 높이 ÷ 행 높이"라는 직관을 측정 기반으로 정확히 구현한 부분을 보세요.
navigation-engine.ts의 scroll()은 "자료구조/위치 표현을 잘 고르면 알고리즘이 단순해진다"의 교과서 사례입니다. 픽셀 대신 인덱스+오프셋을 택한 것만으로 가변 높이 탐색이 O(1)이 됩니다. 더불어 입력 방어(NaN/Infinity 거부), 서브픽셀 누적 방지(Math.round로 정수 픽셀 고정), GC 최적화(결과 객체 재사용) 같은 실전 디테일이 주석과 함께 들어 있습니다.
// navigation-engine.ts — 잦은 작은 델타가 만드는 서브픽셀 드리프트를 막는다
offset = Math.round(offset); // 정수 픽셀로 스냅
const finalHeight = Math.max(1, getElementHeight(element));
if (offset >= finalHeight) offset = finalHeight - 1;
if (offset < 0) offset = 0;
브라우저는 휠 이벤트로 소수점 deltaY(예: 49.6px)를 자주 보냅니다. 이걸 그대로 누적하면 스크롤바 썸과 미세하게 어긋나는데, 정수 스냅으로 막습니다. "작은 오차가 쌓여 큰 버그가 된다"는 부동소수점 함정의 좋은 예입니다.
performance-cache.ts여기가 "O(1) memory" 주장의 실체입니다. 측정 높이를 Map에 담되 250개를 넘으면 현재 위치 ±100 밖을 잘라 냅니다. 추가로 균일 높이 자동 감지(_isUniformHeight)로, 모든 행 높이가 같으면 누적 높이·위치 계산을 O(1) 산술로 단축합니다(row × uniformHeight). 가변이면 측정값으로 선형 탐색하되 setTotalElements로 상한을 둬서 잘못된 입력에도 무한 루프에 빠지지 않게 방어합니다.
viewport-renderer.ts화면 밖으로 나간 요소를 버리지 않고 recycledElements 풀에 모아 두었다가, 새로 들어오는 자리에 재사용합니다(acquireRow()). 더 미묘한 최적화는 "이미 측정된 높이는 offsetHeight를 다시 읽지 않는다"입니다 — offsetHeight 읽기는 브라우저에 강제 레이아웃(reflow)을 유발해 비싸기 때문입니다.
offsetHeight를 읽으면 정확한 값을 주려고 브라우저가 그 자리에서 리플로우를 강제합니다. 매 프레임 수십 번 읽으면 레이아웃 스래싱(읽기-쓰기-읽기 반복으로 리플로우가 폭증)이 일어나 프레임이 떨어집니다. cerious-scroll은 측정값을 캐시해 새 행에서만 한 번 읽도록 해 이를 피합니다.// viewport-renderer.ts measureReused() — 캐시 우선, 없을 때만 측정+재캐시
if (hasMeasuredHeight(index)) return getMeasuredHeight(index); // 리플로우 회피
const hint = getUniformHeightHint?.();
if (hint !== undefined) { setMeasuredHeight(index, hint); return hint; }
const height = element.offsetHeight; // 캐시 미스일 때만 1회 강제 측정
setMeasuredHeight(index, height); // 즉시 재캐시(다음엔 안 읽음)
return height;
native-scrollbar.ts가상 위치(항목+오프셋)와 실제 스크롤바의 픽셀 위치를 양방향으로 맞추는 건 의외로 까다롭습니다. 프로그램이 scrollTop을 쓰면 브라우저가 scroll 이벤트를 비동기로 되돌려 주는데(에코), 이걸 사용자 조작으로 오해하면 피드백 루프가 생깁니다. CHANGELOG 1.0.7은 이 "에코 억제 마커가 낡아서 스크롤바를 맨 위로 끌어도 70행쯤에서 멈추던" 실제 버그의 추적·수정 과정을 상세히 기록합니다 — 실전 동기화 버그가 어떻게 생기고 잡히는지 배우기 좋은 사례입니다.
예전엔 하나의 거대한 핸들러가 모든 입력을 처리했지만, 지금은 장치별 컨트롤러로 분리돼 각각 attach()가 정리(cleanup) 함수를 반환하는 일관된 생명주기를 가집니다(dispose()에서 한 번에 정리). 특히 wheel-controller의 트랙패드 vs 마우스 휠 자동 분류(델타의 모양·빈도로 판별)와 touch-controller의 고정 크기 링버퍼(Float64Array 16칸) 속도 추적은 "제스처 길이와 무관하게 O(1)로 속도 계산"하는 영리한 디테일입니다.
row-placement.ts같은 렌더링 알고리즘 위에서 행 배치 방식만 바꿉니다. 인터페이스(prepare/createRow/attach/position/detach/commit)를 두고, AbsolutePlacement는 top을 쓰고 TableFlowPlacement는 <tbody>에 트랜스폼 한 번을 씁니다. "공통 알고리즘 + 교체 가능한 세부 구현"이라는 전략 패턴(Strategy Pattern)의 실전 예시이고, 디자인 패턴을 책이 아니라 코드로 보고 싶을 때 딱입니다.
viewport-renderer.ts는 제거 단계(STEP 5)보다 먼저 하단 경계 행 인덱스를 "보여야 할 집합"에 넣어 둡니다. 안 그러면 매 프레임 하단 경계 행이 제거됐다가 다시 만들어져(160번 넘는 appendChild) 성능이 무너지기 때문 — 주석에 그 이유가 적혀 있습니다. "순서를 바꿔 불필요한 재생성을 막는" 미묘한 최적화입니다.
스크롤이 아니라 멀리 점프(이전 위치에서 100개 초과 이동)하면 placement.clear()로 DOM을 싹 비우고 재구축합니다. "조금씩 스크롤"과 "확 점프"는 성격이 달라서, 후자는 통째로 다시 그리는 게 메모리·정확성에 유리하다는 판단입니다(풀은 유지해 재사용).
브라우저 라이브러리라 요구사항은 가볍다 — 핵심은 "어떤 브라우저 API가 있어야 하나"이다.
| 항목 | 요구사항 / 메모 |
|---|---|
| 런타임 | 모던 브라우저(또는 jsdom). 런타임 의존성 0 — 외부 라이브러리 불필요. |
| 필수 브라우저 API | ResizeObserver·MutationObserver·requestAnimationFrame·CustomEvent. 대부분의 모던 브라우저가 기본 제공. |
| JS 타깃 | 컴파일 타깃 ES2017(번들) / 타입은 ES2015 기준. 구형 브라우저는 폴리필 필요할 수 있음. |
| 설치(개발) | Node.js + npm. npm install → npm run build → npx http-server . -p 8080로 데모 실행. |
| 설치(사용) | npm @ceriousdevtech/cerious-scroll, 또는 unpkg/jsDelivr CDN <script> 한 줄. |
| 프레임워크 | 비종속. 바닐라 JS / React / Vue / Angular 어디든. (래퍼는 각 프레임워크별 제공 안내.) |
| 메모리 | 데이터 크기와 무관하게 ~200KB 베이스라인(문서 벤치마크). 격한 스크롤 시 3~4MB까지, GC 후 복귀. |
| SSR | DOM이 없는 서버 렌더링 환경에서도 안전(스크롤바 스타일 주입 등은 첫 DOM 사용 시 지연 처리). |
공부가 목적이라면 설치할 것도 거의 없습니다. 저장소를 클론해 npm install && npm run build 후 데모 HTML을 브라우저로 여는 것만으로 충분합니다. 특히 vanilla-js-demo.html과 performance-monitor.js를 띄워 두고, 항목 수를 100만으로 올린 뒤 스크롤하면서 DOM 노드 수와 메모리가 그대로인지 개발자도구로 직접 확인해 보세요 — O(1)을 눈으로 보는 게 가장 강력한 학습입니다.
읽기만 하면 금방 잊는다. 난이도별로 손을 움직여 보자.
저장소를 빌드한 뒤 vanilla-js-demo.html(또는 data-grid-demo.html)을 열고, 크롬 개발자도구의 Elements 탭에서 스크롤 컨테이너 자식 수를 세어 보세요. 데이터가 1만 개여도 자식이 수십 개뿐임을 눈으로 확인하는 게 목표입니다.
data-element-index 속성이 어떻게 바뀌는가?새 HTML 한 장에 CeriousScroll을 불러와 항목 1만 개짜리 목록을 직접 띄워 보세요. renderViewport(height, container, (index, el) => {...}) 콜백에서 el.innerHTML로 내용만 채우면 됩니다. "내가 데이터를, 라이브러리가 화면을" 담당하는 역할 분담을 체감하는 게 핵심입니다.
각 항목의 텍스트 길이를 랜덤하게 줘서 행 높이가 제각각이 되게 하세요. cerious-scroll이 미리 계산 없이 자연스럽게 처리하는지, 스크롤이 떨리지 않는지 확인합니다. table-dynamic-heights-demo.html이 좋은 참고가 됩니다.
navigation-engine.ts의 scroll() 루프를 종이에 손으로 돌려보기§4의 트레이스처럼 초기 상태(예: element=5, offset=35, 높이 배열 [40,40,40,40,40,40,...])를 정하고, scroll(deltaY=130)을 코드대로 손으로 계산해 최종 {element, offset}을 구해 보세요. 그다음 콘솔에서 같은 입력으로 실제 결과와 맞춰 봅니다.
offset = prevHeight + offset가 왜 맞는지 설명할 수 있는가?performance-cache.ts의 getCacheStats()를 콘솔에 주기적으로 찍으면서, 100만 행을 끝까지 스크롤해 보세요. measuredElements가 200~250 부근에서 더 늘지 않고 유지되는지 확인합니다. 가지치기 코드를 잠시 주석 처리하면 어떻게 되는지도 비교해 보세요.
row-placement.ts의 RowPlacement 인터페이스를 구현해 새 배치 전략을 만들어 보세요(예: 트랜스폼 기반 윈도 시프트, 또는 CSS Grid 배치). 렌더링 알고리즘은 건드리지 않고 'DOM 쓰는 방식'만 바꿔도 동작하는지 확인하면, 전략 패턴의 분리가 얼마나 깔끔한지 체감합니다.
AbsolutePlacement와 내 전략의 스크롤 부드러움·페인트 비용을 performance-monitor로 비교해 보라.이 저장소를 '교과서'로 삼아 5주간 프런트엔드 성능 가상화를 정복하는 코스.
| 주차 | 주제 | 이 저장소에서 볼 곳 + 곁들일 자료 |
|---|---|---|
| 1주 | 가상화 큰 그림 | docs/ARCHITECTURE.md 정독 + vanilla-js-demo 띄워보기. 곁들임: react-window 개념 글, "왜 긴 목록은 느린가". |
| 2주 | 위치 모델 · 스크롤 수학 | engine/navigation-engine.ts 줄 단위 분석. 개념: 인덱스+오프셋 vs 픽셀, 부동소수점 누적 오차. |
| 3주 | 측정 · O(1) 메모리 | core/performance-cache.ts + viewport-renderer.ts. 개념: offsetHeight/리플로우, 슬라이딩 윈도우 캐시, 가지치기. |
| 4주 | 브라우저 성능 엔지니어링 | 풀링·rAF 스로틀·레이아웃 스래싱 회피. 곁들임: 크롬 Performance 탭으로 직접 프로파일링, "리플로우/리페인트" 자료. |
| 5주 | 입력·동기화·확장 | controllers/(휠/터치 분류) + native-scrollbar.ts(에코 억제) + row-placement.ts(전략 패턴). 곁들임: CHANGELOG 1.0.4~1.0.7의 버그 추적기. |
한 번에 다 이해하려 하지 마세요. 1~2주(가상화 큰 그림 + 위치 모델)만 제대로 소화해도 "react-window가 안에서 뭘 하는지" 아는 사람이 됩니다. 어려운 부분(동기화·풀링)은 §4·§6의 스니펫과 주석을 길잡이 삼아, 실제로 데모를 프로파일링하며 따라가는 게 가장 빠릅니다.
이 문서·저장소에 나온 용어를 한 줄씩 정리. 모르면 여기로 돌아오기.
| 용어 | 한 줄 뜻 |
|---|---|
| 가상 스크롤(virtual scroll) | 긴 목록 전체가 아니라 화면에 보이는 부분만 그려 성능을 내는 기법. |
| 가상화(virtualization) | 가상 스크롤의 일반 용어 — 실제로는 일부만 렌더하고 나머지를 흉내 냄. |
| 윈도잉(windowing) | 목록 위에 '창문'을 두고 그 안 + 여유분만 렌더하는 가상화 구현 방식. |
| O(1) / O(N) | 비용 표기 — O(1)은 입력 크기와 무관하게 일정, O(N)은 비례해 증가. |
| 인덱스+오프셋 | 스크롤 위치를 픽셀이 아니라 '몇 번째 항목 + 그 안 몇 px'로 표현하는 모델. |
| 오버스캔(overscan) | 화면 위아래로 미리 그려 두는 여유 행(이 라이브러리는 각 5개) — 스크롤 시 깜빡임 방지. |
| FPS | 초당 프레임 수 — 60FPS면 1프레임에 약 16ms 안에 모든 작업을 끝내야 함. |
| offsetHeight | 요소의 실제 렌더 높이(px). 읽는 순간 브라우저가 리플로우를 강제해 비쌈. |
| 리플로우(reflow) | 브라우저가 요소 크기·위치를 다시 계산하는 무거운 작업(레이아웃). |
| 리페인트(repaint) | 레이아웃은 그대로 두고 픽셀(색·테두리 등)만 다시 칠하는 작업. |
| 레이아웃 스래싱 | 읽기-쓰기를 번갈아 해 리플로우가 폭증하는 안티패턴 — 캐시로 회피. |
| 가지치기(pruning) | 캐시가 너무 커지면 현재 위치에서 먼 항목을 삭제해 메모리를 일정하게 유지. |
| 객체 풀링(pooling) | 화면 밖 요소를 버리지 않고 모아 두었다가 재사용해 GC 부담을 줄임. |
| GC(가비지 컬렉션) | 안 쓰는 메모리를 자동 회수하는 과정 — 잦으면 끊김의 원인이 됨. |
| requestAnimationFrame(rAF) | 다음 화면 갱신 직전에 콜백을 부르는 API — 고빈도 이벤트를 60FPS로 스로틀. |
| 스로틀(throttle) | 잦은 이벤트를 일정 간격(여기선 프레임당 1회)으로 묶어 처리량을 제한. |
| ResizeObserver | 요소 크기 변화를 감지하는 브라우저 API — 행 높이 변화 추적에 사용. |
| MutationObserver | DOM 변경(추가·삭제·속성)을 감지하는 API — 콘텐츠 변화 시 캐시 무효화. |
| transform: translateY | GPU 가속 위치 이동 — 기본 모드는 안 쓰고, table 모드만 <tbody>에 1회 사용. |
| 프레임워크 비종속 | 특정 UI 프레임워크에 묶이지 않아 어디서든 쓸 수 있는 설계. |
| 전략 패턴(Strategy) | 공통 알고리즘은 두고 세부 구현(여기선 행 배치)을 갈아 끼우는 디자인 패턴. |
| true-bottom(진짜 바닥) | 마지막 행 아래가 뷰포트 바닥에 딱 닿는 정확한 스크롤 위치 — 끝에서 어긋남 방지. |
| 에코 억제 | 프로그램이 스크롤바를 움직일 때 되돌아오는 이벤트를 사용자 조작으로 오해하지 않게 거름. |
| 난독화(obfuscation) | 배포 JS를 읽기 어렵게 변환 — 공부는 src/ 원본으로 해야 하는 이유. |
| jsdom | 브라우저 없이 DOM을 흉내 내는 Node 라이브러리 — 단위 테스트 환경. |
원본 저장소와, 각 개념을 더 깊이 팔 때의 출발점.