Skip to content
Open
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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도 대응해야 합니다.
6 changes: 5 additions & 1 deletion src/main/resources/static/assets/viewer/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}

Expand Down
8 changes: 7 additions & 1 deletion src/test/js/demo-integration.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/test/js/dom-utils.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
};

Expand Down
42 changes: 41 additions & 1 deletion src/test/js/mock-dom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down