발행일

창 밖 노드를 고르면 트리가 맨 위로 돌아갔다 — 가상 스크롤이 만든 틈을 여섯 자리에서 메우기

창 밖 노드를 고르면 트리가 맨 위로 돌아갔다

인스턴스 트리를 가상 스크롤로 옮긴 게 9월 1일입니다. 클래스 아래 인스턴스를 창(window) 크기만큼만 DOM에 두고, 창 위아래는 여백으로 높이만 세우는 구조예요. DOM은 작아졌는데, "화면에 있는 것"과 "데이터에 있는 것"이 처음으로 갈라졌습니다.

여드레 뒤에 그 틈으로 샌 것들을 한 번에 메웠습니다(805a4355).

이름을 고쳐 저장하면 선택이 풀렸다

증상은 이랬습니다. 인스턴스를 편집해 저장하면 저장은 되는데 선택이 풀리고 트리가 클래스 맨 위로 갔습니다. 방금 고친 데이터가 어디 있는지 다시 찾아야 했어요.

그런데 늘 그런 게 아니었습니다. 요약이나 설명만 고친 저장은 멀쩡했어요. 이 차이가 원인을 가리켰습니다.

저장 후 복귀 경로(syncAndSelect)는 이름(키)이 바뀐 저장과 새로 추가한 저장일 때 트리를 다시 받습니다. 서버가 이름순으로 내려 주니 이름이 바뀌거나 새로 생기면 위치가 달라지거든요. 그래서 refresh_node로 네임스페이스를 다시 받는데, jstree는 펼쳐진 클래스까지 함께 다시 받고 그 요청은 첫 창(offset 0)입니다.

이름순 뒤쪽에 있는 데이터는 첫 창에 없습니다. kadif:Message 104건 중 104번째 같은 자리요. 그 상태에서 노드를 고르라고 하면 get_node가 빈 값을 돌려주고, 선택은 아무 데도 안 걸립니다. 키 아닌 값만 고친 저장은 트리를 안 받으니 정상이었고, 그래서 간헐적으로 보였습니다.

창을 옮긴 뒤에 고른다

창 밖 노드를 고르는 문제는 이 화면에 이미 답이 있었습니다. 참조 칩이나 ?selected_node=inst-N 주소로 들어올 때 쓰던 ensureInstanceNode예요. 서버에 순번을 묻고(tree-children/?class_id=&instance_id=) 그 자리로 창을 옮깁니다.

저장 복귀 경로가 이 보정을 빠뜨리고 있었습니다. 같은 함수를 태웠습니다.

