diff --git a/.jules/bolt.md b/.jules/bolt.md index 284d180a7..dc8b841e9 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-10-05 - Batch DOM operations with DocumentFragment +**Learning:** Appending DOM elements individually inside loops causes multiple reflows and repaints, which becomes a performance bottleneck for long lists or detailed views. +**Action:** Always batch DOM updates using `DocumentFragment` before appending them to the active document tree to minimize rendering overhead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1187deb2a..4ad11f8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ ### Changed +- **프론트엔드 성능 최적화**: 뷰어 데모 스크립트(`demo.js`)에서 여러 DOM 엘리먼트를 반복적으로 추가할 때 리플로우(reflow)와 리페인트(repaint)를 줄이기 위해 `DocumentFragment`를 사용하도록 개선했습니다. + - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. - CI가 pull request의 정확한 head SHA를 명시적으로 체크아웃하고 검증하며, 합성 merge revision은 별도 호환성 작업에서 검증하도록 분리했습니다. - Maven `verify` 단계에서 JaCoCo production line 및 branch missed count가 각각 0인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다. diff --git a/src/main/resources/static/assets/viewer/demo.js b/src/main/resources/static/assets/viewer/demo.js index 42998be56..9e472de37 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; + // Optimized with DocumentFragment for performance + const fragment = document.createDocumentFragment(); + for (const job of history) { const row = document.createElement("tr"); const fileCell = document.createElement("td"); @@ -143,18 +146,19 @@ function renderHistory(history = loadHistory()) { } row.append(fileCell, statusCell, submittedCell, actionsCell); - el.historyBody.appendChild(row); + fragment.appendChild(row); } + el.historyBody.appendChild(fragment); renderRecoveryEvidence(history); } -function addDetailRow(label, value) { +function addDetailRow(fragment, label, value) { const term = document.createElement("dt"); const description = document.createElement("dd"); term.textContent = label; description.textContent = formatDetailValue(value); - el.jobDetailBody.append(term, description); + fragment.append(term, description); } function formatDetailValue(value) { @@ -177,17 +181,22 @@ function renderJobDetail(detail) { : "Operational evidence"; el.jobDetailBody.textContent = ""; - addDetailRow("Job ID", detail.jobId); - addDetailRow("Tenant", detail.tenantId); - addDetailRow("Status", detail.status); - addDetailRow("Message", detail.message); - addDetailRow("Attempts", `${detail.attemptCount ?? 0} / ${detail.maxAttempts ?? "n/a"}`); - addDetailRow("Dead-lettered", Boolean(detail.deadLettered)); - addDetailRow("Retry at", detail.retryAt); - addDetailRow("Created", detail.createdAt); - addDetailRow("Started", detail.startedAt); - addDetailRow("Completed", detail.completedAt); - addDetailRow("Artifact", detail.convertedResourcePath); + // Optimized with DocumentFragment for performance + const fragment = document.createDocumentFragment(); + + addDetailRow(fragment, "Job ID", detail.jobId); + addDetailRow(fragment, "Tenant", detail.tenantId); + addDetailRow(fragment, "Status", detail.status); + addDetailRow(fragment, "Message", detail.message); + addDetailRow(fragment, "Attempts", `${detail.attemptCount ?? 0} / ${detail.maxAttempts ?? "n/a"}`); + addDetailRow(fragment, "Dead-lettered", Boolean(detail.deadLettered)); + addDetailRow(fragment, "Retry at", detail.retryAt); + addDetailRow(fragment, "Created", detail.createdAt); + addDetailRow(fragment, "Started", detail.startedAt); + addDetailRow(fragment, "Completed", detail.completedAt); + addDetailRow(fragment, "Artifact", detail.convertedResourcePath); + + el.jobDetailBody.appendChild(fragment); el.retryJobBtn.hidden = !detail.deadLettered; } diff --git a/src/test/js/demo-integration.test.mjs b/src/test/js/demo-integration.test.mjs index 0f0437ea2..dbf64f7f1 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() { 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..4b0e1a03f 100644 --- a/src/test/js/mock-dom.mjs +++ b/src/test/js/mock-dom.mjs @@ -31,12 +31,24 @@ export class MockElement { } appendChild(node) { - this.childNodes.push(node); + if (node instanceof MockDocumentFragment) { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + } else { + this.childNodes.push(node); + } return node; } append(...nodes) { - this.childNodes.push(...nodes); + for (const node of nodes) { + if (node instanceof MockDocumentFragment) { + this.childNodes.push(...node.childNodes); + node.childNodes = []; + } else { + this.childNodes.push(node); + } + } } replaceChildren(...nodes) { @@ -75,3 +87,17 @@ export class MockElement { reset() {} } + +export class MockDocumentFragment { + constructor() { + this.nodeType = 11; + this.childNodes = []; + } + appendChild(node) { + this.childNodes.push(node); + return node; + } + append(...nodes) { + this.childNodes.push(...nodes); + } +}