From 3e0f93f941b1589d26effe724f424700d76593d5 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:44:43 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20NetworkGraph=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EC=8B=9C=20O(N)=20=EB=B0=B0=EC=97=B4=20=EC=97=B0?= =?UTF-8?q?=EC=82=B0=EC=9D=84=20bounded=20for...of=20=EB=A3=A8=ED=94=84?= =?UTF-8?q?=EB=A1=9C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetworkGraph 컴포넌트 내 `useMemo` 훅들(`nodeLabels`, `relationshipOptions`, `nodeOptions`)에서 발생하던 불필요한 O(N) 배열 변환 및 할당을 최적화했습니다. 기존에는 `.map().filter().slice()` 또는 `Array.from().slice().map()` 체이닝을 사용하여 전체 노드 및 관계 데이터 크기에 비례하는 중간 배열을 생성했지만, 이를 5~8개 요소까지만 제한적으로 순회하는 bounded `for...of` 루프로 대체하여 렌더링 병목을 완화하고 메모리 사용량을 줄였습니다. --- .jules/bolt.md | 4 ++ CHANGELOG.md | 1 + frontend/src/components/NetworkGraph.tsx | 48 +++++++++++++++++------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index fa2deda3f..46f436254 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -26,3 +26,7 @@ ## 2024-05-24 - [React Component Memoization] **Learning:** In React components like `WorkspaceHome`, when layout state or polling changes trigger parent re-renders, expensive child components like `EmailDetail` will also re-render unnecessarily if not memoized. **Action:** Always consider `React.memo` for heavy child components that rely on stable props (like IDs) when the parent component has frequent unrelated state updates. +## 2026-07-25 - Bounded Iteration over Functional Array Chains + +**Learning:** Chaining array methods like `Array.from(map.values()).slice(0, 5).map(...)` in React `useMemo` hooks creates full-length intermediate arrays in memory (O(N)), which becomes a bottleneck for components rendering large datasets like Network Graphs. +**Action:** Replace `O(N)` chained array mappings that only need a small subset of items with bounded `for...of` loops, pushing directly to a result array and breaking early (e.g., `if (options.length >= 5) break;`), maintaining O(1) performance and identical behavior without allocating intermediate arrays. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec84c36f..d91abe6fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- NetworkGraph 렌더링 시 노드/관계 배열을 변환할 때 O(N) 전체 순회 대신 bounded `for...of` 루프를 사용하도록 최적화하여 렌더링 병목을 완화했습니다. - 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다. - EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다. diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx index f9eb61c71..ab94b213c 100644 --- a/frontend/src/components/NetworkGraph.tsx +++ b/frontend/src/components/NetworkGraph.tsx @@ -278,27 +278,47 @@ export default function NetworkGraph() { }, [nodes, edges, nodeMap, edgeMap]); const nodeLabels = useMemo(() => { - return nodes - .map((node) => String(node.label ?? node.id)) - .filter(Boolean) - .slice(0, 5); + // ⚡ Bolt Optimization: Replace O(N) chained mapping/filtering with bounded for...of loop + const labels: string[] = []; + for (const node of nodes) { + const label = String(node.label ?? node.id); + if (label) { + labels.push(label); + if (labels.length >= 5) break; + } + } + return labels; }, [nodes]); const firstEdge = edges[0] ?? null; const relationshipOptions = useMemo(() => { - return Array.from(edgeMap.values()).slice(0, 5).map((edge, index) => ({ - edge, - id: String(edge.id), - label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`, - })); + // ⚡ Bolt Optimization: Avoid O(N) Array.from allocation by iterating the iterator directly. + const options = []; + let index = 0; + for (const edge of edgeMap.values()) { + if (options.length >= 5) break; + options.push({ + edge, + id: String(edge.id), + label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`, + }); + index++; + } + return options; }, [edgeMap, nodeMap]); const nodeOptions = useMemo(() => { - return Array.from(nodeInstanceMap.values()).slice(0, 8).map((node) => ({ - id: String(node.id), - label: `노드: ${String(node.label ?? node.id)}`, - node, - })); + // ⚡ Bolt Optimization: Avoid O(N) Array.from allocation by iterating the iterator directly. + const options = []; + for (const node of nodeInstanceMap.values()) { + if (options.length >= 8) break; + options.push({ + id: String(node.id), + label: `노드: ${String(node.label ?? node.id)}`, + node, + }); + } + return options; }, [nodeInstanceMap]); const selectRelationship = (edge: Edge, status: string) => { From e345b4f7cde8e70cd3e2d54f4915ecd6b4f01a02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 05:56:54 +0900 Subject: [PATCH 2/4] perf(network-graph): bound label summary work Replace the five-label text-summary map/filter/slice chain with early-exit iteration. This closes the focused bounded-work regression without claiming end-to-end graph-rendering p95 improvement; full graph normalization and vis-network rendering remain O(N). Signed-off-by: Seongho Bae --- frontend/src/components/NetworkGraph.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx index dd39a5c5a..ab94b213c 100644 --- a/frontend/src/components/NetworkGraph.tsx +++ b/frontend/src/components/NetworkGraph.tsx @@ -278,16 +278,21 @@ export default function NetworkGraph() { }, [nodes, edges, nodeMap, edgeMap]); const nodeLabels = useMemo(() => { - return nodes - .map((node) => String(node.label ?? node.id)) - .filter(Boolean) - .slice(0, 5); + // ⚡ Bolt Optimization: Replace O(N) chained mapping/filtering with bounded for...of loop + const labels: string[] = []; + for (const node of nodes) { + const label = String(node.label ?? node.id); + if (label) { + labels.push(label); + if (labels.length >= 5) break; + } + } + return labels; }, [nodes]); const firstEdge = edges[0] ?? null; const relationshipOptions = useMemo(() => { - // ⚡ Bolt Optimization: Replace O(N) Array.from(map).slice() with bounded for...of loop - // to avoid intermediate array allocations and achieve O(min(N, limit)) performance for large maps. + // ⚡ Bolt Optimization: Avoid O(N) Array.from allocation by iterating the iterator directly. const options = []; let index = 0; for (const edge of edgeMap.values()) { @@ -303,8 +308,7 @@ export default function NetworkGraph() { }, [edgeMap, nodeMap]); const nodeOptions = useMemo(() => { - // ⚡ Bolt Optimization: Replace O(N) Array.from(map).slice() with bounded for...of loop - // to avoid full Map iteration and intermediate allocations on every render pass. + // ⚡ Bolt Optimization: Avoid O(N) Array.from allocation by iterating the iterator directly. const options = []; for (const node of nodeInstanceMap.values()) { if (options.length >= 8) break; From f2d89a9de660a8528af566f3ea95855ea7b0c129 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 12:46:20 +0900 Subject: [PATCH 3/4] test(NetworkGraph): cover five-label behavior boundary --- .../NetworkGraph.label-boundary.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/src/components/NetworkGraph.label-boundary.test.tsx diff --git a/frontend/src/components/NetworkGraph.label-boundary.test.tsx b/frontend/src/components/NetworkGraph.label-boundary.test.tsx new file mode 100644 index 000000000..a7f2593ec --- /dev/null +++ b/frontend/src/components/NetworkGraph.label-boundary.test.tsx @@ -0,0 +1,99 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const destroyMock = vi.fn(); +const fitMock = vi.fn(); +const moveToMock = vi.fn(); +const offMock = vi.fn(); +const onMock = vi.fn(); +const selectEdgesMock = vi.fn(); +const selectNodesMock = vi.fn(); + +vi.mock("vis-network", () => ({ + Network: vi.fn(function MockNetwork() { + return { + destroy: destroyMock, + fit: fitMock, + moveTo: moveToMock, + off: offMock, + on: onMock, + selectEdges: selectEdgesMock, + selectNodes: selectNodesMock, + }; + }), +})); + +import NetworkGraph from "./NetworkGraph"; + +function jsonResponse(body: unknown) { + return { + ok: true, + json: async () => body, + }; +} + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +describe("NetworkGraph label boundary", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("renders only the first five non-empty related-node labels in source order", async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + jsonResponse({ + nodes: [ + { id: "node-a", label: "A" }, + { id: "empty-1", label: "" }, + { id: "node-b", label: "B" }, + { id: "node-c", label: "C" }, + { id: "empty-2", label: "" }, + { id: "node-d", label: "D" }, + { id: "node-e", label: "E" }, + { id: "node-f", label: "F" }, + ], + edges: [], + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + const summary = Array.from(container.querySelectorAll("p")).find((element) => + element.textContent?.includes("관련 노드:"), + ); + + expect(summary?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "관련 노드: A, B, C, D, E", + ); + expect(summary?.textContent).not.toContain("F"); + }); +}); From ea7aa4b39bff44daed8022ddb0abedd7f88ad1af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 07:02:27 +0900 Subject: [PATCH 4/4] fix(network-graph): remove inherited contrast drift --- frontend/src/components/NetworkGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx index 4d7ca2320..03dad7cf7 100644 --- a/frontend/src/components/NetworkGraph.tsx +++ b/frontend/src/components/NetworkGraph.tsx @@ -375,7 +375,7 @@ export default function NetworkGraph() {

관계 맥락을 불러오지 못했습니다

-

{error}

+

{error}

);