dockview + tanstack-virtual 가상 스크롤 어긋남 - DOM 재부착 문제

마지막 수정일 · 2026. 07. 29.


tags: - react - 가상화 - virtual-scroll - dockview - tanstack-virtual - 버그진단 created: 2026-07-29

TL;DR

DOM 노드를 다른 부모로 옮기는 레이아웃 라이브러리(dockview 등)에서 패널을 옮기면 브라우저가 스크롤 컨테이너의 scrollTop을 0으로 리셋하는데, 이때 scroll 이벤트를 안 쏨. 가상화기(TanStack Virtual 등)는 scroll 이벤트로만 오프셋을 갱신하니까 예전 오프셋 자리에 행을 계속 그림 → 뷰포트엔 빈 스페이서만 → 백지. 클릭·휠로 첫 이벤트가 나면 0으로 동기화되며 살아나는데 위치는 맨 위임.

해결방법: 재부착/재표시 시점을 잡아서 가상화기와 스크롤 위치를 다시 동기화하면 됨. 셋 중 하나.

  1. 레이아웃 라이브러리의 "보이게 됨 / 위치 바뀜" 이벤트(dockview면 onDidVisibilityChange, onDidLocationChange)로 measure() + 스크롤 복원
  2. 스크롤 엘리먼트 rect가 0 → 양수로 돌아오는 순간을 리스트 내부에서 감지해 마지막 오프셋 복원 (라이브러리 비의존)
  3. 스크롤 위치를 패널 단위로 저장·복원 (팝아웃처럼 진짜 리마운트되는 케이스까지 커버, 제일 튼튼)

문제상황

패널을 옮기거나 탭을 바꾸고 돌아오면 가상 스크롤 리스트가 텅 비어 보임.
클릭하거나 휠을 굴리면 그제서야 행이 나타나는데, 원래 보던 위치가 아니라 맨 위에서 시작함.

dockview(도킹/패널), react-mosaic, golden-layout, 드래그하다 부모가 바뀌는 DnD, 팝아웃 창처럼 DOM 노드를 다른 부모로 옮기는 레이아웃 라이브러리
TanStack Virtual / react-window / react-virtuoso처럼 scroll 이벤트로만 오프셋을 추적하는 가상화기를 같이 쓰면 나는 문제임.
라이브러리 조합이 뭐든 원리는 같음.

원인 1 : 레이아웃 라이브러리는 React 몰래 DOM을 옮김

보통 React 앱에서는 배치가 바뀌어도 엘리먼트가 문서 트리에서 통째로 빠지는 일이 드묾.
근데 이런 라이브러리들은 패널마다 콘텐츠 div를 하나 만들어 두고 React는 그 안에만 계속 렌더링하게 한 뒤,
배치는 자기가 순수 DOM 조작으로 그 div를 옮겨서 처리함. 탭 전환, 그룹 간 드래그, 팝아웃, 재도킹 전부 이 경로.

js
// dockview-core/dist/cjs/dockview/components/panel/content.js (renderPanel)
this._element.removeChild(this.panel.view.content.element);  // 문서에서 뗌
// ... 새 그룹/팝아웃에서 ...
this._element.appendChild(this.panel.view.content.element);  // 다시 붙임

여기에 숨겨진 탭/패널은 보통 display:none 처리까지 같이 들어감.

리마운트가 아니라 재부착이라는 게 포인트임. React 입장에선 아무 일도 안 일어난 거라 effect도 안 돌고 ref도 같은 객체 그대로임.

원인 2 : 브라우저는 분리됐던 엘리먼트의 스크롤을 안 지켜줌

브라우저는 문서에 붙어 있고 레이아웃이 있는 엘리먼트에 대해서만 스크롤 위치를 유지함.
문서에서 분리되는 순간 레이아웃이 사라지면서 스크롤 위치도 같이 버려지고, 다시 붙이면 scrollTop = 0부터 시작함.

결정적인 건 이 리셋이 scroll 이벤트를 발생시키지 않는다는 것.
사용자가 스크롤한 게 아니라 브라우저가 상태를 폐기한 거라서 밖에서 지켜보는 코드한테는 통보가 안 감.

원인 3 : 가상화기의 유일한 귀는 scroll 이벤트

가상화기는 스크롤 오프셋을 딱 한 경로로만 갱신함. 스크롤 엘리먼트에 붙인 scroll 리스너.
컨테이너 크기는 ResizeObserver로 따로 보지만 scrollTop 값 자체를 다시 읽어보는 감시자는 없음.

