ํต์ฌ ์์ฝ
์ฌ๋ด DB ๋ชจ๋ํฐ๋ง ์์คํ ์ ๋ง๋ค๋ฉด์, ๋งค์ผ ์์นจ ์ฌ๋์ด ๋์ผ๋ก ํ๋ DB ์ ๊ฒ์ ์๋ํํ์ต๋๋ค. ํต์ฌ์ ๋ ๊ฐ์ง ์ด์ ํ์ง๋ฅผ ํ ํ๋ฉด์ ๋ น์ฌ ๋ฃ๋ ์ผ์ด์์ต๋๋ค.
- ๋ฃฐ๋ฒ ์ด์ค 3์ข — ์ ๋ ์๊ณ ์ด๊ณผ(๊ทธ๋ ์ต๊ณ ๊ฐ์ด ๊ฒฝ๊ณ /์ํ ์ ์ ๋๋๊ฐ), ์ ์ผ ๋๋น ๊ธ์ฆ(์ผํ๊ท ์ด 2๋ฐฐ ์ด์ ๋ฐ์๋๊ฐ), ํจํด ๋ณํ(% ์งํ๊ฐ ์ ์ผ ๋๋น 20%p ์ด์ ์ฌ๋๋๊ฐ)๋ฅผ ๋ถ ๋จ์๋ก ๊ฒ์ฌํฉ๋๋ค. DB๋ง๋ค ์๊ณ๊ฐ์ด ๋ฌ๋ผ ์ค์ ํ์ผ๋ก ๋ถ๋ฆฌํ์ต๋๋ค.
- ๋น์ง๋ ํ์ต(PCA ๋ณต์์ค์ฐจ) — ํ ๋ฌ์น ์ ์ ํจํด์ ์ฃผ์ฑ๋ถ์ผ๋ก ์์ถํ๋ค๊ฐ ๋ณต์ํด, ํ์์ ๋ค๋ฅธ ์กฐํฉ์ด ๋ค์ด์ค๋ฉด ๋ณต์์ด ์ ์ ๋๋ ์ฑ์ง(๋ณต์์ค์ฐจ ์ฆ๊ฐ)๋ก "์๊ณ๋ ์ ๋์์ง๋ง ํ์์ ๋ค๋ฅธ ๋ "์ ์ก์ต๋๋ค. ๋จ์ผ ์งํ๊ฐ ์๋๋ผ ์ฌ๋ฌ ์งํ๊ฐ ๋์์ ํ์ ์กฐํฉ์์ ๋ฒ์ด๋๋ ์กฐํฉ ์ด์์ ์ก๋ ๊ฒ ๋ชฉ์ ์ ๋๋ค.
- ์ง๋จ ๋ฆฌํฌํธ — ๊ฐ์ง๋ ์ด์์ ์นดํ ๊ณ ๋ฆฌ๋ก ๋ถ๋ฅํ๊ณ ๋์ ๋ฐ์ ํจํด์ ์๊ด ๊ท์น์ ๋งค์นญํด โ ๊ฐ์ง๋ ์ด์ โก ์์ธ ์ถ์ โข ๊ถ์ฅ ์กฐ์น โฃ ์ข ํฉ์ผ๋ก ์นด๋๋ฅผ ๋ง๋ญ๋๋ค. ๋ฃฐ๋ฒ ์ด์ค ๊ฒฐ๊ณผ๋ ๊ธฐ๋ณธ์, ML(๋น์ง๋) ๊ฒฐ๊ณผ๋ ๋ณด๋ผ์์ผ๋ก ๊ฐ์ ์นด๋ ์์ ํตํฉํฉ๋๋ค.
์ด ๊ธ์ ๊ทธ ํ์ง ๋ก์ง๊ณผ ๋ฆฌํฌํธ ๊ตฌ์ฑ์ด ์ค์ ๋ก ์ด๋ป๊ฒ ์ง์๋์ง๋ฅผ ์ฝ๋์ ํจ๊ป ์์ธํ ์ ๋ฆฌํ ๊ธฐ๋ก์ ๋๋ค. ์ค๊ฐ์ ๋ถ๋ชํ ๋ฌธ์ (๋ํ ์งํ ์ ์ , ์นดํ ๊ณ ๋ฆฌ ๋์ ๋น๊ฒฐ์ ๋ฒ๊ทธ, ๊ทธ๋ํ ๊ฐ๋ ์ฑ ๋ฑ)์ ๊ทธ๊ฑธ ์ด๋ป๊ฒ ํ์๋์ง๋ ํจ๊ป ์ ์์ต๋๋ค.
๋ฐฐ๊ฒฝ — ๋จ์ผ ์๊ณ๊ฐ์ ํ๊ณ
์ฒ์ ๋ ์ฌ๋ฆฌ๋ ์๋ํ๋ ๋จ์ํฉ๋๋ค. ์งํ๋ง๋ค "์ด ๊ฐ์ ๋์ผ๋ฉด ๊ฒฝ๊ณ "๋ผ๋ ์ ์ ๊ธ๊ณ , ๋์ผ๋ฉด ์๋ฆฐ๋ค. CPU ์ฌ์ฉ๋ฅ 80% ๋์ผ๋ฉด ๊ฒฝ๊ณ , 90% ๋์ผ๋ฉด ์ํ. ์ด๋ฐ ์ ๋ ์๊ณ๋ ์ง๊ด์ ์ด๊ณ ์ค๋ช ํ๊ธฐ ์ฝ์ต๋๋ค.
๊ทธ๋ฐ๋ฐ ์ค์ ๋ฐ์ดํฐ๋ฅผ ๋ค์ฌ๋ค๋ณด๋ฉด ์๊ณ๋ง์ผ๋ก๋ ๋ชป ์ก๋ ์ํฉ์ด ๋ง์ต๋๋ค. ๋ ๊ฐ์ง ํ๊ณ๊ฐ ์์์ต๋๋ค.
์ฒซ์งธ, ์๊ณ๋ ์ ๋์ง๋ง ํ์์ ๋ถ๋ช ํ ๋ค๋ฅธ ๋ . ์๋ฅผ ๋ค์ด CPU ์ฌ์ฉ๋ฅ ํ์ 15%๋์ธ DB๊ฐ ์ด๋ ๋ 45%๊น์ง ์ฌ๋ผ๋ 80% ์ ์๋ ํ์ฐธ ๋ชป ๋ฏธ์นฉ๋๋ค. ์๊ณ ๊ธฐ์ค์ผ๋ก๋ "์ ์"์ ๋๋ค. ํ์ง๋ง ํ์์ 3๋ฐฐ์ ๋๋ค. ๋ฌด์ธ๊ฐ ์ผ์ด๋ ๊ฒ๋๋ค. ๊ทธ๋์ ์ ๋ ์๊ณ๋ง์ด ์๋๋ผ ์ ์ผ ๋๋น ๊ธ์ฆ๋ ๋ด์ผ ํฉ๋๋ค.
๋์งธ, ๋จ์ผ ์งํ๋ ๋ฉ์ฉกํ๋ฐ ์ฌ๋ฌ ์งํ์ ์กฐํฉ์ด ์ด์ํ ๋ . ์ด๊ฒ ๋ ๊น๋ค๋กญ์ต๋๋ค. CPU๋ ์๊ณ ์๋, ์ธ์ ์๋ ์๊ณ ์๋, ๋์คํฌ ์ฝ๊ธฐ๋ ์๊ณ ์๋์ธ๋ฐ, ์ด ์ ์ด ๊ฐ์ ์๊ฐ์ ๋์์ ํ์๋ณด๋ค ๋ค๋ ์๋ค๋ฉด? ๊ฐ๊ฐ์ ์ ์ ๋ฒ์๋ผ ์ด๋ค ๋จ์ผ ์๊ณ ๊ท์น๋ ๋ฐ๋ํ์ง ์์ต๋๋ค. ํ์ง๋ง "CPU·์ธ์ ·๋์คํฌ๊ฐ ํ๊บผ๋ฒ์ ํ์์ ๋ค๋ฅธ ์กฐํฉ"์ ๋ถ๋ช ๋น์ ์์ ๋๋ค.
์ผ์์ ๋น์ ํ๋ฉด ์ด๋ ์ต๋๋ค. ์ฒด์จ 37๋, ๋งฅ๋ฐ 90, ํ์ 130. ํ๋์ฉ ๋ณด๋ฉด ๋ค ์ ์ ๋ฒ์์ ๋๋ค. ๊ทธ๋ฐ๋ฐ ํ์ ์ด ์ฌ๋์ ์ฒด์จ 36.3๋, ๋งฅ๋ฐ 65, ํ์ 110์ด์์ต๋๋ค. ์ธ ์์น๊ฐ ๋์์ ํ์๋ณด๋ค ์ฌ๋ผ ์๋ค๋ฉด, ๊ฐ ํญ๋ชฉ์ด "์ ์ ๋ฒ์"์ฌ๋ ๋ชธ ์ํ๊ฐ ํ์์ ๋ค๋ฅด๋ค๋ ์ ํธ์ ๋๋ค. ํญ๋ชฉ๋ณ ๊ธฐ์ค์ (๋ฃฐ๋ฒ ์ด์ค)๋ง ๋ณด๋ฉด ๋์น๊ณ , "์ด ์ฌ๋์ ํ์ ์กฐํฉ"์ ํ์ตํด ๋ฌ์ผ ์กํ๋๋ค.
๊ทธ๋์ ํ์ง๋ฅผ ๋ ๊ฐ๋๋ก ์งฐ์ต๋๋ค. ๋ช ์์ ์ธ ๊ท์น์ผ๋ก ์ก๋ ๋ฃฐ๋ฒ ์ด์ค, ๊ทธ๋ฆฌ๊ณ "ํ์ ์กฐํฉ"์ ํ์ตํด ๋ฒ์ด๋จ์ ์ก๋ ๋น์ง๋ ํ์ต. ๋์ ์ํธ๋ณด์์ ๋๋ค. ๋ฃฐ๋ฒ ์ด์ค๋ ์ค๋ช ์ด ๋ช ํํ์ง๋ง ๋ฏธ๋ฆฌ ์ ์ํ ๊ฒ๋ง ์ก๊ณ , ๋น์ง๋๋ ๋ฏธ๋ฆฌ ๊ท์น์ ๋ชป ์ ์ ์กฐํฉ ์ด์์ ์ก์ง๋ง "์ ํํ ๋ฌด์์ด ์ ์ด์์ธ์ง"๋ ๋ฐ๋ก ์ค๋ช ํด ์ค์ผ ํฉ๋๋ค. ๊ทธ ์ค๋ช ์ ๋ง๋๋ ๊ฒ ๋ค์ ๋์ค๋ ์ํฅ ์งํ ์ฐ์ถ๊ณผ ์ง๋จ ์นด๋์ ๋๋ค.
์ ์ฒด ๊ทธ๋ฆผ — ํ์ดํ๋ผ์ธ ๊ตฌ์กฐ

๊ทธ๋ฆผ1์ ๋งค์ผ ์๋ฒฝ cron์ด ๋๋ ํ์ดํ๋ผ์ธ์ ๋๋ค. ๋ค ๋ชจ๋์ด ํ ์ค๋ก ์ฐ๊ฒฐ๋ฉ๋๋ค. collector๊ฐ ๋ฐ์ดํฐ๋ฅผ ํ์ค ํํ๋ก ๋ง๋ค๊ณ , detector๊ฐ ์ด์ ํญ๋ชฉ ๋ฆฌ์คํธ๋ฅผ ๋ฝ๊ณ , reporter๊ฐ ๊ทธ๊ฑธ HTML๊ณผ ๊ทธ๋ํ๋ก ๋ฐ๊พธ๊ณ , mailer๊ฐ ๋ฉ์ผ๋ก ๋ณด๋ ๋๋ค. ๊ฐ ๋ชจ๋์ ์ ๋ชจ๋์ ์ถ๋ ฅ์๋ง ์์กดํ๋๋ก ๊ฒฝ๊ณ๋ฅผ ๋ถ๋ช ํ ํ์ต๋๋ค.
๋ชจ๋ ์ฑ ์์ ์ ๋ฆฌํ๋ฉด ์ด๋ ์ต๋๋ค.
- collector — ์์ฒ ๋ฐ์ดํฐ์์ "์ค๋ 1๋ถ ์๊ณ์ด"๊ณผ "์ ์ผ 1๋ถ ์๊ณ์ด"์ ์๋ผ๋ด
DbMetrics๋ผ๋ ํ์ค ๊ฐ์ฒด๋ก ๋ง๋ญ๋๋ค. POC ๋จ๊ณ์์๋ ์์ ๋ชฉ๋ฐ์ดํฐ๋ฅผ ์ฝ๊ณ , ํ์ฅ์์๋ API๋ก ๋ฐ๊ฟ๋๋ค. ๋ฐ์ดํฐ ์ถ์ฒ๊ฐ ๋ฐ๋์ด๋ ์ด ํ์ผ ํ ๊ณณ๋ง ๊ณ ์น๋ฉด ๋๋๋ก ๊ฒฉ๋ฆฌํ์ต๋๋ค. ๋ท ๋ชจ๋์ collector๊ฐ ๋ฌด์์ ์ฝ์๋์ง ๋ชจ๋ฆ ๋๋ค.DbMetrics๋ง ๋ด ๋๋ค. - detector —
DbMetrics๋ฅผ ๋ฐ์ DB๋ณ๋ก ์ด์ ํญ๋ชฉ(Anomaly) ๋ฆฌ์คํธ๋ฅผ ๋ง๋ญ๋๋ค. ๋ฃฐ๋ฒ ์ด์ค 3์ข ๊ณผ ML(PCA) ํ ์ข ์ด ์ฌ๊ธฐ ์์ต๋๋ค. - reporter —
detector์ ๊ฒฐ๊ณผ(DbDetection)์collector๋ฐ์ดํฐ๋ฅผ ๋ฐ์ HTML ๋ฆฌํฌํธ์ ์ถ์ด ๊ทธ๋ํ(PNG)๋ฅผ ๋ง๋ญ๋๋ค. ์ง๋จ ์นด๋๋ ์ฌ๊ธฐ์ ์กฐ๋ฆฝํฉ๋๋ค. - mailer — HTML์ ๋ณธ๋ฌธ์, ๊ทธ๋ํ PNG๋ฅผ CID๋ก ์๋ฒ ๋ํด ๋ฉ์ผ์ ๋ณด๋ ๋๋ค.
ํต์ฌ ์ค๊ณ ์์น์ "reporter๋ Anomaly ๋ฆฌ์คํธ์๋ง ์์กดํ๋ค" ์
๋๋ค. detector๊ฐ ๋ฃฐ๋ฒ ์ด์ค๋ก ์ก์๋ PCA๋ก ์ก์๋ , ๊ฒฐ๊ณผ๋ ๋๊ฐ์ด Anomaly ๊ฐ์ฒด ํ ์ข
๋ฅ๋ก ํ๋ฌ๊ฐ๋๋ค. reporter๋ ๊ทธ Anomaly์ kind ํ๋(threshold/surge/pattern/ml)๋ง ๋ณด๊ณ ํ์๋ฅผ ๋ค๋ฅด๊ฒ ํฉ๋๋ค. ๋๋ถ์ ํ์ง ๋ฐฉ์์ ๋๋ ค๋ reporter ๊ตฌ์กฐ๋ ์ ํ๋ค๋ฆฝ๋๋ค.
์ด Anomaly๊ฐ ๋ชจ๋ ์ฌ์ด์ ๊ณ์ฝ์
๋๋ค. ์ค์ ์ ์๋ ์ด๋ ์ต๋๋ค.
@dataclass
class Anomaly:
"""์ด์ ํญ๋ชฉ 1๊ฑด."""
db_name: str
instance: str
metric: str # ์งํ source๋ช
(config ์ ๋์ผ ๋ฌธ์์ด)
kind: str # 'threshold' | 'surge' | 'pattern'
grade: str # 'critical' | 'warning'
value: float # ๋ํ๊ฐ(threshold=peak, surge/pattern=์ค๋ ์ผํ๊ท )
threshold: Optional[float] = None # ๋น๊ต ์๊ณ๊ฐ(threshold ์ผ ๋)
baseline: Optional[float] = None # ์ ์ผ ๋น๊ต๊ฐ(surge/pattern ์ผ ๋)
at_time: Optional[pd.Timestamp] = None # ๋ฐ์ ์๊ฐ(threshold peak ์๊ฐ)
detail: dict = field(default_factory=dict) # ๋ถ๊ฐ ์ ๋ณด(์ง์๋ถ/๋น์จ/์ฆ๊ฐํญ ๋ฑ)
kind๋ง๋ค ์๋ฏธ ์๋ ํ๋๊ฐ ์กฐ๊ธ์ฉ ๋ค๋ฆ
๋๋ค. ์๊ณ ์ด๊ณผ๋ฉด threshold์ at_time(ํผํฌ ์๊ฐ)์ด ์ฐจ๊ณ , ๊ธ์ฆ์ด๋ฉด baseline(์ ์ผ๊ฐ)๊ณผ detail["ratio"](๋ฐฐ์)๊ฐ ์ฐน๋๋ค. ๊ณตํต์ด ์๋ ๋ถ๊ฐ ์ ๋ณด๋ ์ ๋ถ detail ๋์
๋๋ฆฌ์ ๋ด์, ์ข
๋ฅ๊ฐ ๋์ด๋ ํด๋์ค ๊ตฌ์กฐ๋ฅผ ๋ฐ๊พธ์ง ์๊ฒ ํ์ต๋๋ค.
์๋ฐ๋ก ๋น์ ํ๋ฉด Anomaly๋ ๋ชจ๋ ๊ฐ์ ์ค๊ฐ๋ DTO์ด๊ณ , kind๋ ์ผ์ข
์ ํ์
๊ตฌ๋ถ์์
๋๋ค. ์์์ผ๋ก 4๊ฐ ์๋ธํด๋์ค๋ฅผ ๋ง๋ค ์๋ ์์์ง๋ง, POC ๊ท๋ชจ์์๋ kind ๋ฌธ์์ด + detail ๋์
๋๋ฆฌ๊ฐ ๋ ๊ฐ๋ณ๊ณ ์ง๋ ฌํ๋ ํธํ์ต๋๋ค.
์ด์ ํ์ง 1 — ๋ฃฐ๋ฒ ์ด์ค 3์ข

