#!/usr/bin/env python3 """ gen_callgraph.py — svc/*.pgc 의 tpcall 관계를 추출해 아키텍처 복잡도 시각화용 데이터(app/ui/callgraph.js)를 만든다. 산출물: CG_MODULES : 모듈 노드 11개 (id, 한글명, 서비스수, 교차호출 in/out) CG_MODEDGES : 모듈간 호출 엣지 (from,to,count) ← 상위 얽힘 뷰 CG_NODES : 서비스 노드 552개 (id, module, x, y, deg) ← 전체 hairball CG_EDGES : 서비스 호출 엣지 494개 (from,to) CG_CHAINS : 대표 심층 체인 (ACQUIRE 6단 등) ← 하이라이트 레이아웃은 순수 파이썬 force-directed 로 여기서 미리 계산한다(외부 의존성 0). 결정적 재현을 위해 시드 고정. """ import glob, re, os, json, math, collections, random random.seed(42) SRC = os.path.join(os.path.dirname(__file__), "..", "app", "src") OUT = os.path.join(os.path.dirname(__file__), "..", "app", "ui", "callgraph.js") MODNM = { "ac": "매입", "au": "승인/한도", "st": "정산/수수료", "py": "지급", "lg": "원장", "cl": "마감", "rc": "대사", "vl": "정합성검증", "mm": "마스터", "mg": "전문게이트웨이", "cm": "공통", } # ── 1. 추출 ──────────────────────────────────────────────────────────────── node_mod, edges = {}, [] svc_count = collections.Counter() for f in sorted(glob.glob(os.path.join(SRC, "*/svc/*.pgc"))): mod = os.path.normpath(f).split(os.sep)[-3] caller = os.path.basename(f)[:-4] node_mod[caller] = mod svc_count[mod] += 1 txt = open(f, encoding="utf-8", errors="ignore").read() for callee in re.findall(r'tpcall\("([A-Z_0-9]+)"', txt): if callee != caller: edges.append((caller, callee)) # 호출에 관여하는 노드만 그래프에 (552개) active = set(a for a, _ in edges) | set(b for _, b in edges) deg = collections.Counter() for a, b in edges: deg[a] += 1 deg[b] += 1 # ── 2. 모듈 그래프 ────────────────────────────────────────────────────────── modedge = collections.Counter() for a, b in edges: ma, mb = node_mod.get(a, "cm"), node_mod.get(b, "cm") if ma != mb: modedge[(ma, mb)] += 1 mod_out = collections.Counter(); mod_in = collections.Counter() for (a, b), c in modedge.items(): mod_out[a] += c; mod_in[b] += c # ── 3. 대표 심층 체인 (BFS 로 가장 깊은 경로 몇 개) ────────────────────────── adj = collections.defaultdict(list) for a, b in edges: if b not in adj[a]: adj[a].append(b) def deepest_from(start, maxd=8): best = [start] stack = [(start, [start], {start})] while stack: n, path, seen = stack.pop() if len(path) > len(best): best = path if len(path) >= maxd: continue for m in adj.get(n, []): if m not in seen: stack.append((m, path + [m], seen | {m})) return best chains = [] for root in ["ACQUIRE", "ACQ_SETTLELINK", "MG_RECV", "CL_DAILY", "PAY_APPROVE"]: if root in active: p = deepest_from(root) if len(p) >= 3: chains.append(p) # ── 4. force-directed 레이아웃 (모듈별 초기 클러스터 → 스프링) ──────────────── nodes = sorted(active) idx = {n: i for i, n in enumerate(nodes)} N = len(nodes) # 모듈을 원주에 배치해 같은 모듈끼리 뭉치게 초기화 mods = sorted(MODNM) mang = {m: 2 * math.pi * i / len(mods) for i, m in enumerate(mods)} X = [0.0] * N; Y = [0.0] * N for n in nodes: m = node_mod.get(n, "cm") a = mang[m] + random.uniform(-0.35, 0.35) r = 340 + random.uniform(-60, 60) X[idx[n]] = math.cos(a) * r Y[idx[n]] = math.sin(a) * r E = [(idx[a], idx[b]) for a, b in edges if a in idx and b in idx] K = 78.0 # 이상 스프링 길이 REP = 3400.0 # 반발 상수 GRAV = 0.020 # 중심 중력 (박스 아티팩트 억제 — 원형 블롭 유지) for it in range(340): fx = [0.0] * N; fy = [0.0] * N # 반발 (O(n^2), 552노드 = 15만/iter, 340회 ≈ 5천만 — 수초) for i in range(N): xi, yi = X[i], Y[i] for j in range(i + 1, N): dx = xi - X[j]; dy = yi - Y[j] d2 = dx * dx + dy * dy + 0.01 f = REP / d2 fdx = dx * f; fdy = dy * f fx[i] += fdx; fy[i] += fdy fx[j] -= fdx; fy[j] -= fdy # 인력 (엣지 스프링) for a, b in E: dx = X[a] - X[b]; dy = Y[a] - Y[b] d = math.sqrt(dx * dx + dy * dy) + 0.01 f = (d - K) * 0.05 fdx = dx / d * f; fdy = dy / d * f fx[a] -= fdx; fy[a] -= fdy fx[b] += fdx; fy[b] += fdy # 중심 중력 (반경 비례로 당겨 원형 유지) cool = 1.0 - it / 400.0 for i in range(N): fx[i] -= X[i] * GRAV fy[i] -= Y[i] * GRAV X[i] += max(-22, min(22, fx[i])) * cool Y[i] += max(-22, min(22, fy[i])) * cool # 정규화 → 종횡비 보존(단일 스케일), 중앙 정렬 → 0..1000 박스에 원형 블롭 유지 xs = [X[i] for i in range(N)]; ys = [Y[i] for i in range(N)] minx, maxx, miny, maxy = min(xs), max(xs), min(ys), max(ys) span = max(maxx - minx, maxy - miny) or 1 cx = (minx + maxx) / 2; cy = (miny + maxy) / 2 def nx(v): return round((v - cx) / span * 900 + 500, 1) def ny(v): return round((v - cy) / span * 900 + 500, 1) cg_nodes = [{"id": n, "m": node_mod.get(n, "cm"), "x": nx(X[idx[n]]), "y": ny(Y[idx[n]]), "d": deg[n]} for n in nodes] cg_edges = [[a, b] for a, b in edges if a in idx and b in idx] cg_modules = [{"id": m, "nm": MODNM[m], "svc": svc_count[m], "in": mod_in[m], "out": mod_out[m]} for m in mods] cg_modedges = [{"f": a, "t": b, "c": c} for (a, b), c in modedge.items()] data = { "MODULES": cg_modules, "MODEDGES": cg_modedges, "NODES": cg_nodes, "EDGES": cg_edges, "CHAINS": chains, "STAT": {"nodes": N, "edges": len(cg_edges), "callees": len(set(b for _, b in edges)), "modxedge": len(modedge), "maxdepth": max((len(c) for c in chains), default=0)}, } payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")) with open(OUT, "w", encoding="utf-8") as w: w.write("/* 자동생성: tools/gen_callgraph.py — svc tpcall 호출그래프 (레이아웃 사전계산). 손대지 말 것. */\n") w.write("window.CALLGRAPH = " + payload + ";\n") # index.html 에 인라인 (게이트웨이는 index.html 만 서빙, 외부 .js 로드 불가). # gen_screens.py 와 동일하게 마커 블록을 교체한다(재실행 idempotent). import re as _re IDX = os.path.join(os.path.dirname(__file__), "..", "app", "ui", "index.html") h = open(IDX, encoding="utf-8").read() block = ("") if "/* CG:START" in h: h = _re.sub(r'', lambda m: block, h, count=1, flags=_re.S) else: # 매니페스트 스크립트 바로 앞에 삽입 → 메인 앱보다 먼저 로드됨 h = h.replace('