- 발행일
인스턴스 트리를 가상 스크롤로 옮겼다 — 하루에 세 번 바꾼 끝에 창 하나만 남겼다
인스턴스 트리를 가상 스크롤로 옮겼다
관리자 인스턴스 화면의 왼쪽은 jsTree입니다. 네임스페이스를 열면 클래스가 나오고, 클래스를 열면 그 아래 인스턴스가 매달려요. 운영 데이터를 미러하고 나니 kadif:DataFrameField 한 클래스에 2,730건 남짓이 달렸습니다(같은 날 다른 문서는 2,732건으로 적었습니다).
원래는 100건씩 끊고 마지막에 더 보기 노드를 붙이는 방식이었습니다. 이날 오후에 그 노드가 눌러도 반응이 없다는 걸 잡았고, 저녁에는 그 노드 자체를 없앴어요. 하루에 세 번 바꿨습니다. 그 과정을 적어 둡니다.
오후 — '더 보기'가 조용히 죽어 있었다
더 보기를 누르면 다음 묶음을 받아 붙여야 하는데 아무 일도 안 일어났습니다. 콘솔 오류도 없어요.
- const data = (node && node.original && node.original.data) || {};
+ const data = (node && node.data) || (node && node.original && node.original.data) || {};
jsTree는 노드를 파싱하면서 data를 node.data로 옮기고 원본에서는 지웁니다. node.original.data만 읽으면 class_id·offset이 빈 값이라, 그 값이 없으면 return하던 경로로 조용히 빠져나갔던 거예요. 한 줄 고치고 회귀 스펙을 붙였습니다.
이 스펙은 그날 저녁에 통째로 지워졌습니다. 더 보기 노드가 없어졌으니까요.
저녁 — 창 하나만 남긴다
더 보기를 고치고 나서 스크롤이 바닥에 닿으면 자동으로 다음 묶음을 붙이는 방식까지 가 봤습니다. 그런데 그건 결국 DOM이 계속 자라는 구조예요. 2,730건을 다 훑으면 li가 2,730개입니다. 목적이 훑기가 아니라 DOM을 작게 유지하는 것이라면 답은 하나였습니다.
| 방법 | DOM 크기 | 포기하는 것 | 판단 |
|---|---|---|---|
① 100건씩 더 보기 | 누를수록 자람 | 끝까지 가면 전량 | 오후까지의 현행 |
| ② 스크롤 바닥에서 자동 적재 | 누를수록 자람 | ①과 같고 클릭만 없어짐 | 잠깐 시도 |
| ③ 창(window)만 DOM에 두고 나머지는 여백 | 창 크기로 고정 | 행 높이가 같아야 하고, 테마가 padding을 안 써야 함 | 채택 |
// ── 가상 스크롤 ────────────────────────────────────────────────────────────
// 클래스 아래 인스턴스는 창(windowSize) 만큼만 DOM 에 둔다. 창 위·아래의 나머지는
// 자식 ul 의 padding 으로 높이만 위조해, 스크롤바 길이와 위치가 전량을 그린 것과 같게 한다.
// 전제 두 가지 — 행 높이가 모든 인스턴스 노드에서 같고(실측 25px), proton 테마가 그
// ul 에 padding 을 쓰지 않는다는 것. 둘 중 하나가 깨지면 스크롤 위치가 어긋난다.
창 위아래의 나머지 행은 자식 ul의 padding-top·padding-bottom으로 높이만 세웁니다. 스크롤바 길이와 위치가 전량을 그린 것과 같아져요.
서버 응답도 바뀌었습니다. 배열이던 것이 {total, offset, nodes}가 됐어요. 이 엔드포인트를 직접 부르는 e2e 두 곳이 같이 고쳐졌습니다.
창 크기는 화면이 정한다
처음엔 보이는 행 × 2였는데, DOM을 더 줄여 달라는 요청으로 버퍼를 고정 8행으로 좁혔습니다.
// 창의 하한은 "한 화면"이다 — 그보다 작으면 채울 행이 모자라 늘 빈 칸이 보인다.
// 그래서 창 = 보이는 행 + 위아래 버퍼(BUFFER 행씩)로만 잡고, 버퍼는 스크롤이 창을
// 갈아끼우는 사이를 메우는 최소한으로 둔다. 화면 높이는 창마다 다르므로 실측해 쓴다.
const WINDOW_BUFFER = 8;
고정 숫자를 두면 큰 모니터에서 버퍼가 사라져 스크롤 중 빈 칸이 보이고, 작은 화면에선 헛되이 많이 그립니다. 1080p 실측으로 한 화면이 약 31행이라 창은 47 안팎이에요.
하한이 "한 화면"인 이유가 중요합니다. 그보다 작게 잡으면 채울 행이 모자라 항상 빈 칸이 남는데, 로딩 표시로도 못 가려요. 데이터가 없는 게 아니라 안 그린 것이니까요.
행 높이도 상수로 안 박았습니다. 테마가 정하는 값이라, 박아 두면 테마가 바뀔 때 조용히 어긋나요. 인스턴스 노드가 그려진 뒤에 실측합니다.
여백은 인라인이 아니라 우리 style 규칙으로
인라인 style로 주는 게 당연해 보였는데, 그게 안 됐습니다. 처음엔 ul.style.paddingTop으로 인라인에 줬어요.
// 창 밖 행들을 padding 으로 세운다.
// ⚠️ 인라인 style 로 주면 안 된다 — jstree 가 서브트리를 다시 그릴 때 ul 을 새로 만들어
// 인라인 값이 통째로 날아가고, 그 사이 스크롤바가 창 크기만큼만 잡힌다. 우리가 소유한
// <style> 규칙이면 어느 시점에 다시 그려져도 그대로 적용된다.
const windowStyleEl = document.createElement("style");
document.head.appendChild(windowStyleEl);
jsTree가 서브트리를 다시 그릴 때 ul을 새로 만듭니다. 인라인 값이 통째로 날아가고, 그 사이 스크롤바가 창 크기만큼만 잡혀요. 화면이 소유한 <style> 요소에 #cls-N > ul.jstree-children { padding-top: …; padding-bottom: … } 규칙을 써 두면 어느 시점에 다시 그려져도 그대로 적용됩니다.
같은 이유로 창을 갈아끼우는 순간에도 손이 갔습니다.
// 노드를 걷어내는 동안 ul 이 잠깐 60행만큼 줄어든다 — 그 사이 브라우저가 스크롤을
// 잘라 버리면 화면이 튀므로, 위치를 붙잡았다가 새 패딩을 씌운 뒤 되돌린다.
옛 창의 노드를 걷어내면 ul이 잠깐 줄어들고, 브라우저가 그 순간 scrollTop을 잘라 버립니다. 위치를 붙잡았다가 새 패딩을 씌운 뒤 되돌려요.
jsTree 애니메이션을 껐다
이건 켜 둔 채로는 알 수 없는 종류였습니다.
// 펼침 애니메이션(jQuery slideDown)은 height 뿐 아니라 padding 도 함께 굴린다.
// 가상 스크롤이 창 밖 높이를 padding 으로 세우므로, 켜 두면 수만 px 를 향해
// 슬라이드하며 스크롤바가 출렁인다.
animation: false,
jQuery slideDown은 height만 굴리는 게 아니라 padding도 같이 굴립니다. 창 밖 높이를 padding으로 세워 뒀으니, 클래스를 펼칠 때마다 수만 px를 향해 슬라이드하면서 스크롤바가 출렁였어요.
창 밖의 것을 고르라는 요청
참조 칩을 눌러 다른 인스턴스로 건너뛰거나, ?selected_node=inst-N 주소로 들어오거나, 저장 뒤 원래 자리로 돌아올 때 그 노드가 창 밖에 있을 수 있습니다. DOM에 없으니 select_node가 안 먹어요.
// 창 밖 인스턴스를 고르라는 요청(참조 칩·주소 복원·저장 후 복귀)은 순번을 서버에 묻고
// 그 자리로 창을 옮긴 뒤에야 select_node 가 먹는다.
서버에 instance_id로 순번을 묻고, 그 자리로 창을 옮긴 뒤 고릅니다. 순번 계산은 트리 정렬 (name, id)를 그대로 재현해야 해요. 서버 쪽에서 정렬 기준을 한 함수로 뽑고, 순번 함수가 그걸 쓰도록 했습니다.
훑기가 안 되면 찾기는 따로 있어야 한다
창 단위라 트리로는 훑기만 됩니다. 수백 건짜리 클래스에서 한 건을 집는 수단이 필요했어요. 같은 날 오른쪽 목록에 이름 검색을 붙였습니다. 데이터 프레임 690건은 페이지 넘김으로 35쪽, 필드 2,730건은 137쪽이라 넘겨서 찾는 건 안 됩니다.
여기서 하나 고집한 게 있습니다.
// 검색 0건은 목록 모드를 유지해야 한다 — showEmpty 는 검색칸이 든 #drDetail 을
// 통째로 감춰서, 그리로 빠지면 검색어를 지울 수단이 사라진다.
0건일 때 빈 상태 화면으로 빠지면 검색칸이 든 영역이 통째로 감춰집니다. 검색어를 지울 수단이 사라져요. 0건에도 목록 모드를 유지하고 "검색 결과가 없습니다" 한 행만 둡니다.
검증
- 500건 넘는 클래스를 실행 시점에 골라 펼친 뒤 DOM의 인스턴스 행 수가 전체의 1/5 미만인 것, 아래 여백이
(전체 − DOM 행 수) × 행 높이와 정확히 같은 것을 스펙으로 잠금. 전제 둘(행 높이 단일,ul인라인 padding 없음)도 같은 스펙이 검사합니다. - 300행 아래로 스크롤 → 첫 행이 바뀌고 DOM 개수는 그대로인 것 확인. 무한 스크롤과 갈리는 지점이라 스펙에 그 단언을 뒀어요.
- 창 응답을 1.2초 늦춰 미적재 구간에서 로딩 표시가 뜨는 것 확인. 빠른 응답에선 스쳐 지나가 확정적이지 않아 일부러 늦췄습니다.
- 창 밖 인스턴스를
selected_node로 지정해 진입 → 창이 옮겨진 뒤 선택되는 것 확인. 순번 조회는 단위 테스트로 잠금. - 목록 이름 검색으로 좁히고, 존재하지 않는 이름으로 0건을 만들어도 검색바가 남는 것 확인.
남은 것 · 한계
- 전제 두 개에 기대고 있습니다. 행 높이가 전부 같을 것, 테마가 그
ul에 padding을 안 쓸 것. 스펙이 검사하긴 하지만 jsTree 테마를 바꾸는 사람이 그 스펙을 먼저 볼 거란 보장은 없어요. - 선택 중인 노드가 창 밖으로 밀리면 트리 하이라이트만 사라집니다. 상세는 그대로예요. 스크롤을 돌리면 다시 나타나지만, 그 사이엔 뭘 보고 있는지 트리가 말하지 않습니다.
- 오후에 붙인
더 보기회귀 스펙을 저녁에 지웠습니다. 한 줄 고치고 스펙까지 만든 게 몇 시간짜리였어요. 바꿀 걸 알았으면 스펙은 안 만들었을 텐데, 그때는 몰랐습니다. - JSTree·VTree 사전 조사 글에서 "가상 스크롤 트리로의 이전은 필요가 생기기 전까지는 하지 않기로 했다"고 적었습니다. 필요가 생겼고 이전은 했는데, jQuery 없는 대안으로 간 게 아니라 jsTree 위에 창을 얹었어요. 그 글이 말한 방향과는 다릅니다.
- 창 크기 산출이 클라이언트에 있고 서버는
limit을 받아 주는 구조입니다. 서버 기본값은 100으로 남아 있어서,limit없이 부르는 곳이 생기면 옛 크기로 돌아갑니다.
관련 글: JSTree & VTree 사전 조사 — 그리고 1년 뒤 무엇이 살아남았나 · jsTree 기본 사용법 — 플러그인·API 정리와 실무에서 걸린 지점 · 무한 스크롤 비교 — 쓸 것인가, 그리고 무엇으로 · 편집 중 트리를 누르면 입력이 사라졌다 · 운영 2,732건이 92,948px 캔버스를 만들었다