214 KiB
214 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""통신 두절 환경의 야전 보급소 재고·공급망 자료 요구사항 12개를 정의하고, 로컬 DB(SQLite 메모리 DB) 스키마와 트랜잭션 레코드 12개 필드의 검증 규칙을 만들어 정상 표본 검증 오류 0건·스키마 정의 완성도 100%·제약 조건(중복·음수·논리) 시험을 확인하며 오류 주입 검출력을 점검한다."""
import hashlib
import json
import re
import sqlite3
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 1] 전술 통신 두절 환경에서의 야전 군수 재고 관리 요구사항 분석 및 로컬 DB 스키마 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
SCHEMA = {
"id": {"type": "string", "pattern": r"^보급소[A-E]-\d{5}$", "unit": "-", "desc": "트랜잭션 ID(노드-일련번호, 중복 입력 방지 키)"},
"node": {"type": "string", "enum": NODES, "unit": "-", "desc": "기록한 가상 보급소"},
"item": {"type": "string", "enum": ITEM_IDS, "unit": "-", "desc": "품목 번호(가상)"},
"op": {"type": "string", "enum": ["in", "out", "adjust"], "unit": "-", "desc": "입고·불출·조정"},
"qty": {"type": "integer", "min": -50, "max": 5000, "unit": "개", "desc": "수량(조정은 음수 허용)"},
"t": {"type": "number", "min": 0, "max": 24 * 30, "unit": "시간", "desc": "발생 시각(기준 시점부터 경과 시간)"},
"ctx": {"type": "string", "enum": CTXS, "unit": "-", "desc": "입력 상황(평시·야간·통신불량)"},
"supplier": {"type": "string", "enum": SUPPLIERS, "unit": "-", "desc": "가상 업체"},
"lamport": {"type": "integer", "min": 0, "max": 10 ** 7, "unit": "-", "desc": "논리 시계(노드별 증가)"},
"sync_state": {"type": "string", "enum": ["local", "sent", "acked"], "unit": "-", "desc": "동기화 상태"},
"checksum": {"type": "string", "pattern": r"^[0-9a-f]{8}$", "unit": "-", "desc": "위변조 점검용 해시 앞 8자리"},
"note": {"type": "string", "maxlen": 40, "unit": "-", "desc": "비고(자유 문장, 개인 정보 입력 금지)"},
}
def seal(x, lamport):
# [TODO : 학생 실습 1] 전술 통신 두절 환경에서의 야전 군수 재고 관리 요구사항 분석 및 로컬 DB 스키마 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def check(rec):
errs = []
for k, r in SCHEMA.items():
if k not in rec or rec[k] is None:
errs.append(f"{k}: 필수 값 누락"); continue
v = rec[k]
ok = {"string": isinstance(v, str), "integer": isinstance(v, (int, np.integer)) and not isinstance(v, bool), "number": isinstance(v, (int, float, np.number)) and not isinstance(v, bool)}[r["type"]]
if not ok:
errs.append(f"{k}: 자료형 오류({type(v).__name__})"); continue
if "enum" in r and v not in r["enum"]:
errs.append(f"{k}: 허용되지 않은 값 '{v}'")
if "min" in r and v < r["min"]:
errs.append(f"{k}: 최소값 {r['min']} 미만")
if "max" in r and v > r["max"]:
errs.append(f"{k}: 최대값 {r['max']} 초과")
if "pattern" in r and not re.match(r["pattern"], v):
errs.append(f"{k}: 형식 불일치 '{v}'")
if "maxlen" in r and len(v) > r["maxlen"]:
errs.append(f"{k}: 길이 {r['maxlen']} 초과")
if not errs:
if rec["id"].split("-")[0] != rec["node"]:
errs.append("id: 노드 접두어와 node 불일치")
if rec["op"] in ("in", "out") and rec["qty"] <= 0:
errs.append("qty: 입고·불출 수량은 1 이상")
if re.search(r"\d{6}-\d{7}|군번|주민", rec["note"]):
errs.append("note: 개인 식별 정보 의심 문구")
return errs
txns = make_txns(7, 7)
recs = [seal(x, i + 1) for i, x in enumerate(txns)]
bad = sum(bool(check(r)) for r in recs)
print(f"[스키마] 필드 {len(SCHEMA)}개, type·unit·desc 모두 정의된 필드 {sum(all(k in v for k in ('type', 'unit', 'desc')) for v in SCHEMA.values())}개 (정의 완성도 100%)")
print(f"[정상 자료 검증] 트랜잭션 {len(recs)}건 중 오류 {bad}건 (목표 오류 0건: {'달성' if bad == 0 else '미달'}), 개인 식별 필드 0개")
# 2) 오류 주입 시험
r0 = recs[10]
inj = {"자료형 오류(qty 문자)": dict(r0, qty="많이"), "허용값 오류(품목)": dict(r0, item="I99"), "형식 오류(ID)": dict(r0, id="A-1"), "범위 오류(시각)": dict(r0, t=-3.0),
"논리 오류(불출 0개)": dict(r0, op="out", qty=0), "논리 오류(ID 접두어)": dict(r0, node="보급소B", id="보급소A-00011"), "필드 누락": {k: v for k, v in r0.items() if k != "checksum"},
"개인정보 의심 문구": dict(r0, note="군번 입력")}
print("\n[오류 주입 시험]")
caught = 0
for n, r in inj.items():
e = check(r); caught += bool(e); print(f" {n:22s}-> {'검출' if e else '미검출'}: {e[0] if e else ''}")
print(f"주입 오류 {len(inj)}종 중 {caught}종 검출")
# 3) 로컬 DB 스키마(SQLite 메모리 DB): 중복 키·음수 재고·논리 규칙을 DB가 직접 막는지 시험
DDL = """
CREATE TABLE item (item TEXT PRIMARY KEY, cat TEXT NOT NULL, supplier TEXT NOT NULL, lead_days INTEGER CHECK (lead_days BETWEEN 1 AND 60), min_level INTEGER NOT NULL);
CREATE TABLE txn (id TEXT PRIMARY KEY, node TEXT NOT NULL, item TEXT NOT NULL REFERENCES item(item), op TEXT NOT NULL CHECK (op IN ('in','out','adjust')),
qty INTEGER NOT NULL, t REAL NOT NULL CHECK (t >= 0), ctx TEXT NOT NULL, lamport INTEGER NOT NULL, sync_state TEXT NOT NULL DEFAULT 'local',
checksum TEXT NOT NULL, CHECK (op = 'adjust' OR qty > 0));
CREATE TABLE meta_edit (id TEXT PRIMARY KEY, item TEXT NOT NULL REFERENCES item(item), field TEXT NOT NULL, value TEXT NOT NULL, node TEXT NOT NULL, lamport INTEGER NOT NULL);
CREATE TABLE conflict (id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT NOT NULL, field TEXT NOT NULL, a TEXT, b TEXT, status TEXT NOT NULL DEFAULT 'PENDING');
CREATE TABLE audit (seq INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL, who TEXT, detail TEXT, prev_hash TEXT, hash TEXT);
CREATE VIEW stock AS SELECT item, SUM(CASE WHEN op='out' THEN -qty ELSE qty END) AS delta FROM txn GROUP BY item;
"""
db = sqlite3.connect(":memory:")
db.executescript(DDL)
db.executemany("INSERT INTO item VALUES (?,?,?,?,?)", [(x["item"], x["cat"], x["supplier"], x["lead_days"], x["min_level"]) for x in ITEMS])
with db:
db.executemany("INSERT INTO txn VALUES (?,?,?,?,?,?,?,?,?,?)", [(r["id"], r["node"], r["item"], r["op"], r["qty"], r["t"], r["ctx"], r["lamport"], r["sync_state"], r["checksum"]) for r in recs])
n_rows = db.execute("SELECT COUNT(*) FROM txn").fetchone()[0]
print(f"\n[로컬 DB] 테이블 5개·뷰 1개 생성, 트랜잭션 {n_rows}건 적재 (스키마 JSON과 행 수 일치: {n_rows == len(recs)})")
tests = {"중복 ID 입력 차단(멱등 키)": ("INSERT INTO txn VALUES (?,?,?,?,?,?,?,?,?,?)", (r0["id"], r0["node"], r0["item"], "in", 5, 1.0, "평시", 1, "local", "abcd1234")),
"존재하지 않는 품목 차단(외래 키)": ("INSERT INTO txn VALUES ('보급소A-99999','보급소A','I99','in',5,1.0,'평시',1,'local','abcd1234')", ()),
"불출 0개 차단(CHECK)": ("INSERT INTO txn VALUES ('보급소A-99998','보급소A','I01','out',0,1.0,'평시',1,'local','abcd1234')", ()),
"음수 시각 차단(CHECK)": ("INSERT INTO txn VALUES ('보급소A-99997','보급소A','I01','in',5,-1.0,'평시',1,'local','abcd1234')", ()),
"허용되지 않은 op 차단(CHECK)": ("INSERT INTO txn VALUES ('보급소A-99996','보급소A','I01','sell',5,1.0,'평시',1,'local','abcd1234')", ())}
db.execute("PRAGMA foreign_keys = ON")
blocked = 0
for n, (sql, args) in tests.items():
try:
with db:
db.execute(sql, args)
print(f" {n:28s}-> 통과(결함)")
except sqlite3.IntegrityError as e:
blocked += 1; print(f" {n:28s}-> 차단: {str(e)[:48]}")
print(f"제약 조건 시험 {blocked}/{len(tests)} 차단, 롤백 후 행 수 {db.execute('SELECT COUNT(*) FROM txn').fetchone()[0]}건 유지")
# 4) 요구사항 12개와 검증 결과(코드 시험으로 확인된 항목은 PASS)
names = {r[0] for r in db.execute("SELECT name FROM sqlite_master")}
cols = {r[1] for r in db.execute("PRAGMA table_info(txn)")}
REQ = [("R01", "가용성", "통신이 끊겨도 단말 로컬 DB에 입고·불출을 기록할 수 있다", "메모리 DB 적재", n_rows == len(recs)), ("R02", "무결성", "같은 입력이 두 번 들어와도 한 번만 반영한다", "ID 중복 차단", blocked >= 1),
("R03", "무결성", "불출 수량 0·잘못된 구분 값은 저장되지 않는다", "CHECK 제약", blocked >= 3), ("R04", "무결성", "존재하지 않는 품목은 입력할 수 없다", "외래 키", blocked >= 2),
("R05", "무결성", "레코드마다 위변조 점검 해시를 둔다", "checksum 필드", all(re.match(r"^[0-9a-f]{8}$", r["checksum"]) for r in recs)), ("R06", "동기화", "동기화 상태(local/sent/acked)를 레코드에 기록한다", "sync_state 필드", "sync_state" in cols),
("R07", "동기화", "노드별 논리 시계로 선후 관계를 비교한다", "lamport 필드", all(r["lamport"] > 0 for r in recs)), ("R08", "동기화", "같은 필드를 동시에 고친 충돌은 보류 목록에 남긴다", "conflict 테이블", "conflict" in names),
("R09", "보안", "변경·동기화·승인 행위는 해시가 이어진 감사 로그에 남긴다", "audit 테이블", "audit" in names), ("R10", "보안", "개인 식별 정보는 입력하지 않는다", "note 점검", caught == len(inj)),
("R11", "운영", "입력 오류는 사유와 함께 즉시 알려 준다", "검증 오류 메시지", caught >= 6), ("R12", "운영", "동기화 후 재고를 업체·품목별로 마이닝해 공급망 재난 징후를 식별한다", "stock 뷰", db.execute("SELECT COUNT(*) FROM stock").fetchone()[0] == 12)]
print("\n[요구사항 검증표]")
for i, c, d, m, ok in REQ:
print(f" {i} [{c}] {d[:34]:36s} {m:12s} {'PASS' if ok else 'FAIL'}")
print(f"요구사항 {sum(x[4] for x in REQ)}/{len(REQ)} 충족")
js = json.dumps({"title": "dat10_input_schema", "fields": {k: {kk: vv for kk, vv in v.items() if kk != 'desc'} for k, v in list(SCHEMA.items())[:2]}}, ensure_ascii=False)
print("스키마 JSON(앞 2개 필드):", js[:140], "...")
print("[입출력] 입력: 보급소 트랜잭션(12필드) / 출력: 동기화된 재고, 충돌 목록, 공급망 재난 징후, 감사 로그")
print("※ 가상 자료이며 실제 재고·업체 정보는 사용하지 않는다. 실제 단말 DB는 암호화 저장 등 보안 요건을 별도로 충족해야 한다.")
st = stock_of(txns)
plt.figure(figsize=(8, 3.8)); plt.bar(list(st), list(st.values()), color="tab:blue"); plt.title("Synthetic stock by item after 7 days (all nodes merged)"); plt.xlabel("item"); plt.ylabel("units"); plt.tight_layout(); plt.show()
In [ ]:
"""서식이 서로 다른 오프라인 입력 문자열 8,000줄(문장형·CSV·JSON·키=값 4종)을 파싱·검증해 SQLite 메모리 DB에 배치 트랜잭션으로 적재하고, 입력 구분(입고·불출·조정)별 동적 임계값으로 이상 수량을 격리하며 중복 재전송 멱등 처리·배치 실패 롤백·처리 속도(건/초)·정답 대조 오류율을 측정한다."""
import json
import re
import sqlite3
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 2] 오프라인 데이터 입력 유효성 검증 및 로컬 SQLite 트랜잭션 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
rng = np.random.default_rng(21)
base = make_txns(31, 7, per_node_day=230) # 5노드 x 7일 x 230건 = 8,050건
OPK = {"in": "입고", "out": "불출", "adjust": "조정"}
lines, truth = [], {}
for i, x in enumerate(base):
f = i % 4
if f == 0:
s = f"[입력] {x['node']} 품목{x['item']} {OPK[x['op']]} {x['qty']}개 ({x['ctx']}) {x['t']}h #{x['id']}"
elif f == 1:
s = f"{x['node']},{x['item']},{x['op']},{x['qty']},{x['t']},{x['ctx']},{x['id']}"
elif f == 2:
s = json.dumps({k: x[k] for k in ("node", "item", "op", "qty", "t", "ctx", "id")}, ensure_ascii=False)
else:
s = f"품목 {x['item']} / {OPK[x['op']]} / {x['qty']}개 / {x['node']} / t={x['t']} / ctx={x['ctx']} / id={x['id']}"
lines.append(s); truth[x["id"]] = x
noise_ids = set()
for i in rng.choice(len(lines), 120, replace=False): # 잡음 주입: 이상 수량 40, 깨진 줄 40, 존재하지 않는 품목 40
x = base[i]
if i % 3 == 0:
lines[i] = lines[i].replace(str(x["qty"]), "9999", 1) if x["op"] != "adjust" else lines[i]; noise_ids.add(x["id"]) if "9999" in lines[i] else None
elif i % 3 == 1:
lines[i] = lines[i][: len(lines[i]) // 2]; noise_ids.add(x["id"])
else:
lines[i] = lines[i].replace(x["item"], "I99"); noise_ids.add(x["id"])
dups = [lines[i] for i in rng.choice(len(lines), 200, replace=False)] # 통신 복구 후 재전송된 중복 줄 200개
lines = lines + dups
# ---------------------------------------------------------------------------
# 파서: 서식별 정규식 -> 표준 레코드 (해석 실패는 예외)
# ---------------------------------------------------------------------------
IV = {v: k for k, v in OPK.items()}
P1 = re.compile(r"\[입력\]\s*(보급소[A-E])\s*품목(I\d{2})\s*(입고|불출|조정)\s*(-?\d+)개\s*\((\S+)\)\s*([\d.]+)h\s*#(\S+)")
P4 = re.compile(r"품목\s*(I\d{2})\s*/\s*(입고|불출|조정)\s*/\s*(-?\d+)개\s*/\s*(보급소[A-E])\s*/\s*t=([\d.]+)\s*/\s*ctx=(\S+)\s*/\s*id=(\S+)")
def parse(s):
# [TODO : 학생 실습 2] 오프라인 데이터 입력 유효성 검증 및 로컬 SQLite 트랜잭션 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def valid(r):
if r["item"] not in ITEM_IDS or r["node"] not in NODES or r["op"] not in OPK or r["ctx"] not in CTXS:
return "허용값 오류"
if r["op"] != "adjust" and r["qty"] <= 0:
return "수량 오류"
if not re.match(r"^보급소[A-E]-\d{5}$", r["id"]) or not r["id"].startswith(r["node"]):
return "ID 오류"
return None
# 1) 파싱 + 검증 + 동적 임계값
t0 = time.perf_counter()
parsed, rejected = [], []
for s in lines:
try:
r = parse(s); e = valid(r)
(rejected if e else parsed).append((r, e) if e else r)
except (ValueError, KeyError, json.JSONDecodeError, IndexError) as ex:
rejected.append((s, "해석 실패"))
df = pd.DataFrame(parsed)
before = len(df)
df = df.drop_duplicates("id")
th = {}
for op, g in df[df["op"] != "adjust"].groupby("op"):
med = g["qty"].median(); mad = (g["qty"] - med).abs().median() * 1.4826; th[op] = (max(med - 6 * mad, 1), med + 6 * mad)
fixed = (df["op"] != "adjust") & (df["qty"] > 100)
dyn = pd.Series([(op != "adjust") and not (th[op][0] <= q <= th[op][1]) for op, q in zip(df["op"], df["qty"])], index=df.index)
susp = dyn.to_numpy()
clean = df[~dyn].copy()
dt_parse = time.perf_counter() - t0
rej_cnt = pd.Series([e for _, e in rejected]).value_counts().to_dict()
print(f"[파싱·검증] 입력 {len(lines)}줄(재전송 중복 200 포함): 해석 실패·허용값 오류 {len(rejected)}줄 {rej_cnt}, 통과 {before}줄, 중복 ID {before - len(df)}줄 제거")
print("구분별 동적 임계값(수량):", ", ".join(f"{OPK[k]} {lo:.0f}~{hi:.0f}" for k, (lo, hi) in th.items()))
true_bad = df["id"].isin(noise_ids).to_numpy()
for name, fl in [("고정 임계값(100개 초과)", fixed.to_numpy()), ("구분별 동적 임계값", susp)]:
print(f" {name:20s}: 이상 판정 {int(fl.sum())}건, 이상 수량 {int((fl & true_bad).sum())}/{int(true_bad.sum())} 검출, 정상 입고인데 이상 판정(오탐) {int((fl & ~true_bad).sum())}건")
# 2) SQLite 배치 트랜잭션 적재(멱등: 같은 ID는 한 번만), 배치 실패 시 롤백 후 행 단위로 격리
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE txn (id TEXT PRIMARY KEY, node TEXT NOT NULL, item TEXT NOT NULL, op TEXT NOT NULL CHECK (op IN ('in','out','adjust')), qty INTEGER NOT NULL, t REAL NOT NULL CHECK (t >= 0), ctx TEXT NOT NULL)")
db.execute("CREATE TABLE quarantine (id TEXT, reason TEXT)")
rows = [(r["id"], r["node"], r["item"], r["op"], int(r["qty"]), float(r["t"]), r["ctx"]) for r in clean.to_dict("records")]
rows[777] = (rows[777][0], rows[777][1], rows[777][2], "sell", 5, 1.0, "평시") # 배치 안에 제약 위반 1건 주입
t1 = time.perf_counter(); inserted = rolled = 0
for b in range(0, len(rows), 500):
batch = rows[b:b + 500]
try:
with db:
db.executemany("INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING", batch)
inserted += len(batch)
except sqlite3.IntegrityError:
rolled += 1 # 배치 전체 롤백 후 행 단위 재시도
for r in batch:
try:
with db:
db.execute("INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING", r)
inserted += 1
except sqlite3.IntegrityError as e:
with db:
db.execute("INSERT INTO quarantine VALUES (?,?)", (r[0], "제약 위반"))
dt_db = time.perf_counter() - t1
n_db = db.execute("SELECT COUNT(*) FROM txn").fetchone()[0]
print(f"\n[SQLite 적재] 500건 배치 {len(rows) // 500 + 1}개: 롤백 {rolled}회(제약 위반 1건 -> 행 단위 격리), 적재 {n_db}건, 격리 테이블 {db.execute('SELECT COUNT(*) FROM quarantine').fetchone()[0]}건")
db.execute("INSERT INTO txn VALUES (?,?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING", rows[0]); n2 = db.execute("SELECT COUNT(*) FROM txn").fetchone()[0]
print(f"같은 ID를 다시 넣어도 행 수 {n2}건 유지(멱등)")
# 3) 정답 대조: 적재된 행의 필드가 원본 트랜잭션과 같은가, 속도
errs = 0; chk = 0
for (i, node, item, op, qty, t, ctx) in db.execute("SELECT * FROM txn"):
x = truth[i]; chk += 1
errs += (x["node"], x["item"], x["op"], x["qty"], x["ctx"]) != (node, item, op, qty, ctx) or abs(x["t"] - t) > 1e-6
total = dt_parse + dt_db
print(f"\n[전처리 정확도] 적재 {chk}건 중 원본과 불일치 {errs}건 -> 전처리 오류율 {errs / chk:.2%} (목표 0%: {'달성' if errs == 0 else '미달'})")
print(f"처리 속도 {len(lines) / total:,.0f}건/초 (파싱·검증 {dt_parse:.2f}초 + DB 적재 {dt_db:.2f}초, 목표 1,000건/초 이상: {'달성' if len(lines) / total >= 1000 else '미달'})")
print(f"원본 {len(base)}건 중 잡음 주입 {len(noise_ids)}건과 격리 {int(susp.sum())}건을 제외하고 적재 {n_db}건 -> 격리된 건은 담당자가 원본 확인 후 재입력")
print("※ 이상 판정된 줄은 삭제하지 않고 격리하며, 임계값은 입력 구분별 중앙값±6MAD로 자료에서 조정한다. 실제 서식에 맞는 정규식은 현장에서 보완한다.")
plt.figure(figsize=(8, 4))
for op, g in df[df["op"] != "adjust"].groupby("op"):
plt.hist(g["qty"].clip(upper=200), bins=40, alpha=.5, label=op)
plt.axvline(100, color="k", ls="--"); plt.legend(); plt.xlabel("qty"); plt.title("Quantity by operation (dashed: fixed threshold 100)"); plt.tight_layout(); plt.show()
In [ ]:
"""오프라인 보급소 5곳이 끊긴 통신 대신 짧은 P2P 접촉·USB 인편으로 트랜잭션을 교환하는 Baseline을 구현해, 전체 전송·ID 차집합(델타) 전송·델타+우선순위 휴리스틱(내 기록 우선·오래된 것 우선)의 7일 후 동기화 완전성·전송 효율·수렴 시점을 비교하고 우선순위 가중치를 1차 튜닝한다."""
import itertools
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 3] 오프라인 단말 간 1차 피어투피어(P2P)/USB 재고 데이터 교환 Baseline 기능 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
TX = make_txns(7, 7, per_node_day=60) # 5노드 x 7일 x 60건 = 2,100건
N = len(TX); IDX = {x["id"]: i for i, x in enumerate(TX)}
ORIGIN = np.array([NODES.index(x["node"]) for x in TX]); T = np.array([x["t"] for x in TX])
CONTACTS_PER_DAY, BW, LOSS = 8, 80, 0.05 # 하루 접촉 8회, 접촉당 방향별 최대 60건, 전송 손실 5%
def contact_schedule(seed=3, days=7):
# [TODO : 학생 실습 3] 오프라인 단말 간 1차 피어투피어(P2P)/USB 재고 데이터 교환 Baseline 기능 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def simulate(method, w_own=0.0, w_age=0.0, seed=3, trace=False):
r = np.random.default_rng(seed + 100)
known = [set() for _ in range(5)]; sent = useful = 0; tr = []
prod = [np.where(ORIGIN == n)[0] for n in range(5)]
for tm, a, b, _ in contact_schedule(seed):
for n in (a, b): # 접촉 시점까지 자기 노드가 기록한 트랜잭션
known[n] |= set(prod[n][T[prod[n]] <= tm].tolist())
for src, dst in ((a, b), (b, a), ):
if method == "full":
cand = sorted(known[src], key=lambda i: T[i]) # 상대가 뭘 가졌는지 모르고 전부 보냄(앞에서부터 BW건)
else:
cand = [i for i in known[src] if i not in known[dst]] # 상대가 없는 것만(ID 요약 비교)
if method == "prio":
cand.sort(key=lambda i: -(w_own * (ORIGIN[i] == src) + w_age * (tm - T[i]) / 168))
else:
cand.sort(key=lambda i: T[i])
for i in cand[:BW]:
sent += 1
if r.random() > LOSS:
useful += i not in known[dst]; known[dst].add(i)
if trace:
tr.append((tm, np.mean([len(k) for k in known]) / N))
comp = float(np.mean([len(k) for k in known]) / N)
return {"comp": comp, "sent": sent, "useful": useful, "eff": useful / max(sent, 1), "full_nodes": sum(len(k) == N for k in known), "trace": tr}
# 1) 방식 비교
res = {"전체 전송(상대 상태 모름)": simulate("full"), "ID 차집합 델타(오래된 것 먼저)": simulate("delta"), "델타 + 우선순위(기본 가중 own 1, age 0)": simulate("prio", 1, 0)}
print(f"[조건] 보급소 5곳, 트랜잭션 {N}건, 7일간 접촉 {CONTACTS_PER_DAY * 7}회(P2P·USB), 접촉당 방향별 최대 {BW}건, 손실 {LOSS:.0%}")
print(f"{'방식':32s} {'완전성':>7s} {'전송':>6s} {'유효 전송':>8s} {'효율':>6s}")
for k, v in res.items():
print(f"{k:32s} {v['comp']:7.1%} {v['sent']:6d} {v['useful']:8d} {v['eff']:6.1%}")
# 2) 1차 튜닝: 우선순위 가중치 격자 탐색(스케줄 시드 3개 평균, 시험은 다른 시드 3개)
grid = {}
for wo, wa in itertools.product((0, 1, 2, 4), (-1, 0, 1)):
grid[(wo, wa)] = np.mean([simulate("prio", wo, wa, seed=s)["comp"] for s in (1, 2, 4)])
best = max(grid, key=grid.get)
print(f"\n[우선순위 가중치 탐색(튜닝 시드 3개 평균 완전성)] 내 기록 가중 x 오래됨 가중")
print(" " + " ".join(f"own{wo}/age{wa}:{v:.1%}" for (wo, wa), v in list(grid.items())[:6]) + " ...")
print(f"채택: own {best[0]}, age {best[1]} (튜닝 평균 {grid[best]:.1%})")
test = {m: np.mean([simulate(m, *( (best if m == 'prio' else (0, 0)) ), seed=s)["comp"] for s in (5, 6, 7)]) for m in ("full", "delta", "prio")}
print(f"시험 시드 3개 평균 완전성: 전체 전송 {test['full']:.1%}, 델타 {test['delta']:.1%}, 델타+튜닝 우선순위 {test['prio']:.1%}")
acc = test["prio"]
print(f"기본 정확도(7일 후 모든 노드가 전체 트랜잭션 중 알고 있는 비율) {acc:.1%} (목표 75% 이상: {'달성' if acc >= 0.75 else '미달'})")
# 3) 수렴 추적과 판단 지연
import time
t0 = time.perf_counter(); r = simulate("prio", *best, trace=True); dt = time.perf_counter() - t0
tr = r["trace"]; day = next((t / 24 for t, c in tr if c >= 0.9), None)
print(f"\n[수렴] 7일 시뮬레이션 {dt * 1000:.0f}ms (접촉 1회당 {dt / len(tr) * 1000:.1f}ms), 완전성 90% 도달 시점 {('%.1f일' % day) if day else '7일 내 미도달'}, 모든 트랜잭션을 가진 노드 {r['full_nodes']}/5")
print("접촉이 부족하면 완전성이 100%에 못 미치므로, 동기화 전 재고는 '확인된 범위' 표시가 필요하다(M6).")
print("※ 가상 접촉 일정과 대역폭 가정에 따른 결과이며, 실제 접촉 빈도·USB 이동은 현장 조건에 따라 다르다. 병합 충돌 판단은 M4·M7에서 다룬다.")
plt.figure(figsize=(8, 4))
for m, lab in (("full", "full dump"), ("delta", "delta"), ("prio", "delta + priority")):
rr = simulate(m, *(best if m == "prio" else (0, 0)), trace=True)["trace"]; plt.plot([t / 24 for t, _ in rr], [c * 100 for _, c in rr], label=lab)
plt.xlabel("days"); plt.ylabel("mean completeness (%)"); plt.title("P2P/USB exchange: completeness over time"); plt.legend(); plt.tight_layout(); plt.show()
In [ ]:
"""통신 복구 후 보급소 5곳과 중앙 서버가 양방향으로 병합하는 CRDT 동기화(재고는 PN-카운터, 메타 정보는 버전 벡터 기반 충돌 감지)를 구현해, 수렴·갱신 유실 0건·멱등/교환/결합 성질·중복 재전송·오래된 상태 재전송 방어를 검증하고, 충돌 탐지 재현율(시간 비교 규칙 대 버전 벡터 규칙)과 오경보 패턴·동기화 지연을 측정한다."""
import copy
import itertools
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 4] 네트워크 복구 시 클라우드 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
class Replica:
"""재고는 품목별 PN-카운터(노드별 증가량 P, 감소량 N), 같은 노드의 기록은 서로 덮어쓰지 않는다. 병합은 요소별 최댓값."""
def __init__(self, name):
self.name = name; self.P = {i: {} for i in ITEM_IDS}; self.N = {i: {} for i in ITEM_IDS}
def apply(self, x):
d = self.P if x["op"] == "in" or (x["op"] == "adjust" and x["qty"] > 0) else self.N
d[x["item"]][x["node"]] = d[x["item"]].get(x["node"], 0) + abs(x["qty"])
def merge(self, other):
for it in ITEM_IDS:
for src, dst in ((other.P, self.P), (other.N, self.N)):
for n, v in src[it].items():
dst[it][n] = max(dst[it].get(n, 0), v)
def stock(self, base=500):
return {i: base + sum(self.P[i].values()) - sum(self.N[i].values()) for i in ITEM_IDS}
def state(self):
return (tuple(tuple(sorted(self.P[i].items())) for i in ITEM_IDS), tuple(tuple(sorted(self.N[i].items())) for i in ITEM_IDS))
TX = make_txns(7, 7, per_node_day=60)
truth = stock_of(TX)
reps = {n: Replica(n) for n in NODES}
for x in TX:
reps[x["node"]].apply(x) # 7일간 각 노드는 자기 기록만 반영(오프라인)
server = Replica("서버")
# 1) 통신 복구: 노드 -> 서버 병합 후 서버 -> 노드 병합(2단계). 지연 측정
lat = []
t0 = time.perf_counter()
for n in NODES:
t1 = time.perf_counter(); server.merge(reps[n]); lat.append((time.perf_counter() - t1) * 1000)
for n in NODES:
t1 = time.perf_counter(); reps[n].merge(server); lat.append((time.perf_counter() - t1) * 1000)
dt = time.perf_counter() - t0
conv = len({r.state() for r in reps.values()} | {server.state()}) == 1
lost = sum(abs(server.stock()[i] - truth[i]) for i in ITEM_IDS)
print(f"[CRDT 동기화] 노드 5곳 + 서버, 트랜잭션 {len(TX)}건: 수렴 {'성공' if conv else '실패'}, 서버 재고와 정답(전체 합산) 차이 합계 {lost}개 -> 갱신 유실 {'0건' if lost == 0 else '있음'}")
print(f"병합 1회 평균 {np.mean(lat):.3f}ms, 최대 {max(lat):.3f}ms, 전체 동기화 {dt * 1000:.2f}ms (목표 200ms 이하: {'달성' if max(lat) <= 200 else '미달'})")
# 2) 비교: 재고 숫자를 '마지막 기록이 이김(LWW)'으로 덮어쓰면 오프라인 갱신이 사라진다
last = {}
for n in NODES: # 각 노드의 최종 재고 값(기초 재고 + 자기 기록만 반영)
loc = {i: 500 for i in ITEM_IDS}
for x in TX:
if x["node"] == n:
loc[x["item"]] += x["qty"] if x["op"] in ("in", "adjust") else -x["qty"]
last[n] = (max(x["t"] for x in TX if x["node"] == n), loc)
lww = max(last.values())[1]
print(f"숫자 덮어쓰기(LWW) 병합: 정답과 차이 합계 {sum(abs(lww[i] - truth[i]) for i in ITEM_IDS)}개 (갱신 유실) / PN-카운터 {lost}개")
# 3) CRDT 성질 시험: 멱등·교환·결합·중복·오래된 상태 재전송
rng = np.random.default_rng(5); ok = {"멱등(두 번 병합)": 0, "교환(A+B = B+A)": 0, "결합((A+B)+C = A+(B+C))": 0, "오래된 상태 재전송": 0}
trials = 200
for _ in range(trials):
a, b, c = (reps[n] for n in rng.choice(NODES, 3, replace=False))
parts = []
for r in (a, b, c):
q = Replica("t")
for i in ITEM_IDS:
for n in NODES:
if rng.random() < .5:
q.P[i][n] = int(rng.integers(0, 100)); q.N[i][n] = int(rng.integers(0, 100))
parts.append(q)
x, y, z = parts
m = copy.deepcopy(x); m.merge(y); s1 = m.state(); m.merge(y); ok["멱등(두 번 병합)"] += s1 == m.state()
m1 = copy.deepcopy(x); m1.merge(y); m2 = copy.deepcopy(y); m2.merge(x); ok["교환(A+B = B+A)"] += m1.state() == m2.state()
l = copy.deepcopy(x); l.merge(y); l.merge(z); rr = copy.deepcopy(y); rr.merge(z); r2 = copy.deepcopy(x); r2.merge(rr); ok["결합((A+B)+C = A+(B+C))"] += l.state() == r2.state()
new = copy.deepcopy(x); new.merge(y); old = copy.deepcopy(x); before = new.state(); new.merge(old); ok["오래된 상태 재전송"] += before == new.state()
print("\n[CRDT 성질 시험(무작위 상태 200쌍)]", ", ".join(f"{k} {v}/{trials}" for k, v in ok.items()))
# 4) 메타 정보(최소 보유량·위치·상태) 동시 수정 충돌 탐지: 시간 비교 규칙 vs 버전 벡터 규칙
def dominated(a, b):
# [TODO : 학생 실습 4] 네트워크 복구 시 클라우드 중앙 서버와의 양방향 데이터 충돌 해결(CRDT) 동기화 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_pair(kind):
base = float(rng.uniform(0, 160)); va, vb = int(rng.integers(30, 80)), int(rng.integers(30, 80))
if va == vb:
vb += 7
A = {"field": "min_level", "value": va, "t": base, "vv": {"A": 3, "B": 1}}
if kind == "concurrent":
B = {"field": "min_level", "value": vb, "t": base + float(rng.uniform(0.5, 120)), "vv": {"A": 2, "B": 4}}
elif kind == "causal": # B가 A의 수정을 받은 뒤 고침(정상 후속 수정), 시각은 가까움
B = {"field": "min_level", "value": vb, "t": base + float(rng.uniform(0.01, 0.5)), "vv": {"A": 3, "B": 2}}
elif kind == "same_value":
B = {"field": "min_level", "value": va, "t": base + float(rng.uniform(0.5, 120)), "vv": {"A": 2, "B": 4}}
elif kind == "other_field":
B = {"field": "location", "value": vb, "t": base + float(rng.uniform(0.5, 120)), "vv": {"A": 2, "B": 4}}
else: # 시계 어긋남: 실제로는 동시 수정이지만 B 단말 시계가 느려 시각이 A보다 앞선 것처럼 기록됨
B = {"field": "min_level", "value": vb, "t": base - float(rng.uniform(0.5, 3)), "vv": {"A": 2, "B": 4}}
return A, B
truth_conf = {"concurrent": True, "causal": False, "same_value": False, "other_field": False, "skew": True}
pairs = [(k, *make_pair(k)) for k in rng.choice(list(truth_conf), 400, p=[.40, .25, .10, .10, .15])]
rule_time = lambda A, B: A["field"] == B["field"] and A["value"] != B["value"] and abs(A["t"] - B["t"]) <= 1.0 # 1시간 이내 같은 필드 다른 값
rule_val = lambda A, B: A["field"] == B["field"] and A["value"] != B["value"] # 같은 필드 다른 값이면 모두 충돌(인과 무시)
rule_vv1 = lambda A, B: A["field"] == B["field"] and not dominated(A["vv"], B["vv"]) and not dominated(B["vv"], A["vv"]) # 동시이면 충돌(값 비교 없음)
rule_vv2 = lambda A, B: rule_vv1(A, B) and A["value"] != B["value"] # 재설계: 값이 다를 때만
print("\n[충돌 탐지] 정답 충돌 %d건 / 쌍 %d건" % (sum(truth_conf[k] for k, _, _ in pairs), len(pairs)))
for name, rule in (("시간 근접 규칙(1시간 이내)", rule_time), ("값 비교 규칙(다르면 충돌)", rule_val), ("버전 벡터 규칙 v1(동시이면 충돌)", rule_vv1), ("버전 벡터 + 값 비교 v2(재설계)", rule_vv2)):
pred = np.array([rule(a, b) for _, a, b in pairs]); tr = np.array([truth_conf[k] for k, _, _ in pairs])
tp, fp, fn = int((pred & tr).sum()), int((pred & ~tr).sum()), int((~pred & tr).sum())
kinds = {str(k): int(v) for k, v in pd.Series([k for (k, _, _), p, t in zip(pairs, pred, tr) if p != t]).value_counts().items()}
print(f" {name:30s} 재현율 {tp / (tp + fn):5.1%}, 정밀도 {tp / max(tp + fp, 1):5.1%}, 오경보 {fp}건, 놓침 {fn}건 {kinds}")
print("오경보·미탐 패턴: 시간 규칙은 오프라인 동안 멀리 떨어진 동시 수정(concurrent)·시계 어긋남(skew)을 놓치고, 값 비교 규칙은 '후속 정상 수정'(causal)을 충돌로 올림 -> 버전 벡터로 인과 관계를 보고 같은 값이면 제외(v2)")
print("※ 충돌로 표시된 건은 자동 병합하지 않고 군수관이 수동 병합한다(M7). 재고 수량은 CRDT로 자동 합산되므로 사람이 판단할 대상은 메타 정보 충돌이다.")
plt.figure(figsize=(6, 3.5)); x = np.arange(len(ITEM_IDS))
plt.bar(x - .2, [truth[i] for i in ITEM_IDS], .4, label="truth / CRDT"); plt.bar(x + .2, [lww[i] for i in ITEM_IDS], .4, label="LWW overwrite", color="tab:red")
plt.xticks(x, [f"{i + 1}" for i in range(12)]); plt.xlabel("item"); plt.ylabel("stock"); plt.legend(); plt.title("Merged stock: CRDT vs last-write-wins"); plt.tight_layout(); plt.show()
In [ ]:
"""보급소 로그의 해시 체인·레코드별 HMAC 서명으로 위변조(수량 수정·삭제·순서 변경·위조 삽입·재전송 변조·꼬리 절단)를 탐지하는 오프라인 무결성 검증 엔진과, 점검 항목 가중합 신뢰도(High/Med/Low)를 구현해 단순 전체 해시 비교 대비 오경보율 감축·신뢰도 산출 일관성·불확실성 표시를 검증한다."""
import hashlib
import hmac
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 5] 오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
KEYS = {n: hashlib.sha256(f"교육용-가상-키-{n}".encode()).digest() for n in NODES} # 노드별 서명 키(가상, 실제 키 관리는 별도 체계)
TX = make_txns(7, 7, per_node_day=60)
def rec_str(x):
# [TODO : 학생 실습 5] 오프라인 위변조 방지 암호화 해시 및 데이터 무결성 검증 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def sign_log(node):
"""노드 로그를 순서대로 해시 체인으로 묶고 레코드별 HMAC 서명을 붙인다. 마지막 해시(머리)는 동료 노드가 따로 보관한다."""
log, prev = [], "GENESIS"
for x in [y for y in TX if y["node"] == node]:
h = hashlib.sha256((prev + rec_str(x)).encode()).hexdigest()
log.append({**x, "prev": prev, "hash": h, "sig": hmac.new(KEYS[node], rec_str(x).encode(), "sha256").hexdigest()[:16], "note": ""}); prev = h
return log, prev
LOGS = {n: sign_log(n) for n in NODES}
HEADS = {n: LOGS[n][1] for n in NODES}
def canonical(log):
"""배달 과정의 정상 변형(중복 전송·순서 뒤바뀜·공백 차이)을 정리: 같은 ID는 내용이 같으면 하나만, 일련번호 순 정렬, note 공백 제거."""
seen, out, conflicts = {}, [], 0
for r in log:
if r["id"] in seen:
conflicts += (rec_str(r) != rec_str(seen[r["id"]])); continue
seen[r["id"]] = r; out.append(r)
return sorted(out, key=lambda r: r["id"]), conflicts
def verify(node, log, head=None):
"""검증 항목 6개의 결과 dict: 체인 연속, 서명, 같은 ID 내용 충돌, 일련번호 연속, 시각 이상, 꼬리 절단."""
log, conf = canonical(log)
chain_ok = sig_ok = True; prev = "GENESIS"; bad_sig = 0
for r in log:
if r["prev"] != prev or hashlib.sha256((prev + rec_str(r)).encode()).hexdigest() != r["hash"]:
chain_ok = False
if not hmac.compare_digest(r["sig"], hmac.new(KEYS[node], rec_str(r).encode(), "sha256").hexdigest()[:16]):
sig_ok = False; bad_sig += 1
prev = r["hash"]
seqs = [int(r["id"].split("-")[1]) for r in log]
gap = seqs != list(range(1, len(seqs) + 1)) if seqs else True
ts = np.array([r["t"] for r in log]); skew = bool(len(ts) and (ts < 0).any())
tail = bool(head is not None and prev != head)
return {"chain": chain_ok, "sig": sig_ok, "conflict": conf == 0, "seq": not gap, "time": not skew, "tail": not tail, "bad_sig": bad_sig}
W = {"chain": 0.30, "sig": 0.30, "conflict": 0.15, "seq": 0.10, "time": 0.05, "tail": 0.10} # 신뢰도 가중치 매트릭스(합 1)
def confidence(v, sample_frac=1.0, rng=None):
"""점검 통과 가중합. 체인·서명·충돌·꼬리 실패는 즉시 Low. 서명은 sample_frac 비율의 표본 점검만 했을 때 불확실성이 생긴다."""
s = sum(W[k] for k in W if v[k])
lvl = "Low" if not (v["chain"] and v["sig"] and v["conflict"] and v["tail"]) else "High" if s >= 0.95 else "Med" if s >= 0.75 else "Low"
return s, lvl
# 시험 1: 악성 변조 6종 + 정상 전송 변형 3종, 각 노드 x 6회
import copy
rng = np.random.default_rng(11)
def tamper(kind, log):
log = copy.deepcopy(log); i = int(rng.integers(5, len(log) - 5))
if kind == "수량 수정":
log[i]["qty"] += 40
elif kind == "기록 삭제":
del log[i]
elif kind == "순서 바꿈+시각 수정":
log[i], log[i + 1] = log[i + 1], log[i]; log[i]["t"] = round(log[i]["t"] + 3, 2)
elif kind == "위조 삽입(서명 없음)":
f = dict(log[i]); f["id"] = log[i]["id"].split("-")[0] + "-" + f"{len(log) + 1:05d}"; f["qty"] = 99; f["sig"] = "0" * 16; log.append(f)
elif kind == "재전송 변조(같은 ID, 다른 수량)":
f = dict(log[i]); f["qty"] += 7; log.append(f)
elif kind == "꼬리 절단":
log = log[: -int(rng.integers(3, 20))]
elif kind == "중복 전송":
log += copy.deepcopy(log[i:i + 5])
elif kind == "순서 뒤바뀜 전송":
rng.shuffle(log)
elif kind == "공백 차이(note)":
for r in log[i:i + 3]:
r["note"] = " "
return log
MAL = ["수량 수정", "기록 삭제", "순서 바꿈+시각 수정", "위조 삽입(서명 없음)", "재전송 변조(같은 ID, 다른 수량)", "꼬리 절단"]
BEN = ["중복 전송", "순서 뒤바뀜 전송", "공백 차이(note)"]
rows = []
for n in NODES:
for kind in MAL + BEN:
for _ in range(6):
lg = tamper(kind, LOGS[n][0])
naive = hashlib.sha256("".join(rec_str(r) + r["hash"] + r["note"] for r in lg).encode()).hexdigest() != hashlib.sha256("".join(rec_str(r) + r["hash"] + r["note"] for r in LOGS[n][0]).encode()).hexdigest()
v = verify(n, lg, HEADS[n]); s, lvl = confidence(v)
rows.append({"node": n, "kind": kind, "mal": kind in MAL, "naive": naive, "alarm": lvl == "Low", "score": s, "lvl": lvl})
R = pd.DataFrame(rows)
det = R[R["mal"]].groupby("kind").agg(naive=("naive", "mean"), layered=("alarm", "mean"))
print(f"[변조 탐지] 악성 {len(MAL)}종 x 노드 5 x 6회 = {int(R['mal'].sum())}건, 정상 전송 변형 {len(BEN)}종 = {int((~R['mal']).sum())}건")
print("악성 종류별 탐지율: 전체 해시 비교 / 계층 검증(체인+서명+충돌+순번+꼬리)")
for k, r in det.iterrows():
print(f" {k:28s} {r['naive']:5.0%} / {r['layered']:5.0%}")
tp_l, fp_l = int((R["alarm"] & R["mal"]).sum()), int((R["alarm"] & ~R["mal"]).sum())
tp_n, fp_n = int((R["naive"] & R["mal"]).sum()), int((R["naive"] & ~R["mal"]).sum())
far_n, far_l = fp_n / (tp_n + fp_n), fp_l / max(tp_l + fp_l, 1)
print(f"\n[오경보] 전체 해시 비교: 경보 {tp_n + fp_n}건 중 정상 변형 {fp_n}건(오경보율 {far_n:.1%}), 탐지율 {tp_n / R['mal'].sum():.0%}")
print(f" 계층 검증(정규화 후): 경보 {tp_l + fp_l}건 중 정상 변형 {fp_l}건(오경보율 {far_l:.1%}), 탐지율 {tp_l / R['mal'].sum():.0%}")
print(f"-> 오경보율 {far_n:.1%} -> {far_l:.1%} ({(far_n - far_l) / far_n:.0%} 감축, 목표 40% 이상: {'달성' if (far_n - far_l) / far_n >= 0.4 else '미달'})")
miss = R[R["mal"] & ~R["alarm"]]["kind"].value_counts().to_dict()
print(f"계층 검증 미탐: {miss or '없음'} (꼬리 절단은 동료가 보관한 머리 해시가 있어야 탐지)")
# 시험 2: 신뢰도 점수와 일관성(같은 로그를 서명 표본 점검 비율을 바꿔가며 20회 점검했을 때 등급이 같은가)
print("\n[신뢰도 가중치 매트릭스]", W, "/ High 0.95 이상, Med 0.75 이상, 체인·서명·충돌·꼬리 실패는 즉시 Low")
cons = []
for n in NODES:
grades, scores = [], []
for k in range(20):
lg = LOGS[n][0] if k % 2 == 0 else tamper("중복 전송", LOGS[n][0]) if k % 4 == 1 else tamper("순서 뒤바뀜 전송", LOGS[n][0])
s, lvl = confidence(verify(n, lg, HEADS[n])); grades.append(lvl); scores.append(s)
cons.append((n, pd.Series(grades).value_counts().iloc[0] / 20, np.mean(scores), np.std(scores)))
cm = np.mean([c[1] for c in cons])
print("정상 로그 20회 반복(정상 전송 변형 포함) 등급 일관성: " + ", ".join(f"{n[-1]} {c:.0%}" for n, c, _, _ in cons) + f" -> 평균 {cm:.0%} (목표 90% 이상: {'달성' if cm >= .9 else '미달'})")
lv = pd.crosstab(R["kind"], R["lvl"]).reindex(columns=["High", "Med", "Low"], fill_value=0)
print("\n[변형 종류별 신뢰도 등급 분포]"); print(lv.to_string())
bad = verify("보급소A", tamper("수량 수정", LOGS["보급소A"][0]), HEADS["보급소A"])
print(f"불확실성 표시 예: 수량 수정 로그의 서명 불일치 레코드 {bad['bad_sig']}건 -> 해당 레코드 번호를 군수관에게 제시(전체 폐기 아님)")
print("※ 서명 키 배포·보관, 단말 분실 시 폐기는 별도 보안 체계가 필요하다. 신뢰도 등급은 점검 항목 가중합이며 통계적 확률이 아니다. 최종 판단은 군수관·보안 담당이 한다.")
plt.figure(figsize=(8, 3.8)); x = np.arange(len(det))
plt.bar(x - .2, det["naive"] * 100, .4, label="whole-hash compare", color="tab:gray"); plt.bar(x + .2, det["layered"] * 100, .4, label="layered verify", color="tab:blue")
plt.xticks(x, [f"M{i + 1}" for i in range(len(det))]); plt.ylabel("detection rate (%)"); plt.title("Tamper detection by type (M1-M6)"); plt.legend(); plt.tight_layout(); plt.show()
In [ ]:
"""5개 보급소의 오프라인/온라인 전환 상태와 품목별 재고 변동 내역을 시간 단위 모의 데이터 스트림으로 갱신하는 실시간 대시보드 화면 모델(상태판·재고 게이지·변동 내역·경보 패널 4종)과 야간 모드·긴급 경보 강조·조작 응답 시간을 구현해 갱신 주기 1초 이내·UI 응답 100ms 이내를 측정한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 6] 오프라인/온라인 전환 상태 및 재고 변동 내역 실시간 대시보드 UI 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
THEMES = {"주간": {"bg": (255, 255, 255), "fg": (30, 30, 30), "ok": (30, 120, 60), "warn": (150, 90, 0), "crit": (190, 30, 30), "off": (90, 90, 90)},
"야간": {"bg": (18, 22, 28), "fg": (225, 230, 235), "ok": (90, 210, 130), "warn": (245, 190, 70), "crit": (255, 110, 110), "off": (160, 165, 170)}}
def lum(c):
# [TODO : 학생 실습 6] 오프라인/온라인 전환 상태 및 재고 변동 내역 실시간 대시보드 UI 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def contrast(a, b):
la, lb = sorted([lum(a), lum(b)], reverse=True)
return (la + 0.05) / (lb + 0.05)
class Dashboard:
"""화면 모델: 매 tick(1시간)마다 새 트랜잭션을 반영하고 4개 패널의 표시 값을 갱신한다. 렌더링은 모델과 분리해 응답 시간을 따로 잰다."""
def __init__(self, txns, outages, base=500):
self.tx = sorted(txns, key=lambda x: x["t"]); self.p = 0; self.outages = outages
self.central = {i: base for i in ITEM_IDS}; self.local = {n: {i: 0 for i in ITEM_IDS} for n in NODES} # 서버 반영 재고 / 노드별 미동기 변동
self.pending = {n: 0 for n in NODES}; self.last_sync = {n: 0.0 for n in NODES}; self.feed = []; self.theme = "주간"; self.node_filter = None; self.only_alert = False; self.acked = set(); self.out24 = {i: [] for i in ITEM_IDS}
def online(self, n, t):
return not any(a <= t < b for a, b in self.outages.get(n, []))
def step(self, t):
while self.p < len(self.tx) and self.tx[self.p]["t"] < t:
x = self.tx[self.p]; self.p += 1; d = x["qty"] if x["op"] in ("in", "adjust") else -x["qty"]
if self.online(x["node"], x["t"]):
self.central[x["item"]] += d; self.last_sync[x["node"]] = x["t"]
else:
self.local[x["node"]][x["item"]] += d; self.pending[x["node"]] += 1
if x["op"] == "out":
self.out24[x["item"]].append((x["t"], x["qty"]))
self.feed.append((x["t"], x["node"], x["item"], x["op"], x["qty"], "반영" if self.online(x["node"], x["t"]) else "대기"))
for n in NODES: # 복구된 노드는 대기분을 한꺼번에 반영
if self.online(n, t) and self.pending[n]:
for i, v in self.local[n].items():
self.central[i] += v; self.local[n][i] = 0
self.pending[n] = 0; self.last_sync[n] = t
return self.view(t)
def view(self, t):
status = [{"node": n, "state": "온라인" if self.online(n, t) else "오프라인", "pending": self.pending[n], "lag_h": round(t - self.last_sync[n], 1)} for n in NODES]
gauges = []
for it in ITEMS:
st = self.central[it["item"]] + sum(self.local[n][it["item"]] for n in NODES)
o24 = sum(q for (tt, q) in self.out24[it["item"]] if tt >= t - 24)
cover = st / max(o24, 1) if t >= 24 else 99.0 # 최근 24시간 불출량 기준 소진 예상 일수
lvl = "긴급" if (st < it["min_level"] or cover < 0.5 * it["lead_days"]) else "주의" if cover < it["lead_days"] else "정상"
gauges.append({"item": it["item"], "stock": st, "min": it["min_level"], "lvl": lvl, "cover": cover, "lead": it["lead_days"], "acked": it["item"] in self.acked})
if self.only_alert:
gauges = [g for g in gauges if g["lvl"] != "정상"]
feed = [f for f in self.feed[-200:] if not self.node_filter or f[1] == self.node_filter][-6:]
alerts = [g for g in gauges if g["lvl"] == "긴급" and not g["acked"]] + [dict(node=s["node"], lvl="장기 오프라인") for s in status if s["lag_h"] >= 48]
return {"t": t, "status": status, "gauges": gauges, "feed": feed, "alerts": alerts, "theme": self.theme}
# ---- 사용자 조작 핸들러 ----
def set_theme(self, name):
self.theme = name; return self.view(self.tx[self.p - 1]["t"] if self.p else 0)
def set_node(self, n):
self.node_filter = n; return self.view(self.tx[self.p - 1]["t"])
def toggle_alert(self):
self.only_alert = not self.only_alert; return self.view(self.tx[self.p - 1]["t"])
def ack(self, item):
self.acked.add(item); return self.view(self.tx[self.p - 1]["t"])
rng = np.random.default_rng(21)
TX = make_txns(7, 7, per_node_day=60)
OUT = {"보급소B": [(24.0, 96.0)], "보급소D": [(60.0, 120.0), (140.0, 150.0)], "보급소E": [(10.0, 168.5)]} # 오프라인 구간(시간)
db = Dashboard(TX, OUT)
lat, snaps, off_n = [], {}, []
for h in range(1, 169):
t0 = time.perf_counter(); v = db.step(float(h)); lat.append((time.perf_counter() - t0) * 1000)
off_n.append(sum(s["state"] == "오프라인" for s in v["status"]))
if h in (12, 72, 168):
snaps[h] = v
lat = np.array(lat)
print(f"[모의 스트림] {len(TX)}건 트랜잭션, 168회 갱신(1시간 단위), 오프라인 구간 {sum(len(x) for x in OUT.values())}개")
print(f"화면 모델 갱신 지연: 평균 {lat.mean():.2f}ms, 95% {np.percentile(lat, 95):.2f}ms, 최대 {lat.max():.2f}ms (목표 1초 이내: {'달성' if lat.max() < 1000 else '미달'})")
for h, v in snaps.items():
print(f" {h:3d}h 시점: 오프라인 {sum(s['state'] == '오프라인' for s in v['status'])}곳, 미동기 {sum(s['pending'] for s in v['status'])}건, 긴급/주의 품목 {sum(g['lvl'] == '긴급' for g in v['gauges'])}/{sum(g['lvl'] == '주의' for g in v['gauges'])}종, 경보 {len(v['alerts'])}건")
last = snaps[168]
print("\n[보급소 상태판 168h] " + " | ".join(f"{s['node'][-1]} {s['state']} 대기{s['pending']} 지연{s['lag_h']}h" for s in last["status"]))
print("[재고 경보 품목] " + ", ".join(f"{g['item']}:{g['stock']}/소진{g['cover']:.1f}일/납기{g['lead']}일({g['lvl']})" for g in [x for x in last["gauges"] if x["lvl"] != "정상"][:4]) + " ...")
print("[변동 내역] " + "; ".join(f"{f[1][-1]}-{f[2]} {f[3]} {f[4]} {f[5]}" for f in last["feed"][-3:]))
# ---- 사용자 조작 시험: 100회씩 응답 시간 ----
def timeit(fn, k=100):
r = []
for _ in range(k):
t0 = time.perf_counter(); fn(); r.append((time.perf_counter() - t0) * 1000)
return np.array(r)
ops = {"야간 모드 전환": lambda: (db.set_theme("야간"), db.set_theme("주간")), "보급소 필터": lambda: (db.set_node("보급소C"), db.set_node(None)),
"경보만 보기": lambda: (db.toggle_alert(), db.toggle_alert()), "경보 확인(ack)": lambda: (db.ack("I01"), db.acked.clear())}
print("\n[UI 조작 응답 시간 ms] 조작 / 평균 / 95% / 최대")
worst = 0
for k, fn in ops.items():
r = timeit(fn); worst = max(worst, r.max()); print(f" {k:12s} {r.mean():6.3f} {np.percentile(r, 95):6.3f} {r.max():6.3f}")
print(f"-> 최대 {worst:.2f}ms (목표 100ms 이내: {'달성' if worst < 100 else '미달'})")
# ---- 야간 모드 가독성(WCAG 대비 4.5 이상)과 긴급 경보 강조 ----
print("\n[테마 대비비(글자-배경)]")
ok = True
for nm, th in THEMES.items():
cs = {k: contrast(th[k], th["bg"]) for k in ("fg", "ok", "warn", "crit", "off")}; ok &= all(c >= 4.5 for c in cs.values())
print(f" {nm}: " + ", ".join(f"{k} {c:.1f}" for k, c in cs.items()))
print(f"모든 색상 대비 4.5 이상: {'달성' if ok else '미달'}")
# ---- 렌더링(4개 패널) ----
th = THEMES["야간"]; col = lambda k: tuple(c / 255 for c in th[k])
t0 = time.perf_counter()
fig, ax = plt.subplots(2, 2, figsize=(10, 6), facecolor=col("bg"))
for a in ax.ravel():
a.set_facecolor(col("bg")); a.tick_params(colors=col("fg")); [s.set_color(col("off")) for s in a.spines.values()]
a = ax[0, 0]; a.set_title("Node status (pending tx)", color=col("fg"))
a.bar([s["node"][-1] for s in last["status"]], [s["pending"] for s in last["status"]], color=[col("ok") if s["state"] == "온라인" else col("crit") for s in last["status"]])
a = ax[0, 1]; a.set_title("Stock vs minimum level", color=col("fg")); ys = np.arange(len(last["gauges"]))
a.barh(ys, [g["stock"] for g in last["gauges"]], color=[col({"정상": "ok", "주의": "warn", "긴급": "crit"}[g["lvl"]]) for g in last["gauges"]])
a.scatter([g["min"] for g in last["gauges"]], ys, color=col("fg"), marker="|", s=80); a.set_yticks(ys); a.set_yticklabels([g["item"] for g in last["gauges"]], fontsize=6)
a = ax[1, 0]; a.set_title("Offline nodes over time", color=col("fg")); a.step(range(1, 169), off_n, color=col("warn")); a.set_xlabel("hour", color=col("fg"))
a = ax[1, 1]; a.set_title("Alert count by stage", color=col("fg"))
a.bar(["critical", "caution", "normal"], [sum(g["lvl"] == k for g in last["gauges"]) for k in ("긴급", "주의", "정상")], color=[col("crit"), col("warn"), col("ok")])
fig.tight_layout(); fig.canvas.draw(); rt = (time.perf_counter() - t0) * 1000
print(f"\n렌더링 4개 패널 1회 그리기 {rt:.0f}ms (갱신 1초 이내: {'달성' if rt + lat.max() < 1000 else '미달'}, 응답성 100ms는 화면 모델 조작 기준)")
print("※ 대시보드는 판단을 대신하지 않는다. 경보 확인(ack)과 재고 조치는 군수관이 한다. 화면의 재고는 동기화된 값과 노드별 미동기 변동의 합이다.")
plt.show()
In [ ]:
"""동기화 충돌 건을 시스템 제안 -> 보급담당 상신 -> 군수관 수동 병합 -> (영향이 큰 건은 지휘관 승인) 순서의 HITL 워크플로우로 처리하되, 자동 확정 금지·병합 전후 값과 사유 기록·반려 사유 필수화·권한 우회 차단·해시 체인 감사 로그·지휘관 피드백 입력 검증을 구현하고 위험도 우선 큐(대기 노화 가산)와 선입선출의 대기시간을 비교한다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 7] 동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
PERM = {"시스템": {"propose"}, "보급담당": {"submit", "redraft", "view"}, "군수관": {"review", "merge", "confirm", "reject", "view"}, "지휘관": {"approve", "reject", "feedback", "view"}, "감사관": {"view", "audit"}}
USERS = {"sys": "시스템", "kim": "보급담당", "lee": "군수관", "park": "지휘관", "jung": "감사관"}
IMPACT_CUT = 0.25 # 두 값의 차이가 큰 쪽 값의 25% 이상이면 지휘관 승인 필요
MAX_REDRAFT = 2
FB_CATS = {"우선순위", "자원", "절차", "기타"} # 지휘관 피드백 입력 폼 분류
SLA_H = 4.0 # 군수관 검토 대기 허용 시간(h)
class Chain:
def __init__(self):
self.log = []
def add(self, **body):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {"seq": len(self.log), **body}
self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})
def verify(self):
prev = "GENESIS"
for e in self.log:
b = {k: v for k, v in e.items() if k not in ("prev", "hash")}
if e["prev"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]:
return e["seq"]
prev = e["hash"]
return None
class Workflow:
def __init__(self):
self.audit, self.cases, self.calls = Chain(), {}, 0
def _deny(self, who, case, action, why):
self.audit.add(event="DENIED", who=who, case=case, action=action, reason=why); return False, why
def act(self, who, case, action, reason="", evidence="", value=None, cat=None, cf=None):
self.calls += 1
role = USERS.get(who)
if role is None:
return self._deny(who, case, action, "미등록 사용자")
if action in ("delete_log", "edit_log"):
return self._deny(who, case, action, "감사 로그 삭제·수정 권한은 누구에게도 없음")
if action not in PERM[role]:
return self._deny(who, case, action, f"권한 없음({role} 역할에 {action} 권한 없음)")
if action == "propose":
a, b = cf
self.cases[case] = {"state": "제안", "submitter": None, "version": 1, "a": a, "b": b, "merged": None, "high": abs(a - b) / max(abs(a), abs(b), 1) >= IMPACT_CUT, "feedback": []}
self.audit.add(event="PROPOSE", who=who, case=case, a=a, b=b, reason=reason); return True, "제안 등록(자동 확정 없음)"
c = self.cases.get(case)
if c is None:
return self._deny(who, case, action, "존재하지 않는 건")
if c["state"] in ("확정", "종결(반려)"):
return self._deny(who, case, action, f"이미 종결된 건({c['state']})")
if not reason.strip() or not evidence.strip():
return self._deny(who, case, action, "사유·근거 입력 필수(반려·병합 사유 포함)")
if action == "feedback":
if cat not in FB_CATS:
return self._deny(who, case, action, f"피드백 분류 오류({cat}), 허용 {sorted(FB_CATS)}")
c["feedback"].append((cat, reason))
self.audit.add(event="FEEDBACK", who=who, role=role, case=case, cat=cat, reason=reason, evidence=evidence); return True, "지휘관 피드백 기록"
if action == "merge":
if c["state"] != "군수관검토":
return self._deny(who, case, action, f"병합은 군수관 검토 단계에서만 가능(현재 {c['state']})")
lo, hi = min(c["a"], c["b"]), max(c["a"], c["b"])
if value is None or not (lo - 0.2 * abs(lo) <= value <= hi + 0.2 * abs(hi)):
return self._deny(who, case, action, f"병합 값 범위 초과(허용 {lo - 0.2 * abs(lo):.0f}~{hi + 0.2 * abs(hi):.0f}, 입력 {value})")
before, c["merged"] = c["merged"], value
self.audit.add(event="MERGE", who=who, role=role, case=case, before=before, after=value, a=c["a"], b=c["b"], reason=reason, evidence=evidence); return True, f"병합 값 {before} -> {value}"
if action == "reject":
if c["state"] in ("제안", "반려"):
return self._deny(who, case, action, f"반려할 수 없는 단계({c['state']})")
c["state"] = "종결(반려)" if c["version"] > MAX_REDRAFT else "반려"
self.audit.add(event="REJECTED", who=who, role=role, case=case, version=c["version"], reason=reason, evidence=evidence); return True, c["state"]
if action == "redraft":
if c["state"] != "반려":
return self._deny(who, case, action, f"재기안은 반려된 건만 가능(현재 {c['state']})")
if who != c["submitter"]:
return self._deny(who, case, action, "재기안은 최초 상신자만 가능")
c["state"], c["version"] = "상신", c["version"] + 1
self.audit.add(event="REDRAFT", who=who, role=role, case=case, version=c["version"], reason=reason, evidence=evidence); return True, "재기안 상신"
if c["state"] == "반려":
return self._deny(who, case, action, "반려된 건은 재기안 후에만 진행 가능")
need = {"제안": "submit", "상신": "review", "군수관검토": "approve" if c["high"] else "confirm"}[c["state"]]
if action != need:
return self._deny(who, case, action, f"단계 위반(현재 {c['state']}{'·영향 큼' if c['high'] else ''}, 필요 행위 {need})")
if action == "confirm" and c["merged"] is None or action == "approve" and c["merged"] is None:
return self._deny(who, case, action, "군수관 병합 값 없음(자동 확정 금지)")
if action == "submit":
c["submitter"] = who
c["state"] = {"submit": "상신", "review": "군수관검토", "confirm": "확정", "approve": "확정"}[action]
self.audit.add(event=action.upper(), who=who, role=role, case=case, version=c["version"], to=c["state"], merged=c["merged"], reason=reason, evidence=evidence); return True, c["state"]
# ---- 충돌 건 12개: 같은 품목의 재고 실사 수량·최소 보유 기준을 두 보급소가 서로 다르게 수정 ----
rng = np.random.default_rng(5)
cands = []
for k in range(12):
it = ITEMS[k]; field = "실사 수량" if k % 3 else "최소 보유 기준"
base = 500 if field == "실사 수량" else it["min_level"]
a = int(base * rng.uniform(0.9, 1.1)); b = int(a * rng.choice([1.05, 1.1, 1.4, 1.8, 0.5]))
cover = rng.uniform(1.5, 14) # 소진 예상 일수 (납기 대비 부족하면 공급망 재난 징후)
risk = 100 * (0.5 * min(abs(a - b) / max(a, b) / 0.5, 1) + 0.5 * max(0, 1 - cover / it["lead_days"]))
cands.append({"id": f"C{k + 1:02d}", "item": it["item"], "field": field, "a": a, "b": b, "cover": cover, "lead": it["lead_days"], "risk": risk})
cd = pd.DataFrame(cands)
print(f"[충돌 건] {len(cd)}건, 영향 큼(차이 {IMPACT_CUT:.0%} 이상) {sum(abs(r.a - r.b) / max(r.a, r.b) >= IMPACT_CUT for r in cd.itertuples())}건, 위험도 평균 {cd['risk'].mean():.0f}")
# ---- 큐 시뮬레이션: 군수관 1명(건당 2시간), 건이 1시간 간격으로 도착 ----
def run_queue(policy, risks, service=2.0, arr_gap=1.0):
# [TODO : 학생 실습 7] 동기화 충돌 발생 시 군수관 수동 병합(Conflict Resolution) HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
rs = list(rng.permutation(cd["risk"].to_numpy()))
wf_, wp_ = run_queue("fifo", rs), run_queue("prio", rs)
hi = [i for i, r in enumerate(rs) if r >= 50]
print(f"\n[검토 큐 대기시간(h)] 건 {len(rs)}개, 위험 50 이상 {len(hi)}건, 허용 {SLA_H:.0f}h")
for nm, w in (("선입선출", wf_), ("위험도 우선(노화 가산)", wp_)):
print(f" {nm:14s}: 전체 평균 {np.mean(list(w.values())):.1f}h, 고위험 평균 {np.mean([w[i] for i in hi]):.1f}h, 최대 {max(w.values()):.1f}h, 허용 초과 {sum(v > SLA_H for v in w.values())}건")
# ---- 워크플로우 모의 ----
wf = Workflow(); res = []
for k, r in cd.iterrows():
case = r["id"]
wf.act("sys", case, "propose", reason=f"{r['item']} {r['field']} 충돌: A {r['a']} / B {r['b']}", cf=(int(r["a"]), int(r["b"])))
wf.act("kim", case, "submit", "수동 병합 검토 요청", f"소진 예상 {r['cover']:.1f}일, 납기 {r['lead']}일")
for _ in range(12):
c = wf.cases[case]; s = c["state"]
if s in ("확정", "종결(반려)"):
break
if s == "상신":
if k in (2, 11) and c["version"] <= (1 if k == 2 else 9): # 2번 건은 1회 반려, 11번 건은 보완 없이 계속 반려(재기안 한도 시연)
wf.act("lee", case, "reject", "근거 사진·실사표 누락, 보완 요청", f"충돌 두 값 {r['a']}/{r['b']}")
else:
wf.act("lee", case, "review", "두 기록과 실사 근거 확인", f"소진 예상 {r['cover']:.1f}일")
elif s == "군수관검토":
if c["merged"] is None:
val = int((c["a"] + c["b"]) / 2) if r["field"] == "실사 수량" else max(c["a"], c["b"]) # 실사는 평균 후보, 기준은 높은 쪽(안전 우선)
wf.act("lee", case, "merge", "수동 병합: 현장 실사·납기 고려해 값 결정", f"A {c['a']} / B {c['b']}", value=val)
if c["high"]:
wf.act("park", case, "feedback", "영향이 큰 건이므로 선별 확인 후 결정", "우선순위", cat="우선순위")
wf.act("park", case, "approve", "승인: 병합 값 확정", f"차이 {abs(c['a'] - c['b']) / max(c['a'], c['b']):.0%}, 군수관 병합 검토 완료")
else:
wf.act("lee", case, "confirm", "확정: 병합 값 반영", "차이가 작아 군수관 확정 범위")
elif s == "반려":
wf.act("kim", case, "redraft", "반려 사유 반영해 재기안", f"근거 보완 {c['version']}차")
c = wf.cases[case]; res.append((case, r["field"], c["a"], c["b"], "-" if c["merged"] is None else str(c["merged"]), "지휘관" if c["high"] else "군수관", c["state"], c["version"]))
print("\n[처리 결과] (모의 결재자 기준, 실제 판단은 사람이 한다)")
print(pd.DataFrame(res, columns=["건", "항목", "A값", "B값", "병합값", "확정권자", "상태", "버전"]).head(12).to_string(index=False))
print(f"군수관 수동 병합 {sum(e['event'] == 'MERGE' for e in wf.audit.log)}건(전후 값·사유 기록), 반려 {sum(e['event'] == 'REJECTED' for e in wf.audit.log)}건(사유 필수), 지휘관 피드백 {sum(e['event'] == 'FEEDBACK' for e in wf.audit.log)}건")
# ---- 권한 우회·절차 위반 시도 ----
def mk(cid, stage="submit", ab=(100, 160)):
wf.act("sys", cid, "propose", reason="시험", cf=ab)
if stage in ("submit", "review"):
wf.act("kim", cid, "submit", "시험 상신", "근거")
if stage == "review":
wf.act("lee", cid, "review", "검토", "이상 없음")
return cid
done_case = next(c for c, v in wf.cases.items() if v["state"] == "확정")
att = [("시스템 자동 병합", lambda: wf.act("sys", mk("T01", "review"), "merge", "자동", "자동", value=130)), ("보급담당이 병합 시도", lambda: wf.act("kim", mk("T02", "review"), "merge", "임의", "근거", value=130)),
("병합 없이 확정", lambda: wf.act("lee", mk("T03", "review", (100, 105)), "confirm", "바로 확정", "근거")), ("미등록 사용자", lambda: wf.act("guest", mk("T04"), "review", "열람", "근거")),
("사유 없는 검토", lambda: wf.act("lee", mk("T05"), "review", "", "")), ("감사 로그 수정", lambda: wf.act("kim", mk("T06"), "edit_log", "정리", "근거")),
("감사관이 확정 시도", lambda: wf.act("jung", mk("T07", "review"), "confirm", "확정", "근거")), ("존재하지 않는 건", lambda: wf.act("park", "X99", "approve", "승인", "근거")),
("종결된 건 재승인", lambda: wf.act("park", done_case, "approve", "재승인", "근거")), ("병합 값 범위 초과(100/160 -> 500)", lambda: wf.act("lee", mk("T08", "review"), "merge", "대폭 변경", "근거", value=500)),
("영향 큰 건을 군수관이 확정", lambda: (wf.act("lee", "T09", "merge", "병합", "근거", value=130) if mk("T09", "review") else None, wf.act("lee", "T09", "confirm", "확정", "근거"))[-1]),
("지휘관이 검토 건너뛰기", lambda: wf.act("park", mk("T10"), "approve", "바로 승인", "근거")), ("피드백 분류 오류", lambda: wf.act("park", mk("T11", "review"), "feedback", "의견", "근거", cat="잡담")),
("상신 전 병합", lambda: wf.act("lee", mk("T12", "submit"), "merge", "병합", "근거", value=130))]
print("\n[권한 우회·절차 위반 시도]")
blocked = 0
for n, fn in att:
ok, why = fn(); blocked += (not ok); print(f" {n:26s}-> {'차단' if not ok else '통과(결함)'}: {why}")
print(f"차단 {blocked}/{len(att)} ({blocked / len(att):.0%})")
# ---- 로깅 성공률과 이력 무결성 ----
n_log = len(wf.audit.log); bad = wf.audit.verify()
print(f"\n처리 요청 {wf.calls}건 -> 감사 로그 {n_log}건(확정·반려·병합·거부 모두 기록), 로깅 성공률 {n_log / wf.calls:.0%} (목표 100%: {'달성' if n_log == wf.calls else '미달'})")
print(f"해시 체인 검증: {'정상' if bad is None else f'seq {bad} 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}")
saved = json.dumps(wf.audit.log[12], ensure_ascii=False); wf.audit.log[12]["reason"] = "사후 수정"; v = wf.audit.verify()
print(f"12번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(v) + ')' if v is not None else '탐지 실패'}"); wf.audit.log[12] = json.loads(saved)
print("※ 충돌 병합은 군수관이 하고, 영향이 큰 건은 지휘관이 승인한다. 시스템은 두 값과 근거만 제시하며 자동 단정하지 않는다. 결재선은 소속 기관 규정을 따른다.")
plt.figure(figsize=(6, 3.5)); plt.bar(["FIFO", "priority"], [np.mean([wf_[i] for i in hi]), np.mean([wp_[i] for i in hi])], color=["tab:gray", "tab:blue"])
plt.ylabel("mean wait of high-risk conflicts (h)"); plt.title("Review queue wait: FIFO vs risk priority"); plt.tight_layout(); plt.show()
In [ ]:
"""7일간 통신이 끊긴 보급소 5곳의 누적 트랜잭션을 복구 직후 서버로 한꺼번에 올리는 상황을 기준 100%·200%·500%로 늘려 큐 기반 작업자 스레드로 처리하고, 중복 재전송·서명 불일치·작업자 오류를 주입해 다운 0건·유실 0건·기능 축소(표본 서명 점검 후 사후 검증)·병목(목록 탐색 중복 확인을 집합으로 교체)·메모리 증가를 측정한다."""
import hashlib
import hmac
import queue
import threading
import time
import tracemalloc
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 8] 7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
KEYS = {n: hashlib.sha256(f"demo-key-{n}".encode()).digest() for n in NODES} # [교육용가상자료] 데모 키
BATCH, QMAX, HIGH_WATER, WORKERS = 50, 12, 8, 4
def sig_of(x):
# [TODO : 학생 실습 8] 7일간 통신 단절 후 대량 재고 트랜잭션 일괄 동기화 스트레스 테스트 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
class Server:
def __init__(self, fast):
self.fast, self.lock = fast, threading.Lock()
self.ids = set() if fast else [] # 최적화 전: 목록 선형 탐색으로 중복 확인
self.stock = {i: 500 for i in ITEM_IDS}; self.quar, self.dups, self.unver, self.lite_batches, self.applied = [], 0, [], 0, 0
def ingest(self, batch, lite):
ok_rows, unv = [], set()
for k, x in enumerate(batch):
if lite and k % 10: # 기능 축소: 표본 10%만 서명 점검, 나머지는 사후 검증 대상
good = True; unv.add(x["id"])
else:
good = hmac.compare_digest(sig_of(x), x["sig"])
if not good:
with self.lock:
self.quar.append(x["id"])
continue
ok_rows.append(x)
with self.lock:
self.lite_batches += lite
for x in ok_rows:
dup = x["id"] in self.ids
if dup:
self.dups += 1; continue
self.ids.add(x["id"]) if self.fast else self.ids.append(x["id"])
self.stock[x["item"]] += x["qty"] if x["op"] in ("in", "adjust") else -x["qty"]; self.applied += 1
if x["id"] in unv:
self.unver.append(x) # 실제로 반영된 미검증 레코드만 사후 검증 대상
def post_verify(self):
"""사후 검증: 표본 점검만 거친 레코드를 전수 점검하고, 위조로 확인되면 반영분을 되돌린다."""
rolled = 0
for x in self.unver:
if not hmac.compare_digest(sig_of(x), x["sig"]):
self.stock[x["item"]] -= x["qty"] if x["op"] in ("in", "adjust") else -x["qty"]; self.quar.append(x["id"]); self.applied -= 1; rolled += 1
return rolled
def run(mult, fast, seed=3):
rng = np.random.default_rng(seed)
tx = make_txns(7, 7, per_node_day=60 * mult)
for x in tx:
x["sig"] = sig_of(x)
bad = set(rng.choice(len(tx), int(len(tx) * 0.01), replace=False).tolist())
for i in bad:
tx[i]["qty"] += 13 # 서명 후 수량 변조(1%)
valid = [x for i, x in enumerate(tx) if i not in bad]
truth = stock_of(valid)
srv, q, lat, depth, dead, crash = Server(fast), queue.Queue(maxsize=QMAX), [], [], [], [0]
lk = threading.Lock(); cnt = {"b": 0}
def worker():
while True:
item = q.get()
if item is None:
return
batch, t_in, retry = item
depth.append(q.qsize())
try:
with lk:
cnt["b"] += 1; inj = (cnt["b"] % 37 == 0) and not retry # 37번째 배치마다 작업자 오류 주입(재시도 1회)
if inj:
raise RuntimeError("worker failure(injected)")
srv.ingest(batch, lite=fast and q.qsize() >= HIGH_WATER)
except RuntimeError:
dead.append((batch, t_in)) # 데드레터 보관 후 재처리(유실 방지)
except Exception:
crash[0] += 1
lat.append((time.perf_counter() - t_in) * 1000)
ths = [threading.Thread(target=worker) for _ in range(WORKERS)]
[t.start() for t in ths]
t0 = time.perf_counter()
batches = []
for n in NODES:
mine = [x for x in tx if x["node"] == n]
batches += [mine[i:i + BATCH] for i in range(0, len(mine), BATCH)]
order = rng.permutation(len(batches)); resend = set(rng.choice(len(batches), max(1, len(batches) // 20), replace=False).tolist())
for i in order:
q.put((batches[i], time.perf_counter(), False)) # 가득 차면 생산자가 기다린다(역압, 유실 없음)
if i in resend:
q.put((batches[i], time.perf_counter(), False)) # 같은 배치 중복 재전송
for _ in ths:
q.put(None)
[t.join() for t in ths]
for b, t_in in dead:
srv.ingest(b, lite=False)
rolled = srv.post_verify() if fast else 0
dt = time.perf_counter() - t0
diff = sum(abs(srv.stock[i] - truth[i]) for i in ITEM_IDS)
lost = len(valid) - srv.applied
return {"n": len(tx), "dt": dt, "tps": len(tx) / dt, "p95": float(np.percentile(lat, 95)), "qmax": max(depth) if depth else 0, "lite": srv.lite_batches, "dead": len(dead),
"dups": srv.dups, "quar": len(set(srv.quar)), "bad": len(bad), "lost": lost, "diff": diff, "crash": crash[0], "rolled": rolled}
print(f"[7일 단절 후 일괄 동기화] 기준 {5 * 7 * 60}건(5곳 x 7일 x 60건) -> 100%/200%/500%, 배치 {BATCH}건, 작업자 {WORKERS}, 큐 {QMAX}, 중복 재전송 5%, 위조 1%, 오류 주입 37배치마다")
rows = []
for mult in (1, 2, 5):
r0, r1 = run(mult, False), run(mult, True); rows.append((mult, r0, r1))
print(f" {mult * 100:3d}% ({r1['n']}건): 최적화 전 {r0['dt']:.2f}초({r0['tps']:.0f}건/초, p95 {r0['p95']:.0f}ms) -> 후 {r1['dt']:.2f}초({r1['tps']:.0f}건/초, p95 {r1['p95']:.0f}ms), 큐 최대 {r1['qmax']}, 축소 배치 {r1['lite']}")
print("\n[500% 상세] 항목: 최적화 전 / 후")
a, b = rows[-1][1], rows[-1][2]
for k, lab in (("dups", "중복 거부"), ("quar", "위조 격리"), ("dead", "작업자 오류 재처리 배치"), ("rolled", "사후 검증 되돌림"), ("lost", "유실 건수"), ("diff", "재고 차이 합계"), ("crash", "미처리 예외(다운)")):
print(f" {lab:16s} {a[k]:6d} / {b[k]:6d}")
lost_all = sum(r[1]["lost"] + r[2]["lost"] for r in rows); diff_all = sum(r[1]["diff"] + r[2]["diff"] for r in rows); crash_all = sum(r[1]["crash"] + r[2]["crash"] for r in rows)
quar_ok = all(r[2]["quar"] == r[2]["bad"] for r in rows)
print(f"\n전체 6회 실행: 다운 {crash_all}건, 유실 {lost_all}건, 재고 차이 {diff_all}, 위조 전부 격리 {'예' if quar_ok else '아니오'} (목표 다운 0건·유실 0%: {'달성' if crash_all == 0 and lost_all == 0 and diff_all == 0 else '미달'})")
print(f"병목 최적화(500%): 중복 확인을 목록 탐색 -> 집합 조회로 교체해 {a['dt']:.2f}초 -> {b['dt']:.2f}초({a['dt'] / b['dt']:.1f}배), 처리량 {a['tps']:.0f} -> {b['tps']:.0f}건/초")
# ---- 단계별 병목 프로파일링(수동 타이머): 서명 점검 / 중복 확인(목록·집합) ----
tx5 = make_txns(7, 7, per_node_day=300)
for x in tx5:
x["sig"] = sig_of(x)
t0 = time.perf_counter(); [hmac.compare_digest(sig_of(x), x["sig"]) for x in tx5]; t_sig = time.perf_counter() - t0
ids = [x["id"] for x in tx5]; t0 = time.perf_counter(); lst = []
for i in ids:
_ = i in lst; lst.append(i)
t_lst = time.perf_counter() - t0; t0 = time.perf_counter(); st = set()
for i in ids:
_ = i in st; st.add(i)
t_set = time.perf_counter() - t0
print(f"\n[병목 프로파일] {len(ids)}건 기준: 서명 점검 {t_sig * 1000:.0f}ms, 중복 확인 목록 {t_lst * 1000:.0f}ms / 집합 {t_set * 1000:.1f}ms -> 병목은 중복 확인이었고 서명 점검이 다음 병목")
print("[기능 축소 정책] 큐가 %d건 이상 쌓이면 서명을 표본 10%%만 점검하고 나머지는 사후 전수 검증한다. 위조가 확인되면 되돌리고 격리한다. 데이터는 버리지 않는다." % HIGH_WATER)
# ---- 메모리 누수 점검: 같은 부하를 3회 반복해 남는 메모리 ----
tracemalloc.start(); mem = []
for _ in range(3):
run(1, True); mem.append(tracemalloc.get_traced_memory()[0] / 1e6)
tracemalloc.stop()
print(f"[메모리] 반복 실행 후 남은 메모리 {', '.join(f'{m:.1f}MB' for m in mem)} -> 증가 {mem[-1] - mem[0]:+.1f}MB ({'누수 징후 없음' if mem[-1] - mem[0] < 5 else '확인 필요'})")
print("※ 처리 시간은 실행 환경에 따라 달라진다. 실제 서버는 별도 작업 큐·영속 저장소·부하 시험 도구가 필요하다. 이 시험은 단일 프로세스 모의이다.")
plt.figure(figsize=(7, 3.6)); x = np.arange(3)
plt.bar(x - .2, [r[1]["dt"] for r in rows], .4, label="before (list scan)", color="tab:gray"); plt.bar(x + .2, [r[2]["dt"] for r in rows], .4, label="after (set + lite mode)", color="tab:blue")
plt.xticks(x, ["100%", "200%", "500%"]); plt.ylabel("seconds"); plt.title("Bulk sync time after 7-day outage"); plt.legend(); plt.tight_layout(); plt.show()
In [ ]:
"""동기화 사후 검토(AAR) 사례 1,200건에서 판정 오류를 원인(중복 판정 방식·시계 어긋남·충돌 탐지 규칙·자동 병합 임계·재시도 횟수)별로 분류하고 규칙 파라미터를 재튜닝해, 별도 시험 3,000건의 종합 정확도 향상폭·AAR 사례 재발 오류·회귀 테스트 통과를 계산하고 튜닝 결과를 YAML 문자열로 출력한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
KINDS = ["dup_resend", "distinct_similar", "meta_conflict", "meta_causal", "qty_diff", "send_fail", "dup_reinput"]
PK = [.20, .12, .18, .15, .15, .15, .05]
CAUSE = {"dup_resend": "D2 재전송 중복 놓침", "distinct_similar": "D1 다른 거래를 중복으로 판정", "meta_conflict": "D4 동시 수정 놓침(조용한 덮어쓰기)", "meta_causal": "D3 후속 수정의 승자 오판·불필요 경보",
"qty_diff": "D5 자동 병합 임계 부적절", "send_fail": "D6 재시도 부족 누락", "dup_reinput": "D2b 재입력(새 ID) 중복 놓침"}
def make_events(seed, n):
"""[교육용가상자료] 동기화 사례 생성. 단말 시계 오차(+-2시간), 버전 벡터 없는 구형 기록 8%, 통신불량 상황의 전송 실패를 포함한다."""
rng = np.random.default_rng(seed); out = []
for i in range(n):
k = str(rng.choice(KINDS, p=PK)); o = rng.uniform(-2, 2, 2); ev = {"id": i, "kind": k, "vv": rng.random() > 0.08}
if k == "dup_resend":
ev.update(same_id=True, dt=0.0 if rng.random() < .5 else float(rng.uniform(0.5, 6)))
elif k == "distinct_similar":
ev.update(same_id=False, dt=float(rng.uniform(0, 0.5)))
elif k == "dup_reinput": # 오입력 정정 등으로 같은 내용을 새 ID로 다시 입력
ev.update(same_id=False, dt=float(rng.uniform(0.1, 6)))
elif k in ("meta_conflict", "meta_causal"):
gap = float(rng.uniform(0.5, 60)) if k == "meta_conflict" else float(rng.uniform(0.01, 1.5))
ev.update(ta=100 + o[0], tb=100 + gap + o[1]) # 기록된 시각 = 실제 시각 + 단말 시계 오차
elif k == "qty_diff":
ev.update(d=float(rng.uniform(0.0, 0.8)))
else:
ev.update(p=0.5 if rng.random() < .6 else 0.8, u=rng.random(8))
out.append(ev)
return out
def decide(ev, P):
# [TODO : 학생 실습 9] AAR 기반 동기화 누락 및 데이터 충돌 사례 분석 및 동기화 룰셋 재튜닝 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def truth(ev):
# [TODO : 학생 실습 9] AAR 기반 동기화 누락 및 데이터 충돌 사례 분석 및 동기화 룰셋 재튜닝 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def evaluate(evs, P):
ok = [decide(e, P) == truth(e) for e in evs]
return np.mean(ok), pd.Series([CAUSE[e["kind"]] for e, o in zip(evs, ok) if not o]).value_counts(), ok
P0 = {"dup": "content", "win": 2.0, "conflict": "time", "tw": 4.0, "thr": 0.3, "retries": 2} # 초기 운용 규칙(M3 단계 설정)
aar, test = make_events(31, 1200), make_events(77, 3000)
a0, causes, ok0 = evaluate(aar, P0)
print(f"[AAR 사례] {len(aar)}건, 현행 규칙 정확도 {a0:.1%}, 오류 {int(len(aar) * (1 - a0))}건")
print("[원인 분류] 원인 / 건수 / 비율")
for c, n in causes.items():
print(f" {c:28s} {n:4d}건 {n / (len(aar) * (1 - a0)):5.1%}")
# ---- 재튜닝: 사례 종류가 서로 다르므로 파라미터마다 AAR 사례에서 따로 탐색(재시도는 횟수당 지연 비용 0.2%p 가산) ----
def best(param, grid, kinds, cost=lambda v: 0.0):
sub = [e for e in aar if e["kind"] in kinds]; sc = []
for v in grid:
P = {**P0, **(v if isinstance(v, dict) else {param: v})}; sc.append((np.mean([decide(e, P) == truth(e) for e in sub]) - cost(v), v))
return max(sc, key=lambda x: x[0]), sc
(_, dup_best), _ = best("dup", [{"dup": "content", "win": w} for w in (0.0, 0.25, 0.5, 1, 2, 4)] + [{"dup": "id+content", "win": w} for w in (0.25, 0.5, 1, 2, 4)] + [{"dup": "id"}], ("dup_resend", "distinct_similar", "dup_reinput"))
(_, cf_best), _ = best("conflict", [{"conflict": "time", "tw": w} for w in (0.25, 0.5, 1, 2, 4)] + [{"conflict": "vv", "tw": w} for w in (0.5, 1, 2, 4, 8)], ("meta_conflict", "meta_causal"))
(_, thr_best), _ = best("thr", [0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5], ("qty_diff",))
(_, rt_best), _ = best("retries", [1, 2, 3, 4, 5, 6], ("send_fail",), cost=lambda v: 0.002 * v)
P1 = {**P0, **dup_best, **cf_best, "thr": thr_best, "retries": rt_best}
a_aar, rec, ok1 = evaluate(aar, P1)
a_old_t, c_old_t, _ = evaluate(test, P0)
a_new_t, c_new_t, _ = evaluate(test, P1)
rec_n = int(sum((not o0) and (not o1) for o0, o1 in zip(ok0, ok1)))
print(f"\n[재튜닝 결과] 중복 판정 {P0['dup']}(창 {P0['win']}h) -> {P1['dup']}{'' if P1['dup'] == 'id' else '(창 ' + str(P1['win']) + 'h)'}, 충돌 탐지 {P0['conflict']}(창 {P0['tw']}h) -> {P1['conflict']}(구형 기록은 시간창 {P1['tw']}h), 자동 병합 임계 {P0['thr']} -> {P1['thr']}, 재시도 {P0['retries']} -> {P1['retries']}회")
print(f"AAR 사례 재실행: 정확도 {a0:.1%} -> {a_aar:.1%}, 같은 사례에서 오류 재발 {rec_n}건 (목표 0건: {'달성' if rec_n == 0 else '미달'})")
print(f"별도 시험 3,000건: {a_old_t:.1%} -> {a_new_t:.1%} ({(a_new_t - a_old_t) * 100:+.1f}%p, 목표 +10%p 이상: {'달성' if a_new_t - a_old_t >= .10 else '미달'})")
print("시험 3,000건 남은 오류:", {c: int(n) for c, n in c_new_t.items()} or "없음")
tt = pd.DataFrame({"before": c_old_t, "after": c_new_t}).fillna(0).astype(int)
# ---- 회귀 테스트: 규칙 단위 시험 8개를 현행/재튜닝 규칙에 각각 실행 ----
T = [("같은 ID 재전송(시각 3h 차이)은 건너뜀", {"kind": "dup_resend", "same_id": True, "dt": 3.0, "vv": True}, "skip"), ("같은 ID 재전송(시각 동일)은 건너뜀", {"kind": "dup_resend", "same_id": True, "dt": 0.0, "vv": True}, "skip"),
("다른 ID의 같은 수량 거래는 반영", {"kind": "distinct_similar", "same_id": False, "dt": 0.2, "vv": True}, "apply"), ("버전 벡터 동시 수정은 군수관 검토", {"kind": "meta_conflict", "ta": 100, "tb": 130, "vv": True}, "hitl"),
("버전 벡터 선후 관계는 후속 값 채택(시계 역전)", {"kind": "meta_causal", "ta": 101.0, "tb": 100.5, "vv": True}, "pick_b"), ("차이 10%는 자동 병합", {"kind": "qty_diff", "d": 0.10, "vv": True}, "auto"),
("차이 30%는 군수관 검토", {"kind": "qty_diff", "d": 0.30, "vv": True}, "hitl"), ("통신불량 전송 3회 안에 성공", {"kind": "send_fail", "p": 0.5, "u": np.array([.9, .8, .1, 0, 0, 0, 0, 0]), "vv": True}, "applied")]
print("\n[회귀 테스트 8개] 현행 / 재튜닝")
p0 = [decide(e, P0) == t for _, e, t in T]; p1 = [decide(e, P1) == t for _, e, t in T]
for (nm, _, _), x, y in zip(T, p0, p1):
print(f" {nm:34s} {'통과' if x else '실패'} / {'통과' if y else '실패'}")
regress = sum(a and not b for a, b in zip(p0, p1))
print(f"통과 {sum(p0)}/8 -> {sum(p1)}/8, 기존 통과 항목이 깨진 회귀 {regress}건 ({'전체 통과' if all(p1) else '실패 있음'})")
# ---- 산출물: 튜닝 파라미터 YAML 문자열(configs/dat10_tuned_params.yaml 내용) ----
yml = "\n".join(["sync_rules:", f" dedup_key: {P1['dup']}", f" dedup_window_h: {P1['win'] if P1['dup'] != 'id' else 0}", f" conflict_detect: {P1['conflict']}", f" legacy_time_window_h: {P1['tw']}", f" auto_merge_diff_max: {P1['thr']}", f" send_retries: {P1['retries']}", " hitl_required_when: [conflict, diff_ge_0.25]"])
print("\n[configs/dat10_tuned_params.yaml]"); print(yml)
print("[지속적 학습 연계] 이번 AAR 오류 유형을 사례 은행에 누적 -> 월 1회 새 사례로 파라미터 재탐색 -> 회귀 테스트 8개 통과 시에만 후보 반영 -> 군수관 승인 후 배포(자동 배포 금지)")
print("※ 사례는 가상 생성이며 시험 분포가 튜닝 분포와 같아 향상폭이 실제보다 낙관적이다. 규칙 변경은 군수관 승인과 회귀 테스트를 거친다.")
fig, ax = plt.subplots(1, 2, figsize=(10, 3.8))
ax[0].bar(["before", "after"], [a_old_t * 100, a_new_t * 100], color=["tab:gray", "tab:blue"]); ax[0].set_ylim(50, 100); ax[0].set_ylabel("accuracy (%)"); ax[0].set_title("Test accuracy (3,000 cases)")
x = np.arange(len(tt)); ax[1].bar(x - .2, tt["before"], .4, label="before", color="tab:gray"); ax[1].bar(x + .2, tt["after"], .4, label="after", color="tab:blue")
ax[1].set_xticks(x); ax[1].set_xticklabels([c.split()[0] for c in tt.index]); ax[1].set_title("Errors by cause"); ax[1].legend(); plt.tight_layout(); plt.show()
In [ ]:
"""오프라인 입력 검증·서명 점검·중복 제거, PN-카운터 병합, 버전 벡터 충돌 탐지, 공급망 재난 징후(업체 공급 감소·재고 소진 임박) 마이닝, 사람 검토 큐(PENDING)와 해시 체인 감사 로그, 설치 스크립트·매뉴얼·브리핑까지 오프라인 동기화 야전 재고 관리 SaaS 전체를 한 파일로 통합 실행하고 E2E 성공률·자체 점검·함수 커버리지를 계산한다."""
import hashlib
import hmac
import json
import sys
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 보급소·업체·품목 자료 생성기 (실제 부대·업체·품목·재고 정보 없음)
# 가상 보급소 5곳이 품목 12종의 입고·불출·조정 트랜잭션을 오프라인으로 기록하고 나중에 동기화한다.
# 공급망 재난 징후(업체 납기 지연, 품목 재고 급감)는 동기화된 자료를 마이닝해 식별한다.
# ---------------------------------------------------------------------------
NODES = ["보급소A", "보급소B", "보급소C", "보급소D", "보급소E"]
SUPPLIERS = ["가상업체1", "가상업체2", "가상업체3", "가상업체4", "가상업체5", "가상업체6"]
CATS = ["일반보급", "정비부품", "의무소모"]
ITEMS = [{"item": f"I{i + 1:02d}", "cat": CATS[i % 3], "supplier": SUPPLIERS[i % 6], "lead_days": 3 + (i * 5) % 12, "use": 4 + (i * 7) % 11, "min_level": 30 + 10 * (i % 5)} for i in range(12)]
ITEM_IDS = [x["item"] for x in ITEMS]
CTXS = ["평시", "야간", "통신불량"]
def make_txns(seed=7, days=7, per_node_day=60, nodes=None):
"""노드별 오프라인 트랜잭션 목록. id는 노드-일련번호(멱등 키), t는 시각(시간 단위), ctx는 입력 상황."""
rng = np.random.default_rng(seed)
out = []
for n in (nodes or NODES):
seq = 0
for k in range(days * per_node_day):
seq += 1
it = ITEMS[int(rng.integers(len(ITEMS)))]
t = float(rng.uniform(0, 24 * days))
ctx = "야간" if (t % 24 >= 20 or t % 24 < 5) else "평시"
if rng.random() < 0.12:
ctx = "통신불량"
r = rng.random()
if r < 0.22:
op, q = "in", int(it["use"] * rng.uniform(4, 9))
elif r < 0.95:
op, q = "out", int(max(1, rng.normal(it["use"] * 1.5, 3)))
else:
op, q = "adjust", int(rng.integers(-5, 6))
out.append({"id": f"{n}-{seq:05d}", "node": n, "item": it["item"], "op": op, "qty": q, "t": round(t, 2), "ctx": ctx, "supplier": it["supplier"]})
return out
def stock_of(txns, base=500):
"""품목별 재고 = 기초 재고 + 입고 - 불출 + 조정 (id 중복은 한 번만 반영)."""
# [TODO : 학생 실습 10] 오프라인 동기화 야전 재고 관리 SaaS 최종 패키징 및 야전 배포 가이드 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
KEYS = {n: hashlib.sha256(f"demo-key-{n}".encode()).digest() for n in NODES} # [교육용가상자료] 데모 키
CONF = {"auto_merge_diff_max": 0.25, "supply_ratio_cut": 0.3, "supplier_flag_frac": 0.5, "queue_min_risk": 40}
MANUAL = {
"1. 목적과 범위": "통신이 끊긴 보급소 5곳의 재고 입출고를 오프라인으로 기록하고 복구 후 서버와 병합하며, 병합된 자료에서 공급 감소·재고 소진 임박 징후를 찾아 군수관에게 보여 주는 판단 보조 도구다.",
"2. 설치와 실행": "install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.",
"3. 입력 데이터": "보급소별 트랜잭션 줄(ID|품목|구분|수량|시각|상황). 범위·형식·서명 오류와 중복은 격리하거나 거부하고 사유를 남긴다.",
"4. 동기화와 충돌": "재고 수량은 PN-카운터로 자동 병합한다. 품목 정보 동시 수정은 버전 벡터로 탐지해 군수관 검토 큐에 올린다.",
"5. 공급망 재난 징후": "업체별 최근 3일 입고량이 첫 3일 대비 급감하거나 소진 예상 일수가 납기보다 짧은 품목을 묶어 징후 후보로 표시한다.",
"6. 사람 검토와 감사 로그": "모든 후보는 PENDING으로 시작하고 군수관·지휘관이 처리한다. 모든 행위는 해시 체인에 남는다.",
"7. 한계와 확장": "가상 자료 기반이며 실제 통신·저장 장치·키 관리는 별도 체계가 필요하다. 인접 체계 연동 API는 차기 과제다.",
}
INSTALL_SH = """#!/bin/sh
# [교육용가상자료] dat10 설치 스크립트 예시
set -e
python3 -c "import sys; assert sys.version_info >= (3, 9)" # 1) 파이썬 버전 점검
python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경
pip install -r requirements.txt # 3) 의존 패키지(numpy pandas matplotlib)
python3 -m pytest tests -q # 4) 자체 시험
python3 src/main_dat10.py --demo # 5) 데모 실행(가상 자료)
"""
def sign(x):
# [TODO : 학생 실습 10] 오프라인 동기화 야전 재고 관리 SaaS 최종 패키징 및 야전 배포 가이드 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def to_line(x):
return f"{x['id']}|{x['item']}|{x['op']}|{x['qty']}|{x['t']}|{x['ctx']}|{x['sig']}"
def parse_line(s):
"""입력 줄 -> 레코드. 형식이 틀리면 ValueError."""
p = s.split("|")
if len(p) != 7:
raise ValueError("필드 수 오류")
return {"id": p[0], "node": p[0].split("-")[0], "item": p[1], "op": p[2], "qty": int(p[3]), "t": float(p[4]), "ctx": p[5], "sig": p[6]}
def validate(x):
"""범위·논리 오류 사유 문자열 또는 None."""
if x["node"] not in NODES:
return "미등록 보급소"
if x["item"] not in ITEM_IDS or x["op"] not in ("in", "out", "adjust") or x["ctx"] not in CTXS:
return "코드 오류"
if not (0 <= x["t"] <= 24 * 7 + 1) or not (-50 <= x["qty"] <= 2000) or (x["op"] != "adjust" and x["qty"] <= 0):
return "수량·시각 범위 오류"
return None
class Chain:
def __init__(self):
self.log = []
def add(self, **body):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {"seq": len(self.log), **body}
self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})
def verify(self):
prev = "GENESIS"
for e in self.log:
b = {k: v for k, v in e.items() if k not in ("prev", "hash")}
if e["prev"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]:
return e["seq"]
prev = e["hash"]
return None
class Store:
"""서버 측 저장소(메모리): PN-카운터, 중복 제거 집합, 격리 목록."""
def __init__(self):
self.P = {i: {} for i in ITEM_IDS}; self.N = {i: {} for i in ITEM_IDS}; self.ids = set(); self.tx = []; self.quar = []; self.dups = 0
def stock(self, base=500):
return {i: base + sum(self.P[i].values()) - sum(self.N[i].values()) for i in ITEM_IDS}
def ingest(st, lines):
"""줄 묶음을 파싱·검증·서명 점검·중복 제거 후 반영한다. 결과 건수를 돌려준다."""
r = {"ok": 0, "dup": 0, "bad": 0}
for s in lines:
try:
x = parse_line(s)
except ValueError:
st.quar.append(("파싱 오류", s[:20])); r["bad"] += 1; continue
why = validate(x)
if why is None and not hmac.compare_digest(sign(x), x["sig"]):
why = "서명 불일치"
if why:
st.quar.append((why, x["id"])); r["bad"] += 1; continue
if x["id"] in st.ids:
st.dups += 1; r["dup"] += 1; continue
st.ids.add(x["id"]); st.tx.append(x)
d = st.P if x["op"] == "in" or (x["op"] == "adjust" and x["qty"] > 0) else st.N
d[x["item"]][x["node"]] = d[x["item"]].get(x["node"], 0) + abs(x["qty"]); r["ok"] += 1
return r
def dominated(a, b):
return all(a.get(k, 0) <= b.get(k, 0) for k in set(a) | set(b)) and a != b
def detect_conflict(A, B):
"""버전 벡터 + 값 비교: 같은 필드를 서로 모르는 채 다른 값으로 고쳤을 때만 충돌."""
return A["field"] == B["field"] and A["value"] != B["value"] and not dominated(A["vv"], B["vv"]) and not dominated(B["vv"], A["vv"])
def make_conflicts(rng, n=10):
out = []
for k in range(n):
it = ITEMS[int(rng.integers(12))]; va = it["min_level"]; vb = int(va * rng.choice([1.1, 1.5, 0.6]))
conc = k % 2 == 0
A = {"item": it["item"], "field": "min_level", "value": va, "vv": {"A": 3, "B": 1}}
B = {"item": it["item"], "field": "min_level", "value": vb, "vv": {"A": 2, "B": 4} if conc else {"A": 3, "B": 2}}
out.append((conc, A, B))
return out
def disruption_scan(tx, t_end=168.0, win=72.0):
"""업체별 공급 감소(최근 3일 입고량 / 첫 3일 입고량)와 소진 예상 일수를 보고 공급망 재난 징후 후보 업체를 찾는다."""
df = pd.DataFrame(tx)
ins = df[df["op"] == "in"]; outs = df[df["op"] == "out"]
rows = []
for it in ITEMS:
a = ins[(ins["item"] == it["item"]) & (ins["t"] < win)]["qty"].sum(); b = ins[(ins["item"] == it["item"]) & (ins["t"] >= t_end - win)]["qty"].sum()
o = outs[(outs["item"] == it["item"]) & (outs["t"] >= t_end - win)]["qty"].sum() / (win / 24)
rows.append({"supplier": it["supplier"], "item": it["item"], "ratio": b / max(a, 1), "out_day": o, "lead": it["lead_days"]})
r = pd.DataFrame(rows); r["drop"] = r["ratio"] < CONF["supply_ratio_cut"]
g = r.groupby("supplier").agg(frac=("drop", "mean"), ratio=("ratio", "min"))
return g[g["frac"] >= CONF["supplier_flag_frac"]].reset_index().to_dict("records"), r
def dashboard_view(st, pending, t):
off = sum(1 for n in NODES if pending.get(n, 0) > 0)
stk = st.stock()
return {"nodes_unsynced": off, "pending_tx": sum(pending.values()), "low_stock": sum(v < 100 for v in stk.values()), "alerts": len(st.quar)}
def run_pipeline(seed, disrupt=None, offline_node=None, noise=True):
"""E2E: 생성 -> (공급 중단·오프라인 주입) -> 줄 변환 -> 반영 -> 충돌 탐지 -> 징후 마이닝 -> 검토 큐 + 감사 로그."""
rng = np.random.default_rng(seed); tx = make_txns(seed, 7, per_node_day=60)
if disrupt:
tx = [x for x in tx if not (x["supplier"] == disrupt and x["op"] == "in" and x["t"] >= 72)]
for x in tx:
x["sig"] = sign(x)
lines, pending = [], {}
for x in tx:
if offline_node and x["node"] == offline_node and x["t"] >= 96:
pending[x["node"]] = pending.get(x["node"], 0) + 1; continue # 통신 단절 노드의 마지막 3일분은 아직 미동기
lines.append(to_line(x))
n_bad = 0
if noise:
idx = rng.permutation(len(lines)); n_bad = 25
for i in idx[:20]:
p = lines[i].split("|"); p[3] = str(int(p[3]) + 9); lines[i] = "|".join(p) # 서명 후 수량 변조
for i in idx[20:25]:
lines[i] = lines[i].rsplit("|", 2)[0] # 줄 깨짐
lines += [lines[i] for i in idx[25:85]] # 정상 줄 60건 중복 재전송
st = Store(); audit = Chain(); res = {"ok": 0, "dup": 0, "bad": 0}
for k in range(0, len(lines), 100):
r = ingest(st, lines[k:k + 100]); res = {a: res[a] + r[a] for a in res}
audit.add(event="SYNC", ok=res["ok"], dup=res["dup"], bad=res["bad"])
conf = make_conflicts(rng); hit = [detect_conflict(A, B) for _, A, B in conf]; ctp = sum(h and c for h, (c, _, _) in zip(hit, conf)); cfp = sum(h and not c for h, (c, _, _) in zip(hit, conf))
flagged, items = disruption_scan(st.tx)
queue = []
for (c, A, B), h in zip(conf, hit):
if h:
queue.append({"kind": "충돌", "ref": A["item"], "risk": 60, "status": "PENDING"}); audit.add(event="QUEUE", kind="충돌", item=A["item"], status="PENDING")
for f in flagged:
queue.append({"kind": "공급 징후", "ref": f["supplier"], "risk": int(100 * (1 - min(f["ratio"], 1))), "status": "PENDING"}); audit.add(event="QUEUE", kind="공급 징후", supplier=f["supplier"], status="PENDING")
return {"st": st, "res": res, "n_bad": n_bad, "queue": queue, "audit": audit, "flagged": [f["supplier"] for f in flagged], "ctp": ctp, "cfp": cfp, "nconf": sum(c for c, _, _ in conf),
"pending": pending, "valid": [x for x in tx if x["id"] in st.ids], "view": dashboard_view(st, pending, 168)}
def self_tests():
out = {}
r = run_pipeline(7)
out["정상 자료 통합 실행(오류 주입 포함)"] = r["res"]["ok"] > 2000 and len(r["st"].quar) == r["n_bad"]
truth = stock_of([x for x in make_txns(7, 7, 60) if x["id"] in r["st"].ids])
out["서버 재고 = 반영된 레코드 합산"] = r["st"].stock() == truth
st = Store(); lines = [to_line({**x, "sig": sign(x)}) for x in make_txns(3, 2, 20)]; a = ingest(st, lines); b = ingest(st, lines)
out["같은 묶음 재전송은 상태를 바꾸지 않음(멱등)"] = a["ok"] == len(lines) and b["ok"] == 0 and b["dup"] == len(lines)
out["파싱·범위 오류 줄 격리"] = ingest(Store(), ["깨진줄", "보급소A-00001|I99|in|5|1.0|평시|0"])["bad"] == 2
out["충돌 탐지(동시 수정만 충돌)"] = r["ctp"] == r["nconf"] and r["cfp"] == 0
r2 = run_pipeline(17, disrupt="가상업체3", offline_node="보급소B")
out["공급 중단 업체 탐지 + 오프라인 노드로 오경보 없음"] = r2["flagged"] == ["가상업체3"]
out["정상 자료에서 공급 징후 오경보 0"] = r["flagged"] == []
out["모든 검토 건이 PENDING(자동 확정 없음)"] = all(q["status"] == "PENDING" for q in r2["queue"]) and len(r2["queue"]) > 0
r2["audit"].log[1]["ok"] = 1
out["감사 로그 변조 탐지"] = r2["audit"].verify() == 1
out["대시보드 요약 4개 지표"] = set(r2["view"]) == {"nodes_unsynced", "pending_tx", "low_stock", "alerts"} and r2["view"]["pending_tx"] > 0
out["매뉴얼 7개 장·설치 스크립트 5단계"] = all(len(v) > 20 for v in MANUAL.values()) and all(k in INSTALL_SH for k in ("requirements", "pytest", "--demo", "venv", "version_info"))
return out
def briefing(r):
q = r["queue"]; v = r["view"]
return [f"1. 동기화: 반영 {r['res']['ok']}건, 중복 거부 {r['res']['dup']}건, 격리 {len(r['st'].quar)}건. 미동기 {v['pending_tx']}건({v['nodes_unsynced']}개 보급소).",
f"2. 검토 큐 {len(q)}건(충돌 {sum(x['kind'] == '충돌' for x in q)}, 공급 징후 {sum(x['kind'] == '공급 징후' for x in q)}): 모두 PENDING.",
f"3. 공급망 재난 징후 후보 업체: {', '.join(r['flagged']) or '없음'} (최근 3일 입고량이 첫 3일의 30% 미만).",
"4. 병합 확정과 보급 조치는 군수관·지휘관이 한다. 본 도구는 가상 자료 기반 판단 보조이다."]
# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----
called = set()
own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, "__module__", "") == "__main__" and hasattr(f, "__code__")}
sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == "call" else None)
t_all = time.perf_counter()
tests = self_tests()
e2e = []
for s, dis, off in ((7, "가상업체1", "보급소A"), (17, "가상업체3", "보급소B"), (27, "가상업체5", None), (37, "가상업체6", "보급소E")):
try:
r = run_pipeline(s, disrupt=dis, offline_node=off); truth = stock_of(r["valid"])
e2e.append((s, True, dis, r["flagged"], r["res"], len(r["st"].quar), r["n_bad"], r["st"].stock() == truth, r["audit"].verify() is None, len(r["queue"])))
except Exception as e:
e2e.append((s, False, dis, [], {}, 0, 0, False, False, 0))
brief = briefing(run_pipeline(7, disrupt="가상업체1", offline_node="보급소A"))
sys.setprofile(None)
cov = len(own & called) / len(own)
print(f"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초")
print("[설치 스크립트(원클릭) 5단계 점검]")
step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in ("numpy", "pandas")), all(tests.values()), all(x[1] for x in e2e)]
for name, ok in zip(["파이썬 버전", "가상환경", "의존 패키지", "자체 시험", "데모 실행"], step_ok):
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
print(f"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (원클릭 설치부터 실행까지: {'달성' if all(step_ok) else '미달'})")
print("\n[End-to-End 4종: 시드 4개, 업체 1곳 공급 중단, 노드 1곳 3일치 미동기, 변조 20·깨진 줄 5·중복 60건 주입]")
for s, ok, dis, fl, rs, nq, nb, st_ok, chain, nqueue in e2e:
print(f" seed {s}: {'성공' if ok else '실패'}, 반영 {rs.get('ok')}건·중복 {rs.get('dup')}건·격리 {nq}/{nb}건, 재고 일치 {'예' if st_ok else '아니오'}, 징후 {fl} (주입 {dis}), 검토 큐 {nqueue}건, 감사 로그 {'정상' if chain else '이상'}")
hit = sum(x[2] in x[3] for x in e2e); fa = sum(len([f for f in x[3] if f != x[2]]) for x in e2e)
print(f"E2E 성공률 {np.mean([x[1] for x in e2e]):.0%} (목표 100%), 공급 중단 업체 탐지 {hit}/{len(e2e)}, 다른 업체 오경보 {fa}건")
print("[자체 점검]")
for k, v in tests.items():
print(f" [{'PASS' if v else 'FAIL'}] {k}")
print(f"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'}, 미호출 {sorted(own - called) or '없음'})")
done = sum(len(v) > 20 for v in MANUAL.values())
print(f"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%}: " + ", ".join(MANUAL))
print("\n[군수관 브리핑 요약]")
for b in brief:
print(b)
print("※ 가상 자료·메모리 모의이며 실제 통신·저장 장치·키 관리·인접 체계 연동은 포함하지 않는다. 함수 커버리지는 최상위 함수 호출 기준이다.")
r = run_pipeline(17, disrupt="가상업체3", offline_node="보급소B"); d, tab = disruption_scan(r["st"].tx)
plt.figure(figsize=(7, 3.5)); g = tab.groupby("supplier")["ratio"].min()
plt.bar([s[-1] for s in g.index], g.values, color=["tab:red" if v < CONF["supply_ratio_cut"] else "tab:blue" for v in g.values]); plt.axhline(CONF["supply_ratio_cut"], color="k", ls="--")
plt.xlabel("supplier"); plt.ylabel("recent / early inbound ratio (min)"); plt.title("Supply disruption scan (seed 17)"); plt.tight_layout(); plt.show()