ํ”„๋กœ์ ํŠธ/DB ์ž๋™ํ™”

DB ๋ชจ๋‹ˆํ„ฐ๋ง ์ด์ƒ ํƒ์ง€์™€ ์ง„๋‹จ ๋ฆฌํฌํŠธ ๋กœ์ง ๊ตฌ์„ฑ

THE NICOLE 2026. 6. 19. 17:18

ํ•ต์‹ฌ ์š”์•ฝ

์‚ฌ๋‚ด 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 ๊ฐ™์€ ์ง€ํ‘œ๋Š” ํ‰์†Œ ์ž‘๋‹ค๊ฐ€ ๊ฐ€๋” ์ˆ˜๋ฐฑ ๋ฐฐ๋กœ ํŠ‘๋‹ˆ๋‹ค. ์œ„ ๋ฐ์ดํ„ฐ์—์„œ MySQL Innodb Rows Read๋Š” ํ‰๊ท  3,966์ธ๋ฐ ์ตœ๋Œ€๊ฐ€ 4,478,067, Oracle Redo 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๊ณผ ์ด๋ฒคํŠธ(์ฟผ๋ฆฌ·์„ธ์…˜) ๋ฐ์ดํ„ฐ๋ฅผ ๋ถ™์—ฌ "์ด ์‹œ๊ฐ์— ์ด ์ฟผ๋ฆฌ๊ฐ€ ์ธ๋ฑ์Šค๋ฅผ ๋ชป ํƒ€๊ณ  ํ’€ ์Šค์บ”ํ–ˆ๋‹ค" ์ˆ˜์ค€๊นŒ์ง€ ๊ตฌ์ฒดํ™”ํ•˜๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ์ง€๊ธˆ ๊ณจ๊ฒฉ(๋ณต์›์˜ค์ฐจ·์˜ํ–ฅ ์ง€ํ‘œ·์ง„๋‹จ ์นด๋“œ)์„ ๊ทธ ์ž๋ฆฌ์— ๊ทธ๋Œ€๋กœ ๋ผ์šธ ์ˆ˜ ์žˆ๊ฒŒ ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๋งž์ถฐ ๋‘” ๊ฒŒ, ๋‘ ๊ฐˆ๋ž˜ ๋ชจ๋‘์—์„œ ๋„์›€์ด ๋  ๊ฑฐ๋ผ๊ณ  ๋ด…๋‹ˆ๋‹ค.