From 3c6e68231c27def509986ae8f43dc90e69ba25c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:17:49 +0900 Subject: [PATCH] =?UTF-8?q?perf(M01):=20=EB=A1=9C=EC=A7=81=C2=B7=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=ED=95=9C=20=EB=B2=88=EB=A7=8C=20=EC=9D=BD?= =?UTF-8?q?=EA=B3=A0=20=EB=A7=89=ED=9E=98=EC=9D=80=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EC=84=B8=EC=96=B4=20=EB=91=A0=20(=EC=9D=BC=EA=B0=90=2032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_Store_Cache` 신설 — 자국(시각·크기)이 바뀐 파일만 다시 읽음 · 목록은 검사를 돌리지 않고 세어 둔 막힘을 씀 - 요소·표가 바뀌면 모든 로직을, 로직 파일이 바뀌면 그 파일과 그 로직을 부르던 줄만 다시 셈 - 저장 뒤에는 고친 파일을 바로 다시 읽게 알려 줌 — 저장 직후 목록 2.5초 → 0.06초 - `/logics` 둘째 번부터 2.5초 → 0.02초 · `/combos` 0.3초 → 0.01초 · 켠 뒤 첫 번만 3.4초 - 시험 `test_m01_cache.py` 다섯 — 세어 둔 막힘이 줄마다 검사한 값과 같음 · 거름 그대로 · 저장 직후 비침 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Store.py | 29 +++-- M01_MasterData/M01_MasterData_Store_Cache.py | 114 +++++++++++++++++++ M01_MasterData/M01_MasterData_Store_Combo.py | 7 +- M01_MasterData/M01_MasterData_Store_Make.py | 4 +- resources/master_data/ref/_검증_화면늦음.md | 18 +++ resources/tester/test_m01_cache.py | 112 ++++++++++++++++++ 6 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Cache.py create mode 100644 resources/tester/test_m01_cache.py diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 58fddb8a..01c7ffb2 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -24,6 +24,7 @@ import master_copy as mcp # noqa: E402,F401 (`_Store_Make` 가 `store.mcp` 로 import master_keys as mk # noqa: E402 import master_text as mtx # noqa: E402 +from M01_MasterData import M01_MasterData_Store_Cache as cache # noqa: E402 from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402 FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈 @@ -56,6 +57,17 @@ def read(file: str) -> tuple[dict, str]: return json.loads(raw, parse_float=Decimal, parse_int=Decimal), version_of(raw) +# ── 한 번 읽어 두기(`_Store_Cache`) ──────────────────────────────────── +def loaded() -> tuple[dict[str, dict], object]: + """(파일 묶음, 색인) — **읽기 전용**. 고칠 것이면 `cm.load` 로 새로 읽을 것.""" + return cache.loaded(FOLDER) + + +def blocks() -> dict[str, list[str]]: + """로직 키마다 막힘 까닭 — 미리 세어 둔 값(목록에서 검사를 돌리지 않음).""" + return cache.blocks(FOLDER) + + def items_key(data: dict) -> str: return "표" if data.get("그룹") in cm.mf.TABLE_GROUPS else "줄" @@ -318,9 +330,10 @@ def _logic_files(files: dict[str, dict]): def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") -> list[dict]: - """로직 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름(장 차례대로 · 파일을 가로지름).""" - files = cm.load(folder=FOLDER) - whole = cm.mf.Master(files) + """로직 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름(장 차례대로 · 파일을 가로지름). + 막힘은 미리 세어 둔 값(`blocks`) — 목록에서 줄마다 검사를 돌리지 않음.""" + files, _ = loaded() + why_of = blocks() out = [] for name, data in sorted(_logic_files(files), key=lambda f: f[1].get("차례") or 0): for row in data.get("줄") or []: @@ -330,7 +343,7 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") continue if not _hit(row, q): continue - reasons = cm.mf.check_logic(whole, row)[0] + reasons = why_of.get(str(row.get("키")), []) if blocked is not None and bool(reasons) != bool(blocked): continue out.append( @@ -358,11 +371,10 @@ def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") def logic(key: str) -> dict: """로직 하나 — 키는 전체에서 하나.""" - files = cm.load(folder=FOLDER) + files, whole = loaded() for name, data in _logic_files(files): for row in data.get("줄") or []: if str(row.get("키")) == key: - whole = cm.mf.Master(files) reasons = cm.mf.check_logic(whole, row)[0] _, version = read(name) return { @@ -427,7 +439,7 @@ def elements(group: str, q: str, limit: int) -> dict: if group not in cm.mf.GROUPS: raise StoreError(404, f"없는 그룹 「{group}」") hits = [] - for name, data in cm.load(folder=FOLDER).items(): + for name, data in loaded()[0].items(): if data.get("그룹") != group: continue for row in data.get(items_key(data)) or []: @@ -491,7 +503,7 @@ def materials(sub: str, detail: str, spec: str, region: str, unit: str, limit: i """재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀. `unit` = 호표 줄 단위 — 주면 그 단위 줄만(단위가 다르면 다른 물건). `기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄) · 그 줄을 `items` 맨 앞에 둠.""" - whole = cm.master(folder=FOLDER) + whole = loaded()[1] cond = {"구분": sub, "상세구분": detail or None, "규격": spec or None} env = {cm.mf.REGION: region} if region else {} keys = cm.mf.candidates(whole, cond, env, unit or None) @@ -664,4 +676,5 @@ def save(batch: list[dict]) -> list[dict]: tmp.write_text(text, encoding="utf-8", newline="\n") os.replace(tmp, FOLDER / name) out.append({"file": name, "version": version_of(text.encode("utf-8"))}) + cache.stale(dict.fromkeys(names)) # 저장 뒤에는 그 파일을 바로 다시 읽게 return out diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py new file mode 100644 index 00000000..2706c0aa --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -0,0 +1,114 @@ +"""M01 마스터 한 번 읽어 두기 — 목록 길이 요청마다 마스터를 새로 읽지 않게. + +`M01_MasterData_Store.loaded` · `.blocks` 가 씀(읽기 전용 길만). +자국(파일 시각·크기)이 바뀐 파일만 다시 읽고, 로직 막힘도 미리 세어 둠 — +목록은 검사를 돌리지 않고 세어 둔 값을 씀. 고치는 길(`calc` · `text` · `save`)은 +사본을 만지므로 그대로 `cm.load` 로 새로 읽음. +""" + +from __future__ import annotations + +import json +import sys +import threading +from decimal import Decimal +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts" +sys.path.insert(0, str(SCRIPTS)) + +import check_master as cm # noqa: E402 + +_FRESH = threading.RLock() +_READ: dict[str, tuple[tuple, dict]] = {} # 파일 이름 → (자국, 읽은 내용) +_INDEX: dict = {} # 폴더 · 자국 · files · whole +_BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: (막힘 까닭, 부르는 로직)}) +_BASE: dict[str, tuple] = {} # 막힘을 셀 때 바탕이던, 로직 아닌 파일의 자국 + + +def _stamps(folder: Path) -> dict[str, tuple]: + """첫 층 JSON 자국 — {이름: (시각, 크기)} · 밑줄 파일은 `cm.load` 가 안 읽으므로 뺌.""" + out = {} + for path in sorted(folder.glob("*.json")): + if not path.name.startswith("_"): + stat = path.stat() + out[path.name] = (stat.st_mtime_ns, stat.st_size) + return out + + +def _logic_files(files: dict[str, dict]): + for name, data in files.items(): + if data.get("그룹") == "로직": + yield name, data + + +def forget() -> None: + """읽어 둔 것을 버림 — 파일을 고친 자리(저장)에서 부름.""" + with _FRESH: + _READ.clear() + _INDEX.clear() + _BLOCK.clear() + _BASE.clear() + + +def stale(names) -> None: + """그 파일만 다시 읽게 함 — 저장처럼 스스로 고친 자리에서 부름 + (자국은 시각이 같은 틱이면 못 가릴 수 있으므로 고친 쪽이 알려 줌).""" + with _FRESH: + for name in names: + was = _READ.pop(name, (None, {}))[1] + _BLOCK.pop(name, None) + if was.get("그룹") != "로직": + _BASE.clear() # 요소·표가 바뀌면 모든 로직을 다시 셈 + _INDEX.pop("자국", None) + + +def loaded(folder: Path) -> tuple[dict[str, dict], object]: + """(파일 묶음, 색인) — **읽기 전용**. 고칠 것이면 `cm.load` 로 새로 읽을 것.""" + with _FRESH: + if _INDEX.get("폴더") != folder: # 시험은 사본 폴더로 바꿈 + forget() + _INDEX["폴더"] = folder + now = _stamps(folder) + for gone in [name for name in _READ if name not in now]: + del _READ[gone] + for name, stamp in now.items(): + if _READ.get(name, (None,))[0] != stamp: + raw = (folder / name).read_bytes() + _READ[name] = (stamp, json.loads(raw, parse_float=Decimal, parse_int=Decimal)) + if _INDEX.get("자국") != now: + files = {name: _READ[name][1] for name in now} + _INDEX.update(자국=now, files=files, whole=cm.mf.Master(files)) + return _INDEX["files"], _INDEX["whole"] + + +def blocks(folder: Path) -> dict[str, list[str]]: + """로직 키마다 막힘 까닭 — 미리 세어 둔 값. 요소 파일이 바뀌면 모든 로직을, + 로직 파일은 바뀐 그 파일과 그 로직을 부르던 줄만 다시 셈.""" + with _FRESH: + files, whole = loaded(folder) + stamps = _INDEX["자국"] + base = {n: s for n, s in stamps.items() if files[n].get("그룹") != "로직"} + if _BASE != base: # 인력 · 재료 · 표가 바뀌면 바탕이 달라짐 + _BLOCK.clear() + _BASE.clear() + _BASE.update(base) + moved, fresh = set(), set() # 바뀐 로직 파일의 키(옛것 · 새것) + names = {name for name, _ in _logic_files(files)} + for gone in [name for name in _BLOCK if name not in names]: # 파일째 사라진 것 + moved |= set(_BLOCK.pop(gone)[1]) + for name, data in _logic_files(files): + if _BLOCK.get(name, (None,))[0] == stamps[name]: + continue + got = {str(r.get("키")): cm.mf.check_logic(whole, r) for r in data.get("줄") or []} + moved |= set(_BLOCK.get(name, (None, {}))[1]) | set(got) + _BLOCK[name] = (stamps[name], got) + fresh.add(name) + for name, data in _logic_files(files): # 다른 파일에서 그 로직을 부르던 줄 + if name in fresh: + continue + got = _BLOCK[name][1] + rows = {str(r.get("키")): r for r in data.get("줄") or []} + for key in [k for k, (_, calls) in got.items() if calls & moved]: + got[key] = cm.mf.check_logic(whole, rows[key]) + return {key: why for _, got in _BLOCK.values() for key, (why, _) in got.items()} diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index e8ae61bb..c363639f 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -103,8 +103,7 @@ def _brief(whole, row: dict) -> list[dict]: def combos(sub: str = "", detail: str = "", q: str = "", owner: str = "") -> list[dict]: """조합 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름.""" - files = cm.load(folder=store.FOLDER) - whole = cm.mf.Master(files) + files, whole = store.loaded() out = [] for _, row in mcb.rows(files): if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail): @@ -130,7 +129,7 @@ def combo(key: str) -> dict: """조합 하나 — 담은 로직에 이름·단위를 붙여 줌.""" data, version = _read() row = _find(data, key) - whole = cm.mf.Master(cm.load(folder=store.FOLDER)) + whole = store.loaded()[1] problems = mcb.check_row(whole, row) return { "file": FILE, @@ -144,7 +143,7 @@ def combo(key: str) -> dict: def combos_of_logic(key: str) -> dict: """「이 로직을 쓰는 조합」 — 로직 화면이 거꾸로 볼 때(소요량 「용도」 와 같은 결).""" - files = cm.load(folder=store.FOLDER) + files, _ = store.loaded() return {"key": key, "combos": mcb.using(files, key)} diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 7fdf6ebe..76dc319d 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -70,7 +70,7 @@ def _all_sections(book: str) -> list[dict]: def _counts() -> dict[tuple[str, str], list[int]]: """(구분, 절 번호) → [표 수, 로직 수] — 아래 절까지 세도록 절 번호의 모든 앞마디에 얹음.""" out: dict[tuple[str, str], list[int]] = {} - for data in cm.load(folder=store.FOLDER).values(): + for data in store.loaded()[0].values(): group = data.get("그룹") at = 0 if group in cm.mf.TABLE_GROUPS else 1 if group == "로직" else None if at is None: @@ -236,7 +236,7 @@ def logic_edit(key: str, version: str, logic: dict, owner: str = "") -> dict: def logic_formula(key: str) -> dict: """로직 한 줄을 별칭 꼴 식 묶음으로 펴 보임 — 되돌릴 원문은 `별칭` 에 그대로(정본은 안 바뀜).""" got = store.logic(key) - master = cm.mf.Master(cm.load(folder=store.FOLDER)) + master = store.loaded()[1] return { "file": got["file"], "version": got["version"], diff --git a/resources/master_data/ref/_검증_화면늦음.md b/resources/master_data/ref/_검증_화면늦음.md index 799f6a9e..39f4004e 100644 --- a/resources/master_data/ref/_검증_화면늦음.md +++ b/resources/master_data/ref/_검증_화면늦음.md @@ -29,3 +29,21 @@ 3. 「로직 개선 시험」 은 같은 길을 그대로 쓰므로 1 · 2 를 고치면 같이 빨라짐. 시험 흔적 없음(어떤 파일도 안 고침 · 프론트 개발서버 재시작 1회는 화면이 빌드 오류로 안 뜨던 것을 되살리려 한 것 — 조합 화면 파일이 다른 창에서 편집 중이던 자국으로 보임). + +## 고친 뒤 (일감 32) + +잰 자리 = 이 창(랩탑 sub_laptop_1) · 임시 사본 폴더 · `TestClient` 로 같은 길을 부름. 데스크탑은 같은 길이 2배쯤 느렸음(위 표 6.0초 = 여기 2.5초). + +| 길 | 앞 | 뒤 | +|---|---|---| +| `/api/m01/logics` 켠 뒤 첫 번 | 2.5초 | 3.4초(마스터 한 번 읽기 + 1,354줄 한 번 세기 · **딱 한 번만**) | +| `/api/m01/logics` 그 다음부터 | 2.5초(매번) | **0.02초** | +| `/api/m01/combos` | 0.3초(데스크탑 1.6초) | **0.01초** | +| 로직 저장 직후 목록 | 2.5초 | **0.06초** | + +- 요청마다 마스터를 새로 읽던 것을 한 번 읽어 두고, 파일 자국(시각·크기)이 바뀐 파일만 다시 읽음(`M01_MasterData_Store_Cache`). +- 목록은 검사를 돌리지 않음 — 로직 막힘을 미리 세어 두고 씀. 요소·표가 바뀌면 모든 로직을, 로직 파일이 바뀌면 그 파일과 그 로직을 부르던 줄만 다시 셈. +- 저장한 쪽이 고친 파일 이름을 알려 주므로 저장 직후 목록에 바로 비침(같은 시각 틱이어도 안 놓침). +- 막힘 거름(`blocked=0·1`)·구분·상세구분·찾기 결과와 줄 내용은 그대로 — 시험 `resources/tester/test_m01_cache.py` 다섯이 줄마다 검사한 값과 맞댐. +- 남은 늦은 자리(이 일감 밖) — **저장 자체가 6.7초**: `check_saved` 가 저장 앞뒤로 1,354줄을 다 검사함. 켠 뒤 첫 목록 3.4초도 서버가 뜰 때 미리 한 번 읽어 두면 없앨 수 있음(서버 시동 자리라 이 창 담당 밖). +- 시험 — `test_m01_*` · `test_masterdata_*` 195 통과 · 4 실패. 실패 넷은 모두 `test_m01_combo.py`(정본 조합 파일이 빈 채라고 보는 옛 기대값 — 일감 31 이 견본 조합 `UA000001` 을 정본에 넣어 어긋남) · 이 고침과 무관(첫 실패는 서버를 거치지 않고 정본 파일만 읽음). `check_master` 틀 0 · 본문 0 · 로직 0 · 조합 0 · 단위 14. diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py new file mode 100644 index 00000000..7d614638 --- /dev/null +++ b/resources/tester/test_m01_cache.py @@ -0,0 +1,112 @@ +"""M01 목록이 마스터를 한 번만 읽는지 — `M01_MasterData_Store_Cache`. + +목록(`/logics` · `/combos`)은 요청마다 마스터를 새로 읽지 않고 막힘도 미리 세어 둔 값을 씀. +여기서 보는 것 = ① 세어 둔 값이 줄마다 검사한 것과 같음 ② 파일이 바뀌면 바로 다시 읽음 +③ 저장 직후 목록에 바로 비침 ④ 다른 파일의 로직이 바뀌면 그것을 부르던 줄도 다시 셈. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +REAL = store.FOLDER +CALLER = "GC000994" # 로직_건설품셈_01장_공통.json — 아래 로직을 부름 +CALLEE = "GC000276" # 로직_건설품셈_08장_건설기계.json — 다른 파일에 있음 + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _logics(client: TestClient, **params) -> list[dict]: + res = client.get("/api/m01/logics", params=params) + assert res.status_code == 200, res.text + return res.json()["logics"] + + +def _write(file: str, change) -> None: + """정본 파일을 손으로 고침(저장 길을 거치지 않음) — 바뀐 것을 알아채는지 보려고.""" + path = store.FOLDER / file + data = json.loads(path.read_text(encoding="utf-8")) + change(data) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n") + + +def test_세어_둔_막힘이_줄마다_검사한_것과_같다(client: TestClient) -> None: + listed = _logics(client) + files, whole = store.loaded() + rows = { + str(r.get("키")): r for _, d in files.items() if d.get("그룹") == "로직" for r in d["줄"] + } + assert len(listed) == len(rows) + for one in listed: + reasons = store.cm.mf.check_logic(whole, rows[one["키"]])[0] + assert one["reasons"] == reasons and one["blocked"] is bool(reasons) + + +def test_거름이_그대로다(client: TestClient) -> None: + whole = _logics(client) + sub, detail = whole[0]["구분"], whole[0]["상세구분"] + by_sub = _logics(client, sub=sub, detail=detail) + assert by_sub == [x for x in whole if (x["구분"], x["상세구분"]) == (sub, detail)] + assert _logics(client, blocked=1) == [x for x in whole if x["blocked"]] + assert _logics(client, blocked=0) == [x for x in whole if not x["blocked"]] + assert _logics(client, q=CALLER) == [x for x in whole if x["키"] == CALLER] + + +def test_안_바뀌면_다시_읽지_않고_바뀌면_바로_읽는다(client: TestClient) -> None: + files, whole = store.loaded() + assert store.loaded() == (files, whole) # 같은 것을 그대로 씀 + _write("로직_건설품셈_01장_공통.json", lambda d: d["줄"][0].update(비고="자국 바뀜")) + fresh, _ = store.loaded() + assert fresh is not files + assert fresh["로직_건설품셈_01장_공통.json"]["줄"][0]["비고"] == "자국 바뀜" + + +def test_로직을_저장하면_목록에_바로_비친다(client: TestClient) -> None: + one = client.get("/api/m01/logic", params={"key": CALLER}).json() + assert [x for x in _logics(client, q=CALLER)][0]["이름"] == one["logic"]["이름"] + row = {**one["logic"], "이름": "저장 뒤 이름"} + res = client.post( + "/api/m01/save", + json={ + "files": [ + { + "file": one["file"], + "version": one["version"], + "changes": [{"op": "edit", "key": CALLER, "row": row}], + } + ] + }, + ) + assert res.status_code == 200, res.text + listed = [x for x in _logics(client) if x["키"] == CALLER] + assert listed and listed[0]["이름"] == "저장 뒤 이름" + + +def test_부르는_로직이_바뀌면_다른_파일의_줄도_다시_센다(client: TestClient) -> None: + assert [x for x in _logics(client) if x["키"] == CALLER][0]["blocked"] is False + + def add_input(data: dict) -> None: + row = next(r for r in data["줄"] if r["키"] == CALLEE) + row["입력"] = [*row.get("입력", []), {"이름": "새입력", "종류": "수"}] + + _write("로직_건설품셈_08장_건설기계.json", add_input) # 부르는 쪽 파일은 그대로 + caller = [x for x in _logics(client) if x["키"] == CALLER][0] + assert caller["blocked"] is True and any("새입력" in x for x in caller["reasons"])