From b1dfb2d41f6fe1214aae7fac1fcf568b9dabe31d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:15:02 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Bolt:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20-=20DocumentFragment=20=EC=A0=81=EC=9A=A9\n\n?= =?UTF-8?q?=F0=9F=92=A1=20What:=20=EB=B7=B0=EC=96=B4=20=EB=8D=B0=EB=AA=A8?= =?UTF-8?q?=20UI=20(`demo.js`)=EC=97=90=EC=84=9C=20`renderHistory`?= =?UTF-8?q?=EC=99=80=20`renderJobDetail`=20=EC=88=98=ED=96=89=20=EC=8B=9C?= =?UTF-8?q?=20=EB=8B=A4=EC=A4=91=20DOM=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20`DocumentFragment`=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=B4=20=EC=9D=BC=EA=B4=84=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=ED=96=88?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.\n=F0=9F=8E=AF=20Why:=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EB=82=B4=EC=97=90=EC=84=9C=20=EA=B0=9C=EB=B3=84=20?= =?UTF-8?q?=EC=97=98=EB=A6=AC=EB=A8=BC=ED=8A=B8=EB=A5=BC=20=EB=A7=A4?= =?UTF-8?q?=EB=B2=88=20DOM=20=ED=8A=B8=EB=A6=AC=EC=97=90=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20`appendChild`=ED=95=A0=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EB=8B=A4=EC=88=98=EC=9D=98=20=EB=A6=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0(reflow)=EC=99=80=20=EB=A6=AC=ED=8E=98=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8(repaint)=EA=B0=80=20=EB=B0=9C=EC=83=9D=ED=95=B4=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=84=B1=EB=8A=A5=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=80=ED=95=98=EB=90=98=EB=8A=94=20=EB=B3=91=EB=AA=A9?= =?UTF-8?q?=EC=9D=84=20=ED=95=B4=EA=B2=B0=ED=95=98=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=ED=95=A8=EC=9E=85=EB=8B=88=EB=8B=A4.\n=F0=9F=93=8A=20Impact:?= =?UTF-8?q?=20=EB=B0=98=EB=B3=B5=EB=AC=B8=20=EB=82=B4=20=EA=B0=9C=EB=B3=84?= =?UTF-8?q?=20=EC=9A=94=EC=86=8C=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EB=8B=A4=EC=A4=91=20=EB=A6=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=EB=A5=BC=20=EB=8B=A8=EC=9D=BC=20=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=EB=A1=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EA=B8=B4=20=EB=AA=A9=EB=A1=9D=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EB=B9=84=EC=9A=A9=EC=9D=84=20=EA=B0=90?= =?UTF-8?q?=EC=86=8C=EC=8B=9C=ED=82=B5=EB=8B=88=EB=8B=A4.\n=F0=9F=94=AC=20?= =?UTF-8?q?Measurement:=20`node=20--test=20src/test/js/*.test.mjs`=20?= =?UTF-8?q?=EB=B0=8F=20UI=20=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=EC=8B=9C=EA=B0=84=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=A7=81=EC=9D=84=20=ED=86=B5=ED=95=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B0=80=EB=8A=A5=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/bolt.md | 3 ++ CHANGELOG.md | 2 + .../resources/static/assets/viewer/demo.js | 37 ++++++++++++------- src/test/js/demo-integration.test.mjs | 5 ++- src/test/js/dom-utils.test.mjs | 5 ++- src/test/js/mock-dom.mjs | 30 ++++++++++++++- 6 files changed, 64 insertions(+), 18 deletions(-) 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); + } +}