๊ทธ๋ฆผ2๋ ํ ์งํ๊ฐ ๋ฃฐ๋ฒ ์ด์ค 3์ข ์ ์ด๋ป๊ฒ ํต๊ณผํ๋์ง ๋ณด์ฌ์ค๋๋ค. ์ ๋ ์๊ณ๋ thresholds์ ์ ์๋ ์งํ๋ง, ๊ธ์ฆ๊ณผ ํจํด์ ๋ชจ๋ํฐ๋ง ๋์ ์ ์ฒด ์งํ๋ฅผ ํ์ต๋๋ค. ํจํด์ % ๋จ์ ์งํ์๋ง ์ ์ฉํฉ๋๋ค. ๊ฐ ๊ฒ์ฌ๊ฐ ๋ ๋ฆฝ์ ์ด๋ผ, ํ ์งํ๊ฐ ๋์์ ์ฌ๋ฌ ์ข ๋ฅ๋ก ์กํ ์๋ ์์ต๋๋ค.
์ง์
์ ์ detect_db์
๋๋ค. ํ DB์ ๋ํด ๋ฃฐ๋ฒ ์ด์ค 3์ข
์ ์ฐจ๋ก๋ก ๋๋ฆฌ๊ณ ML์ ๋ํฉ๋๋ค.
def detect_db(db_metrics: DbMetrics, config: dict) -> DbDetection:
"""ํ DB์ ์ด์ ํ์ง(๋ฃฐ๋ฒ ์ด์ค + ML)."""
db_name = db_metrics.db_name
db_config = config["databases"][db_name]
anomaly_cfg = config.get("global", {}).get("anomaly", {})
surge_ratio = float(anomaly_cfg.get("surge_ratio", 2.0))
rise_pp = float(anomaly_cfg.get("pattern_rise_pp", 20))
today, prev_day = db_metrics.today, db_metrics.prev_day
has_data = today is not None and not today.empty
anomalies: List[Anomaly] = []
if has_data:
# 1) ์ ๋ ์๊ณ (thresholds ์ ์ ์งํ๋ง)
for key, spec in db_config.get("thresholds", {}).items():
a = _check_threshold(db_name, db_metrics.instance, key, spec, today)
if a:
anomalies.append(a)
# 2)·3) ๊ธ์ฆ/ํจํด (threshold + monitored ์ ์ฒด — ์๊ณ ๋ฏธ์ด๊ณผ ํจํด๋ ์ก๊ธฐ ์ํจ)
for source in _wanted_metrics(db_config):
s = _check_surge(db_name, db_metrics.instance, source,
today, prev_day, surge_ratio)
if s:
anomalies.append(s)
p = _check_pattern(db_name, db_metrics.instance, source,
today, prev_day, rise_pp)
if p:
anomalies.append(p)
# 4) ML
anomalies.extend(detect_ml(db_metrics, config))
...
์ฌ๊ธฐ์ ๋์ฌ๊ฒจ๋ณผ ์ ์ด ๋์ ๋๋ค.
์ฒซ์งธ, ์๊ณ๊ฐ๊ณผ ๊ธ์ฆ/ํจํด ๋น์จ์ ์ฝ๋๊ฐ ์๋๋ผ ์ค์ ์์ ์ฝ์ต๋๋ค. surge_ratio๋ ๊ธฐ๋ณธ 2.0, pattern_rise_pp๋ ๊ธฐ๋ณธ 20์
๋๋ค. ์ ๋ ์๊ณ๋ db_config["thresholds"]์ DB๋ณ๋ก ๋ฐ๋ก ๋ค์ด ์์ต๋๋ค. ์ ๊ตณ์ด ๋ถ๋ฆฌํ๋๋ฉด, DB๋ง๋ค ์ ์ ๋ฒ์๊ฐ ์์ ํ ๋ค๋ฅด๊ธฐ ๋๋ฌธ์
๋๋ค. ๊ฐ์ "ํ์ฑ ์ธ์
์"๋ผ๋ ํ DB๋ ํ์ 1, ๋ค๋ฅธ DB๋ ํ์ 800๋์
๋๋ค. ์๊ณ๋ฅผ ์ฝ๋์ ๋ฐ์ผ๋ฉด DB๊ฐ ๋ ๋๋ง๋ค ์ฝ๋๋ฅผ ๊ณ ์ณ์ผ ํฉ๋๋ค. ์ค์ ์ผ๋ก ๋นผ๋๋ฉด DBA ํผ๋๋ฐฑ์ ๋ฐ์ ๊ฐ๋ง ๋ฐ๊พธ๋ฉด ๋ฉ๋๋ค. ์ด์ํ๋ฉด์ ์๊ณ๋ ๊ณ์ ์กฐ์ ๋ ๊ฑฐ๋ผ, ์ฒ์๋ถํฐ ๋ฐ์ดํฐ(์ค์ )์ ๋ก์ง(์ฝ๋)์ ๊ฐ๋ผ๋จ์ต๋๋ค.
๋์งธ, ๊ธ์ฆ·ํจํด์ ์๊ณ ์งํ๋ง์ด ์๋๋ผ ๋ชจ๋ํฐ๋ง ๋์ ์ ์ฒด๋ฅผ ํ์ต๋๋ค. _wanted_metrics๊ฐ ์๊ณ ์ ์ ์งํ์ ์ถ๊ฐ ๋ชจ๋ํฐ๋ง ์งํ๋ฅผ ํฉ์ณ ์ค๋ณต ์ ๊ฑฐํ ๋ชฉ๋ก์ ์ค๋๋ค.
def _wanted_metrics(db_config: dict) -> List[str]:
"""์ด DB์์ surge/pattern ์ผ๋ก ๋ณผ ์ ์ฒด ์งํ(threshold source + monitored, ์ค๋ณต ์ ๊ฑฐ)."""
threshold_sources = [s["source"] for s in db_config.get("thresholds", {}).values()]
monitored = list(db_config.get("monitored_metrics", []))
seen: Dict[str, None] = {}
for name in threshold_sources + monitored:
seen.setdefault(name, None)
return list(seen.keys())
์๊ณ๊ฐ ์๋ ์งํ๋ผ๋ "์ ์ผ ๋๋น 2๋ฐฐ"๋ ๋ด์ผ ํ๊ธฐ ๋๋ฌธ์ ๋๋ค. ์๊ณ๋ฅผ ์ ํด ๋ ํต์ฌ ์งํ ๋ช ๊ฐ๋ง ๊ธ์ฆ ๊ฒ์ฌํ๋ฉด, ์๊ณ๋ฅผ ์ ์ ํ ์งํ๊ฐ ํญ์ฆํด๋ ๋ชป ์ก์ต๋๋ค. ๊ทธ๋์ ๊ธ์ฆ/ํจํด์ ๊ฐ๋ฅํ ํ ๋๊ฒ ๋ด ๋๋ค.
์๊ณ ์ด๊ณผ ํ์
_check_threshold๋ ๋ถ ๋จ์๋ก ์๊ณ๋ฅผ ๊ฒ์ฌํ๋, ๊ฒฐ๊ณผ๋ "๊ทธ๋ ์ต๊ณ ๋ฑ๊ธ + ํผํฌ๊ฐ + ๋ฐ์ ์๊ฐ + ์ง์ ๋ถ"์ผ๋ก ์์ฝํฉ๋๋ค.
def _check_threshold(db_name, instance, key, spec, today) -> Optional[Anomaly]:
"""์ ๋ ์๊ณ ์ด๊ณผ ํ์ (๋ถ ๋จ์ ๊ฒ์ฌ → ์ต๊ณ ๋ฑ๊ธ ์์ฝ)."""
if key in DISABLED_THRESHOLDS.get(db_name, set()):
return None
source = spec["source"]
s = _series(today, source)
if s is None:
return None
warning = spec.get("warning")
critical = spec.get("critical")
direction = spec.get("direction", "above")
if direction == "above":
over_crit = s[s >= critical] if critical is not None else s.iloc[0:0]
over_warn = s[s >= warning] if warning is not None else s.iloc[0:0]
peak, at_time = s.max(), s.idxmax()
else: # below — ๊ฐ์ด ๋ฎ์์๋ก ์ํ
over_crit = s[s <= critical] if critical is not None else s.iloc[0:0]
over_warn = s[s <= warning] if warning is not None else s.iloc[0:0]
peak, at_time = s.min(), s.idxmin()
if len(over_crit) > 0:
grade, thr, dur = "critical", critical, int(len(over_crit))
elif len(over_warn) > 0:
grade, thr, dur = "warning", warning, int(len(over_warn))
else:
return None
return Anomaly(
db_name=db_name, instance=instance, metric=source,
kind="threshold", grade=grade, value=float(peak), threshold=float(thr),
at_time=at_time, detail={"duration_min": dur, "direction": direction},
)
์ค๋ณ๋ก ๋ณด๋ฉด ์ด๋ ์ต๋๋ค.
direction์ดabove๋ฉด ๊ฐ์ด ๋์์๋ก ์ํ(CPU, ์ธ์ ๋ฑ),below๋ฉด ๋ฎ์์๋ก ์ํ(์ฌ์ ๊ณต๊ฐ ๋ฑ)์ ๋๋ค. ๋ฐฉํฅ์ ๋ฐ๋ผ ๋น๊ต ๋ถํธ์ ํผํฌ ๊ณ์ฐ(max/min)์ ๋ค์ง์ต๋๋ค.s[s >= critical]์ ์ํ ์ ์ ๋์ ๋ถ๋ค๋ง ๊ณจ๋ผ๋ธ ์๋ฆฌ์ฆ์ ๋๋ค. ๊ทธ ๊ธธ์ด๊ฐ "๋ช ๋ถ๊ฐ ์ด๊ณผํ๋๊ฐ"(duration_min)๊ฐ ๋ฉ๋๋ค. 5์ด ํฑ์ 1๋ถ์ผ๋ก ์ง๊ณํ ๋ฐ์ดํฐ๋ผ ํ ํ์ด 1๋ถ์ ๋๋ค.- ์ํ์ ํ ๋ฒ์ด๋ผ๋ ๋ฟ์ผ๋ฉด ์ํ ๋ฑ๊ธ, ์๋๋ฉด ๊ฒฝ๊ณ ์ ๋ฟ์๋์ง ๋ด
๋๋ค. ๋ ๋ค ์๋๋ฉด
None(์ด์ ์๋). - ๋ํ๊ฐ(
value)์ ๊ทธ๋ ํผํฌ๊ฐ์ผ๋ก, ๋ฐ์ ์๊ฐ(at_time)์ ํผํฌ๊ฐ ์ฐํ ๋ถ์ผ๋ก ์ก์ต๋๋ค. ํ๋ฃจ์น๋ฅผ ํ ์ค๋ก ์์ฝํ๋ฉด์๋ "์ธ์ ๊ฐ์ฅ ์ฌํ๋์ง"๋ฅผ ๋จ๊น๋๋ค.
์ฌ๊ธฐ์ ํ์ค ๋ฐ์ดํฐ๋ฅผ ๋ค๋ฃจ๋ฉฐ ๋ฃ์ ๊ฐ๋๊ฐ ๋ ๊ฐ ์์ต๋๋ค.
DISABLED_THRESHOLDS: Dict[str, set] = {
"oracle": {"storage_ratio"}, # Free Storage Space=์ฌ์ byte, ์๊ณ=์ฌ์ฉ๋ฅ % → ๊ณ์ฐ ๋ถ๊ฐ
}
Oracle์ ์ฌ์ ์ ์ฅ ๊ณต๊ฐ ์งํ๋ ์ ๋ ์๊ณ ํ๊ฐ๋ฅผ ๊ป์ต๋๋ค. ๋ฐ์ ๋ฐ์ดํฐ๋ "์ฌ์ ๋ฐ์ดํธ(์ฝ 690GB)"์ธ๋ฐ, ์๊ณํ๋ "์ฌ์ฉ๋ฅ 80/85%"๋ก ์ ํ ์์ต๋๋ค. ๋จ์๊ฐ ๋ค๋ฅด๊ณ (๋ฐ์ดํธ vs %), ์ด์ฉ๋์ ์ฃผ์ง ์์ ์ฌ์ฉ๋ฅ (%)์ ๊ณ์ฐํ ์๋ ์์ต๋๋ค. ๋จ์·๋ฐฉํฅ์ด ์ ๋ง๋ ์๊ณ๋ฅผ ์ต์ง๋ก ์ ์ฉํ๋ฉด ๋งค์ผ ์๋ฑํ ๊ฒฝ๊ณ ๊ฐ ๋น๋๋ค. ๊ทธ๋์ ์ด ์งํ๋ ์ ๋ ์๊ณ ๋์ "์ฌ์ ๊ฐ ๊ธ๊ฒฉํ ์ค์ด๋๋ ๊ฒ"๋ง ๋ณด๋ ์ชฝ์ผ๋ก ๋๋ ธ์ต๋๋ค(์๋ ๊ธ๊ฐ ๊ฐ์).
์ ์ผ ๋๋น ๊ธ์ฆ
_check_surge๋ ์ ์ผ ์ผํ๊ท ๋๋น ์ค๋ ์ผํ๊ท ์ด 2๋ฐฐ ์ด์์ด๋ฉด ๊ธ์ฆ์ผ๋ก ๋ด
๋๋ค.
def _check_surge(db_name, instance, source, today, prev_day, surge_ratio):
"""์ ์ผ ์ผํ๊ท ๋๋น ๊ธ์ฆ(๋๋ DROP_WATCH ์งํ์ ๊ธ๊ฐ) ํ์ ."""
t = _series(today, source)
p = _series(prev_day, source)
if t is None or p is None:
return None
today_mean, prev_mean = float(t.mean()), float(p.mean())
if prev_mean <= 0:
return None
# ๋
ธ์ด์ฆ ๊ฐ๋: ๋ ๋ค ๋๋ฌด ์์ผ๋ฉด(์ฅ๊ผฌ๋ฆฌ) ๋ฐฐ์๊ฐ ์ปค๋ ์๋ฏธ ์์ → ๊ธ์ฆ ์ ์ธ
if today_mean < SURGE_FLOOR and prev_mean < SURGE_FLOOR:
return None
ratio = today_mean / prev_mean
is_drop_watch = source in DROP_WATCH.get(db_name, set())
if ratio >= surge_ratio:
return Anomaly(
db_name=db_name, instance=instance, metric=source,
kind="surge", grade="warning", value=today_mean, baseline=prev_mean,
detail={"ratio": ratio, "drop": False},
)
if is_drop_watch and ratio <= (1.0 / surge_ratio):
return Anomaly(
db_name=db_name, instance=instance, metric=source,
kind="surge", grade="warning", value=today_mean, baseline=prev_mean,
detail={"ratio": ratio, "drop": True},
)
return None
ํ์ค ๋ฐ์ดํฐ ๋ณด์ ์ด ๋ ๊ตฐ๋ฐ ๋ค์ด๊ฐ๋๋ค.
0 ๋๋์
๊ฐ๋ — prev_mean <= 0์ด๋ฉด ๋ฐฐ์๋ฅผ ๊ณ์ฐํ ์ ์์ผ๋ ๊ทธ๋ฅ ๋์ด๊ฐ๋๋ค. ์ ์ผ ํ๊ท ์ด 0์ธ ์งํ(์: ํ์ ๊ฑฐ์ 0์ธ deadlocks)๋ ๋๋์
์ด ๋ฌดํ๋๊ฐ ๋๊ฑฐ๋ ์๋ฏธ๊ฐ ์์ต๋๋ค.
๋ฐ๋ฅ๊ฐ(floor) ๊ฐ๋ — SURGE_FLOOR = 1.0. ์ค๋·์ ์ผ ๋ ๋ค 1 ๋ฏธ๋ง์ด๋ฉด ๋ฐฐ์๊ฐ ์๋ฌด๋ฆฌ ์ปค๋ ๊ธ์ฆ์ผ๋ก ์ ์นฉ๋๋ค. ์๋ํ๋ฉด ์ค๋ฐ์ดํฐ์์ ์ด๋ฐ ์ผ์ด ํํฉ๋๋ค. ์ด๋ค ์งํ๊ฐ ์ ์ผ ํ๊ท 0.01, ์ค๋ ํ๊ท 1.14๋ฉด ๋ฐฐ์๋ก๋ 114๋ฐฐ์
๋๋ค. ์ซ์๋ง ๋ณด๋ฉด "ํญ์ฆ"์ด์ง๋ง, ์ ๋๋์ด ์๋ ์์ ์ค๋ฌด์ ์ผ๋ก ์๋ฏธ๊ฐ ์์ต๋๋ค. ์๋ฅผ ๋ค์ด ์ ๋ฐ์ดํฐ์์ MySQL์ Aborted Connects๋ ํ๊ท 0.000246, Com Delete๋ ํ๊ท 0.000313 ์์ค์
๋๋ค. ์ด๋ฐ ์ฅ๊ผฌ๋ฆฌ ์งํ๋ ํ๋ฃจ ํ๋ ๊ฑด๋ง ๋ ์๊ฒจ๋ ๋ฐฐ์๊ฐ ํญ๋ฐํฉ๋๋ค. ๋ฐ๋ฅ๊ฐ ๊ฐ๋๊ฐ ์ด ๋
ธ์ด์ฆ๋ฅผ ๊ฑธ๋ฌ ์ค๋๋ค. POC ๋จ๊ณ๋ผ ๋จ์ผ ๊ณ ์ ๊ฐ์ผ๋ก ๋๊ณ , ์ด์ ํผ๋๋ฐฑ์ ๋ฐ์ผ๋ฉฐ ์กฐ์ ํ ๊ณํ์
๋๋ค.
๊ธ๊ฐ ๊ฐ์(DROP_WATCH) — ๋ณดํต์ ๊ฐ์ด ๋ฐ๋ ๊ฒ ์ํ์ด์ง๋ง, ์ฌ์ ๊ณต๊ฐ ๊ฐ์ ์งํ๋ ์ค์ด๋๋ ๊ฒ ์ํ์ ๋๋ค.
DROP_WATCH: Dict[str, set] = {
"oracle": {"Free Storage Space"},
}
์ด ์งํ๋ ์ค๋ ํ๊ท ์ด ์ ์ผ์ ์ ๋ฐ ์ดํ(ratio <= 1/2)๋ก ๋จ์ด์ง๋ฉด ๊ฒฝ๊ณ ๋ฅผ ๋
๋๋ค. ์์์ ์ ๋ ์๊ณ๋ฅผ ๋ Oracle ์ฌ์ ๊ณต๊ฐ์, ์ฌ๊ธฐ์ "๊ธ๊ฐ"์ผ๋ก ๋์ ๊ฐ์ํ๋ ๊ฒ๋๋ค. ์ฝ 690~729GB๋ฅผ ์ค๊ฐ๋ ์ฌ์ ๊ณต๊ฐ์ด ํ๋ฃจ ๋ง์ ์ ๋ฐ์ผ๋ก ์ค๋ฉด ๋ถ๋ช
๋น์ ์์
๋๋ค.
ํจํด ๋ณํ
_check_pattern์ % ๋จ์ ์งํ์ ํํด ์ ์ผ ๋๋น ์ ๋ ์์นํญ(%ํฌ์ธํธ)์ ๋ด
๋๋ค.
def _check_pattern(db_name, instance, source, today, prev_day, rise_pp):
"""% ๋จ์ ์งํ์ ์ ์ผ ๋๋น +rise_pp(%p) ์ด์ ์์น ํ์ ."""
t = _series(today, source)
p = _series(prev_day, source)
if t is None or p is None:
return None
if not _is_percent(source, t):
return None
today_mean, prev_mean = float(t.mean()), float(p.mean())
rise = today_mean - prev_mean
if rise >= rise_pp:
return Anomaly(
db_name=db_name, instance=instance, metric=source,
kind="pattern", grade="warning", value=today_mean, baseline=prev_mean,
detail={"rise_pp": rise},
)
return None
๊ธ์ฆ์ด "๋ฐฐ์"๋ผ๋ฉด ํจํด์ "%ํฌ์ธํธ ์ฐจ์ด"์ ๋๋ค. ๋์ ๋๋ ์ด์ ๊ฐ ์์ต๋๋ค. % ๋จ์ ์งํ๋ ๋ฐฐ์๊ฐ ๋ถ์ ์ ํฉ๋๋ค. CPU ์ฌ์ฉ๋ฅ ์ด 5%์์ 40%๋ก ์ฌ๋ผ๋ ๋ฐฐ์๋ก๋ 8๋ฐฐ๋ผ ๊ธ์ฆ์ผ๋ก ์กํ ์ ์์ง๋ง, ๊ฐ์ 8๋ฐฐ๋ผ๋ 0.5%์์ 4%๋ฉด ์ค๋ฌด์ ์ผ๋ก ๋ณ์ผ ์๋๋๋ค. ๋ฐ๋๋ก 30%์์ 55%๋ ๋ฐฐ์๋ก 1.8๋ฐฐ๋ผ ๊ธ์ฆ ๊ธฐ์ค(2๋ฐฐ)์ ์ ๊ฑธ๋ฆฌ์ง๋ง, +25%ํฌ์ธํธ๋ ๋ถ๋ช ํจํด ๋ณํ์ ๋๋ค. ๊ทธ๋์ % ์งํ๋ ๋ฐฐ์๊ฐ ์๋๋ผ %ํฌ์ธํธ ์ฐจ์ด๋ก ๋ด ๋๋ค.
% ์งํ์ธ์ง๋ ์ด๋ฆ ํํธ์ ๊ฐ ๋ฒ์๋ก ํ์ ํฉ๋๋ค.
_PERCENT_NAME_HINTS = ("usage", "percent", "ratio", "%")
def _is_percent(source: str, series: pd.Series) -> bool:
"""% ๋จ์ ์งํ์ธ๊ฐ — ์ด๋ฆ ํํธ + ๊ฐ ๋ฒ์(0~100)๋ก ํ์ ."""
name = source.lower()
if not any(h in name for h in _PERCENT_NAME_HINTS):
return False
return series.max() <= 100.5 and series.min() >= -0.5
์ด๋ฆ์ usage/percent/ratio/% ๊ฐ ๋ค์ด๊ฐ๋ฉด์, ๊ฐ์ด ์ค์ ๋ก 0~100 ๋ฒ์ ์์ผ ๋๋ง % ์งํ๋ก ์ธ์ ํฉ๋๋ค. ์ด๋ฆ๋ง ๋ณด๋ฉด "Cache Hit Ratio" ๊ฐ์ ๊ฒ ์กํ๋๋ฐ, ๊ฐ์ด 0~100์ธ์ง ํ ๋ฒ ๋ ํ์ธํด์ผ ์์ ํฉ๋๋ค. ์ด๋ฆ์ ratio๊ฐ ๋ค์ด๊ฐ๋ ๊ฐ์ด 100์ ํ์ฐธ ๋๋ ๋น์จ ์งํ๊ฐ ์์ ์ ์์ด์์ ๋๋ค.
๋ง์ง๋ง์ผ๋ก ํ DB์ ์ต์ข ๋ฑ๊ธ์ ๊ฐ์ฅ ๋์ ํญ๋ชฉ ํ๋๋ก ๊ฒฐ์ ํฉ๋๋ค. ๋ฆฌํฌํธ ๋งจ ์ ์ ํธ๋ฑ(์ํ/์ฃผ์/์ ์ ์นด์ดํธ)์ ์ฐ์ ๋๋ค.
@property
def worst_grade(self) -> str:
"""์ด DB์ ์ต์ข
๋ฑ๊ธ(critical > warning > normal) — ๋ฆฌํฌํธ ์ ํธ๋ฑ์ฉ."""
if any(a.grade == "critical" for a in self.anomalies):
return "critical"
if any(a.grade == "warning" for a in self.anomalies):
return "warning"
return "normal"
์ด์ ํ์ง 2 — PCA ๋ณต์์ค์ฐจ๋ก ์กฐํฉ ์ด์ ์ก๊ธฐ
๋ฃฐ๋ฒ ์ด์ค๋ "๋ฏธ๋ฆฌ ์ ์ ๊ท์น"๋ง ์ก์ต๋๋ค. CPU·์ธ์ ·๋์คํฌ๊ฐ ๋์์ ํ์๋ณด๋ค ๋ค๋ฌ ์กฐํฉ ์ด์์, ๊ฐ๊ฐ์ด ์๊ณ๋ฅผ ์ ๋์ผ๋ฉด ์ด๋ค ๋ฃฐ๋ ๋ฐ๋ํ์ง ์์ต๋๋ค. ์ด๊ฑธ ์ก์ผ๋ ค๊ณ ๋น์ง๋ ํ์ต์ ๋ถ์์ต๋๋ค. ๋ฐฉ๋ฒ์ PCA ๋ณต์์ค์ฐจ์ ๋๋ค.
PCA ๋ณต์์ค์ฐจ์ ์๋ฆฌ

