Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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-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.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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인지 강제하고, 실패 시 누락 위치 진단을 출력하도록 했습니다.
Expand Down
37 changes: 23 additions & 14 deletions 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;

// Optimized with DocumentFragment for performance
const fragment = document.createDocumentFragment();

for (const job of history) {
const row = document.createElement("tr");
const fileCell = document.createElement("td");
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
5 changes: 4 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
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
30 changes: 28 additions & 2 deletions src/test/js/mock-dom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
Loading