perf(m02): 엑셀 엔진 조각 늦게 불러오기 — 면적 칸 지정 · 칸 고르기 · 일위대가 상세(111-1)

- AreaLink · AreaCellPick: link_ask · link_set 을 누를 때 import()
- Labor_Detail: recalc 를 면적 문서와 함께 import()
- test_spreadsheet_size: DataFlow 세 파일(loadFlowMods 로만 열림) 이름 예외 · 실측에 첫 화면 · M02 페이지 정적 사슬 안 스프레드시트 모듈 = 없음 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-08 16:40:40 +09:00
co-authored by Claude Opus 5.5
parent 6cd5cec1d9
commit a57d8901a5
5 changed files with 58 additions and 23 deletions
@@ -8,8 +8,6 @@
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask";
import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set";
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import "./M02_MasterTemplete_AreaPanel.css";
@@ -57,6 +55,11 @@ export function mountAreaSheet(host: HTMLElement, opts: AreaSheetOptions): AreaS
host.replaceChildren(el("p", { className: "m02-cellpick__now", text: TEXT.loading }));
const choose = async (): Promise<void> => {
const at = picked;
// 엔진 조각은 고를 때만(정적 import 금지 — 계약 `spreadsheet_types.ts` 머리)
const [{ askShare }, { linksAt }] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet_link_ask"),
import("../A00_Common/spreadsheet/spreadsheet_link_set"),
]);
const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id;
const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : [];
const choice = others.length
@@ -8,8 +8,6 @@
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask";
import { linksAt, normA1 } from "../A00_Common/spreadsheet/spreadsheet_link_set";
import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
const TEXT = {
@@ -72,6 +70,11 @@ export function openAreaLink(opts: AreaLinkOptions): void {
};
/** 잇기 · 풀기 — 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음(취소 = 모달 그대로) · 틀린 주소 false */
const done = async (cell: string | null): Promise<boolean> => {
// 엔진 조각은 누를 때만(정적 import 금지 — 계약 `spreadsheet_types.ts` 머리)
const [{ askShare }, { linksAt, normA1 }] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet_link_ask"),
import("../A00_Common/spreadsheet/spreadsheet_link_set"),
]);
const a1 = cell === null ? null : normA1(cell);
if (cell !== null && !a1) return false;
const book = opts.sheet.getDoc();
@@ -29,7 +29,7 @@ import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import type { SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc";
type Recalc = (typeof import("../A00_Common/spreadsheet/spreadsheet_recalc"))["recalcWorkbook"];
import {
getActiveLayer,
getActiveOwner,
@@ -122,7 +122,8 @@ export function mountLaborDetail(
const given = (): Record<string, number> =>
Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]])));
/** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */
let area: { doc: Workbook; 판: string } | null = null;
// 엔진(recalc)은 면적 문서와 함께 늦게 받음 — 정적 import 금지(계약 `spreadsheet_types.ts` 머리)
let area: { doc: Workbook; 판: string; recalc: Recalc } | null = null;
let areaDirty = false;
/** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */
let pills = new Map<string, { 이름?: string; 단위?: string }>();
@@ -285,7 +286,7 @@ export function mountLaborDetail(
showToast(tc("Labor_BadCell"), "error");
return false;
}
next.계산값 = recalcWorkbook(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈
next.계산값 = area.recalc(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈
area.doc = next;
links = areaLinks(next);
areaDirty = true;
@@ -638,9 +639,12 @@ export function mountLaborDetail(
loadLogics(),
lib // 서고 모드엔 면적산출 없음
? undefined
: readTemplate(opt.layer, "area", opt.column, null).then(
(got) => {
area = { doc: got.문서 as Workbook, 판: got.판 ?? "" };
: Promise.all([
readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook };
links = areaLinks(got.문서);
},
() => undefined,
@@ -1,8 +1,9 @@
// 스프레드시트 크기 재기 — gzip 크기(바이트). `test_spreadsheet_size.py` 가 부름.
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}, "static": [...]}
// node spreadsheet_size.mjs <저장소 뿌리> → {"engine": {js, css}, "total": {js, css}, "static": [...], "first": [...]}
// engine = 엔진 파일만 줄여 묶은 크기 · total = 실제 앱 빌드에서 스프레드시트를 열 때 받는 조각
// (스프레드시트 조각 + 끌어오는 공용 조각 + css · 첫 화면 `index.html` 조각은 뺌).
// static = 스프레드시트 조각을 정적으로 끌어오는 조각(비어야 함 — 늦게만 받음).
// first = 첫 화면 · M02 페이지 정적 사슬 조각 안에 든 스프레드시트 폴더 모듈(비어야 함 — 엔진 조각이 정적 사슬로 끌려오면 잡힘).
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -51,28 +52,39 @@ async function engineSize() {
async function appSize() {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-sheet-size-"));
const mods = new Map(); // 조각 파일 → 든 모듈
try {
await build({
configLoader: "runner",
configFile: path.join(root, "vite.config.ts"),
logLevel: "silent",
build: { outDir, emptyOutDir: true, manifest: true },
plugins: [
{
name: "sheet-size-modules",
generateBundle(_o, bundle) {
for (const c of Object.values(bundle))
if (c.type === "chunk") mods.set(c.fileName, Object.keys(c.modules));
},
},
],
});
const manifest = JSON.parse(fs.readFileSync(path.join(outDir, ".vite/manifest.json"), "utf8"));
const key = Object.keys(manifest).find((k) => k.endsWith("spreadsheet/spreadsheet.ts"));
const gz = (file) => gzipSync(fs.readFileSync(path.join(outDir, file))).length;
// M02 가 이미 받은 조각은 새 몫이 아님 — 첫 화면 · M02 페이지 · M02 표(ui_template_sheet 등)
// 의 정적 import 사슬. 산출근거 카드는 집계표를 연 뒤에만 열림.
const first = new Set();
const mark = (k) => {
if (first.has(k)) return;
first.add(k);
for (const i of manifest[k].imports ?? []) mark(i);
const mark = (set, k) => {
if (set.has(k)) return;
set.add(k);
for (const i of manifest[k].imports ?? []) mark(set, i);
};
const page = Object.keys(manifest).find((k) => k.endsWith("M02_MasterTemplete_UI_Page.ts"));
mark("index.html");
mark(page);
for (const d of manifest[page].dynamicImports ?? []) if (d !== key) mark(d);
const opening = new Set(); // 첫 화면 · M02 페이지 정적 사슬만(늦은 불러오기 뺌)
mark(opening, "index.html");
mark(opening, page);
const first = new Set(opening);
for (const d of manifest[page].dynamicImports ?? []) if (d !== key) mark(first, d);
const size = { js: 0, css: 0 };
const seen = new Set();
const walk = (k) => {
@@ -87,12 +99,18 @@ async function appSize() {
const statics = Object.entries(manifest)
.filter(([, v]) => (v.imports ?? []).includes(key))
.map(([k]) => k);
return { total: size, static: statics };
const firstSheet = [...opening].flatMap((k) =>
(mods.get(manifest[k].file) ?? [])
.map((id) => id.replaceAll("\\", "/"))
.filter((id) => id.includes("/A00_Common/spreadsheet/"))
.map((id) => id.slice(id.indexOf("A00_Common/"))),
);
return { total: size, static: statics, first: firstSheet };
} finally {
fs.rmSync(outDir, { recursive: true, force: true });
}
}
const engine = await engineSize();
const { total, static: statics } = await appSize();
console.log(JSON.stringify({ engine, total, static: statics }));
const { total, static: statics, first } = await appSize();
console.log(JSON.stringify({ engine, total, static: statics, first }));
@@ -38,6 +38,7 @@ def test_크기_예산():
f"gzip — 엔진 {engine} · 화면 {view} · css {total['css']} · 합 {total['js'] + total['css']}"
)
assert size["static"] == [], f"스프레드시트 조각을 정적으로 끌어옴 — {size['static']}"
assert size["first"] == [], f"M02 첫 조각에 스프레드시트 모듈 — {size['first']}"
assert engine <= BUDGET["engine"], f"엔진 {engine} B > {BUDGET['engine']}"
assert view <= BUDGET["view"], f"화면 {view} B > {BUDGET['view']}"
assert total["css"] <= BUDGET["css"], f"css {total['css']} B > {BUDGET['css']}"
@@ -49,6 +50,12 @@ _STATIC = re.compile(
r"^\s*import\s+(?!type\b)[^;]*?from\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M
)
_SIDE = re.compile(r"^\s*import\s+[\"'][^\"']*spreadsheet/spreadsheet", re.M)
# 흐름도 동기 계산 — M01 로직 실험실 `loadFlowMods` 의 import() 로만 열림 · 첫 묶음 막음은 번들 실측 시험(static · first)이 지킴
_FLOW = {
"M02_MasterTemplete/DataFlow/M02_DF_Eval.ts",
"M02_MasterTemplete/DataFlow/M02_DF_Rule.ts",
"M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts",
}
def test_M02_만_늦게_불러옴():
@@ -63,7 +70,7 @@ def test_M02_만_늦게_불러옴():
).stdout.splitlines()
for rel in listed:
# 시험용 진입(`resources/tester/`)은 앱 번들 밖
if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")):
if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")) or rel in _FLOW:
continue
if not (ROOT / rel).is_file():
continue