// refresh_node 는 펼쳐진 클래스를 첫 창(offset 0)으로 다시 받는다 — 이름순 뒤쪽 데이터는
// 창 밖이라 그냥 고르면 선택이 풀리고 트리는 클래스 맨 위에 선다. 순번을 물어 창을 옮긴다.
const finish = function () {
  tree.open_node(classNodeId, function () {
    ensureInstanceNode(instNodeId).then(function () {
      tree.deselect_all();
      ...

다만 그대로 붙이면 순서가 안 맞습니다. ensureInstanceNode가 창을 옮기라고 시키기만 하고 그려지기를 기다리지 않아서, 그려지기 전에 고르면 여전히 빈손이에요. 창을 그리는 renderWindowfetch Promise를 돌려주게 하고, ensureInstanceNode는 창이 그려진 뒤 resolve하도록 고쳤습니다.

return Promise.resolve(
  renderWindow(item.classNode, json.index - Math.floor(windowSize() / 2)),
).then(function () { return true; });

순번 계산은 서버가 합니다(_instance_tree_index). 트리 정렬 (name, id)을 그대로 재현하지 않으면 창이 엉뚱한 데로 갑니다. 정렬 규칙이 트리 조회와 순번 조회 두 곳에 사는 셈이라, 한쪽만 바뀌면 조용히 어긋납니다.

전수로 훑었더니 네 곳이 더 있었다

같은 날 "고른 노드가 DOM에 있다"는 전제를 쓰는 자리를 전부 봤습니다. 선택이 풀리는 곳은 저장 복귀뿐이었지만, 선택은 되는데 고른 노드가 화면 밖에 있는 곳이 네 군데 있었어요.

자리증상
초기 ?selected_node= 진입선택은 되는데 패널을 안 굴려 노드가 화면 밖
참조 칩으로 이동같음
목록 링크(openInstance)같음
뒤로가기 복원(restoreSelectedNodeFromQuery)같음

inst-13은 패널 맨 위에서 3,196px 아래에 있었습니다. 선택 표시는 거기 걸려 있는데 사용자 눈에는 아무것도 안 고른 화면이에요.

네 곳 모두 같은 보정을 붙였습니다. 스크롤은 직접 계산했습니다.

// 패널만 굴린다 — scrollIntoView 는 문서까지 함께 밀어 상세 패널이 눈앞에서 튄다.
function scrollTreeNodeToCenter(nodeId) {
  const li = document.getElementById(nodeId);
  const panel = $tree[0].parentElement;
  if (!li || !panel) return;
  panel.scrollTop +=
    li.getBoundingClientRect().top - panel.getBoundingClientRect().top -
    panel.clientHeight / 2;
}

scrollIntoView를 안 쓴 이유가 이 함수의 전부입니다. 그 API는 스크롤되는 조상을 전부 밀어서 트리 패널뿐 아니라 문서까지 움직이고, 그러면 오른쪽 상세 패널이 눈앞에서 튑니다. 트리 안의 위치만 바꾸고 싶은 자리라 패널의 scrollTop만 직접 옮겼습니다.

범주 셀렉트도 같은 증상이었다

트리 위의 범주 셀렉트를 바꾸면 refresh()로 트리를 다시 그리는데, 그 뒤에 선택·주소·상세를 무조건 비우고 있었습니다. 범주를 바꿨다고 보던 데이터가 사라질 이유는 없는데도요.

바뀐 범주에도 보던 것이 남는지를 먼저 판정하게 했습니다.

const keptClass = classItems[selectedNodeId]
  ? selectedNodeId
  : (instItems[selectedNodeId] || {}).classNode;
const survives =
  isNamespaceNodeId(selectedNodeId) || (keptClass && matchesCategory(keptClass));
if (survives) $tree.one("refresh.jstree", restoreSelectedNodeFromQuery);

남으면 refresh.jstree 뒤에 주소 복원 경로를 한 번 태웁니다. 그 경로가 이미 창 옮기기와 스크롤 보정을 다 갖고 있어서, 여기서 따로 만들 게 없었어요. refresh도 첫 창만 다시 받으니 같은 보정이 필요한 게 당연했습니다. 걸러진 경우에만 초기 안내로 돌아갑니다.

네임스페이스 노드는 늘 남습니다. 범주 셀렉트는 클래스를 거르지 네임스페이스를 감추지 않으니까요.

이번에 다시 확인한 것

가상 스크롤을 넣은 날 생각한 비용은 "행 높이가 같아야 한다"와 "펼침 애니메이션을 꺼야 한다" 정도였습니다. 실제 비용은 그게 아니라, "노드가 DOM에 있다"를 전제로 쓰던 코드가 전부 조건부가 된다는 것이었어요. 이 화면에서만 여섯 자리였습니다(저장 복귀, 화면 밖 진입점 넷, 범주 셀렉트).

그리고 그 여섯 중 넷은 눈에 띄는 증상이 없던 자리입니다. 선택이 아예 풀리는 저장 복귀·범주 셀렉트와 달리, 나머지는 "선택은 됐는데 화면 밖"이라 사용자가 그냥 다시 찾았을 겁니다. 하나를 고치면서 같은 전제를 쓰는 곳을 세어 보지 않았으면 넷은 그대로 남았습니다.

남은 것 · 한계

  • e2e를 새로 붙이지 않았습니다. 창을 옮긴 뒤 선택하는 동작은 서버 순번 조회와 창 렌더가 겹쳐 있어서, 확인은 화면에서 했습니다. 이름순 뒤쪽 데이터를 고쳐 저장하는 시나리오는 회귀로 잠겨 있지 않아요.
  • 순번 계산이 두 곳에 삽니다. 트리 조회의 정렬과 _instance_tree_index의 정렬이 같아야 창이 맞습니다. 한쪽만 바뀌면 "고른 것과 다른 자리로 창이 간다"는, 오류 없이 조용한 증상으로 나옵니다.
  • scrollTreeNodeToCenter는 li가 DOM에 있을 때만 굴립니다. 창 옮기기가 실패하면 아무 일도 안 하고 끝나요. 그 자리에 사용자에게 보일 표시는 없습니다.
  • 범주 전환 복원은 클래스 기준으로 판정합니다. 보던 것이 인스턴스면 그 클래스가 남는지로 봅니다. 클래스가 남는데 그 인스턴스만 걸러지는 경우는 지금 범주 규칙에 없어서 따로 안 다뤘습니다.
  • 문서(docs/instance-management-implementation.md)의 해당 항목에 🔄 표시로 경위를 적어 뒀습니다. 가상 스크롤 항목 바로 아래라, 다음에 이 구조를 건드리는 사람이 같이 읽게 됩니다.

관련 글: 인스턴스 트리를 가상 스크롤로 옮겼다 · 편집 중 트리를 누르면 입력이 사라졌다 · 운영 2,732건이 92,948px 캔버스를 만들었다