From ef7dc801984a1048f960190e4976442f3a412bb9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 02:39:33 +0900 Subject: [PATCH] =?UTF-8?q?perf(M01):=20=EC=A0=80=EC=9E=A5=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=20=EC=A4=84=20=EB=91=98?= =?UTF-8?q?=EB=A0=88=EB=A1=9C=20=EC=A2=81=ED=9E=88=EA=B3=A0=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=EA=B0=80=20=EB=9C=B0=20=EB=95=8C=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EB=AF=B8=EB=A6=AC=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?(=EC=9D=BC=EA=B0=90=2033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장(`POST /save`) 6.8초 → 0.8초 — 고친 줄과 그 줄을 부르던 줄만 검사 (안 보는 줄이 부르는 키는 목록이 미리 세어 둔 것을 씀 · 돌고 도는 길은 전체 그림으로) - 로직 줄만 고쳤으면 품셈재료 연결 · 인력 준용 검사(`check_links`)도 건너뜀 - 로직 밖 파일(요소 · 표)이 섞인 저장은 예전처럼 전부 검사 — 전체 검사는 `check_master` 몫 - 켠 뒤 첫 목록 3.4초 → 0.03초 — M01 라우터가 뜰 때 뒤에서 한 번 읽어 둠(`cache.warm`) - 저장 전 검사(`check_saved`)를 M01 쪽(`_Store_Cache`)으로 옮김 — 좁히는 값이 있는 자리 · `check_master` 가 700줄에 닿음 - 시험 `test_m01_cache.py` 에 넷 더함 — 없는 로직 키 · 없는 요소 키 · 돌고 도는 참조 · 다른 파일에서 그 줄을 부르던 줄이 깨짐(모두 422) · 미리 읽은 뒤 첫 목록 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Router.py | 4 + M01_MasterData/M01_MasterData_Store.py | 11 ++- M01_MasterData/M01_MasterData_Store_Cache.py | 53 ++++++++++++ resources/master_data/ref/_검증_화면늦음.md | 16 ++++ resources/master_data/scripts/check_master.py | 26 +++--- resources/tester/test_m01_cache.py | 81 ++++++++++++++++++- 6 files changed, 174 insertions(+), 17 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 2c212d81..191ed674 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -5,6 +5,7 @@ from __future__ import annotations +import sys from decimal import Decimal from typing import Any, Literal @@ -17,6 +18,9 @@ from M01_MasterData import M01_MasterData_Store_Make as make router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"]) +if "pytest" not in sys.modules: # 시험은 사본 폴더를 써서 정본을 미리 읽을 까닭이 없음 + store.warm() # 서버가 뜰 때 뒤에서 마스터를 한 번 읽어 둠 — 첫 목록을 기다리지 않게 + class CalcBody(BaseModel): key: str diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 01c7ffb2..0bef24c9 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -68,6 +68,11 @@ def blocks() -> dict[str, list[str]]: return cache.blocks(FOLDER) +def warm() -> None: + """서버가 뜰 때 뒤에서 미리 읽어 둠 — 첫 목록을 기다리지 않게.""" + cache.warm(FOLDER) + + def items_key(data: dict) -> str: return "표" if data.get("그룹") in cm.mf.TABLE_GROUPS else "줄" @@ -660,8 +665,10 @@ def save(batch: list[dict]) -> list[dict]: for part in batch: _apply(after[part["file"]], part.get("changes") or [], part["file"], book) mk.finish(book) - old = set(cm.check_saved(before, names, mk.load_book(book_path))) - new = [x for x in cm.check_saved(after, names, book) if x not in old] + # 고친 줄과 그 줄을 부르던 줄만 검사 — 마스터 전체 검사는 `check_master` 몫 + narrowed = cache.narrow(FOLDER, batch, before, after) + old = set(cache.check_saved(before, names, mk.load_book(book_path), narrowed)) + new = [x for x in cache.check_saved(after, names, book, narrowed) if x not in old] if new: raise StoreError(422, {"errors": new}) if book["다음"] != issued: diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index 2706c0aa..a2fa27a3 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -4,6 +4,7 @@ 자국(파일 시각·크기)이 바뀐 파일만 다시 읽고, 로직 막힘도 미리 세어 둠 — 목록은 검사를 돌리지 않고 세어 둔 값을 씀. 고치는 길(`calc` · `text` · `save`)은 사본을 만지므로 그대로 `cm.load` 로 새로 읽음. +저장 전 검사(`check_saved`)도 여기 — 세어 둔 부르기 그림으로 고친 줄 둘레만 봄(`narrow`). """ from __future__ import annotations @@ -112,3 +113,55 @@ def blocks(folder: Path) -> dict[str, list[str]]: 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()} + + +def graph(folder: Path) -> dict[str, set]: + """로직 키마다 부르는 로직 키 — 미리 세어 둔 것(저장 때 검사를 좁히는 데 씀).""" + with _FRESH: + blocks(folder) + return {key: calls for _, got in _BLOCK.values() for key, (_, calls) in got.items()} + + +def narrow(folder: Path, batch: list[dict], before: dict, after: dict): + """(검사할 로직 키, 안 보는 줄이 부르는 키) — 저장이 건드린 줄과 그 줄을 부르던 줄만. + + 로직 밖 파일(요소 · 표)이 섞이면 어느 로직이 흔들릴지 모르므로 None — 전부 검사. + """ + if any(after[part["file"]].get("그룹") != "로직" for part in batch): + return None + calls, touched, gone = graph(folder), set(), set() + for part in batch: + was, now = [ + {str(r.get("키")) for r in side[part["file"]].get("줄") or []} + for side in (before, after) + ] + touched |= now - was # 더한 줄(키는 저장 때 대장이 냄) + gone |= was - now + touched |= {str(c.get("key")) for c in part.get("changes") or [] if c.get("op") != "add"} + only = touched | {key for key, called in calls.items() if called & touched} + return only, {key: called for key, called in calls.items() if key not in gone} + + +def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> list[str]: + """저장 전 검사(M01) — 고친 파일의 틀 + 로직의 변수 · 키 + 조합. + + `files` = 폴더 전부(고친 뒤) · `book` = 키 대장 · `narrowed` = `narrow` 가 준 좁힘 + (None 이면 전부 검사 — 마스터 전체 검사는 `check_master` 몫). + """ + whole = cm.mf.Master(files) + out = [x for name in changed for x in cm.check_form(name, files[name])] + return out + cm.check_logics(files, whole, book, narrowed) + cm.mcb.check_all(files, whole) + + +def warm(folder: Path) -> threading.Thread: + """서버가 뜰 때 뒤에서 한 번 읽어 둠 — 켠 뒤 첫 목록이 3초 걸리던 것을 없앰.""" + + def run() -> None: + try: + blocks(folder) + except Exception: # 마스터가 깨져 있어도 서버 시동을 막지 않음 — 목록에서 다시 드러남 + pass + + thread = threading.Thread(target=run, name="m01-warm", daemon=True) + thread.start() + return thread diff --git a/resources/master_data/ref/_검증_화면늦음.md b/resources/master_data/ref/_검증_화면늦음.md index 39f4004e..a93f925b 100644 --- a/resources/master_data/ref/_검증_화면늦음.md +++ b/resources/master_data/ref/_검증_화면늦음.md @@ -47,3 +47,19 @@ - 막힘 거름(`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. + +## 고친 뒤 2 (일감 33) + +남은 늦음 둘 — 잰 자리는 위와 같음(이 창 · 사본 폴더 · `TestClient`). + +| 길 | 앞 | 뒤 | +|---|---|---| +| 로직 한 줄 저장(`POST /save`) | 6.8초 | **0.8초** | +| 켠 뒤 첫 목록 | 3.2초 | **0.03초**(미리 읽기 2.7초는 뒤에서 · 기다리지 않음) | + +- **저장 검사 좁힘** — 고친 줄과 **그 줄을 부르던 줄**만 검사. 안 보는 줄이 부르는 키는 목록이 미리 세어 둔 것을 그대로 씀(돌고 도는 길 찾기는 그대로 전체 그림으로 봄). 로직 줄만 고쳤으면 품셈재료 연결·인력 준용 검사(`check_links`)도 건너뜀 — 로직 줄이 못 건드리는 자리. 저장 검사(`check_saved`)는 M01 쪽(`_Store_Cache`)으로 옮김 — 좁히는 값이 거기 있고 `check_master` 가 700줄에 닿아서. +- 로직 밖 파일(요소 · 표 · 조합)이 섞인 저장은 어느 로직이 흔들릴지 모르므로 **예전처럼 전부 검사**(그 자리는 그대로 6.8초). 마스터 전체 검사는 `check_master` 몫. +- **미리 읽기** — M01 라우터가 뜰 때 뒤에서 한 번 읽어 둠(`cache.warm` · 데몬 실). 시동을 막지 않고, 시험은 사본 폴더를 쓰므로 건너뜀. +- 저장 뒤에도 잡히는지 — 견본 넷이 모두 422 로 막힘: 없는 로직 키(GC999999) · 없는 인력 요소 키(LB999999) · 돌고 도는 참조 · 고친 줄을 **다른 파일에서** 부르던 줄이 깨짐(입력 하나 더함). 시험 `resources/tester/test_m01_cache.py` 아홉(넷이 이번 것). +- 단위 경고(`check_units`)는 애초부터 저장 검사가 아니라 `check_master 단위` 몫 — 14건 그대로. +- 남은 늦은 자리 — 저장 0.8초 가운데 0.6초는 마스터를 두 번(고치기 전·후) 새로 읽는 것. **요소 파일 저장은 그대로 7.0초**(인력 한 줄 실측): 요소를 가리키는 로직을 키 글자로만 찾으면 원문번호로 가리키는 자리를 놓쳐 저장 검사에 구멍이 나므로 좁히지 않음 — 좁히려면 「요소를 부르는 로직」 지도부터 만들어야 함. diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 6daf320f..0b74792b 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -174,16 +174,6 @@ def master(folder: Path = MASTER) -> mf.Master: return mf.Master(load(folder=folder)) -def check_saved(files: dict[str, dict], changed: list[str], book: dict | None = None) -> list[str]: - """저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수 · 키 + 조합. - - `files` = 폴더 전부(고친 뒤) · `book` = 키 대장. - """ - whole = mf.Master(files) - out = [x for name in changed for x in check_form(name, files[name])] - return out + check_logics(files, whole, book) + mcb.check_all(files, whole) - - def calc(files: dict[str, dict], ref: str, given: dict) -> dict: """시험 계산(M01) — `ref` = 로직 키 · 멈추면 FormulaError.""" return mf.run(mf.Master(files), ref, given) @@ -562,17 +552,25 @@ def _elements_into(files: dict[str, dict], by_section: dict) -> None: # ── (3) 로직 ────────────────────────────────────────────────────────── -def check_logics(files: dict[str, dict], whole: mf.Master, book: dict | None = None) -> list[str]: - out, graph = [], {} +def check_logics( + files: dict[str, dict], whole: mf.Master, book: dict | None = None, narrow=None +) -> list[str]: + """`narrow` = (검사할 로직 키, 안 보는 줄이 부르는 키) — 주면 그 키만 검사하고 + 품셈재료 연결 · 인력 준용(`check_links`)도 건너뜀(로직 줄 고침이 못 건드리는 자리).""" + only, graph = narrow or (None, {}) + out, graph = [], dict(graph) for name, data in files.items(): if data.get("그룹") != "로직": continue for row in data.get("줄", []): + key = str(row.get("키")) + if only is not None and key not in only: + continue found, calls = mf.check_logic(whole, row) out += [f"{name} · {x}" for x in found] - graph[str(row.get("키"))] = calls + graph[key] = calls out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)] - return out + duplicate_keys(files, book) + check_links(whole) + return out + duplicate_keys(files, book) + ([] if only else check_links(whole)) def duplicate_keys(files: dict[str, dict], book: dict | None = None) -> list[str]: diff --git a/resources/tester/test_m01_cache.py b/resources/tester/test_m01_cache.py index 7d614638..b28139b7 100644 --- a/resources/tester/test_m01_cache.py +++ b/resources/tester/test_m01_cache.py @@ -2,13 +2,15 @@ 목록(`/logics` · `/combos`)은 요청마다 마스터를 새로 읽지 않고 막힘도 미리 세어 둔 값을 씀. 여기서 보는 것 = ① 세어 둔 값이 줄마다 검사한 것과 같음 ② 파일이 바뀌면 바로 다시 읽음 -③ 저장 직후 목록에 바로 비침 ④ 다른 파일의 로직이 바뀌면 그것을 부르던 줄도 다시 셈. +③ 저장 직후 목록에 바로 비침 ④ 다른 파일의 로직이 바뀌면 그것을 부르던 줄도 다시 셈 +⑤ 저장 검사를 고친 줄 둘레로 좁혀도 흠(없는 키 · 없는 요소 · 돌고 도는 참조)을 그대로 잡음. """ from __future__ import annotations import json import shutil +import time from pathlib import Path import pytest @@ -110,3 +112,80 @@ def test_부르는_로직이_바뀌면_다른_파일의_줄도_다시_센다(cli _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"]) + + +def _save(client: TestClient, key: str, spoil) -> object: + """그 로직 줄을 고쳐 저장 길로 보냄 — 저장 검사가 흠을 잡는지 보려고.""" + one = client.get("/api/m01/logic", params={"key": key}).json() + row = json.loads(json.dumps(one["logic"], ensure_ascii=False)) + spoil(row) + return client.post( + "/api/m01/save", + json={ + "files": [ + { + "file": one["file"], + "version": one["version"], + "changes": [{"op": "edit", "key": key, "row": row}], + } + ] + }, + ) + + +def _errors(res) -> list[str]: + assert res.status_code == 422, res.text + return res.json()["detail"]["errors"] + + +def test_저장_검사를_좁혀도_흠을_잡는다(client: TestClient) -> None: + """고친 줄과 그 줄을 부르던 줄만 검사하지만 없는 키 · 없는 요소는 그대로 걸림.""" + _logics(client) # 미리 세어 둔 값이 선 평소 모양 + + def no_logic(row: dict) -> None: + at = next(i for i, h in enumerate(row["호표"]) if h["종류"] == "로직") + row["호표"][at]["요소"] = "로직(GC999999, 기계='5401-0017', 지역=지역)" + + def no_element(row: dict) -> None: + at = next(i for i, h in enumerate(row["호표"]) if h["종류"] == "인력") + row["호표"][at]["요소"] = "LB999999" + + for spoil, word in ((no_logic, "GC999999"), (no_element, "LB999999")): + assert any(word in x for x in _errors(_save(client, CALLER, spoil))) + + +def test_돌고_도는_참조도_저장이_막는다(client: TestClient) -> None: + _logics(client) + + def call_back(row: dict) -> None: + row.setdefault("호표", []).append( + { + "종류": "로직", + "요소": f"로직({CALLER}, 지반구분='토사', 크레인='2104-0010', 지역=지역)", + "이름": "되부르기", + "단위": "시간", + "수량": "1", + } + ) + + assert any("돌고 도는" in x for x in _errors(_save(client, CALLEE, call_back))) + + +def test_고친_줄을_부르던_줄이_깨지면_저장이_막힌다(client: TestClient) -> None: + """좁힌 검사가 「그 줄을 부르는 줄」 을 빠뜨리지 않는지 — 다른 파일에 있는 줄.""" + _logics(client) + + def add_input(row: dict) -> None: + row["입력"] = [*row.get("입력", []), {"이름": "새입력", "종류": "수"}] + + assert any("새입력" in x for x in _errors(_save(client, CALLEE, add_input))) + + +def test_미리_읽어_두면_첫_목록이_기다리지_않는다(client: TestClient) -> None: + store.cache.forget() + thread = store.cache.warm(store.FOLDER) + thread.join(120) + assert not thread.is_alive() + start = time.perf_counter() + assert _logics(client) + assert time.perf_counter() - start < 1.0 # 미리 안 읽으면 2~3초