acquire-core-x/tools/gen_metrics.py
hyeongwoo 46ff41b633 IR 자료: 마이그레이션 난이도 브리핑 (3축 전량 실측)
"Forge가 얼마나 복잡한 레거시를 다룰 수 있는가"를 증명하는 Before 자산.
지표는 전부 소스에서 자동 집계(추정·과장 없음).

- tools/gen_metrics.py: 소스 실측 → app/ui/metrics.js + index.html 인라인
  · 축A 규모: 파일 4,626 / 33만 LOC / EXEC SQL 23,742 / tpcall 989 /
    tpbegin 1,234 / UBF 46,904 / 테이블 75 / 카피북 2,207
  · 축B 자동변환 실패지점: ATMI·XA2PC(6단)·ECPG·UBF·카피북팬아웃·매크로
    각각 실측 카운트 + Java/Spring 이전이 어려운 이유
  · 축B 거래추적: ACQUIRE 6프로그램·6XA브랜치, PAY_APPROVE 5·5, CL_DAILY 6·6
    (프로그램→테이블쓰기→XA브랜치 트리)
  · 축C 레거시 인벤토리: acq_common.h 139프로그램 공유, 매크로 2,247,
    sqlca 수동분기 18,719, EXEC밀도 9.8/파일
- 포털 신규 화면 "마이그레이션 난이도"(AR0002, #mig): 위 3축을 한 화면에
- docs/MIGRATION_COMPLEXITY.md: 피치덱용 브리프 (실동작 증거 + 발견된 실결함 4건)
- README: IR 자료 안내 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:18:40 +09:00

167 lines
9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
gen_metrics.py — IR·데모용 "마이그레이션 난이도" 실측 지표를 뽑아
app/ui/metrics.js (+ index.html 인라인) 로 굽는다. 전부 소스에서 계산(지어내지 않음).
산출:
SCALE : 양적 규모 (파일/LOC/EXEC SQL/tpcall/tpbegin/UBF/테이블/배치) ← 축A
HAZARDS : 자동변환 실패 지점 (설명 + 실측 카운트 + 왜 어려운지) ← 축B
TRACE : 대표 거래(ACQUIRE 등)가 건드리는 프로그램/테이블/XA브랜치 트리 ← 축B
LEGACY : 레거시 실측 인벤토리 (카피북 팬아웃/매크로/임베디드SQL밀도 등) ← 축C
"""
import glob, re, os, json, collections
ROOT = os.path.join(os.path.dirname(__file__), "..")
SRC = os.path.join(ROOT, "app", "src")
SCHEMA = os.path.join(ROOT, "db", "schema.d")
OUT_JS = os.path.join(ROOT, "app", "ui", "metrics.js")
IDX = os.path.join(ROOT, "app", "ui", "index.html")
def sh_count(pat_files, needle_re):
n = 0
rx = re.compile(needle_re)
for f in pat_files:
n += len(rx.findall(open(f, encoding="utf-8", errors="ignore").read()))
return n
pgc = glob.glob(os.path.join(SRC, "*/**/*.pgc"), recursive=True)
c_files = glob.glob(os.path.join(SRC, "**/*.c"), recursive=True)
h_files = glob.glob(os.path.join(SRC, "**/*.h"), recursive=True)
svc_files = glob.glob(os.path.join(SRC, "*/svc/*.pgc"))
batch_files = glob.glob(os.path.join(SRC, "*/batch/*.pgc"))
dbio_files = glob.glob(os.path.join(SRC, "*/dbio/*.pgc"))
def loc(files):
return sum(sum(1 for _ in open(f, encoding="utf-8", errors="ignore")) for f in files)
# ── 축 A: 규모 ──────────────────────────────────────────────────────────────
tables = sh_count(glob.glob(os.path.join(SCHEMA, "*.sql")), r"(?i)create table")
SCALE = [
{"k": "소스 파일 (.pgc/.c/.h)", "v": len(pgc) + len(c_files) + len(h_files), "u": ""},
{"k": "총 코드 라인", "v": loc(pgc + c_files + h_files), "u": "LOC"},
{"k": "온라인 서비스", "v": len(svc_files), "u": ""},
{"k": "배치 잡", "v": len(batch_files), "u": ""},
{"k": "임베디드 SQL(EXEC SQL)", "v": sh_count(pgc, r"EXEC SQL"), "u": "블록"},
{"k": "Tuxedo tpcall 호출", "v": sh_count(pgc, r"\btpcall\s*\("), "u": ""},
{"k": "전역 트랜잭션 tpbegin", "v": sh_count(pgc, r"\btpbegin\b"), "u": ""},
{"k": "UBF 버퍼 필드접근", "v": sh_count(pgc, r"\b(?:C?Bget|C?Bchg|getl|setl|gets_)\s*\("), "u": ""},
{"k": "DB 테이블", "v": tables, "u": ""},
{"k": "카피북 헤더(.h)", "v": len(h_files), "u": ""},
]
# ── 축 B: 자동변환 실패 지점 ─────────────────────────────────────────────────
xa_deep = sh_count(pgc, r"\btpbegin\b")
HAZARDS = [
{"t": "Tuxedo ATMI (tpcall/tpservice/tpreturn)",
"n": sh_count(pgc, r"\btpcall\s*\("), "u": "호출",
"why": "Java·Spring에 1:1 대응 API가 없다. 서비스 호출 모델 자체를 REST/메시지로 재설계해야 하며, 문법 치환으로는 불가능."},
{"t": "XA 2PC 전역 트랜잭션 (최대 6단 중첩)",
"n": xa_deep, "u": "tpbegin",
"why": "형제 XA 브랜치 격리·2단계 커밋 의미론이 Spring @Transactional 경계와 다르다. 잘못 옮기면 교착·이중전기가 그대로 재현된다."},
{"t": "ECPG 임베디드 SQL (호스트변수·커서·sqlca)",
"n": sh_count(pgc, r"EXEC SQL"), "u": "블록",
"why": "EXEC SQL 블록마다 호스트변수 바인딩·sqlcode 분기·커서 수명을 JPA/MyBatis 의미로 옮겨야 한다. 자동 변환기가 가장 많이 깨지는 지점."},
{"t": "UBF(FML32) 타입리스 버퍼",
"n": sh_count(pgc, r"\b(?:C?Bget|C?Bchg|getl|setl|gets_)\s*\("), "u": "필드접근",
"why": "필드ID 기반 동적 버퍼라 타입·필수여부가 컴파일타임에 없다. Java DTO로 옮기려면 47,000여 접근점의 계약을 사람이 복원해야 한다."},
{"t": "카피북 공유(#include) 팬아웃",
"n": sh_count(pgc, r"#include"), "u": "참조",
"why": "한 카피북을 수십 프로그램이 공유해 구조체 변경이 전방위로 파급된다. 경계 없는 결합이라 모듈 단위 절단이 어렵다."},
{"t": "전처리 매크로(#define)",
"n": sh_count(h_files + pgc, r"#define"), "u": "정의",
"why": "매크로가 코드 생성·분기를 숨겨 정적 분석을 방해한다. 전개 후에야 실제 로직이 드러난다."},
]
# ── 축 B: 거래 추적 (프로그램→테이블→XA브랜치) ────────────────────────────────
# 각 svc 가 건드리는 테이블 (svc 파일 + 같은 모듈 dbio 전체를 근사)
def tables_of(path):
txt = open(path, encoding="utf-8", errors="ignore").read()
w = set(re.findall(r"(?i)(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([a-z_][a-z0-9_]*)", txt))
r = set(re.findall(r"(?i)FROM\s+([a-z_][a-z0-9_]*)", txt))
return w, r
svc_tbl = {}
svc_path = {}
for f in svc_files:
name = os.path.basename(f)[:-4]
svc_path[name] = f
svc_tbl[name] = tables_of(f)
# 호출 그래프
adj = collections.defaultdict(list)
for f in svc_files:
caller = os.path.basename(f)[:-4]
for callee in re.findall(r'tpcall\("([A-Z_0-9]+)"', open(f, encoding="utf-8", errors="ignore").read()):
if callee != caller and callee not in adj[caller]:
adj[caller].append(callee)
def trace(root, depth=0, seen=None):
seen = seen or set()
w, r = svc_tbl.get(root, (set(), set()))
node = {"svc": root, "mod": os.path.normpath(svc_path[root]).split(os.sep)[-3] if root in svc_path else "?",
"wr": sorted(w), "children": []}
if root in seen or depth >= 6:
return node
seen = seen | {root}
for callee in adj.get(root, []):
if callee in svc_path:
node["children"].append(trace(callee, depth + 1, seen))
return node
def flat_stats(tree):
progs, wt, branches = set(), set(), 0
def walk(n):
nonlocal branches
progs.add(n["svc"]); branches += 1
for t in n["wr"]:
wt.add(t)
for c in n["children"]:
walk(c)
walk(tree)
return {"programs": len(progs), "tables": len(wt), "xa_branches": branches, "tablelist": sorted(wt)}
TRACE = []
for root in ["ACQUIRE", "PAY_APPROVE", "CL_DAILY"]:
if root in svc_path:
tr = trace(root)
st = flat_stats(tr)
TRACE.append({"root": root, "tree": tr, "stat": st})
# ── 축 C: 레거시 실측 인벤토리 ───────────────────────────────────────────────
inc = collections.Counter()
for f in pgc:
for h in re.findall(r'#include\s+"([^"]+)"', open(f, encoding="utf-8", errors="ignore").read()):
inc[h] += 1
top_copybook = inc.most_common(1)
LEGACY = {
"copybook_total": len(h_files),
"copybook_shared_max": (top_copybook[0][1] if top_copybook else 0),
"copybook_shared_name": (top_copybook[0][0] if top_copybook else ""),
"macro_defs": sh_count(h_files + pgc, r"#define"),
"goto": sh_count(pgc, r"\bgoto\b"),
"userlog": sh_count(pgc, r"\buserlog\s*\("),
"sqlca_checks": sh_count(pgc, r"sqlca\.sqlcode"),
"embedded_sql_density": round(sh_count(pgc, r"EXEC SQL") / max(1, len(pgc)), 1),
}
data = {"SCALE": SCALE, "HAZARDS": HAZARDS, "TRACE": TRACE, "LEGACY": LEGACY}
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
with open(OUT_JS, "w", encoding="utf-8") as w:
w.write("/* 자동생성: tools/gen_metrics.py — 소스 실측 마이그레이션 난이도 지표. 손대지 말 것. */\n")
w.write("window.METRICS=" + payload + ";\n")
# index.html 인라인 (마커 idempotent)
h = open(IDX, encoding="utf-8").read()
block = ("<script>/* MET:START 자동생성 마이그레이션 지표 (tools/gen_metrics.py) */\n"
"window.METRICS=" + payload + ";\n/* MET:END */</script>")
if "/* MET:START" in h:
h = re.sub(r'<script>/\* MET:START.*?/\* MET:END \*/</script>', lambda m: block, h, count=1, flags=re.S)
else:
h = h.replace('<script>/* CG:START', block + '\n<script>/* CG:START', 1)
open(IDX, "w", encoding="utf-8").write(h)
print("metrics: scale=%d hazards=%d trace=%d" % (len(SCALE), len(HAZARDS), len(TRACE)))
for t in TRACE:
s = t["stat"]
print(f" 거래 {t['root']}: 프로그램 {s['programs']} · 테이블 {s['tables']} · XA브랜치 {s['xa_branches']}")
print(" 레거시: 카피북 %d(최대공유 %d×%s) 매크로 %d sqlca분기 %d EXEC밀도 %.1f/파일"
% (LEGACY["copybook_total"], LEGACY["copybook_shared_max"], LEGACY["copybook_shared_name"],
LEGACY["macro_defs"], LEGACY["sqlca_checks"], LEGACY["embedded_sql_density"]))