diff --git a/.jules/bolt.md b/.jules/bolt.md index 284d180a7..e011d9761 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -19,3 +19,6 @@ ## 2026-07-13 - 단일 패스 문자열 치환 최적화 (O(N) 단일 스캔 및 지연 할당) **Learning:** `String.replace()`를 여러 번 체이닝하여 호출하면, 문자열 치환이 발생하지 않는 경우에도 내부적으로 불필요한 스캔이 중복 발생하고, 치환 시마다 새로운 문자열 객체와 char 배열이 할당되어 메모리 낭비와 성능 저하(GC 압박)가 발생한다. **Action:** 여러 문자를 한 번에 치환해야 하는 경우, O(N) 단일 스캔을 통해 `charAt()`으로 문자를 확인하고, 치환이 실제로 필요한 경우에만 `StringBuilder`를 지연 할당(Lazy allocation)하여 성능을 최적화하고 불필요한 메모리 할당을 방지한다. +## 2026-07-14 - 프론트엔드 리스트 렌더링 시 DocumentFragment를 활용한 DOM 일괄 처리 최적화 +**Learning:** 반복문 내에서 `appendChild`를 통해 DOM 엘리먼트를 여러 번 추가하면 각 추가 시점마다 브라우저의 레이아웃 스레싱(Layout Thrashing) 및 불필요한 리렌더링이 발생하여 성능 저하의 원인이 됩니다. +**Action:** 렌더링 성능을 개선하고 브라우저 리플로우를 최소화하기 위해 다중 요소를 삽입할 때는 항상 `document.createDocumentFragment()`를 생성하여 반복문 내에서 프래그먼트에 자식을 추가하고, 루프가 끝난 뒤 프래그먼트를 실제 DOM에 단일 삽입하는 방식으로 코드를 작성해야 합니다. 또한 커스텀 테스트 목(DOM 모의 객체) 환경이 이를 올바르게 지원하도록 MockDocumentFragment도 대응해야 합니다. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 42998be56..dce56fad4 100644 --- a/src/main/resources/static/assets/viewer/demo.js +++ b/src/main/resources/static/assets/viewer/demo.js @@ -114,6 +114,9 @@ function renderHistory(history = loadHistory()) { el.historyBody.textContent = ""; el.emptyHistory.hidden = history.length > 0; + // ⚡ Bolt: Batch DOM insertions using DocumentFragment to prevent unnecessary layout thrashing + const fragment = document.createDocumentFragment(); + for (const job of history) { const row = document.createElement("tr"); const fileCell = document.createElement("td"); @@ -143,9 +146,10 @@ function renderHistory(history = loadHistory()) { } row.append(fileCell, statusCell, submittedCell, actionsCell); - el.historyBody.appendChild(row); + fragment.appendChild(row); } + el.historyBody.appendChild(fragment); renderRecoveryEvidence(history); } diff --git a/src/test/js/demo-integration.test.mjs b/src/test/js/demo-integration.test.mjs index 0f0437ea2..69d3b2f99 100644 --- a/src/test/js/demo-integration.test.mjs +++ b/src/test/js/demo-integration.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { MockElement } from "./mock-dom.mjs"; +import { MockElement, MockDocumentFragment } from "./mock-dom.mjs"; const elementIds = [ "upload-form", @@ -54,6 +54,9 @@ test("the executable demo renders inert actions and blocks repeated status activ createElement(tagName) { return new MockElement(tagName); }, + createDocumentFragment() { + return new MockDocumentFragment(); + }, }; globalThis.window = { confirm() { @@ -125,6 +128,9 @@ test("the executable demo renders inert actions and blocks repeated status activ createElement(tagName) { return new MockElement(tagName); }, + createDocumentFragment() { + return new MockDocumentFragment(); + }, }, }; globalThis.window.open = () => popup; diff --git a/src/test/js/dom-utils.test.mjs b/src/test/js/dom-utils.test.mjs index 94b151063..9daf9358a 100644 --- a/src/test/js/dom-utils.test.mjs +++ b/src/test/js/dom-utils.test.mjs @@ -6,11 +6,14 @@ import { createLink, setBusyState } from "../../main/resources/static/assets/viewer/dom-utils.js"; -import { MockElement, MockTextNode } from "./mock-dom.mjs"; +import { MockElement, MockTextNode, MockDocumentFragment } from "./mock-dom.mjs"; globalThis.document = { createElement(tagName) { return new MockElement(tagName); + }, + createDocumentFragment() { + return new MockDocumentFragment(); } }; diff --git a/src/test/js/mock-dom.mjs b/src/test/js/mock-dom.mjs index 774116bc0..bef4f93bc 100644 --- a/src/test/js/mock-dom.mjs +++ b/src/test/js/mock-dom.mjs @@ -5,6 +5,34 @@ export class MockTextNode { } } +export class MockDocumentFragment { + constructor() { + this.type = "fragment"; + this.childNodes = []; + } + + appendChild(node) { + if (node && node.type === "fragment") { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + return node; + } + this.childNodes.push(node); + return node; + } + + append(...nodes) { + for (const node of nodes) { + if (node && node.type === "fragment") { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + } else { + this.childNodes.push(node); + } + } + } +} + export class MockElement { constructor(tagName = "div") { this.tagName = tagName.toUpperCase(); @@ -31,12 +59,24 @@ export class MockElement { } appendChild(node) { + if (node && node.type === "fragment") { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + return node; + } this.childNodes.push(node); return node; } append(...nodes) { - this.childNodes.push(...nodes); + for (const node of nodes) { + if (node && node.type === "fragment") { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + } else { + this.childNodes.push(node); + } + } } replaceChildren(...nodes) {