๊ทธ๋ฆผ3์ ํต์ฌ ์ง๊ด์ ๋๋ค. ํ ๋ฌ์น ์ ์ ๋ฐ์ดํฐ๋ฅผ PCA๋ก ์ฃผ์ฑ๋ถ ๋ช ๊ฐ๋ก ์์ถํ๋ค๊ฐ ๋ค์ ๋ณต์ํฉ๋๋ค. ํ์์ ๊ฐ์ ์กฐํฉ์ด๋ฉด ์ ๋ณต์๋์ด ์ค์ฐจ๊ฐ ์๊ณ , ํ์์ ์๋ ์กฐํฉ์ด๋ฉด ๋ณต์์ด ์ด๊ธ๋ ์ค์ฐจ๊ฐ ํฝ๋๋ค. ๊ทธ ๋ณต์์ค์ฐจ๋ฅผ ์ด์ ์ ์๋ก ์๋๋ค.
๋น์ ํ๋ฉด ์ด๋ ์ต๋๋ค. ํ์์ ๋ ๊ฐ์ ๋ฉ๋ก๋๋ฅผ ๋ฃ๋ ์ฌ๋์๊ฒ ๊ทธ ๊ณก์ ๋ค์ฏ ์๋ง ๋ค๋ ค์ฃผ๊ณ ๋๋จธ์ง๋ฅผ ํฅ์ผ๊ฑฐ๋ ค ๋ณด๋ผ๊ณ ํฉ๋๋ค. ์ต์ํ ๊ณก์ด๋ฉด ๋น ๋ถ๋ถ์ ๊ฑฐ์ ์ ํํ ์ฑ์๋๋ค(๋ณต์ ์ ๋จ, ์ค์ฐจ ์์). ํ ๋ฒ๋ ์ ๋ค์ด๋ณธ ๊ณก์ ๋ค์ฏ ์๋ง ์ฃผ๋ฉด ๋๋จธ์ง๋ฅผ ์๋ฑํ๊ฒ ์ฑ์๋๋ค(๋ณต์ ์คํจ, ์ค์ฐจ ํผ). PCA์ ์ฃผ์ฑ๋ถ์ "ํ์ ๋ฐ์ดํฐ๊ฐ ์ฃผ๋ก ์์ง์ด๋ ๋ฐฉํฅ ๋ช ๊ฐ"์ ๋๋ค. ํ์ ์กฐํฉ์ ๊ทธ ๋ฐฉํฅ๋ค๋ก ์ ํํ๋์ด ๋ณต์๋๊ณ , ํ์์ ์๋ ์กฐํฉ์ ๊ทธ ๋ฐฉํฅ์ผ๋ก ํํ์ด ์ ๋๋ ๋ณต์์ด ์ด๊ธ๋ฉ๋๋ค.
์ข ๋ ์ ํํ๋, ์ฌ๋ฌ ์งํ(์์ญ ๊ฐ ์ปฌ๋ผ)๋ฅผ ํ ์ ์ผ๋ก ๋ณด๋ฉด ํ ๋ฌ์น ๋ฐ์ดํฐ๊ฐ ๊ณ ์ฐจ์ ๊ณต๊ฐ์ ํฉ์ด์ง ์ ๊ตฌ๋ฆ์ ๋๋ค. PCA๋ ๊ทธ ์ ๊ตฌ๋ฆ์ด ๊ฐ์ฅ ๋๊ฒ ํผ์ง ์ถ ๋ช ๊ฐ(์ฃผ์ฑ๋ถ)๋ฅผ ์ฐพ์ต๋๋ค. ๊ฐ 1๋ถ ๋ฐ์ดํฐ๋ฅผ ๊ทธ ์ถ๋ค์๋ง ํฌ์(์์ถ)ํ๋ค๊ฐ ์๋ ์ฐจ์์ผ๋ก ๋๋๋ฆฝ๋๋ค(๋ณต์). ์ ๊ตฌ๋ฆ ์์ ์๋ ํ์ ํจํด์ ๊ฑฐ์ ์ ์๋ฆฌ๋ก ๋์์ค๊ณ , ๊ตฌ๋ฆ ๋ฐ์ผ๋ก ํ ์ด์ ํจํด์ ๋ฉ๋ฆฌ ์ด๊ธ๋ ์ฑ ๋์์ต๋๋ค. ๊ทธ ์ด๊ธ๋ ๊ฑฐ๋ฆฌ๊ฐ ๋ณต์์ค์ฐจ์ ๋๋ค.
ํ์ต๊ณผ ์ถ๋ก ์ ๋ถ๋ฆฌํ ์ด์