js
// @tanstack/virtual-core — observeElementOffset (요약)
const createHandler = (isScrolling) => () => {
  offset = element.scrollTop;   // scrollTop은 여기서만 읽힌다
  cb(offset, isScrolling);
};
element.addEventListener("scroll", handler, { passive: true });

게다가 getScrollElement()같은 엘리먼트 인스턴스를 돌려주는 한 옵저버를 재초기화하지도 않음.
재부착은 리마운트가 아니라서 ref는 계속 같은 객체 → 재초기화 계기 자체가 없음 → 캐시된 옛날 오프셋이 그대로 살아남음.

어긋나는 순간

가상 리스트 구조는 전체 높이만큼의 스페이서 div 하나에 화면 근처 행 몇 개만 translateY(오프셋)으로 얹는 형태임.
"어디쯤을 그릴지"는 가상화기가 기억하는 오프셋이 정하고, "어디가 보일지"는 실제 scrollTop이 정함.
이 둘이 어긋나면 겹치는 구간이 사라지고 화면엔 빈 스페이서만 남음.

DOM 분리 / display:none 동안에는 ResizeObserver가 0×0을 보고해서 rect가 0이 되는 것도 같이 겹침.

순서대로 보면

  1. 정상 상태 — 실제 scrollTop 3400, 가상화기 오프셋 3400. 일치
  2. 패널 조작 — 라이브러리가 콘텐츠 div를 removeChild → appendChild
  3. 브라우저가 scrollTop을 0으로 리셋. scroll 이벤트 없음 → 아무도 통보 못 받음 (실제 0 / 가상화기 3400)
  4. 가상화기는 3400 자리에 행을 그리는데 뷰포트는 0을 보여줌 → 백지
  5. 클릭·휠로 첫 scroll 이벤트 발생 → 오프셋이 0으로 동기화되며 행이 다시 나타남
  6. 근데 위치는 맨 위. 원래 자리로 보내주는 로직은 재실행 안 됨

왜 원래 위치로 안 돌아오나

"처음에 맨 아래로 보내주는" 류의 effect는 보통 마운트 시점 + 데이터 개수 변화에만 걸려 있음.

ts
useEffect(() => {
  if (flat.length > 0 && scrollToBottom && !newestFirst) { ... }
}, [flat.length, rowVirtualizer, scrollToBottom, newestFirst])
// 패널 조작으로는 어느 의존값도 안 변함

재부착은 리마운트도 아니고 데이터 변화도 아니라서 재실행 계기가 없음. 한 번 어긋나면 계속 맨 위에서 시작함.

추가로 진짜 리마운트가 일어나는 케이스(그리드↔팝아웃 이동, 레이아웃 clear() + fromJSON 복원)도 조심해야 함.
이때 effect가 setTimeout 몇십 ms 뒤에 돌면 그 시점엔 레이아웃 높이가 아직 0이라 scrollToIndex가 엉뚱한 오프셋을 계산함.

해결방법

원리는 하나. 재부착/재표시 시점을 감지해서 가상화기와 스크롤 위치를 다시 동기화하면 됨.

  • 레이아웃 라이브러리 이벤트로 트리거 — dockview면 props.api.onDidVisibilityChange / onDidLocationChange 같은 걸 리스트까지 내려보내서, 다시 보이는 순간 measure() + 스크롤 복원. 라이브러리마다 이름만 다르지 "보이게 됨 / 위치 바뀜" 이벤트는 대부분 있음
  • rect 0 → 양수 전환을 리스트 내부에서 감지 — 라이브러리에 의존 안 하고 해결하는 방법. 스크롤 엘리먼트 크기가 0이었다가 실측값으로 돌아오는 순간을 훅으로 잡아서 마지막 오프셋 복원
  • 스크롤 위치를 패널 단위로 저장·복원 — 팝아웃 이동처럼 진짜 리마운트가 일어나는 케이스까지 한 번에 커버됨. 제일 튼튼한 방향

scrollTop을 직접 되돌려도 됨. 그러면 scroll 이벤트가 나면서 가상화기도 따라 동기화되니까 레이아웃 높이가 잡힌 뒤에 하는 순서만 지키면 됨.

비슷한 문제 의심 체크리스트

  • 리스트가 처음엔 멀쩡한데 레이아웃 조작 후에만 깨짐
  • 클릭·휠 한 번이면 복구됨 (= 이벤트만 오면 되는 상태)
  • 리마운트는 안 일어나는데(effect 로그 안 찍힘) 화면만 비어 있음
  • 숨겼다 보여주는 탭 / 아코디언 / 모달 안의 가상 리스트에서 높이가 0으로 측정됨

이 중 두 개 이상이면 거의 이 패턴임.