IR·데모용으로 "복잡하게 얽힌 시스템"을 눈에 보이게 만든다. 지금까지 복잡도는 백엔드에 실재했지만 그리드 화면에는 드러나지 않았다. - tools/gen_callgraph.py: svc/*.pgc 의 tpcall 관계를 추출해 호출그래프를 뽑고, force-directed 레이아웃을 사전계산(외부 의존성 0, 시드 고정)해 index.html 에 인라인 주입(게이트웨이는 index.html 만 서빙 → 외부 .js 로드 불가). 552 노드 · 494 엣지 · 11 모듈 · 41 모듈간 호출경로 · 최대 체인 깊이 6단. - 포털 신규 화면 "아키텍처·복잡도" (AR0001, 최상단 nav): · 모듈뷰: 11개 도메인을 링으로 배치, 방향 화살표 + 호출수 비례 두께 · 서비스뷰: 552개 서비스 hairball, 모듈 색상 + 호출차수 비례 크기, 팬/줌/호버 · 대표 심층 업무체인 5개 칩(ACQUIRE 6단 등) 클릭 시 경로 하이라이트 · KPI: 온라인 1,463 / 배치 506 / 체인연결 552 / 엣지 494 / 최대깊이 6단 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
7.6 KiB
Python
178 lines
7.6 KiB
Python
#!/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 = ("<script>/* CG:START 자동생성 호출그래프 데이터 (tools/gen_callgraph.py) */\n"
|
|
"window.CALLGRAPH=" + payload + ";\n/* CG:END */</script>")
|
|
if "/* CG:START" in h:
|
|
h = _re.sub(r'<script>/\* CG:START.*?/\* CG:END \*/</script>', lambda m: block, h, count=1, flags=_re.S)
|
|
else:
|
|
# 매니페스트 스크립트 바로 앞에 삽입 → 메인 앱보다 먼저 로드됨
|
|
h = h.replace('<script>\n/* === 화면 매니페스트', block + '\n<script>\n/* === 화면 매니페스트', 1)
|
|
open(IDX, "w", encoding="utf-8").write(h)
|
|
print(f"callgraph.js: nodes={N} edges={len(cg_edges)} modxedge={len(modedge)} "
|
|
f"maxdepth={data['STAT']['maxdepth']} chains={len(chains)}")
|
|
for c in chains:
|
|
print(" 체인:", " → ".join(c))
|