๊ทธ๋ฆผ4์ฒ๋ผ ํ์ต๊ณผ ์ถ๋ก ์ ๋ถ๋ฆฌํ์ต๋๋ค. train.py(๋๋ ํ์ต ์คํฌ๋ฆฝํธ)๊ฐ ํ ๋ฌ์น ๋ฐ์ดํฐ๋ก ๋ชจ๋ธ์ ํ์ตํด models/{db}.pkl๋ก ์ ์ฅํ๊ณ , ๋งค์ผ ๋๋ detector.detect_ml์ ๊ทธ ์ ์ฅ๋ ๋ชจ๋ธ์ ์ฝ์ด ์ค๋ ๋ฐ์ดํฐ๋ง ์ฑ์ ํฉ๋๋ค. ๋งค์ผ ์๋ก ํ์ตํ์ง ์์ต๋๋ค.
์ด์ ๊ฐ ์์ต๋๋ค. ์ฒซ์งธ, ๋งค์ผ ํ์ต์ ๋น์๋๋ค. ๋์งธ, "์ ์ ๊ธฐ์ค"์ด ๋งค์ผ ํ๋ค๋ฆฌ๋ฉด ์ ๋ฉ๋๋ค. ์ด์ ์ด์ํ๋ ๋ ์ ๋ฐ์ดํฐ๋ก ์ค๋ ๊ธฐ์ค์ ๋ค์ ์ก์ผ๋ฉด, ์ด์์ด ์ ์์ผ๋ก ํก์๋์ด ๋ฒ๋ฆฝ๋๋ค. ๊ทธ๋์ ํ ๋ฌ์น ์ ์ ํจํด์ผ๋ก ๊ธฐ์ค์ ๊ณ ์ ํด ๋๊ณ , ๊ทธ ๊ธฐ์ค์ ๋น์ถฐ ์ค๋์ ๋ด
๋๋ค. ์
์งธ, ํ์ต๊ณผ ์ถ๋ก ์ด ๋๊ฐ์ ์ ์ฒ๋ฆฌ๋ฅผ ์จ์ผ ๋ณต์์ค์ฐจ๊ฐ ์๋ฏธ๋ฅผ ๊ฐ์ง๋๋ค. ๊ทธ๋์ ์ ์ฒ๋ฆฌ·๋ชจ๋ธ ๋ก์ง์ ml.py ํ ๊ณณ์ ๋ชจ์ผ๊ณ , ํ์ต ๋ ๋ง๋ ๋ณํ ๊ท์น(ํผ์ฒ ๋ชฉ๋ก, ๊ฒฐ์ธก ์ฑ์ธ ์ค์๊ฐ, ๋ก๊ทธ ๋์ ์ปฌ๋ผ, ํ์คํ๊ธฐ)์ ๋ชจ๋ธ ๋ฌถ์์ ํจ๊ป ์ ์ฅํฉ๋๋ค.
๊ทธ ๋ฌถ์์ด ModelBundle์
๋๋ค.
@dataclass
class ModelBundle:
"""ํ์ต ๊ฒฐ๊ณผ ํ ๋ฌถ์(์ ์ฅ/๋ก๋ ๋จ์). ์ถ๋ก ์ ํ์ํ ๋ชจ๋ ๊ท์น์ ๋ด๋๋ค."""
columns: List[str] # ํ์ต์ ์ด ํผ์ฒ(์ด ์์ ๊ทธ๋๋ก ์ถ๋ก )
medians: Dict[str, float] # ๊ฒฐ์ธก ์ฑ์ธ ์ค์๊ฐ(ํผ์ฒ๋ณ)
log_columns: List[str] # log1p ์ ์ฉํ ํผ์ฒ
scaler: StandardScaler # ํ์คํ๊ธฐ(ํ๊ท 0·ํ์คํธ์ฐจ1)
pca: PCA # ์ฃผ์ฑ๋ถ ๋ชจ๋ธ
error_threshold: float # ๋ถ๋น ๋ณต์์ค์ฐจ ์๊ณ(์ ๋จ์ ๋ถ์์)
contamination: float # ์๊ณ ์ฐ์ถ์ ์ด ๋น์จ
variance_kept: float # ๋ชฉํ ์ค๋ช
๋ถ์ฐ
daily_minutes_threshold: float = 0.0 # ์ ์์ผ์ '์๊ณ ์ด๊ณผ ๋ถ ์' 95ํผ์ผํ์ผ(ํ๋ฃจ ํ์ ๊ธฐ์ค)
n_components: int = 0 # ์ค์ ์ ํ๋ ์ฃผ์ฑ๋ถ ์
n_train_rows: int = 0 # ํ์ต์ ์ด ํ(๋ถ) ์
trained_at: str = "" # ํ์ต ์๊ฐ
dropped_constant: List[str] = field(default_factory=list) # ๋ถ์ฐ0์ผ๋ก ์ ์ธํ ํผ์ฒ
์๋ฐ๋ก ๋น์ ํ๋ฉด ModelBundle์ ์ง๋ ฌํํด์ ํ์ผ๋ก ๋จ๊ตฐ ํ์ต ๊ฒฐ๊ณผ ๊ฐ์ฒด์
๋๋ค. joblib.dump/load๊ฐ ObjectOutputStream/ObjectInputStream ์ญํ ์
๋๋ค. ๋ชจ๋ธ ๊ฐ์ค์น๋ง์ด ์๋๋ผ ์ ์ฒ๋ฆฌ ๊ท์น๊น์ง ํ ๊ฐ์ฒด์ ๋ด์ ์ ์ฅํ๋ ๊ฒ ํต์ฌ์
๋๋ค. ์ถ๋ก ๋ ๊ฐ์ ๊ท์น์ ๊ทธ๋๋ก ๊บผ๋ด ์จ์ผ, ํ์ต ๋ ๋ณธ ์ขํ๊ณ์ ์ถ๋ก ๋ ์ขํ๊ณ๊ฐ ์ผ์นํฉ๋๋ค.
์ ์ฒ๋ฆฌ ๋จ๊ณ
ml.py์ ์ ์ฒ๋ฆฌ๋ ๋ค์ฏ ๋จ๊ณ์
๋๋ค. ํ์ต์ฉ fit_preprocess๋ฅผ ๋ณด๊ฒ ์ต๋๋ค.
def fit_preprocess(frame, wanted, skew_threshold):
"""ํ์ต์ฉ ์ ์ฒ๋ฆฌ โ ~โค ๋ฅผ ์ํํ๊ณ , ์ถ๋ก ๋ ์ฌํํ ๊ท์น์ bundle ์ ์ ์ฌ."""
raw = _numeric_frame(frame, wanted)
# ์ค์๊ฐ ๊ธฐ๋ก(โข ์ ์๋ณธ ๊ธฐ์ค) ํ ๊ฒฐ์ธก ์ฑ์ฐ๊ธฐ
medians = {c: float(raw[c].median()) for c in raw.columns}
filled = _fill_missing(raw, medians)
# โข ์์ ์ปฌ๋ผ ์ ๊ฑฐ(๋ถ์ฐ 0 / ๊ฐ 1์ข
) — PCA ์ ๋ฌด์๋ฏธ
dropped = [c for c in filled.columns if filled[c].nunique(dropna=True) <= 1]
kept = [c for c in filled.columns if c not in dropped]
filled = filled[kept]
# โฃ ๋ก๊ทธ ๋ณํ — ํ์ชฝ์ผ๋ก ์ฌํ๊ฒ ์ ๋ฆฐ(์๋ ํฐ) ์์์๋ ์งํ
log_cols: List[str] = []
for col in kept:
s = filled[col]
if s.min() >= 0 and abs(float(s.skew())) > skew_threshold:
log_cols.append(col)
transformed = filled.copy()
if log_cols:
transformed[log_cols] = np.log1p(transformed[log_cols])
# โค ํ์คํ
scaler = StandardScaler()
matrix = scaler.fit_transform(transformed.values)
...
๊ฐ ๋จ๊ณ๊ฐ ์ ํ์ํ์ง ์ ๋ฆฌํ๋ฉด ์ด๋ ์ต๋๋ค.
- โ ํผ์ฒ ์ ํ — ๋ชจ๋ํฐ๋งํ ์งํ ์ปฌ๋ผ๋ง ๊ณจ๋ผ ์ซ์๋ก ๋ณํํฉ๋๋ค.
- โก ๊ฒฐ์ธก ์ฑ์ฐ๊ธฐ — ์งง์ ๊ฐญ์ ์ ํ ๋ณด๊ฐ, ๊ทธ๋๋ ๋จ์ ๊ฒฐ์ธก์ ํ์ต ์ค์๊ฐ์ผ๋ก ์ฑ์๋๋ค. ํ ๋ฌ์น 1๋ถ ๋ฐ์ดํฐ์๋ ์์ง ๋๋ฝ์ด ๊ตฐ๋ฐ๊ตฐ๋ฐ ์์ด, ๋น์นธ์ ๊ทธ๋๋ก ๋๋ฉด PCA๊ฐ ๋ชป ๋๋๋ค.
- โข ์์ ์ปฌ๋ผ ์ ๊ฑฐ — ํ ๋ฌ ๋ด๋ด ๊ฐ์ด ํ ์ข
๋ฅ(๋ถ์ฐ 0)์ธ ์งํ๋ PCA์ ์ ๋ณด๊ฐ ์์ด ๋บ๋๋ค. ์๋ฅผ ๋ค์ด ์ ๋ฐ์ดํฐ์์ Oracle
EBS IO Balance Max๋ ํ ๋ฌ ๋ด๋ด 99๋ก ๊ฑฐ์ ๊ณ ์ ์ ๋๋ค. ์ด๋ฐ ์ปฌ๋ผ์ ์ฃผ์ฑ๋ถ ๊ณ์ฐ์ ๊ธฐ์ฌํ์ง ๋ชปํ๊ณ ํ์คํ์์ 0 ๋๋์ ๋ง ์ผ์ผํต๋๋ค. - โฃ ๋ก๊ทธ ๋ณํ — ํ์ชฝ์ผ๋ก ์ฌํ๊ฒ ์ ๋ฆฐ(์๋ ํฐ) ์์ ์งํ์
log1p๋ฅผ ์์๋๋ค. ๋์คํฌ ์ฝ๊ธฐ๋ Redo ๊ฐ์ ์งํ๋ ํ์ ์๋ค๊ฐ ๊ฐ๋ ์๋ฐฑ ๋ฐฐ๋ก ํ๋๋ค. ์ ๋ฐ์ดํฐ์์ MySQLInnodb Rows Read๋ ํ๊ท 3,966์ธ๋ฐ ์ต๋๊ฐ 4,478,067, OracleRedo Entries๋ ํ๊ท 2,772์ธ๋ฐ ์ต๋ 1,407,527์ ๋๋ค. ์ด๋ ๊ฒ ๋ถํฌ๊ฐ ๊ทน๋จ์ ์ผ๋ก ๊ธธ๋ฉด ๊ฑฐ๋ํ ๋ช ๊ฐ์ด ํ์คํ๋ฅผ ์ง๋ฐฐํด ๋ฒ๋ฆฝ๋๋ค. ๋ก๊ทธ๋ฅผ ์์ ๋ถํฌ๋ฅผ ๋๋ฌ์ผ ํ์ ํจํด์ด ์ ๋๋ก ํ์ต๋ฉ๋๋ค. ์์๊ฐ ์๋ ์งํ์๋ ์ ์๋๋ค(๋ก๊ทธ๊ฐ ์ ์ ์ ๋จ). - โค ํ์คํ — ํ๊ท 0, ํ์คํธ์ฐจ 1๋ก ๋ง์ถฅ๋๋ค. ๋จ์์ ํฌ๊ธฐ๊ฐ ์ ๊ฐ๊ฐ์ธ ์งํ๋ค์(๋ฐ์ดํธ vs %, ์นด์ดํธ vs ์ด) ๊ฐ์ ์ ์ธ์ ์ฌ๋ ค์ผ PCA๊ฐ ํ ์งํ์ ํ๋๋ฆฌ์ง ์์ต๋๋ค. ํ์คํ ์ ํ๋ฉด ์์ญ์ต ๋ฐ์ดํธ ๋จ์ ์งํ๊ฐ 0~100 % ์งํ๋ฅผ ์๋ํฉ๋๋ค.
์ฌ๊ธฐ์ ์ค์ํ ๊ฑด ํ์ต์์ ๋ง๋ ๊ท์น(์ค์๊ฐ, ๋ก๊ทธ ๋์, ํ์คํ ํ๋ผ๋ฏธํฐ)์ ์ถ๋ก ์์ ๊ทธ๋๋ก ์ฌํํ๋ค๋ ์ ์
๋๋ค. ์ถ๋ก ์ฉ transform์ ๋ณด๋ฉด, ์ ๊ท์น์ ๋ง๋ค์ง ์๊ณ ๋ฌถ์์ ์ ์ฅ๋ ๊ท์น์ ๊บผ๋ด ์๋๋ค.
def transform(bundle: ModelBundle, frame: pd.DataFrame):
"""์ถ๋ก ์ฉ ์ ์ฒ๋ฆฌ — ํ์ต๊ณผ '๊ฐ์ ๊ท์น'์ผ๋ก ๋ณํ(์ปฌ๋ผ/์์/์ค์๊ฐ/๋ก๊ทธ/์ค์ผ์ผ ๋์ผ)."""
if frame is None or frame.empty:
return np.empty((0, len(bundle.columns))), pd.Index([])
# ํ์ต ์ปฌ๋ผ ๊ธฐ์ค์ผ๋ก ์ฌ์ ๋ ฌ(์๋ ์ปฌ๋ผ์ NaN → ์ค์๊ฐ์ผ๋ก ์ฑ์)
raw = frame.reindex(columns=bundle.columns).apply(pd.to_numeric, errors="coerce")
filled = _fill_missing(raw, bundle.medians)
if bundle.log_columns:
cols = [c for c in bundle.log_columns if c in filled.columns]
filled[cols] = np.log1p(filled[cols].clip(lower=0))
matrix = bundle.scaler.transform(filled.values)
return matrix, frame.index
fit_transform(ํ์ต)๊ณผ transform(์ถ๋ก )์ ์๋ฐ์ ๋น์ ๋ก ๋ณด๋ฉด, ํ์ต ๋ scaler.fit_transform์ ํ๊ท ·ํ์คํธ์ฐจ๋ฅผ ๊ณ์ฐํ๋ฉด์ ๋ณํ๊น์ง ํ์ง๋ง, ์ถ๋ก ๋ scaler.transform์ ํ์ต์์ ๊ณ์ฐํด ๋ ํ๊ท ·ํ์คํธ์ฐจ๋ก ๋ณํ๋ง ํฉ๋๋ค. ์ถ๋ก ๋ฐ์ดํฐ๋ก ๋ค์ ํ๊ท ์ ์ฌ๋ฉด ์ขํ๊ณ๊ฐ ์ด๊ธ๋ ๋ณต์์ค์ฐจ๊ฐ ๋ฌด์๋ฏธํด์ง๋๋ค. ์ปฌ๋ผ ์์๋ reindex๋ก ํ์ต ๋ ์์์ ๋ง์ถฅ๋๋ค. ์ ๋ฐ์ดํฐ์ ์๋ ์ปฌ๋ผ์ ์ค์๊ฐ์ผ๋ก ์ฑ์๋๋ค.
๋ชจ๋ธ ํ์ต๊ณผ ์๊ณ ์ฐ์ถ
fit_model์ด ์ฃผ์ฑ๋ถ์ ํ์ตํ๊ณ ๋ณต์์ค์ฐจ ์๊ณ๋ฅผ ์ ํฉ๋๋ค.
def fit_model(frame, wanted, variance_kept=0.95, contamination=0.05, skew_threshold=1.5):
"""ํ ๋ฌ์น frame ์ผ๋ก PCA ๋ชจ๋ธ ํ์ต + ๋ณต์์ค์ฐจ ์๊ณ ์ฐ์ถ → ์์ฑ๋ bundle ๋ฐํ."""
matrix, bundle = fit_preprocess(frame, wanted, skew_threshold)
if matrix.shape[0] < 10 or matrix.shape[1] < 2:
raise ValueError(f"ํ์ต ๋ฐ์ดํฐ ๋ถ์กฑ: rows={matrix.shape[0]}, features={matrix.shape[1]}")
pca = PCA(n_components=variance_kept, svd_solver="full")
pca.fit(matrix)
bundle.pca = pca
bundle.n_components = int(pca.n_components_)
...
per_row, _ = reconstruction_error(bundle, matrix)
bundle.error_threshold = float(np.quantile(per_row, 1.0 - contamination))
# ํ๋ฃจ ํ์ ๊ธฐ์ค: ์ ์์ผ์๋ ์ ์๊ณ๋ (contamination)๋งํผ ๋์ผ๋ฏ๋ก(์ 0.05×1440≈72๋ถ),
# '์ ์์ผ์ ์ผ๋ณ ์ด๊ณผ ๋ถ ์' ๋ถํฌ์ 95ํผ์ผํ์ผ์ ํ๋ฃจ ์๊ณ๋ก ์ก๋๋ค(๋ฐ์ดํฐ ๊ธฐ๋ฐ).
over = pd.Series(per_row > bundle.error_threshold, index=frame.index)
daily_counts = over.groupby(over.index.normalize()).sum()
bundle.daily_minutes_threshold = float(daily_counts.quantile(0.95)) if len(daily_counts) else 0.0
return bundle
PCA(n_components=0.95)๋ "๋์ ์ค๋ช
๋ถ์ฐ 95%๋ฅผ ์ฑ์ฐ๋ ๋งํผ" ์ฃผ์ฑ๋ถ์ ์๋ ์ ํํฉ๋๋ค. ์ฃผ์ฑ๋ถ ์๋ฅผ ์์ผ๋ก ์ ํ์ง ์๊ณ , ๋ฐ์ดํฐ์ 95%๋ฅผ ์ค๋ช
ํ ๋งํผ๋ง ๋จ๊น๋๋ค. ๋๋จธ์ง 5%๋ ์ก์์ผ๋ก ๋ณด๊ณ ๋ฒ๋ฆฝ๋๋ค. ์ด "๋ฒ๋ฆฐ 5%"๊ฐ ๋ณต์์ค์ฐจ๋ก ๋ํ๋ฉ๋๋ค. ํ์ ํจํด์ 95% ์ฃผ์ฑ๋ถ์ผ๋ก ์ถฉ๋ถํ ํํ๋์ง๋ง, ์ด์ ํจํด์ ๋ฒ๋ ค์ง ๋ฐฉํฅ์ ์ ๋ณด๊ฐ ์ค๋ ค ์์ด ๋ณต์์ด ํฌ๊ฒ ์ด๊ธ๋ฉ๋๋ค.
๋ณต์์ค์ฐจ๋ ์์ถํ๋ค๊ฐ ๋ณต์ํ ๋ค ์๋ณธ๊ณผ์ ์ฐจ์ด๋ฅผ ์ ๊ณฑํด ํ๊ท ๋ธ ๊ฐ์ ๋๋ค.
def reconstruction_error(bundle, matrix):
"""ํ(๋ถ)๋ณ ๋ณต์์ค์ฐจ์ ํผ์ฒ๋ณ ์ ๊ณฑ์ค์ฐจ๋ฅผ ๋ฐํ."""
if matrix.shape[0] == 0:
return np.empty(0), np.empty((0, len(bundle.columns)))
reduced = bundle.pca.transform(matrix) # ์์ถ(์ฃผ์ฑ๋ถ ์ขํ)
restored = bundle.pca.inverse_transform(reduced) # ๋ณต์
per_feature_sq = (matrix - restored) ** 2
per_row = per_feature_sq.mean(axis=1)
return per_row, per_feature_sq
์ฌ๊ธฐ์ ๋ ๊ฐ์ง๋ฅผ ๊ฐ์ด ๋ฐํํ๋ ๊ฒ ๋ค์ ์ค์ํด์ง๋๋ค. per_row๋ 1๋ถ๋ง๋ค์ ๋ณต์์ค์ฐจ(์ด์ ์ ์), per_feature_sq๋ ๊ทธ ๋ถ์์ ์ด๋ ์งํ๊ฐ ์ผ๋ง๋ ๋ณต์์ ์ด๊ธ๋๊ฒ ํ๋์ง(ํผ์ฒ๋ณ ์ ๊ณฑ์ค์ฐจ)์
๋๋ค. ํ์๊ฐ "๋ฌด์์ด ํ์์ ๋ฌ๋๋"๋ฅผ ์๋ ค์ฃผ๋ ์ฌ๋ฃ์
๋๋ค.
์ ์๊ณ์ ํ๋ฃจ ์๊ณ ๋ ๋จ๊ณ
์๊ณ๋ฅผ ๋ ๋จ๊ณ๋ก ๋ ๊ฒ ์ด ์ค๊ณ์ ํต์ฌ ๊ฒฐ์ ์ ๋๋ค.
์ ์๊ณ(error_threshold) — ํ์ต ๋ณต์์ค์ฐจ์ 95ํผ์ผํ์ผ์
๋๋ค(contamination=0.05). ์ฆ "์ ์ ๋ฐ์ดํฐ์ ์์ 5%๋งํผ ์ด๊ธ๋ ๋ถ"์ ์ด์ ๋ถ์ผ๋ก ์นฉ๋๋ค.
๋ฌธ์ ๋, ์ด ์ ์๊ณ๋ง ์ฐ๋ฉด ์ ์์ผ์๋ ๋งค์ผ ์ฝ 5%(ํ๋ฃจ 1440๋ถ ์ค ์ฝ 72๋ถ)๊ฐ ์ด์์ผ๋ก ์กํ๋ค๋ ์ ์ ๋๋ค. 95ํผ์ผํ์ผ์ด๋ ์ ์์ ๊ทธ๋ ์ต๋๋ค. ๋งค์ผ 72๋ถ์ฉ "์ด์"์ด๋ผ๊ณ ์๋ฆฌ๋ฉด ์๋ฆผ์ด ๋ฌด์๋ฏธํด์ง๋๋ค.

