acquire-core-x/tools/gen_callgraph.py
hyeongwoo 5b572989e3 아키텍처 복잡도 시각화 — tpcall 호출그래프 (모듈뷰 + 서비스뷰)
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>
2026-07-22 12:56:44 +09:00

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))