발행일

OID 등록 신청 화면이 16초 멈춰 있었다 — 범인은 트리가 아니라 같은 폼의 선택 상자였다

OID 등록 신청 화면이 16초 멈춰 있었다

OID 등록 신청 화면에는 상위 OID를 고르는 트리가 있습니다. 이 트리가 처음 열릴 때 스피너 없이 빈 상자로 한참 서 있었어요. 같은 날 앞 커밋(ea833bc7)에서는 트리 상자가 18px이라 스피너가 반토막(검색 화면)·가는 줄(신청 화면)로 잘리던 문제를 고쳤습니다. 그런데 신청 화면에는 그것과 다른 문제가 하나 더 있었습니다.

이 글은 그 두 번째 문제의 기록입니다(96a13169).

트리는 0.6초에 끝나 있었다

트리 응답은 dev에서 1.2MB쯤이라 의심할 만한 크기였습니다. 그런데 트리 요청은 페이지 준비 초입에 나가서 0.6초에 끝나 있었어요. 응답은 도착했는데 그려지지 않은 겁니다.

그래서 페이지 쪽을 쟀습니다. DOMContentLoaded 처리 시간이 16.1초였어요. 그동안 메인 스레드가 막혀 있었으니 스피너는 한 번도 그려지지 못했고, 이미 도착한 트리 응답도 처리되지 못했습니다. 화면에서는 "빈 상자로 멈췄다"로만 보이니까 원인을 트리에서 찾기 쉬웠던 거예요.

같은 폼의 참조 선택 상자

16초는 같은 폼의 참조 선택 상자(dualMultiSelect.js)에서 나왔습니다. 이 화면에서는 참조할 표준데이터 후보 7,099개와 OID 후보 1,520개를 이 부품이 목록으로 그립니다. 항목을 하나씩 appendChild로 붙이고 있었어요.

Array.from(select.options).forEach((option) => {
  // ...
  searchContent.appendChild(itemDiv);
});

항목 7,099개를 만드는 코드 자체는 50ms였습니다. 시간을 쓴 건 붙인 뒤에 일어나는 일이었어요. 이 화면은 FontAwesome의 all.min.js를 싣는데, 이 스크립트는 <i class="fa-…">를 SVG로 바꾸려고 페이지 전체의 DOM 변경을 감시합니다(observeMutations). 하나씩 붙이면 변경 기록이 항목 수만큼 쌓이고, 그 기록을 처리하는 데 15초가 걸렸습니다.

동기 코드만 재면 이게 안 보입니다. 루프는 금방 끝나고 비용은 그 뒤 비동기로 나가거든요. 그래서 await new Promise(r => setTimeout(r, 0))까지 기다린 뒤에 쟀고, 페이지 로드 중에는 PerformanceObserver의 long-animation-frame(buffered)으로 어느 스크립트가 몇 초를 썼는지 확인했습니다.

모아서 한 번에 붙였다

고친 건 붙이는 방식 하나입니다. 항목을 DocumentFragment에 모았다가 replaceChildren으로 한 번에 넣었어요. 선택된 항목 칩도 조각에 모아 한 번에 붙이게 했습니다.

const fragment = document.createDocumentFragment();
Array.from(select.options).forEach((option) => {
  // ...
  fragment.appendChild(itemDiv);
});
searchContent.replaceChildren(fragment);

검색 목록 쪽은 innerHTML = ""로 비우고 다시 붙이던 것까지 replaceChildren 한 번으로 합쳐졌습니다. 결과는 이렇습니다.

잰 것고치기 전고친 뒤
DOMContentLoaded 처리(신청 화면)16.1초0.06초
목록 붙이기만(참조 표준데이터 7,099개)13.9초0.1초

지금 돌아보면 감시(observeMutations)를 끄는 길도 있었습니다. 그러면 나중에 JS로 넣는 아이콘이 SVG로 바뀌지 않아 사이트 전체 동작이 달라집니다. 커밋은 이 비교 없이 부품이 변경을 한 번만 일으키게 하는 데서 멈췄습니다.

회귀 잠금

e2e에 페이지 준비 시간을 재는 스펙을 하나 더했습니다.

test("페이지 준비가 멈추지 않는다 — 참조 선택 상자가 트리 그리기를 막지 않는다", async ({ page }) => {
  await gotoRequestAdd(page);
  const blocked = await page.evaluate(() => {
    const nav = window.performance.getEntriesByType("navigation")[0];
    return nav.domContentLoadedEventEnd - nav.domContentLoadedEventStart;
  });
  expect(blocked).toBeLessThan(2000);
});

기준을 2초로 넉넉하게 잡은 건 장비마다 흔들리는 수십 ms를 잡으려는 게 아니라 초 단위 회귀만 잡으려는 거라서입니다. 고치기 전 코드로 돌려서 이 스펙이 16.4초로 실패하는 것도 확인했습니다. 규칙은 UI 관례 문서에 "수십 개가 넘을 수 있는 목록은 모아 붙인다"로 남겼어요.

한계

  • 숫자는 전부 dev 기준입니다. 운영에서 트리 응답 자체가 더 느리다면 그건 별개 문제라, 트리 데이터를 줄이는 쪽(잎 제외 등)을 따로 봐야 한다고 문서에 적어 두었습니다.
  • 이 커밋은 dualMultiSelect.js 하나만 고쳤습니다. 같은 "하나씩 붙이기"가 다른 스크립트에 남아 있는지는 이때 훑지 않았어요. 문서의 규칙이 다음에 고칠 자리를 알려 주는 정도입니다.
  • 증상은 "스피너가 안 보인다"였고 원인은 메인 스레드였습니다. 지난번 스피너 하나 넣으려다 jQuery를 두 번 실을 뻔했다에서도 로딩 표시가 결국 로드 순서 문제였는데, 이번에는 메인 스레드 문제였습니다. 스피너가 안 보이면 스피너 코드보다 그 시간에 메인 스레드가 무엇을 하고 있었는지를 먼저 보는 게 맞았습니다.