๊ทธ๋์ ํ๋ฃจ ์๊ณ(daily_minutes_threshold) ๋ฅผ ํ ๊ฒน ๋ ๋์ต๋๋ค. ํ์ต ๋ฐ์ดํฐ๋ฅผ ๋ ์ง๋ณ๋ก ๋ฌถ์ด "๊ทธ๋ ์ ์๊ณ๋ฅผ ๋์ ๋ถ ์"๋ฅผ ์ธ๊ณ , ๊ทธ ๋ถํฌ์ 95ํผ์ผํ์ผ์ ํ๋ฃจ ๋ฐ๋ ๊ธฐ์ค์ผ๋ก ์ก์ต๋๋ค. ์ ์์ผ์ด๋ผ๋ ๋ณดํต ๋ฉฐ์น ์ ์ ์๊ณ๋ฅผ 60~80๋ถ ๋๊ธฐ๋๋ฐ, ๊ทธ ์ ์ ๋ณ๋ ํญ์ ๋ฐ์ดํฐ๋ก ์ธก์ ํด ๋๋ ๊ฒ๋๋ค. ์ค๋์ ์ด๊ณผ ๋ถ์ด ๊ทธ ํ์ ๋ฒ์๋ฅผ ์ ์ํ๊ฒ ๋์ ๋๋ง "์ด์ํ ๋ "๋ก ๋ฐ๋ํฉ๋๋ค. ์๊ณ๋ฅผ ์์ผ๋ก ์ ํ ๊ฒ ์๋๋ผ ์ ์ ๋ฐ์ดํฐ ๋ถํฌ์์ ๋ฝ์๋ค๋ ์ ์ด ์ค์ํฉ๋๋ค. ๋งค์ผ ์ค๋ฐ๋ํ์ง ์๊ฒ ํ๋ ์์ ์ฅ์น์
๋๋ค.
์ถ๋ก ๊ณผ ํ๋ฃจ ํ์
๋งค์ผ ๋๋ detect_ml์ด ์ด ๋์ ์ข
ํฉํฉ๋๋ค.
def detect_ml(db_metrics: DbMetrics, config: dict) -> List[Anomaly]:
"""PCA ๋ณต์์ค์ฐจ ๊ธฐ๋ฐ ํจํด ์ด์ ํ์ง."""
ml_cfg = config.get("global", {}).get("ml", {})
if not ml_cfg.get("enabled", False):
return []
db_name = db_metrics.db_name
model_path = os.path.join(ml_cfg.get("model_dir", "models"), f"{db_name}.pkl")
today = db_metrics.today
if not os.path.exists(model_path) or today is None or today.empty:
if not os.path.exists(model_path):
print(f"[ml] ๋ชจ๋ธ ์์(skip): {model_path} — train.py ๋ก ํ์ต ํ์")
return []
bundle = joblib.load(model_path)
matrix, index = ml.transform(bundle, today)
if matrix.shape[0] == 0:
return []
per_row, per_feature_sq = ml.reconstruction_error(bundle, matrix)
over = per_row > bundle.error_threshold
anomaly_minutes = int(over.sum())
min_floor = int(ml_cfg.get("min_anomaly_minutes", 10))
need_minutes = max(min_floor, bundle.daily_minutes_threshold)
if anomaly_minutes < need_minutes:
return []
...
ํ๋ฆ์ ์ด๋ ์ต๋๋ค. ๋ชจ๋ธ์ด ์๊ฑฐ๋ ๋ฐ์ดํฐ๊ฐ ์์ผ๋ฉด ์กฐ์ฉํ ๋น ๋ฆฌ์คํธ๋ฅผ ๋๋ ค์ค๋๋ค(๋งค์ผ ๋๋ cron์ด ๋ชจ๋ธ ํ ๊ฐ ์๋ค๊ณ ์ฃฝ์ผ๋ฉด ์ ๋ฉ๋๋ค). ๋ชจ๋ธ์ด ์์ผ๋ฉด ์ค๋ ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ์ ์ฒ๋ฆฌ๋ก ๋ณํํ๊ณ , ๋ถ๋น ๋ณต์์ค์ฐจ๋ฅผ ๊ตฌํด ์ ์๊ณ๋ฅผ ๋์ ๋ถ ์๋ฅผ ์ ๋๋ค. ๊ทธ ๋ถ ์๊ฐ ํ๋ฃจ ์๊ณ(์ค์ ์ต์๊ฐ๊ณผ ํ์ต ์๊ณ ์ค ํฐ ๊ฐ)๋ณด๋ค ์์ผ๋ฉด ๋ฐ๋ํ์ง ์์ต๋๋ค. ์ฆ "ํ์๋ณด๋ค ์ ์ํ๊ฒ ๋ง์ ๋ถ์ด ์ด๊ธ๋ ๋ " ๋ง ์ด์์ผ๋ก ๋ด ๋๋ค.
์ Isolation Forest๋ ์คํ ์ธ์ฝ๋๊ฐ ์๋๋ผ PCA์ธ๊ฐ
์ฒ์ ๋น์ง๋ ํ์ง๋ก ๊ฒํ ํ ๊ฑด Isolation Forest์ ์คํ ์ธ์ฝ๋์์ต๋๋ค. ๊ฒฐ๊ตญ PCA๋ก ์ ํ ๋ฐ๋ ์ด์ ๊ฐ ์์ต๋๋ค.
- ์ค๋ช
๊ฐ๋ฅ์ฑ — PCA ๋ณต์์ค์ฐจ๋ "์ด๋ ์งํ๊ฐ ๋ณต์์ ์ผ๋ง๋ ์ด๊ธ๋๊ฒ ํ๋์ง"๋ฅผ ํผ์ฒ๋ณ ์ ๊ณฑ์ค์ฐจ๋ก ๋ฐ๋ก ๋ถํดํ ์ ์์ต๋๋ค(
per_feature_sq). ์ด๊ฒ ๋ค์์ "๋ฌด์์ด ์ด์ํ๊ฐ"๋ฅผ ๋ง๋๋ ์ฌ๋ฃ๊ฐ ๋ฉ๋๋ค. Isolation Forest๋ ์ด์ ์ ์๋ ์ฃผ์ง๋ง "์ด๋ ์งํ ๋๋ฌธ์"๋ฅผ ๊น๋ํ๊ฒ ๋ถํดํ๊ธฐ ์ด๋ ต์ต๋๋ค. DBA์๊ฒ "์ด์์ ๋๋ค"๋ง ๋์ง๋ฉด ์ธ๋ชจ๊ฐ ์๊ณ , "์ด ์งํ๋ค์ด ํ์์ ๋ฌ๋์ต๋๋ค"๊น์ง ๋งํด์ผ ํฉ๋๋ค. - ๊ฐ๋ฒผ์๊ณผ ๊ฒฐ์ ์ฑ — PCA๋ ์ ํ์ด๋ผ ํ์ต์ด ๋น ๋ฅด๊ณ , ์ฐ์ถ๋ฌผ์ด ์์ zip ๋ฉํ(๋์ปค ์๋ ์จํ๋ ๋ฏธ์ค)์ ๋ง์ต๋๋ค. ์คํ ์ธ์ฝ๋๋ ๋ ๋ณต์กํ ๋น์ ํ ํจํด์ ์ก์ ์ ์์ง๋ง ํ์ต ๋น์ฉ·์์กด์ฑ·ํ๋ ๋ถ๋ด์ด ํฝ๋๋ค. POC ๋จ๊ณ์์๋ ๊ณผํฉ๋๋ค.
- ๋จ๊ณ์ ๊ณ ๋ํ — PCA๋ก ๊ณจ๊ฒฉ(์ ์ฒ๋ฆฌ·๋ณต์์ค์ฐจ·์๊ณ·์ํฅ ์งํ ์ฐ์ถ)์ ๋จผ์ ์ธ์ ๋๋ฉด, ๋์ค์ ๊ฐ์ ์๋ฆฌ์ ์คํ ์ธ์ฝ๋๋ฅผ ๋ผ์ ๋ฃ๊ธฐ ์ฝ์ต๋๋ค. ๋ณต์์ค์ฐจ๋ผ๋ ๊ฐ๋ ๊ณผ ์ธํฐํ์ด์ค๊ฐ ๋์ผํ๊ธฐ ๋๋ฌธ์ ๋๋ค. ๊ทธ๋์ POC๋ PCA๋ก ํ๊ณ , ๊ณ ๋ํ ๋จ๊ณ์์ ์คํ ์ธ์ฝ๋๋ก ๊ต์ฒดํ๋ ๋ฐฉํฅ์ผ๋ก ์ก์์ต๋๋ค.
๋ฌด์์ด ์ด์ํ๊ฐ ๋ง๋ค๊ธฐ — ์ํฅ ์งํ ์ฐ์ถ
๋ณต์์ค์ฐจ๊ฐ ํฌ๋ค๋ ๊ฑด "ํ์์ ๋ค๋ฅด๋ค"๋ ์ ํธ์ผ ๋ฟ, "๋ฌด์์ด ์ด๋ป๊ฒ ๋ฌ๋๋์ง"๋ ์๋๋๋ค. DBA์๊ฒ๋ ๊ทธ "๋ฌด์"์ด ํ์ํฉ๋๋ค. ๊ทธ๋์ detect_ml ๋ค์ชฝ์์ ์ํฅ ์งํ๋ฅผ ๋ฝ์ต๋๋ค.

๊ทธ๋ฆผ6์ ์ํฅ ์งํ๋ฅผ ๋ง๋๋ ํ๋ฆ์ ๋๋ค. ํ๋ฃจ ์ค ๋ณต์์ค์ฐจ๊ฐ ๊ฐ์ฅ ํฐ 1๋ถ(์ต์ ์ ๋ถ)์ ๊ณ ๋ฅด๊ณ , ๊ทธ ๋ถ์์ ํผ์ฒ๋ณ ์ ๊ณฑ์ค์ฐจ๊ฐ ํฐ ์งํ Top3๋ฅผ ๋ฝ์ต๋๋ค. ๊ทธ๋ค์ ๊ฐ ์งํ๊ฐ ํ์(๊ทธ๋ ํ๊ท ) ๋๋น ํผํฌ ์๊ฐ์ ์ด๋ป๊ฒ ๋ฌ๋๋์ง(์ฌ๋๋์ง/๋ด๋ ธ๋์ง, ๋ช ๋ฐฐ์ธ์ง)๋ฅผ ๊ณ์ฐํฉ๋๋ค.
# ์ต์
์ ๋ถ + ๊ทธ ๋ถ์์ ๋ณต์์ค์ฐจ๊ฐ ํฐ ์งํ Top3('๋ฌด์์ด ํ์์ ๋ฌ๋๋')
worst_pos = int(per_row.argmax())
worst_error = float(per_row[worst_pos])
peak_at = index[worst_pos] if len(index) > worst_pos else None
top_idx = per_feature_sq[worst_pos].argsort()[::-1][:3]
top_metrics = [bundle.columns[i] for i in top_idx]
# ์ํฅ ์งํ๊ฐ 'ํ์(๊ทธ๋ ํ๊ท ) ๋๋น ํผํฌ ์๊ฐ์ ์ด๋ป๊ฒ ๋ฌ๋๋' ๊น์ง ๊ณ์ฐ
top_detail = []
for col in top_metrics:
entry = {"name": col}
if col in today.columns and peak_at is not None:
s = pd.to_numeric(today[col], errors="coerce").dropna()
if not s.empty and peak_at in s.index:
peak_v = float(s.loc[peak_at])
avg_v = float(s.mean())
entry.update({
"peak": peak_v, "avg": avg_v,
"arrow": "↑" if peak_v >= avg_v else "↓",
"ratio": (peak_v / avg_v) if avg_v not in (0, 0.0) else None,
})
top_detail.append(entry)
์ค๋ณ๋ก ๋ณด๋ฉด, per_row.argmax()๋ก ํ๋ฃจ ์ค ๋ณต์์ค์ฐจ๊ฐ ๊ฐ์ฅ ํฐ ๋ถ์ ์์น๋ฅผ ์ฐพ๊ณ , peak_at์ผ๋ก ๊ทธ ์๊ฐ์ ์ก์ต๋๋ค. ๊ทธ ๋ถ์ per_feature_sq[worst_pos](์งํ๋ณ ์ ๊ณฑ์ค์ฐจ)๋ฅผ ๋ด๋ฆผ์ฐจ์ ์ ๋ ฌํด ์์ 3๊ฐ๋ฅผ ์ํฅ ์งํ๋ก ๊ณ ๋ฆ
๋๋ค. ๊ทธ ๋ถ์์ ๋ณต์์ ๊ฐ์ฅ ํฌ๊ฒ ์ด๊ธ๋๊ฒ ํ ์งํ๋ค์ด ๊ณง "๋ฌด์์ด ํ์์ ๋ฌ๋๋"์
๋๋ค.
๊ทธ๋ค์ ์ํฅ ์งํ ๊ฐ๊ฐ์ ๋ํด, ๊ทธ๋ ํ๊ท (avg_v)๊ณผ ํผํฌ ์๊ฐ ๊ฐ(peak_v)์ ๋น๊ตํด ๋ฐฉํฅ(arrow, ์ฌ๋๋์ง ๋ด๋ ธ๋์ง)๊ณผ ๋ฐฐ์(ratio)๋ฅผ ์ฑ์๋๋ค. ์ด๋ฆ๋ง์ผ๋ก๋ "Innodb Rows Read๊ฐ ์ํฅ ์งํ"๋ผ๊ณ ํด ๋ด์ผ ๋ฌด์จ ์ผ์ด ๋ฌ๋์ง ๋ชจ๋ฆ
๋๋ค. "ํ์ 4,495์๋๋ฐ ํผํฌ 938,813์ผ๋ก ์ฝ 208๋ฐฐ ํ์๋ค"๊น์ง ์ค์ผ ์๋ฏธ๊ฐ ํตํฉ๋๋ค. ์ฌ๊ธฐ์๋ avg_v๊ฐ 0์ด๋ฉด ๋ฐฐ์๋ฅผ None์ผ๋ก ๋ฌ 0 ๋๋์
์ ํผํฉ๋๋ค.
์ค์ ๋ฐ์ดํฐ๋ก ๋ณด๋ฉด ์ด๋ฐ ์ผ์ด์ค๊ฐ ๋์ต๋๋ค. MySQL ์ด๋ ๋ , ๋ณต์์ค์ฐจ ์ต์
์ ๋ถ์์ ์ํฅ ์งํ Top์ผ๋ก Innodb Rows Read๊ฐ ํ์ 4,495 → ํผํฌ 938,813(์ฝ 208๋ฐฐ), Innodb Buffer Pool Read Requests๊ฐ ํ์ 7,822 → ํผํฌ 3,154,251(์ฝ 403๋ฐฐ)๋ก ํจ๊ป ํ์์ต๋๋ค. ๋ ๋ค ๋์คํฌ ์ฝ๊ธฐ·์กฐํ ๊ณ์ด์
๋๋ค. ๊ฐ๊ฐ์ ์ ๋๊ฐ์ ์๊ณ ๊ท์น์ผ๋ก ๋ชป ์ก์์ง๋ง, ๋์ด ๊ฐ์ ์๊ฐ์ ํจ๊ป ํ์ ์กฐํฉ์ ํฌ๊ฒ ๋ฒ์ด๋ ๊ฒ๋๋ค. ์ํฅ ์งํ ์นดํ
๊ณ ๋ฆฌ๊ฐ "๋์คํฌ ์ฝ๊ธฐ·์กฐํ"๋ก ๋ชจ์ด๋, ์ง๋จ์ "์ธ๋ฑ์ค๋ฅผ ๋ชป ํ๋ ๋๋ ์กฐํ"๋ก ์ถ์ ํฉ๋๋ค.
๋ง์ง๋ง์ผ๋ก ๋ฑ๊ธ์ ๋งค๊ธฐ๊ณ Anomaly๋ก ํฌ์ฅํฉ๋๋ค.
# ๋ฑ๊ธ: ์ด๊ณผ ๋ถ์ด ์ ์์ผ ๊ธฐ์ค์ 2๋ฐฐ↑ ๊ฑฐ๋ ์ค์ฐจ๊ฐ ์ ์๊ณ์ 3๋ฐฐ↑ ๋ฉด critical, ๊ทธ ์ธ warning
grade = "critical" if (anomaly_minutes >= 2 * need_minutes or
worst_error >= 3 * bundle.error_threshold) else "warning"
return [Anomaly(
db_name=db_name, instance=db_metrics.instance,
metric="ํจํด ์ด์(PCA)", kind="ml", grade=grade,
value=worst_error, threshold=float(bundle.error_threshold),
at_time=peak_at,
detail={"anomaly_minutes": anomaly_minutes, "top_metrics": top_metrics,
"top_metrics_detail": top_detail},
)]
ML ์ด์๋ ๊ฒฐ๊ตญ ๊ฐ์ Anomaly ๊ฐ์ฒด๋ก ๋์ต๋๋ค. kind="ml"์ด๊ณ , ์งํ๋ช
์ "ํจํด ์ด์(PCA)"์ด๋ผ๋ ๊ณ ์ ๋ฌธ์์ด์ด๋ฉฐ, ์ํฅ ์งํ ๋ชฉ๋ก(top_metrics)๊ณผ ์์ธ(top_metrics_detail)๊ฐ detail์ ๋ค์ด๊ฐ๋๋ค. ๋ฑ๊ธ์ ์ด๊ณผ ๋ถ์ด ํ๋ฃจ ๊ธฐ์ค์ 2๋ฐฐ ์ด์์ด๊ฑฐ๋ ์ต์
์ค์ฐจ๊ฐ ์ ์๊ณ์ 3๋ฐฐ ์ด์์ด๋ฉด ์ํ, ์๋๋ฉด ์ฃผ์์
๋๋ค. ์ด๊ธ๋จ์ด ๋๊ฒ(๋ถ ์) ํน์ ๊น๊ฒ(์ค์ฐจ ํฌ๊ธฐ) ์ผ์ด๋๋ฉด ์ํ์ผ๋ก ์ฌ๋ฆฝ๋๋ค.
์กฐ๊ธ ์ ์ํฅ ์งํ ์์ธ์์ ํ ๊ฐ์ง๋ฅผ ๋ ์ฒ๋ฆฌํฉ๋๋ค. ๋ณํ๊ฐ ๋ฏธ๋ฏธํ ์ํฅ ์งํ๋ฅผ "ํ์ ์์ค"์ผ๋ก ํ์ํ๋ ๊ฑด๋ฐ, ์ด๊ฑด ํ์ ๋จ๊ณ์ ์ผ์ด๋ผ ๋ฆฌํฌํธ ์ชฝ์์ ๋ค๋ฃน๋๋ค. ๋ค์ ์ ์์ ๋ด ๋๋ค.
๋ฆฌํฌํธ์ ๋ น์ด๊ธฐ — ๋ฃฐ๋ฒ ์ด์ค์ ML์ ํ ํ๋ฉด์
ํ์ง๊ฐ ๋ ๊ฐ๋์ฌ๋ DBA๊ฐ ๋ณด๋ ๊ฑด ํ ์ฅ์ ๋ฆฌํฌํธ์ ๋๋ค. ๋ฃฐ๋ฒ ์ด์ค๋ก ์ก์ ๊ฒ๊ณผ ML๋ก ์ก์ ๊ฒ์ ๋ฐ๋ก ๋๋ฉด ํผ๋์ค๋ฝ์ต๋๋ค. ๊ทธ๋์ ๊ฐ์ ์ง๋จ ์นด๋ ์์ ํตํฉํ๋, ์ถ์ฒ๋ฅผ ์์ผ๋ก ๊ตฌ๋ถํ์ต๋๋ค.

๊ทธ๋ฆผ7์ด ์ง๋จ ์นด๋ ํ ์ฅ์ ๊ตฌ์กฐ์ ๋๋ค. ์์์๋ถํฐ โ ๊ฐ์ง๋ ์ด์(๋ฌด์์ด ์กํ๋), โก ์์ธ ์ถ์ (์ ๊ทธ๋ด๊น), โข ๊ถ์ฅ ์กฐ์น(๋ฌด์์ ํ์ธํ ๊น), โฃ ์ข ํฉ ํ ์ค. ๋ฃฐ๋ฒ ์ด์ค ํญ๋ชฉ์ ๊ธฐ๋ณธ์(๋นจ๊ฐ/์ฃผํฉ), ML ํญ๋ชฉ์ ๋ณด๋ผ์์ผ๋ก ๊ฐ์ ์นธ์ ์์ ๋๋ค. ๋ณด๋ผ์๋ง ๋ฐ๋ผ๊ฐ๋ฉด "๊ธฐ๊ณ๊ฐ ์ถ๊ฐ๋ก ์ก์ ๊ฒ"์ด ํ๋์ ๋ณด์ ๋๋ค.
โ ๊ฐ์ง๋ ์ด์ ์ค ๋ง๋ค๊ธฐ
_anomaly_detail_line์ด Anomaly ํ ๊ฑด์ ์ฌ๋์ด ์ฝ๋ ํ ์ค๋ก ๋ฐ๊ฟ๋๋ค. kind๋ง๋ค ํํ์ด ๋ค๋ฆ
๋๋ค.
def _anomaly_detail_line(a: Anomaly) -> str:
"""โ ๊ฐ์ง๋ ์ด์ — ํญ๋ชฉ 1๊ฑด ์์ธ(๋จ์·์ ํ ์๊ฐ ํฌํจ)."""
if a.kind == "threshold":
dur = a.detail.get("duration_min")
at = f" · ์ต๊ณ ์๊ฐ {a.at_time:%Y-%m-%d %H:%M:%S}" if a.at_time is not None else ""
return f"{a.metric}: ์ต๊ณ <b>{_fmt_value(a.metric, a.value)}</b> (์๊ณ {a.threshold:g} · {dur}๋ถ ์ด๊ณผ{at})"
if a.kind == "surge":
return (f"{a.metric}: ์ค๋ ํ๊ท <b>{_fmt_value(a.metric, a.value)}</b> "
f"(์ ์ผ {_fmt_value(a.metric, a.baseline)} · {a.detail.get('ratio',0):.2f}๋ฐฐ↑)")
if a.kind == "pattern":
return (f"{a.metric}: ์ ์ผ ๋๋น <b>+{a.detail.get('rise_pp',0):.0f}%p</b> "
f"({_fmt_value(a.metric, a.baseline)}→{_fmt_value(a.metric, a.value)})")
...
๋ฃฐ๋ฒ ์ด์ค ์
์ ๋จ์ํฉ๋๋ค. ์๊ณ๋ "์ต๊ณ X (์๊ณ Y · N๋ถ ์ด๊ณผ · ์ต๊ณ ์๊ฐ)", ๊ธ์ฆ์ "์ค๋ ํ๊ท X (์ ์ผ Y · N๋ฐฐ↑)", ํจํด์ "์ ์ผ ๋๋น +N%p"์
๋๋ค. _fmt_value๋ก ๋จ์์ ๋ง๊ฒ ํ๊ธฐํฉ๋๋ค(% / count / bytes ๋ฑ).
ML ์ค์ด ๊ฐ์ฅ ๊ณต์ด ๋ค์ด๊ฐ์ต๋๋ค.
if a.kind == "ml":
minutes = a.detail.get("anomaly_minutes")
at = a.at_time.strftime("%Y-%m-%d %H:%M:%S") if a.at_time is not None else "-"
detail = a.detail.get("top_metrics_detail") or []
def _changed(d):
if "peak" not in d:
return False
r = d.get("ratio")
return not (_fmt_value(d["name"], d["avg"]) == _fmt_value(d["name"], d["peak"])
or (r is not None and 0.9 <= r <= 1.1))
def _val(d): # 'ํ์ X → ํผํฌ Y ↑N๋ฐฐ' ๋๋ 'ํ์ ์์ค'
if not _changed(d):
return "ํ์ ์์ค"
r = d.get("ratio")
rt = f", {r:.1f}๋ฐฐ" if r and r >= 1.5 else ""
return f"ํ์ {_fmt_value(d['name'], d['avg'])} → ํผํฌ {_fmt_value(d['name'], d['peak'])} {d['arrow']}{rt}"
# ๋ณํ ํญ(์ฆ๊ฐ๋ ๋ฐฐ์, ๊ฐ์๋ ์ญ์)์ด ๊ฐ์ฅ ํฐ ์งํ๋ฅผ ๋ํ๋ก ๋งจ ์์
def _mag(d):
r = d.get("ratio")
return max(r, 1.0 / r) if r else 0.0
changed = [d for d in detail if _changed(d)]
lead = max(changed, key=_mag) if changed else (detail[0] if detail else None)
if lead is None:
return f"<b>ํจํด ์ด์</b> <span style='color:#7b3fb5;font-weight:bold;'>(ML · ํจํด ์ด์)</span>: ํ์์ ๋ค๋ฅธ ํจํด {minutes}๋ถ (ํผํฌ {at})"
head = (f"<b>{lead['name']}</b> <span style='color:#7b3fb5;font-weight:bold;'>(ML · ํจํด ์ด์)</span>: "
f"{_val(lead)}")
rest = [f"{d['name']}({_val(d)})" for d in detail if d is not lead]
rest_str = (" · ํจ๊ป ๋ณ๋: " + ", ".join(rest)) if rest else ""
return f"{head}<br><span style='color:#888;'>(ํ์์ ๋ค๋ฅธ ํจํด {minutes}๋ถ · ํผํฌ {at}{rest_str})</span>"
return a.metric
์ฌ๊ธฐ ๋ค์ด๊ฐ ํ๋จ์ด ์ ์ ๋๋ค.
๋ํ ์งํ๋ฅผ ๋งจ ์์ — ์ํฅ ์งํ Top3 ์ค ๋ณํ ํญ์ด ๊ฐ์ฅ ํฐ ์งํ๋ฅผ ๋ํ๋ก ๊ณจ๋ผ ์ค ๋งจ ์์ ๋ก๋๋ค(_mag๋ก ์ ๋ ฌ). ๋ณํ ํญ์ ์ฆ๊ฐ๋ฉด ๋ฐฐ์, ๊ฐ์๋ฉด ์ญ์๋ก ๋ด์ "๊ฐ์ฅ ํฌ๊ฒ ๋ณํ ๊ฒ"์ ์ก์ต๋๋ค. ๋ฃฐ๋ฒ ์ด์ค ํญ๋ชฉ์ด "์งํ๋ช
: ๊ฐ" ํ์์ผ๋ก ํ๋์ ์ฝํ๋ ๊ฒ๊ณผ ๊ฐ์ ๊ฐ๋
์ฑ์ ML ํญ๋ชฉ์๋ ์ฃผ๋ ค๋ ๊ฒ๋๋ค. ์์๋ก๋ Innodb Buffer Pool Read Requests๊ฐ 403๋ฐฐ๋ก ๊ฐ์ฅ ํฌ๊ฒ ๋ณํ๋ค๋ฉด ๊ทธ๊ฒ ๋ํ๋ก ์์ ์๊ณ , ๋๋จธ์ง๋ "ํจ๊ป ๋ณ๋"์ผ๋ก ๋ค์ ๋ถ์ต๋๋ค.
๋ณํ๊ฐ ๋ฏธ๋ฏธํ ์ํฅ ์งํ๋ "ํ์ ์์ค" — _changed๊ฐ ํต์ฌ์
๋๋ค. ์ํฅ ์งํ Top3๋ผ๊ณ ๋ค ํฌ๊ฒ ๋ณํ ๊ฑด ์๋๋๋ค. ๋ณต์์ค์ฐจ์ ์ฝ๊ฐ ๊ธฐ์ฌํ์ง๋ง ์ค์ ๊ฐ์ ํ์์ ๊ฑฐ์ ๊ฐ์ ์งํ๋ ๋ผ์ด ์์ ์ ์์ต๋๋ค. ํนํ ํ์ 0์ด๋ ์งํ๊ฐ ํผํฌ์๋ 0์ด๋ฉด, ๋จ์ ํฌ๋งท์ ๋ ๋ค "0"์ผ๋ก ์ฐํ "ํ์ 0 → ํผํฌ 0"์ด๋ผ๋ ๋ฌด์๋ฏธํ ์ค์ด ๋์ต๋๋ค. ๊ทธ๋์ ํ์ ํฌ๋งท์ด ๊ฐ๊ฑฐ๋(_fmt_value ๊ฒฐ๊ณผ ๋์ผ) ๋ฐฐ์๊ฐ 0.9~1.1 ์ฌ์ด๋ฉด ๋ณํ ์์์ผ๋ก ๋ณด๊ณ "ํ์ ์์ค"์ด๋ผ๊ณ ๋ง ์ ์ต๋๋ค. ๊ฑฐ์ง ๋ณํ๋ฅผ ์ ๋ณด์ฌ์ฃผ๋ ๊ฒ๋๋ค.
(ML · ํจํด ์ด์) ๋ผ๋ฒจ๊ณผ ๋ณด๋ผ์ — ML ํญ๋ชฉ์๋ ๋ณด๋ผ์ (ML · ํจํด ์ด์) ๋ผ๋ฒจ์ ๋ถ์
๋๋ค(#7b3fb5). ๋ฃฐ๋ฒ ์ด์ค ํญ๋ชฉ๊ณผ ํ ๋ชฉ๋ก์ ์์ด๋ ์ถ์ฒ๊ฐ ๋ณด์
๋๋ค.
โก ์์ธ ์ถ์ / โข ๊ถ์ฅ ์กฐ์น — ์๊ด ๊ท์น ๋งค์นญ
๋ฃฐ๋ฒ ์ด์ค ์ง๋จ์ "์งํ๋ฅผ ์นดํ ๊ณ ๋ฆฌ๋ก ๋ถ๋ฅ → ๋์ ๋ฐ์ ํจํด์ ์๊ด ๊ท์น์ ๋งค์นญ"์ผ๋ก ๋ง๋ญ๋๋ค.

๊ทธ๋ฆผ8์ ์ง๋จ์ ๋ง๋๋ ํ๋ฆ์ ๋๋ค. ์ด์ ์งํ๋ค์ ์นดํ ๊ณ ๋ฆฌ(cpu/session/write/lock ๋ฑ)๋ก ๋ถ๋ฅํด ์งํฉ์ผ๋ก ๋ชจ์ ๋ค, ์ฐ์ ์์ ์์๋ก ์๊ด ๊ท์น์ ๊ฒ์ฌํฉ๋๋ค. ์ฒซ ๋งค์นญ์์ ๋ฉ์ถ๊ณ ๊ทธ ๊ท์น์ ์์ธ·์กฐ์น·์ข ํฉ์ ์๋๋ค. ์๋ฌด ๊ท์น์๋ ์ ๊ฑธ๋ฆฌ๋ฉด ํด๋ฐฑ ํ ํ๋ฆฟ์ ์๋๋ค.
์งํ๋ฅผ ์นดํ ๊ณ ๋ฆฌ๋ก ๋ถ๋ฅํ๋ ๊ท์น์ ๋๋ค.
_CATEGORY_RULES = [
("cpu", ("cpu",)),
("lock", ("lock", "์ ๊ธ")),
("longq", ("long active", "long query", "trx time", "elapse")),
("write", ("redo", "undo", "log file sync", "com insert", "com update",
"com delete", "xact commit", "tup updated", "tup inserted", "tup deleted")),
("session", ("active session", "active backend", "threads running", "session", "backend", "numbackends")),
("conn", ("connection",)),
("mem", ("memory",)),
("storage", ("storage", "filesystem", "disk", "volume")),
("io", ("iops", "tps", "throughput", "queries", "physical reads", "blks read", "rows read")),
]
def _category(metric: str) -> Optional[str]:
"""์งํ๋ช
์ ์ง๋จ์ฉ ์นดํ
๊ณ ๋ฆฌ๋ก ๋ถ๋ฅ(์ฒซ ๋งค์นญ)."""
low = metric.lower()
for cat, keywords in _CATEGORY_RULES:
if any(k in low for k in keywords):
return cat
return None
์งํ๋ช ์ ๋ค์ด๊ฐ ๋จ์ด๋ก ์นดํ ๊ณ ๋ฆฌ๋ฅผ ์ ํฉ๋๋ค. "rows read"๋ "physical reads"๊ฐ ๋ค์ด๊ฐ๋ฉด io, "redo"·"undo"·"com insert"๋ฉด write, "cpu"๋ฉด cpu์ ๋๋ค. ์์์๋ถํฐ ์ฒซ ๋งค์นญ์ด๋ผ ์์๊ฐ ์ฐ์ ์์์ ๋๋ค.
๊ทธ๋ค์ _diagnose๊ฐ ์นดํ
๊ณ ๋ฆฌ ์กฐํฉ์ ์๊ด ๊ท์น์ ๋งค์นญํฉ๋๋ค.
def _diagnose(det: DbDetection) -> dict:
"""์ด์ ์งํ ์นดํ
๊ณ ๋ฆฌ ๋์ ๋ฐ์ → ์๊ด ๊ท์น์ผ๋ก ์์ธ์ถ์ ·๊ถ์ฅ์กฐ์น·์ข
ํฉ ์์ฑ.
ML(PCA) ์ด์์ ์งํ๋ช
์ด 'ํจํด ์ด์(PCA)'์ด๋ผ ๋ถ๋ฅ ๋ถ๊ฐ → ์ํฅ ์งํ(top_metrics)๋ก ๋ถ๋ฅํ๋ค.
"""
cats: set = set()
for a in det.anomalies:
names = a.detail.get("top_metrics", []) if a.kind == "ml" else [a.metric]
cats.update(c for c in (_category(n) for n in names) if c)
tool = _DB_TOOLS.get(det.db_name, "")
def has(*names):
return any(n in cats for n in names)
# ์ฐ์ ์์ ์๊ด ๊ท์น
if has("cpu") and has("session", "write"):
return {
"title": "๋๋ DML ๋ฐฐ์น/๋น์ ์ ํธ๋์ญ์
์ ์ํ ๋ถํ (์ถ์ )",
"causes": [
"CPU ๊ธ๋ฑ๊ณผ ์ธ์
·์ฐ๊ธฐ(Redo·Undo·DML) ์งํ๊ฐ ๊ฐ์ ์๊ฐ๋์ ๋๋ฐ ๋ฐ์ → ๋จ์ผ ์์
์ด ์์ธ์ผ ๊ฐ๋ฅ์ฑ.",
...
],
"actions": [
f"ํผํฌ ์๊ฐ ์ ํ ํ์ฑ ์ธ์
·Top SQL ํ์ธ ({tool})",
...
],
"summary": "์ผ์์ ๋ฐฐ์น์ฑ ๋ถํ๋ก ๋ณด์ด๋ฉฐ, ๋์ผ ํจํด ๋ฐ๋ณต ์ ์ผ๊ฐ ๋ฐฐ์น ์ฌ์ค๊ณ๋ฅผ ๊ถ์ฅํฉ๋๋ค.",
}
if has("lock") and has("longq"):
return { "title": "์ฅ๊ธฐ ํธ๋์ญ์
์ ์ํ ์ ๊ธ ๊ฒฝํฉ (์ถ์ )", ... }
if has("lock"):
return { "title": "์ ๊ธ ๋๊ธฐ ๋ฐ์ (์ถ์ )", ... }
if has("conn"):
return { "title": "์ปค๋ฅ์
์ฌ์ฉ๋ฅ ์์น (์ถ์ )", ... }
if has("storage"):
return { "title": "์ฌ์ ๊ณต๊ฐ ๊ธ๊ฐ (์ถ์ )", ... }
if has("cpu"):
return { "title": "CPU ์ฌ์ฉ๋ฅ ์์น (์ถ์ )", ... }
if has("write"):
return { "title": "์ฐ๊ธฐ ๋ถํ ๊ธ์ฆ (์ถ์ )", ... }
# ํด๋ฐฑ
return {
"title": "ํ์์ ๋ค๋ฅธ ์ถ์ด ๊ฐ์ง",
"causes": ["์ผ๋ถ ์งํ๊ฐ ํ์(์ ์ผ) ๋๋น ๋ฒ์ด๋ ์ถ์ด๋ฅผ ๋ณด์
๋๋ค."],
"actions": [f"ํด๋น ์๊ฐ ์ํ ์์
·์ธ์
ํ์ธ ({tool})" if tool else "ํด๋น ์๊ฐ ์ํ ์์
ํ์ธ"],
"summary": "์ง์·๋ฐ๋ณต ์ฌ๋ถ๋ฅผ ๋ชจ๋ํฐ๋งํ๊ณ ์ถ์ธ ๋ณํ ์ ์์ธ ์ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.",
}
์ฌ๊ธฐ์ ๋ ๊ฐ์ง๊ฐ ํต์ฌ์ ๋๋ค.
ML๋ ๊ฐ์ ๋ถ๋ฅ ์ฒด๊ณ๋ก ๋ค์ด์ต๋๋ค. ML ์ด์์ ์งํ๋ช
์ด "ํจํด ์ด์(PCA)"์ด๋ผ ๊ทธ๋๋ก๋ ์นดํ
๊ณ ๋ฆฌ ๋ถ๋ฅ๊ฐ ์ ๋ฉ๋๋ค. ๊ทธ๋์ ML์ผ ๋๋ ์ํฅ ์งํ(top_metrics)๋ฅผ ๋ถ๋ฅ ๋์์ผ๋ก ์๋๋ค. ์ ์ฝ๋์ names = a.detail.get("top_metrics", []) if a.kind == "ml" else [a.metric]์ด ๊ทธ ์ฒ๋ฆฌ์
๋๋ค. ๋๋ถ์ ๋ฃฐ๋ฒ ์ด์ค๋ก ์ก์ CPU์ ML๋ก ์ก์ ๋์คํฌ ์ฝ๊ธฐ๊ฐ ๊ฐ์ ์นดํ
๊ณ ๋ฆฌ ์งํฉ์ ๋ชจ์ฌ, ํจ๊ป ์๊ด ๊ท์น์ ๋งค์นญ๋ฉ๋๋ค.
์ฐ์ ์์ ๊ท์น์ ๊ตฌ์ฒด์ ์ธ ๊ฒ๋ถํฐ. CPU+์ธ์ +์ฐ๊ธฐ๊ฐ ๋์์ ์กํ๋ฉด "๋๋ DML ๋ฐฐ์น/๋น์ ์ ํธ๋์ญ์ "์ผ๋ก, lock+longq๋ฉด "์ฅ๊ธฐ ํธ๋์ญ์ ์ ๊ธ ๊ฒฝํฉ"์ผ๋ก ๋ด ๋๋ค. ์ด๋ฐ ๋ณตํฉ ํจํด์ ๋จผ์ ๊ฒ์ฌํ๊ณ , ๋จ์ผ ์นดํ ๊ณ ๋ฆฌ(lock๋ง, cpu๋ง)๋ ๋ค์ ๋ก๋๋ค. ์ฌ๋ฌ ์งํ๊ฐ ํจ๊ป ํ๋ ๊ฑด ๋จ์ผ ์์ธ์ผ ๊ฐ๋ฅ์ฑ์ด ๋์, ๋ฌถ์ด์ ๋ ๊ตฌ์ฒด์ ์ธ ์ง๋จ์ ์ค ์ ์๊ธฐ ๋๋ฌธ์ ๋๋ค. ์๋ฌด๊ฒ๋ ์ ๊ฑธ๋ฆฌ๋ฉด ํด๋ฐฑ์ผ๋ก "ํ์์ ๋ค๋ฅธ ์ถ์ด ๊ฐ์ง"๋ฅผ ์๋๋ค. POC๋ LLM ์์ด ์ด ํ ํ๋ฆฟ·๋ฃฐ๋ง ์๋๋ค. ์ ๋ฐํ ์ฟผ๋ฆฌ ๋จ์ ์์ธ ๋ถ์์ ์ด๋ฒคํธ ๋ฐ์ดํฐ์ LLM์ด ๋ถ๋ ๊ณ ๋ํ ๋จ๊ณ์ ๋ชซ์ ๋๋ค.
์ง๋จ ๋ฐ์ค ์กฐ๋ฆฝ๊ณผ ML ํตํฉ
_diagnosis_box๊ฐ ์นด๋ ํ ์ฅ์ ์กฐ๋ฆฝํฉ๋๋ค. ํต์ฌ์ ML ํญ๋ชฉ์ ๋ณ๋ ๋ฐ์ค๊ฐ ์๋๋ผ โก์์ธ·โข์กฐ์น์ ์ ์๋ฆฌ์ ๋ณด๋ผ์์ผ๋ก ๋ผ์ ๋ฃ๋ ๋ถ๋ถ์
๋๋ค.
def _diagnosis_box(det: DbDetection, accent: str, bg: str) -> str:
"""์ด์/์์ธ์ถ์ /๊ถ์ฅ์กฐ์น/ํจ๊ป ํ์ธํ ์งํ/์ข
ํฉ ์์ธ ๋ฐ์ค."""
diag = _diagnose(det)
found = "".join(f"<li>{_anomaly_detail_line(a)}</li>" for a in det.anomalies)
# ML(๋น์ง๋) ์ด์์ด ์์ผ๋ฉด ๊ทธ ์์ธ·๊ถ์ฅ์ โก์์ธ์ถ์ ·โข๊ถ์ฅ์กฐ์น ์ ์๋ฆฌ์ ๋ณด๋ผ์์ผ๋ก ํฉ์นจ
cause_items = list(diag["causes"])
action_items = list(diag["actions"])
ml_anoms = [a for a in det.anomalies if a.kind == "ml"]
if ml_anoms:
cats = [c for c in (_category(m) for m in ml_anoms[0].detail.get("top_metrics", [])) if c]
hints = {
"io": ("๋์คํฌ ์ฝ๊ธฐ·์กฐํ", "์ธ๋ฑ์ค๋ฅผ ๋ชป ํ๋ ๋๋ ์กฐํ·ํต๊ณ์ฑ ์ฟผ๋ฆฌ·๋น์ ์ ๋ฐฐ์น"),
"cpu": ("CPU·์ฐ์ฐ", "๋ณต์กํ ์ฐ์ฐ·ํ ์ค์บ·๋นํจ์จ ์ฟผ๋ฆฌ"),
"write": ("์ฐ๊ธฐ·ํธ๋์ญ์
", "๋๋ INSERT/UPDATE·๋ฐฐ์น ์ ์ฌ"),
"session": ("์ธ์
·์ฐ๊ฒฐ", "์ฐ๊ฒฐ ๊ธ์ฆ·์ธ์
๋ฏธ๋ฐํ"),
"lock": ("์ ๊ธ·๊ฒฝํฉ", "์ฅ๊ธฐ ํธ๋์ญ์
·๋ธ๋กํน ์ธ์
"),
"longq": ("์ฅ๊ธฐ ์คํ ์ฟผ๋ฆฌ", "๋ฏธ์๊ฒฐ ํธ๋์ญ์
·๋๋ฆฐ ์ฟผ๋ฆฌ"),
"conn": ("์ปค๋ฅ์
", "์ปค๋ฅ์
ํ ๊ณ ๊ฐ·์ฐ๊ฒฐ ๊ธ์ฆ"),
"mem": ("๋ฉ๋ชจ๋ฆฌ", "์บ์ ๋ถ์กฑ·๋๋ ์ ๋ ฌ"),
"storage": ("์ ์ฅ ๊ณต๊ฐ", "๊ธ๊ฒฉํ ๋ฐ์ดํฐ ์ฆ๊ฐ·๋ก๊ทธ ์ ์ฌ"),
}
from collections import Counter
# ๋์ ์ผ ๋ ๋น๊ฒฐ์ ์ ์ด์ง ์๋๋ก Counter(์ํฅ์งํ ์์=๋ณต์์ค์ฐจ ์์ ์ฐ์ )๋ก ๋ํ ์นดํ
๊ณ ๋ฆฌ ๊ฒฐ์
top_cat = Counter(cats).most_common(1)[0][0] if cats else None
PURPLE = "#7b3fb5"
if top_cat in hints:
sit, cause = hints[top_cat]
cause_items.append(f'<span style="color:{PURPLE};"><b>(ML)</b> {sit} ๊ด๋ จ ์งํ๊ฐ ํ์ ํจํด์์ ํจ๊ป ๋ฒ์ด๋จ — ๋ณดํต {cause}</span>')
else:
cause_items.append(f'<span style="color:{PURPLE};"><b>(ML)</b> ์ฌ๋ฌ ์งํ๊ฐ ๊ฐ์ ์๊ฐ ํ์ ์กฐํฉ์์ ํจ๊ป ๋ฒ์ด๋จ</span>')
action_items.append(f'<span style="color:{PURPLE};"><b>(ML)</b> ๊ทธ ์๊ฐ ์คํ๋ ์ฟผ๋ฆฌ·๋ฐฐ์น ํ์ธ (์๊ณ๊ฐ ๋ฏธ์ด๊ณผ์ฌ๋ ํ์์ ๋ค๋ฅธ ๋์)</span>')
...
ML ์ํฅ ์งํ๋ค์ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ธ์ด ๋ํ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ ํ๊ณ (Counter.most_common), ๊ทธ ์นดํ
๊ณ ๋ฆฌ์ ๋ง๋ ์ํฉ·ํํ ์์ธ ํํธ๋ฅผ ๋ณด๋ผ์ ์์ธ ํญ๋ชฉ์ผ๋ก ์ถ๊ฐํฉ๋๋ค. ์๋ฅผ ๋ค์ด ์ํฅ ์งํ๊ฐ ๋์คํฌ ์ฝ๊ธฐ ๊ณ์ด์ด๋ฉด "๋์คํฌ ์ฝ๊ธฐ·์กฐํ ๊ด๋ จ ์งํ๊ฐ ํ์ ํจํด์์ ํจ๊ป ๋ฒ์ด๋จ — ๋ณดํต ์ธ๋ฑ์ค๋ฅผ ๋ชป ํ๋ ๋๋ ์กฐํ·ํต๊ณ์ฑ ์ฟผ๋ฆฌ·๋น์ ์ ๋ฐฐ์น"๋ผ๊ณ ๋ถ์ต๋๋ค. ๊ถ์ฅ ์กฐ์น์๋ "๊ทธ ์๊ฐ ์คํ๋ ์ฟผ๋ฆฌ·๋ฐฐ์น ํ์ธ(์๊ณ๊ฐ ๋ฏธ์ด๊ณผ์ฌ๋ ํ์์ ๋ค๋ฅธ ๋์)"์ ๋ณด๋ผ์์ผ๋ก ๋ํฉ๋๋ค.
์ ๋ณ๋ ๋ฐ์ค๊ฐ ์๋๋ผ ๊ฐ์ ์๋ฆฌ์ ํตํฉํ๋. ML ์ง๋จ์ ๋ฐ๋ก ๋ฐ์ค๋ก ๋นผ๋ฉด, DBA๊ฐ "๋ฃฐ๋ฒ ์ด์ค ๋ฐ์ค"์ "ML ๋ฐ์ค"๋ฅผ ๋ ๋ฒ ์ฝ์ด์ผ ํฉ๋๋ค. ๊ฐ์ ์ฌ๊ฑด์ ๋ ๋ชจ๋์ด ๋ณธ ๊ฒ๋ฟ์ธ๋ฐ ํ๋ฉด์ด ๋๋ก ๊ฐ๋ฆฌ๋ฉด ์ธ์ง ๋ถ๋ด์ด ์ปค์ง๋๋ค. ๊ทธ๋์ ์์ธ·์กฐ์น ๋ชฉ๋ก ํ ๊ตฐ๋ฐ์ ๋ฃฐ๋ฒ ์ด์ค ํญ๋ชฉ(๊ธฐ๋ณธ์)๊ณผ ML ํญ๋ชฉ(๋ณด๋ผ์)์ ํจ๊ป ๋ก๋๋ค. ํ ๋ฒ์ ์ฝ๋, ์์ผ๋ก ์ถ์ฒ๋ฅผ ๊ตฌ๋ถํฉ๋๋ค.
๋ณด๋ผ์์ ๊ณ ๋ฅธ ์ด์ . ์ํ์ ๋นจ๊ฐ, ์ฃผ์๋ ์ฃผํฉ์ผ๋ก ์ด๋ฏธ ๋ฑ๊ธ์ ์์ ์ผ์ต๋๋ค. ML์ ๋ฑ๊ธ์ด ์๋๋ผ "์ถ์ฒ"์ ๊ตฌ๋ถ์ด๋ผ, ๋ฑ๊ธ ์๊ณผ ๊ฒน์น๋ฉด ์ ๋ฉ๋๋ค. ๊ทธ๋์ ๋ฑ๊ธ ํ๋ ํธ์ ์ถฉ๋ถํ ๋ค๋ฅธ ๋ณด๋ผ์(#7b3fb5)์ ์ถ์ฒ ํ์ ์ ์ฉ์ผ๋ก ์ ํ์ต๋๋ค. ๋ฆฌํฌํธ ์ด๋์๋ ๋ณด๋ผ์์ "๊ธฐ๊ณ๊ฐ ์ถ๊ฐ๋ก ๋ณธ ๊ฒ"์ด๋ผ๋ ์ผ๊ด๋ ์๋ฏธ๋ฅผ ๊ฐ์ต๋๋ค.
์ง๋จ ๋ฐ์ค๋ ๋ง์ง๋ง์ผ๋ก "ํจ๊ป ํ์ธํ ์งํ"(์ฐ๊ด ์งํ ์ถ์ฒ)๊น์ง ๋ถ์ฌ โ ~โฃ ๊ตฌ์กฐ๋ก ๋ ๋๋งํฉ๋๋ค.
return f"""
<div style="...border-left:3px solid {accent};...">
<div style="...color:{accent};...">์ง๋จ — {diag['title']}</div>
<div>โ ๊ฐ์ง๋ ์ด์</div>
<ul>{found}</ul>
<div>โก ์์ธ ์ถ์ <span>(์งํ ์๊ด ๋ถ์)</span></div>
<ul>{causes}</ul>
<div>โข ๊ถ์ฅ ์กฐ์น</div>
<ol>{actions}</ol>
{related_html}
<div>โฃ ์ข
ํฉ · {diag['summary']}</div>
</div>"""
์ฐ๊ด ์งํ๋ ์นดํ
๊ณ ๋ฆฌ๋ณ๋ก "์ด ์ ํ ์ด์์ด๋ฉด ํจ๊ป ๋ณด๋ฉด ์ข์ ์งํ"๋ฅผ ๋ฏธ๋ฆฌ ์ ์ํด ๋ _RELATED_METRICS์์ ๊ฐ์ ธ์ต๋๋ค. ์๋ฅผ ๋ค์ด io ์นดํ
๊ณ ๋ฆฌ๋ฉด "Read/Write IOPS · Throughput · Physical Reads"๋ฅผ ์ถ์ฒํฉ๋๋ค. ์ด ๋ชฉ๋ก์ 1์ฐจ์์ด๋ผ DBA ๊ฒ์ฆ์ผ๋ก ๋ณด์ ํ ๋์์
๋๋ค.
๊ทธ๋ํ
๋ฆฌํฌํธ์๋ ์ถ์ด ๊ทธ๋ํ๊ฐ ๋ค์ด๊ฐ๋๋ค. ์ํ ๋ฑ๊ธ ์งํ๋ง ๊ทธ๋ฆฌ๊ณ , ML ์ด์์ด ์์ผ๋ฉด ์ํฅ ์งํ๋ฅผ ๋ฐ๋ก ํ ์ฅ ๋ ๊ทธ๋ฆฝ๋๋ค.
_make_graph๊ฐ ํ๋ฃจ์น ์๊ณ์ด ํ ์ฅ์ ๊ทธ๋ฆฝ๋๋ค. kind์ ๋ฐ๋ผ ๊ธฐ์ค์ ์ด ๋ค๋ฅธ ๊ฒ ํต์ฌ์
๋๋ค.
def _make_graph(series, anomaly, out_path, metric_name=None) -> bool:
"""์ํ ์งํ ํ๋ฃจ ์ถ์ด ๊ทธ๋ํ(PNG, ๋ผ์ดํธ ๋ชจ๋). ์ฑ๊ณต ์ True."""
...
ax.plot(series.index, series.values, color="#3d7ebf", linewidth=1.6)
if anomaly.kind == "threshold" and anomaly.threshold is not None:
ax.axhline(anomaly.threshold, color=C_CRIT, linewidth=1, linestyle="--")
ax.text(series.index[-1], anomaly.threshold, f"Threshold {anomaly.threshold:g} ",
color=C_CRIT, fontsize=8, va="bottom", ha="right")
elif anomaly.kind == "ml":
# ML ์ํฅ์งํ๋ ์๊ณ๊ฐ์ด ์์ผ๋ฏ๋ก, ๊ธฐ์ค์ ์ผ๋ก 'ํ์ ํ๊ท ์ '(ํ์ ์ ์ )์ ํ์
avg = float(series.mean())
ax.axhline(avg, color="#888", linewidth=1, linestyle="--")
ax.text(series.index[-1], avg, f"Avg {avg:g} ",
color="#888", fontsize=8, va="bottom", ha="right")
if anomaly.at_time is not None and anomaly.at_time in series.index:
yv = series.loc[anomaly.at_time]
ax.scatter([anomaly.at_time], [yv], color=C_CRIT, s=26, zorder=5)
# ํ ์ง์ ์ ํ ์๊ฐ — ํผํฌ ์ ๋ฐ๋ก ์์ ํ๊ธฐ, ํฐ ๋ฐฐ๊ฒฝ ๋ฐ์ค๋ก ์ ๊ณผ ๊ฒน์ณ๋ ์ฝํ๊ฒ
ax.annotate(anomaly.at_time.strftime("%Y-%m-%d %H:%M:%S"),
xy=(anomaly.at_time, yv), xytext=(0, 10), textcoords="offset points",
fontsize=7.5, color=C_CRIT, ha="center", va="bottom", zorder=7,
bbox=dict(boxstyle="round,pad=0.25", fc="white", ec="none", alpha=0.5))
๊ธฐ์ค์ ์ด ๋ค๋ฆ ๋๋ค. ๋ฃฐ๋ฒ ์ด์ค(์๊ณ) ๊ทธ๋ํ๋ ๋นจ๊ฐ ์ ์ ์ผ๋ก ์๊ณ๊ฐ์ ๊ธ์ต๋๋ค. "์ด ์ ์ ๋์๋ค"๊ฐ ํ๋์ ๋ณด์ ๋๋ค. ํ์ง๋ง ML ์ํฅ ์งํ๋ ์๊ณ๊ฐ์ด ์์ต๋๋ค(ํ์์ ๋ค๋ฅด๋ค๋ ๊ฒ์ด์ง ์ด๋ค ์ ๋๊ฐ์ ๋์ ๊ฒ ์๋๋๋ค). ๊ทธ๋์ ML ๊ทธ๋ํ๋ ์๊ณ์ ๋์ ํ์ ํ๊ท ์ (ํ์ ์ ์ ) ์ ๊ธ์ต๋๋ค. "์ด ์งํ๊ฐ ํ์ ํ๊ท ์ ์ด ์ ๋์ธ๋ฐ ์ด๋งํผ ํ์๋ค"๋ฅผ ๋ณด์ฌ ์ค๋๋ค. ๊ฐ์ ๊ทธ๋ํ ํจ์์ง๋ง ๋น๊ต ๊ธฐ์ค์ด ํ์ง ๋ฐฉ์์ ๋ง๊ฒ ๋ฐ๋๋๋ค.
ํผํฌ ์๊ฐ ๊ฐ๋
์ฑ. ์ฒ์์ ํผํฌ ์ ์ ์๊ฐ์ ๊ทธ๋ฅ ํ
์คํธ๋ก ์ฐ์๋๋ฐ, ๋ฐ์ดํฐ ์ ๊ณผ ๊ฒน์ณ ์ ์ ์ฝํ์ต๋๋ค. ๊ทธ๋์ ์ ๋ฐ๋ก ์์ ํฐ ๋ฐฐ๊ฒฝ ๋ฐ์ค(bbox, ๋ฐํฌ๋ช
)๋ฅผ ๊น๊ณ ๊ทธ ์์ ์๊ฐ์ ์น์์ต๋๋ค. ์ ๊ณผ ๊ฒน์ณ๋ ์๊ฐ์ด ์ฝํ๋๋ค.
y์ถ ํฐ ์ ์ถ์ฝ. ๋์คํฌ ์ฝ๊ธฐ ๊ฐ์ ์งํ๋ ๊ฐ์ด ์๋ฐฑ๋ง๊น์ง ๊ฐ๋๋ค. matplotlib ๊ธฐ๋ณธ์ 1e6 ๊ฐ์ ์ง์ ํ๊ธฐ๋ฅผ ์ฐ๋๋ฐ ์ง๊ด์ ์ด์ง ์์ต๋๋ค. ๊ทธ๋์ K·M์ผ๋ก ๋ฐ๊ฟจ์ต๋๋ค.
def _yfmt(v, _):
a = abs(v)
if a >= 1e6:
return f"{v/1e6:.1f}M"
if a >= 1e3:
return f"{v/1e3:.0f}K"
return f"{v:.0f}" if (a >= 1 or v == 0) else f"{v:.2f}"
ax.yaxis.set_major_formatter(FuncFormatter(_yfmt))
3,154,251์ "3.2M", 4,899๋ "5K"๋ก ์ฐํ๋๋ค. ๊ทธ๋ํ๋ ์๋ฌธ·์ซ์๋ง ์จ์(ํฐํธ์ ๋ฌด๊ดํ๊ฒ) ํ๊ธ ๊นจ์ง์ ํผํ๊ณ , DPI 260์ผ๋ก ์ ์ฅํด ๋ฉ์ผ์์๋ ์ ๋ช ํ๊ฒ ๋ณด์ด๊ฒ ํ์ต๋๋ค.
ML ์ํฅ ์งํ ๊ทธ๋ํ๋ _ml_graph๊ฐ ๋ฐ๋ก ํ ์ฅ ๋ ๊ทธ๋ฆฝ๋๋ค. ์ํฅ ์งํ Top1์ ํ์ ํ๊ท ์ ๊ณผ ํจ๊ป ๊ทธ๋ ค, "์ด ์งํ๊ฐ ์ด๋ ๊ฒ ํ์๋ค"๋ฅผ ์๊ฐ์ ์ผ๋ก ๋ณด๊ฐํฉ๋๋ค. ๋ณด๋ผ์ ๋ผ๋ฒจ "(ML ๊ฒฐ๊ณผ · ์ํฅ ์งํ Top1)"์ ๋ฌ์ ์ถ์ฒ๋ฅผ ๋ช
์ํฉ๋๋ค.
def _ml_graph(det, db, today, out_dir, images) -> str:
"""ML(๋น์ง๋) ์ด์์ ์ํฅ ์งํ Top1 ์ถ์ด ๊ทธ๋ํ. ์์ผ๋ฉด ๋น ๋ฌธ์์ด."""
ml = next((a for a in det.anomalies if a.kind == "ml"), None)
if ml is None:
return ""
metric = (ml.detail.get("top_metrics") or [None])[0]
if today is None or metric is None or metric not in today.columns:
return ""
series = pd.to_numeric(today[metric], errors="coerce").dropna()
if series.empty:
return ""
cid = f"mlgraph_{db}"
png = os.path.join(out_dir, f"{cid}.png")
if not _make_graph(series, ml, png, metric):
return ""
images[cid] = png
...
์ค๊ณํ๋ฉฐ ๋ถ๋ชํ ๊ฒ๋ค
๊ธฐ๋ฅ์ ์ง๋ ๊ฒ๋ณด๋ค "ํ์ค ๋ฐ์ดํฐ์์ ์์ฐ์ค๋ฝ๊ฒ ์ฝํ๊ฒ" ๋ค๋ฌ๋ ๋ฐ ๋ ๋ง์ ์๊ฐ์ด ๋ค์์ต๋๋ค. ๋ถ๋ชํ ๊ฒ๋ค์ ์ฆ์ → ์์ธ → ํด๊ฒฐ๋ก ์ ๋ฆฌํฉ๋๋ค.
๋ํ ์งํ๋ฅผ ๋ณต์์ค์ฐจ 1์์์์ ๋ณํ ํญ ๊ฐ์ฅ ํฐ ๊ฒ์ผ๋ก
์ฆ์. ML ์ด์ ์ค์ ๋งจ ์(๋ํ ์งํ)์ด ์ด์ํ ๋๊ฐ ์์์ต๋๋ค. ๋ณต์์ค์ฐจ ๊ธฐ์ฌ 1์์ ์งํ๊ฐ ์ ์ ๊ฐ์ผ๋ก๋ ํ์์ ๊ฑฐ์ ์ ๋ณํ ๊ฒฝ์ฐ, ์ค ๋งจ ์์ "๊ฑฐ์ ์ ๋ณํ ์งํ"๊ฐ ์๊ณ ์ ์ ํฌ๊ฒ ํ ์งํ๋ ๋ค์ ๋ฌปํ์ต๋๋ค.
์์ธ. ์ฒ์์ ๋ํ ์งํ๋ฅผ "๋ณต์์ค์ฐจ ๊ธฐ์ฌ 1์์"๋ก ๊ณจ๋์ต๋๋ค. ๊ทธ๋ฐ๋ฐ ๋ณต์์ค์ฐจ ๊ธฐ์ฌ๊ฐ ํฐ ๊ฒ๊ณผ ์ฌ๋ ๋์ ํฌ๊ฒ ๋ณํด ๋ณด์ด๋ ๊ฒ์ด ํญ์ ์ผ์นํ์ง ์์ต๋๋ค. ํ์คํ·๋ก๊ทธ๋ฅผ ๊ฑฐ์น ์ขํ๊ณ์์์ ๊ธฐ์ฌ๋๋ผ, ์๋ ๊ฐ์ ์ฒด๊ฐ ๋ณํ์ ์ด๊ธ๋ ์ ์์ต๋๋ค.
ํด๊ฒฐ. ๋ํ ์งํ๋ฅผ "์๋ ๊ฐ ๊ธฐ์ค ๋ณํ ํญ์ด ๊ฐ์ฅ ํฐ ๊ฒ"์ผ๋ก ๋ฐ๊ฟจ์ต๋๋ค. _mag๋ก ์ฆ๊ฐ๋ ๋ฐฐ์, ๊ฐ์๋ ์ญ์๋ฅผ ์ก์ ๊ฐ์ฅ ํฌ๊ฒ ๋ณํ ์งํ๋ฅผ ๋ํ๋ก ์ธ์๋๋ค(์ _anomaly_detail_line ์ฝ๋). ๋ฃฐ๋ฒ ์ด์ค ํญ๋ชฉ์ด "์งํ๋ช
: ํฐ ๋ณํ"๋ก ์ฝํ๋ ๊ฒ๊ณผ ๊ฐ์ ์ง๊ด์ ML ํญ๋ชฉ์๋ ์คฌ์ต๋๋ค. ๋ณต์์ค์ฐจ 1์์ ์ ๋ณด๋ ์ํฅ ์งํ ๋ชฉ๋ก ์์ฒด(Top3 ์ ์ )์๋ ๊ทธ๋๋ก ์ฐ๋, ํ์ ์์๋ง ๋ณํ ํญ ๊ธฐ์ค์ผ๋ก ๋ฐ๊พผ ๊ฒ๋๋ค.
์นดํ ๊ณ ๋ฆฌ ๋์ ์ ๋น๊ฒฐ์ ๋ฒ๊ทธ
์ฆ์. ML ์ง๋จ์ ๋ํ ์นดํ ๊ณ ๋ฆฌ๊ฐ ์คํํ ๋๋ง๋ค ๋ฌ๋ผ์ง ์ ์๋ ์๋ฆฌ๊ฐ ์์์ต๋๋ค. ์ํฅ ์งํ๊ฐ io 1๊ฐ, cpu 1๊ฐ์ฒ๋ผ ์นดํ ๊ณ ๋ฆฌ๊ฐ ๋์ ์ด๋ฉด, ์ด๋ค ์นดํ ๊ณ ๋ฆฌ๋ก ์ง๋จ๋๋๋๊ฐ ๋ค์ญ๋ ์ญํ์ต๋๋ค.
์์ธ. ์ฒ์์ ๋ํ ์นดํ
๊ณ ๋ฆฌ๋ฅผ max(set(cats), ...) ๋น์ทํ๊ฒ ๊ณจ๋๋๋ฐ, set์ ์์๊ฐ ๋ณด์ฅ๋์ง ์์ต๋๋ค. ๋์ ์ผ ๋ ์ด๋ ๊ฒ ๋จผ์ ๋์ค๋์ง๊ฐ ์คํ ํ๊ฒฝ์ ๋ฐ๋ผ ๋ฌ๋ผ์ ธ, ๊ฐ์ ์
๋ ฅ์ ๋ค๋ฅธ ์ง๋จ์ด ๋์ฌ ์ ์์์ต๋๋ค. ๋งค์ผ ๋๋ ๋ฆฌํฌํธ์์ ์ด๋ฐ ๋น๊ฒฐ์ ์ฑ์ ์ ๋ขฐ๋ฅผ ๊น์ต๋๋ค.
ํด๊ฒฐ. collections.Counter๋ก ๋ฐ๊ฟจ์ต๋๋ค.
from collections import Counter
top_cat = Counter(cats).most_common(1)[0][0] if cats else None
Counter์ ๋๊ธฐ๋ cats ๋ฆฌ์คํธ๋ ์ํฅ ์งํ ์์(๋ณต์์ค์ฐจ ์์)๋๋ก ๋ง๋ค์ด์ง๋๋ค. most_common์ ๋์ ์ผ ๋ ๋จผ์ ๋ค์ด์จ ๊ฒ์ ์ ์งํ๋ฏ๋ก, ๊ฒฐ๊ณผ์ ์ผ๋ก "๋ณต์์ค์ฐจ ๊ธฐ์ฌ๊ฐ ์์ ์งํ์ ์นดํ
๊ณ ๋ฆฌ"๊ฐ ๋ํ๊ฐ ๋ฉ๋๋ค. ๋์ ์ด์ด๋ ํญ์ ๊ฐ์ ๊ฒฐ๊ณผ๊ฐ ๋์ค๊ณ , ๊ทธ ์ฐ์ ์์๋ ์๋ฏธ๊ฐ ์์ต๋๋ค(๋ ํฌ๊ฒ ์ด๊ธ๋ ์ชฝ ์ฐ์ ).
๋ณํ ๋ฏธ๋ฏธํ ์ํฅ ์งํ๋ฅผ ํ์ ์์ค์ผ๋ก
์ฆ์. ML ์ค์ "ํ์ 0 → ํผํฌ 0" ๊ฐ์ ๋ฌด์๋ฏธํ ๋ณํ๊ฐ ์ฐํ์ต๋๋ค.
์์ธ. ์ํฅ ์งํ Top3๋ ๋ณต์์ค์ฐจ ๊ธฐ์ฌ ์์ผ๋ก ๋ฝ๋๋ฐ, ๊ทธ์ค์๋ ๋ณต์์๋ ์ฝ๊ฐ ๊ธฐ์ฌํ์ด๋ ์ค์ ๊ฐ์ ํ์์ ๊ฑฐ์ ๊ฐ์ ์งํ๊ฐ ์์ผ ์ ์์ต๋๋ค. ํ์ 0์ด๋ ์งํ๊ฐ ํผํฌ์๋ 0์ด๋ฉด, ๋จ์ ํฌ๋งท์ ๋ ๋ค "0"์ผ๋ก ์ฐํ "ํ์ 0 → ํผํฌ 0 ↑"์ฒ๋ผ ๋ณํ๊ฐ ์๋ ์ฒ ๋ณด์ ๋๋ค.
ํด๊ฒฐ. _changed ํ์ ์ ๋ฃ์์ต๋๋ค(์ ์ฝ๋). ํ์ ํฌ๋งท ๊ฒฐ๊ณผ๊ฐ ๊ฐ๊ฑฐ๋(_fmt_value๋ก ์ฐ์์ ๋ ํ์๊ฐ๊ณผ ํผํฌ๊ฐ ๋ฌธ์์ด์ด ๋์ผ) ๋ฐฐ์๊ฐ 0.9~1.1 ๋ฒ์๋ฉด ๋ณํ ์์์ผ๋ก ๋ณด๊ณ , ๊ทธ ์งํ๋ "ํ์ ์์ค"์ด๋ผ๊ณ ๋ง ์ ์ต๋๋ค. ์ง์ง ๋ณํ ์งํ๋ง "ํ์ X → ํผํฌ Y"๋ก ๋ณด์ฌ ์ฃผ๊ณ , ์ ๋ณํ ๊ฑด ์์งํ๊ฒ ํ์ ์์ค์ด๋ผ๊ณ ๋งํฉ๋๋ค. ๊ฑฐ์ง ๋ณํ๋ก DBA๋ฅผ ํท๊ฐ๋ฆฌ๊ฒ ํ์ง ์์ผ๋ ค๋ ์ฒ๋ฆฌ์
๋๋ค.
๊ทธ๋ํ ํผํฌ ์๊ฐ์ด ์ ๊ณผ ๊ฒน์นจ
์ฆ์. ํผํฌ ์๊ฐ ํ
์คํธ๊ฐ ๋ฐ์ดํฐ ์ ์ด๋ ๋ค๋ฅธ ์์์ ๊ฒน์ณ ์ ์ฝํ์ต๋๋ค. ํฐ ์ y์ถ์ 1e6๋ก ์ฐํ ์ง๊ด์ ์ด์ง ์์์ต๋๋ค.
์์ธ. ์๊ฐ์ ์ ์์ ๊ทธ๋ฅ ํ ์คํธ๋ก ์น์ผ๋ ์ ์๊ณผ ๊ธ์ ์์ด ๊ฒน์ณค์ต๋๋ค. y์ถ์ matplotlib ๊ธฐ๋ณธ ์ง์ ํ๊ธฐ์์ต๋๋ค.
ํด๊ฒฐ. ์ธ ๊ฐ์ง๋ฅผ ์๋ดค์ต๋๋ค. ์ฒซ์งธ, ํผํฌ ์๊ฐ์ ๋ฐํฌ๋ช
ํฐ ๋ฐฐ๊ฒฝ ๋ฐ์ค(bbox)๋ฅผ ๊น์ ์ ๊ณผ ๊ฒน์ณ๋ ์ฝํ๊ฒ ํ์ต๋๋ค. ๋์งธ, y์ถ์ K·M ํ๊ธฐ๋ก ๋ฐ๊ฟ 3,154,251์ "3.2M"์ผ๋ก ๋ณด์ด๊ฒ ํ์ต๋๋ค. ์
์งธ, ์ ์ฅ DPI๋ฅผ 260์ผ๋ก ์ฌ๋ ค ๋ฉ์ผ ๋ณธ๋ฌธ์์๋ ์ ๋ช
ํ๊ฒ ํ์ต๋๋ค. ๊ทธ๋ํ ํ
์คํธ๋ ์๋ฌธ·์ซ์๋ง ์จ์ ํฐํธ ํ๊ฒฝ๊ณผ ๋ฌด๊ดํ๊ฒ ๊นจ์ง์ง ์๊ฒ ํ์ต๋๋ค.
์ ์์ผ ์ค๋ฐ๋ 0 ํ์ธ
์ฆ์์ด๋ผ๊ธฐ๋ณด๋ค ๊ฒ์ฆ. ๋น์ง๋ ํ์ง์์ ๊ฐ์ฅ ๊ฑฑ์ ํ ๊ฑด "์ ์์ธ ๋ ์๋ ๋งค์ผ ML ์ด์์ด ๋จ๋ ๊ฒ"์ด์์ต๋๋ค. 95ํผ์ผํ์ผ ์ ์๊ณ๋ง ์ฐ๋ฉด ์ ์์ผ์๋ ์ฝ 5%(ํ๋ฃจ ์ฝ 72๋ถ)๊ฐ ์ ์๊ณ๋ฅผ ๋๊ธฐ ๋๋ฌธ์ ๋๋ค.
ํด๊ฒฐ๊ณผ ํ์ธ. ์์ ๋ ํ๋ฃจ ์๊ณ(daily_minutes_threshold, ์ ์์ผ ์ผ๋ณ ์ด๊ณผ ๋ถ ์์ 95ํผ์ผํ์ผ)๊ฐ ์ด ์์ ์ฅ์น์
๋๋ค. ํ์ต ๋ฐ์ดํฐ๋ก ์ธก์ ํ ์ ์ ๋ณ๋ ํญ์ ๋์ ๋๋ง ๋ฐ๋ํ๊ฒ ํ๊ณ , ์ ์์ผ ๋ฐ์ดํฐ๋ฅผ ๋ค์ ๋๋ ค ML ์ค๋ฐ๋์ด ๋์ง ์๋์ง ํ์ธํ์ต๋๋ค. "์ด์ ์๋ ๋ ๋ ์ ์ ๋ฆฌํฌํธ๋ฅผ ๋ณด๋ธ๋ค"๊ฐ ์๊ตฌ์ฌํญ์ด๋ผ, ์ ์์ผ์ ๊ฐ์ง ์ด์์ด ๋ผ๋ฉด ๋ฆฌํฌํธ ์ ๋ขฐ๊ฐ ๋ฌด๋์ง๋๋ค. ์ ์๊ณ ํ ๊ฒน์ด ์๋๋ผ ํ๋ฃจ ์๊ณ๊น์ง ๋ ๊ฒน์ผ๋ก ๊ฑฐ๋ฅธ ๊ฒ ์ฌ๊ธฐ์ ํจ๊ณผ๋ฅผ ๋ดค์ต๋๋ค.
์ ๋ฆฌํ๋ฉฐ
ํ์ง๋ฅผ ๋ ๊ฐ๋๋ก ์ง ๊ฒ ์ด ์์คํ ์ ๋ผ๋์ ๋๋ค.
๋ฃฐ๋ฒ ์ด์ค๋ ์ฌ๋์ด ๋ช ์ํ ๊ท์น์ ๋๋ค. ์๊ณ ์ด๊ณผ, ์ ์ผ ๋๋น 2๋ฐฐ ๊ธ์ฆ, % ์งํ 20%p ํจํด ์์น. ์ค๋ช ์ด ๋ช ํํ๊ณ DBA๊ฐ ์๊ณ๋ง ์กฐ์ ํ๋ฉด ๋ฉ๋๋ค. ๋ค๋ง ๋ฏธ๋ฆฌ ์ ์ ๊ฒ๋ง ์ก์ต๋๋ค.
๋น์ง๋(PCA ๋ณต์์ค์ฐจ) ๋ "ํ์์ ๋ค๋ฆ"์ ์ก์ต๋๋ค. ํ ๋ฌ์น ์ ์ ํจํด์ ํ์ตํด, ๋จ์ผ ์๊ณ๋ฅผ ์ ๋์ด๋ ์ฌ๋ฌ ์งํ๊ฐ ๋์์ ํ์ ์กฐํฉ์์ ๋ฒ์ด๋๋ ์กฐํฉ ์ด์์ ๊ฐ์งํฉ๋๋ค. ๋์ "๋ฌด์์ด ์ ์ด์ํ์ง"๋ ์ํฅ ์งํ ์ฐ์ถ(๋ณต์์ค์ฐจ ๊ธฐ์ฌ Top3 + ํ์ ๋๋น ํผํฌ·๋ฐฉํฅ·๋ฐฐ์)๋ก ๋ฐ๋ก ์ค๋ช ํด ์ค์ผ ํฉ๋๋ค.
๋์ ์ํธ๋ณด์์ ๋๋ค. ๋ฃฐ๋ฒ ์ด์ค๊ฐ ๋์น๋ ์กฐํฉ ์ด์์ ๋น์ง๋๊ฐ ์ค๊ณ , ๋น์ง๋๊ฐ ๋ชป ์ฃผ๋ ๋ช ํํ ์ค๋ช ์ ๋ฃฐ๋ฒ ์ด์ค๊ฐ ์ค๋๋ค. ๊ทธ๋ฆฌ๊ณ ์ง๋จ ๋ฆฌํฌํธ๊ฐ ์ด ๋์ ํ ์นด๋๋ก ํฉ์ณ, ๋ฃฐ๋ฒ ์ด์ค๋ ๊ธฐ๋ณธ์·ML์ ๋ณด๋ผ์์ผ๋ก ์ถ์ฒ๋ฅผ ๊ตฌ๋ถํ๋ โ ๊ฐ์ง๋ ์ด์ โก ์์ธ ์ถ์ โข ๊ถ์ฅ ์กฐ์น โฃ ์ข ํฉ์ด๋ผ๋ ๊ฐ์ ํ์ ๋ น์ ๋๋ค.
POC ๋จ๊ณ๋ผ ์ง๋จ์ ํ ํ๋ฆฟ·์๊ด ๊ท์น์ผ๋ก๋ง ๋ง๋ญ๋๋ค. ๋ค์ ๋จ๊ณ๋ก๋ ๋ ๊ฐ๋๋ฅผ ์๊ฐํ๊ณ ์์ต๋๋ค. ํ๋๋ PCA๋ฅผ ์คํ ์ธ์ฝ๋๋ก ๋ฐ๊ฟ ๋ ๋ณต์กํ ๋น์ ํ ํจํด๊น์ง ์ก๋ ๊ฒ, ๋ค๋ฅธ ํ๋๋ ์ง๋จ์ ์์ธ ์ถ์ ·๊ถ์ฅ ์กฐ์น์ LLM๊ณผ ์ด๋ฒคํธ(์ฟผ๋ฆฌ·์ธ์ ) ๋ฐ์ดํฐ๋ฅผ ๋ถ์ฌ "์ด ์๊ฐ์ ์ด ์ฟผ๋ฆฌ๊ฐ ์ธ๋ฑ์ค๋ฅผ ๋ชป ํ๊ณ ํ ์ค์บํ๋ค" ์์ค๊น์ง ๊ตฌ์ฒดํํ๋ ๊ฒ์ ๋๋ค. ์ง๊ธ ๊ณจ๊ฒฉ(๋ณต์์ค์ฐจ·์ํฅ ์งํ·์ง๋จ ์นด๋)์ ๊ทธ ์๋ฆฌ์ ๊ทธ๋๋ก ๋ผ์ธ ์ ์๊ฒ ์ธํฐํ์ด์ค๋ฅผ ๋ง์ถฐ ๋ ๊ฒ, ๋ ๊ฐ๋ ๋ชจ๋์์ ๋์์ด ๋ ๊ฑฐ๋ผ๊ณ ๋ด ๋๋ค.