- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
558 lines
20 KiB
Python
558 lines
20 KiB
Python
"""
|
|
E-5: FinOps 비용 분석
|
|
|
|
기능:
|
|
1. 서버/인프라 운영 비용 등록 및 조회
|
|
2. 월별·분기별 비용 트렌드 분석
|
|
3. 서비스/부서별 비용 배분 (Cost Allocation)
|
|
4. 비용 이상 탐지 (전월 대비 임계치 초과)
|
|
5. 비용 절감 권고 (미사용·저활용 자원 감지)
|
|
6. 예산 대비 실적 (Budget vs Actual)
|
|
7. Ollama sLLM 기반 비용 최적화 코멘트 생성
|
|
|
|
엔드포인트:
|
|
POST /api/finops/costs — 비용 항목 등록
|
|
GET /api/finops/costs — 비용 목록 조회
|
|
GET /api/finops/summary — 월별 비용 요약
|
|
GET /api/finops/trend — 비용 트렌드 (N개월)
|
|
GET /api/finops/allocation — 서비스별 비용 배분
|
|
GET /api/finops/anomalies — 비용 이상 탐지
|
|
GET /api/finops/recommendations — 비용 절감 권고
|
|
POST /api/finops/budget — 예산 등록/수정
|
|
GET /api/finops/budget — 예산 대비 실적
|
|
GET /api/finops/optimize — AI 비용 최적화 분석
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, date, timedelta
|
|
from typing import Dict, List, Optional
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from database import get_db
|
|
from models import User, UserRole
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/finops", tags=["finops"])
|
|
|
|
# ── 인메모리 저장소 ─────────────────────────────────────────────────────────
|
|
_costs: Dict[str, Dict] = {} # cost_id -> cost record
|
|
_budgets: Dict[str, Dict] = {} # "YYYY-MM" -> budget record
|
|
|
|
|
|
# ── 비용 카테고리 ────────────────────────────────────────────────────────────
|
|
COST_CATEGORIES = {
|
|
"SERVER": "서버 운영비",
|
|
"NETWORK": "네트워크/통신비",
|
|
"STORAGE": "스토리지 비용",
|
|
"LICENSE": "소프트웨어 라이선스",
|
|
"MAINTENANCE": "유지보수비",
|
|
"PERSONNEL": "인건비",
|
|
"OTHER": "기타",
|
|
}
|
|
|
|
SERVICES = ["MES", "ERP", "GROUPWARE", "SECURITY", "INFRA", "BACKUP", "OTHER"]
|
|
|
|
|
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
|
|
|
class CostIn(BaseModel):
|
|
year: int
|
|
month: int # 1~12
|
|
category: str # COST_CATEGORIES key
|
|
service: str = "INFRA" # SERVICES
|
|
amount: float # 원화 (원)
|
|
description: str = ""
|
|
server_id: Optional[int] = None
|
|
department: str = "IT"
|
|
|
|
class BudgetIn(BaseModel):
|
|
year: int
|
|
month: int
|
|
amount: float
|
|
service: str = "ALL"
|
|
|
|
|
|
# ── 헬퍼 ────────────────────────────────────────────────────────────────────
|
|
|
|
def _gen_cost_id() -> str:
|
|
return f"COST-{datetime.utcnow().strftime('%Y%m%d')}-{uuid4().hex[:6].upper()}"
|
|
|
|
|
|
def _filter_costs(year: int, month: Optional[int] = None,
|
|
service: Optional[str] = None) -> List[Dict]:
|
|
result = []
|
|
for c in _costs.values():
|
|
if c["year"] != year:
|
|
continue
|
|
if month is not None and c["month"] != month:
|
|
continue
|
|
if service is not None and c["service"] != service:
|
|
continue
|
|
result.append(c)
|
|
return result
|
|
|
|
|
|
def _sum_costs(costs: List[Dict]) -> float:
|
|
return round(sum(c["amount"] for c in costs), 2)
|
|
|
|
|
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/costs", status_code=201)
|
|
async def create_cost(
|
|
body: CostIn,
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""비용 항목 등록 (PM/ADMIN)."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
if body.category not in COST_CATEGORIES:
|
|
raise HTTPException(400, f"유효하지 않은 카테고리: {body.category}. "
|
|
f"허용: {list(COST_CATEGORIES)}")
|
|
if not (1 <= body.month <= 12):
|
|
raise HTTPException(400, "월은 1~12 사이여야 합니다.")
|
|
if body.amount < 0:
|
|
raise HTTPException(400, "비용은 0 이상이어야 합니다.")
|
|
if body.service not in SERVICES:
|
|
raise HTTPException(400, f"유효하지 않은 서비스: {body.service}")
|
|
|
|
cost_id = _gen_cost_id()
|
|
record = {
|
|
"cost_id": cost_id,
|
|
"year": body.year,
|
|
"month": body.month,
|
|
"category": body.category,
|
|
"service": body.service,
|
|
"amount": body.amount,
|
|
"description": body.description,
|
|
"server_id": body.server_id,
|
|
"department": body.department,
|
|
"created_by": current_user.username,
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
}
|
|
_costs[cost_id] = record
|
|
logger.info("비용 등록: %s %.0f원 by %s", cost_id, body.amount, current_user.username)
|
|
return record
|
|
|
|
|
|
@router.get("/costs")
|
|
async def list_costs(
|
|
year: int = Query(...),
|
|
month: Optional[int] = Query(None),
|
|
service: Optional[str] = Query(None),
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""비용 목록 조회."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
items = _filter_costs(year, month, service)
|
|
return {"total": len(items), "total_amount": _sum_costs(items), "items": items}
|
|
|
|
|
|
@router.get("/summary")
|
|
async def cost_summary(
|
|
year: int = Query(...),
|
|
month: int = Query(...),
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""월별 비용 요약 (카테고리·서비스별 집계)."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
if not (1 <= month <= 12):
|
|
raise HTTPException(400, "월은 1~12 사이여야 합니다.")
|
|
|
|
items = _filter_costs(year, month)
|
|
total = _sum_costs(items)
|
|
|
|
by_category: Dict[str, float] = {}
|
|
by_service: Dict[str, float] = {}
|
|
for c in items:
|
|
cat = c["category"]
|
|
svc = c["service"]
|
|
by_category[cat] = round(by_category.get(cat, 0) + c["amount"], 2)
|
|
by_service[svc] = round(by_service.get(svc, 0) + c["amount"], 2)
|
|
|
|
# 전월 비교
|
|
prev_month = month - 1 if month > 1 else 12
|
|
prev_year = year if month > 1 else year - 1
|
|
prev_items = _filter_costs(prev_year, prev_month)
|
|
prev_total = _sum_costs(prev_items)
|
|
mom_change = round((total - prev_total) / prev_total * 100, 1) if prev_total > 0 else 0.0
|
|
|
|
# 예산 대비
|
|
budget_key = f"{year}-{month:02d}"
|
|
budget_rec = _budgets.get(budget_key)
|
|
budget_amt = budget_rec["amount"] if budget_rec else None
|
|
budget_rate = round(total / budget_amt * 100, 1) if budget_amt else None
|
|
|
|
return {
|
|
"year": year,
|
|
"month": month,
|
|
"period": f"{year}년 {month}월",
|
|
"total_amount": total,
|
|
"by_category": {
|
|
k: {"amount": v, "label": COST_CATEGORIES.get(k, k),
|
|
"pct": round(v / total * 100, 1) if total > 0 else 0}
|
|
for k, v in sorted(by_category.items(), key=lambda x: -x[1])
|
|
},
|
|
"by_service": {
|
|
k: {"amount": v, "pct": round(v / total * 100, 1) if total > 0 else 0}
|
|
for k, v in sorted(by_service.items(), key=lambda x: -x[1])
|
|
},
|
|
"mom_change_pct": mom_change,
|
|
"budget": {
|
|
"amount": budget_amt,
|
|
"usage_pct": budget_rate,
|
|
"status": (
|
|
"OVER" if budget_rate and budget_rate > 110 else
|
|
"WARNING" if budget_rate and budget_rate > 90 else
|
|
"OK"
|
|
) if budget_rate else "NO_BUDGET",
|
|
},
|
|
"item_count": len(items),
|
|
}
|
|
|
|
|
|
@router.get("/trend")
|
|
async def cost_trend(
|
|
months: int = Query(6, ge=1, le=24),
|
|
service: Optional[str] = Query(None),
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""최근 N개월 비용 트렌드."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
|
|
now = datetime.utcnow()
|
|
trend = []
|
|
for i in range(months - 1, -1, -1):
|
|
# i개월 전
|
|
target_date = (now.replace(day=1) - timedelta(days=1) * (i * 30))
|
|
y, m = target_date.year, target_date.month
|
|
items = _filter_costs(y, m, service)
|
|
total = _sum_costs(items)
|
|
trend.append({
|
|
"year": y,
|
|
"month": m,
|
|
"period": f"{y}-{m:02d}",
|
|
"total_amount": total,
|
|
"item_count": len(items),
|
|
})
|
|
|
|
# 추세 계산 (첫 달 대비 마지막 달)
|
|
first_amt = trend[0]["total_amount"] if trend else 0
|
|
last_amt = trend[-1]["total_amount"] if trend else 0
|
|
trend_pct = round((last_amt - first_amt) / first_amt * 100, 1) if first_amt > 0 else 0.0
|
|
|
|
return {
|
|
"months": months,
|
|
"service": service or "ALL",
|
|
"data": trend,
|
|
"trend_pct": trend_pct,
|
|
"trend_dir": "UP" if trend_pct > 5 else "DOWN" if trend_pct < -5 else "STABLE",
|
|
}
|
|
|
|
|
|
@router.get("/allocation")
|
|
async def cost_allocation(
|
|
year: int = Query(...),
|
|
month: int = Query(...),
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""서비스별 비용 배분 (Cost Allocation)."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
|
|
items = _filter_costs(year, month)
|
|
total = _sum_costs(items)
|
|
|
|
allocation: Dict[str, Dict] = {}
|
|
for svc in SERVICES:
|
|
svc_items = [c for c in items if c["service"] == svc]
|
|
svc_total = _sum_costs(svc_items)
|
|
svc_pct = round(svc_total / total * 100, 1) if total > 0 else 0.0
|
|
by_cat = {}
|
|
for c in svc_items:
|
|
cat = c["category"]
|
|
by_cat[cat] = round(by_cat.get(cat, 0) + c["amount"], 2)
|
|
allocation[svc] = {
|
|
"amount": svc_total,
|
|
"pct": svc_pct,
|
|
"item_count": len(svc_items),
|
|
"by_category": by_cat,
|
|
}
|
|
|
|
return {
|
|
"year": year,
|
|
"month": month,
|
|
"total": total,
|
|
"allocation": allocation,
|
|
}
|
|
|
|
|
|
@router.get("/anomalies")
|
|
async def cost_anomalies(
|
|
year: int = Query(...),
|
|
month: int = Query(...),
|
|
threshold: float = Query(30.0, ge=5.0, le=200.0), # % 초과시 이상
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""비용 이상 탐지 (전월 대비 임계치 초과 서비스 감지)."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
|
|
prev_month = month - 1 if month > 1 else 12
|
|
prev_year = year if month > 1 else year - 1
|
|
|
|
anomalies = []
|
|
for svc in SERVICES:
|
|
curr_items = _filter_costs(year, month, svc)
|
|
prev_items = _filter_costs(prev_year, prev_month, svc)
|
|
curr_amt = _sum_costs(curr_items)
|
|
prev_amt = _sum_costs(prev_items)
|
|
|
|
if prev_amt <= 0:
|
|
continue
|
|
change_pct = round((curr_amt - prev_amt) / prev_amt * 100, 1)
|
|
if abs(change_pct) >= threshold:
|
|
anomalies.append({
|
|
"service": svc,
|
|
"curr_amount": curr_amt,
|
|
"prev_amount": prev_amt,
|
|
"change_pct": change_pct,
|
|
"direction": "UP" if change_pct > 0 else "DOWN",
|
|
"severity": "CRITICAL" if abs(change_pct) >= 50 else "WARNING",
|
|
})
|
|
|
|
anomalies.sort(key=lambda x: abs(x["change_pct"]), reverse=True)
|
|
return {
|
|
"year": year,
|
|
"month": month,
|
|
"threshold_pct": threshold,
|
|
"anomaly_count": len(anomalies),
|
|
"anomalies": anomalies,
|
|
}
|
|
|
|
|
|
@router.get("/recommendations")
|
|
async def cost_recommendations(
|
|
year: int = Query(...),
|
|
month: int = Query(...),
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""비용 절감 권고사항 생성."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
|
|
items = _filter_costs(year, month)
|
|
total = _sum_costs(items)
|
|
recs = []
|
|
|
|
# 단순 규칙 기반 권고
|
|
by_cat: Dict[str, float] = {}
|
|
for c in items:
|
|
cat = c["category"]
|
|
by_cat[cat] = round(by_cat.get(cat, 0) + c["amount"], 2)
|
|
|
|
if total > 0:
|
|
license_pct = by_cat.get("LICENSE", 0) / total * 100
|
|
if license_pct > 30:
|
|
recs.append({
|
|
"type": "LICENSE_REVIEW",
|
|
"severity": "HIGH",
|
|
"message": f"소프트웨어 라이선스 비용이 전체의 {license_pct:.1f}%입니다. "
|
|
"미사용 라이선스 해지를 검토하세요.",
|
|
"potential_saving_pct": 10,
|
|
})
|
|
|
|
maintenance_pct = by_cat.get("MAINTENANCE", 0) / total * 100
|
|
if maintenance_pct > 25:
|
|
recs.append({
|
|
"type": "MAINTENANCE_REVIEW",
|
|
"severity": "MEDIUM",
|
|
"message": f"유지보수 비용({maintenance_pct:.1f}%)이 높습니다. "
|
|
"계약 재협상 또는 자체 유지보수 전환을 검토하세요.",
|
|
"potential_saving_pct": 5,
|
|
})
|
|
|
|
# 전월 대비 급증 서비스 경고
|
|
prev_month = month - 1 if month > 1 else 12
|
|
prev_year = year if month > 1 else year - 1
|
|
for svc in SERVICES:
|
|
curr = _sum_costs(_filter_costs(year, month, svc))
|
|
prev = _sum_costs(_filter_costs(prev_year, prev_month, svc))
|
|
if prev > 0 and (curr - prev) / prev > 0.5:
|
|
recs.append({
|
|
"type": "COST_SPIKE",
|
|
"severity": "HIGH",
|
|
"message": f"{svc} 서비스 비용이 전월 대비 "
|
|
f"{(curr-prev)/prev*100:.0f}% 급증했습니다. 원인 조사가 필요합니다.",
|
|
"potential_saving_pct": 15,
|
|
})
|
|
|
|
if not recs:
|
|
recs.append({
|
|
"type": "NORMAL",
|
|
"severity": "INFO",
|
|
"message": "현재 비용 구조가 정상 범위입니다.",
|
|
"potential_saving_pct": 0,
|
|
})
|
|
|
|
total_potential = sum(r.get("potential_saving_pct", 0) for r in recs if r["type"] != "NORMAL")
|
|
return {
|
|
"year": year,
|
|
"month": month,
|
|
"total_amount": total,
|
|
"recommendation_count": len(recs),
|
|
"recommendations": recs,
|
|
"total_potential_saving_pct": total_potential,
|
|
}
|
|
|
|
|
|
@router.post("/budget", status_code=201)
|
|
async def set_budget(
|
|
body: BudgetIn,
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""예산 등록 또는 수정 (ADMIN)."""
|
|
if current_user.role != UserRole.ADMIN:
|
|
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
|
|
if not (1 <= body.month <= 12):
|
|
raise HTTPException(400, "월은 1~12 사이여야 합니다.")
|
|
if body.amount < 0:
|
|
raise HTTPException(400, "예산은 0 이상이어야 합니다.")
|
|
|
|
key = f"{body.year}-{body.month:02d}"
|
|
_budgets[key] = {
|
|
"year": body.year,
|
|
"month": body.month,
|
|
"service": body.service,
|
|
"amount": body.amount,
|
|
"set_by": current_user.username,
|
|
"set_at": datetime.utcnow().isoformat(),
|
|
}
|
|
logger.info("예산 설정: %s %s %.0f원", key, body.service, body.amount)
|
|
return {"message": f"{body.year}년 {body.month}월 예산이 설정되었습니다.",
|
|
"budget": _budgets[key]}
|
|
|
|
|
|
@router.get("/budget")
|
|
async def get_budget(
|
|
year: int = Query(...),
|
|
month: int = Query(...),
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""예산 대비 실적 조회."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
|
|
key = f"{year}-{month:02d}"
|
|
budget_rec = _budgets.get(key)
|
|
items = _filter_costs(year, month)
|
|
actual = _sum_costs(items)
|
|
|
|
if not budget_rec:
|
|
return {
|
|
"year": year, "month": month,
|
|
"budget": None,
|
|
"actual": actual,
|
|
"status": "NO_BUDGET",
|
|
"message": "해당 월 예산이 설정되지 않았습니다.",
|
|
}
|
|
|
|
budget_amt = budget_rec["amount"]
|
|
variance = round(actual - budget_amt, 2)
|
|
usage_pct = round(actual / budget_amt * 100, 1) if budget_amt > 0 else 0.0
|
|
status = (
|
|
"OVER" if usage_pct > 110 else
|
|
"WARNING" if usage_pct > 90 else
|
|
"OK"
|
|
)
|
|
|
|
return {
|
|
"year": year,
|
|
"month": month,
|
|
"budget": budget_amt,
|
|
"actual": actual,
|
|
"variance": variance,
|
|
"usage_pct": usage_pct,
|
|
"status": status,
|
|
"remaining": round(budget_amt - actual, 2),
|
|
"item_count": len(items),
|
|
}
|
|
|
|
|
|
@router.get("/optimize")
|
|
async def ai_optimize(
|
|
year: int = Query(...),
|
|
month: int = Query(...),
|
|
current_user: User = Depends(get_current_user),
|
|
_db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Ollama sLLM 기반 비용 최적화 분석."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
|
|
|
|
items = _filter_costs(year, month)
|
|
total = _sum_costs(items)
|
|
by_cat: Dict[str, float] = {}
|
|
for c in items:
|
|
cat = c["category"]
|
|
by_cat[cat] = round(by_cat.get(cat, 0) + c["amount"], 2)
|
|
|
|
# Ollama sLLM 최적화 분석 (내부 LLM only)
|
|
llm_analysis: Optional[str] = None
|
|
try:
|
|
import httpx
|
|
prompt = (
|
|
"다음 IT 인프라 비용 현황을 분석하여 비용 절감 방안을 한국어로 3-5가지 제시하세요.\n\n"
|
|
f"월: {year}년 {month}월\n"
|
|
f"총 비용: {total:,.0f}원\n"
|
|
+ "\n".join(f"{COST_CATEGORIES.get(k, k)}: {v:,.0f}원" for k, v in by_cat.items())
|
|
)
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
resp = await client.post(
|
|
"http://localhost:11434/api/generate",
|
|
json={"model": "llama3", "prompt": prompt, "stream": False},
|
|
)
|
|
if resp.status_code == 200:
|
|
llm_analysis = resp.json().get("response", "").strip()
|
|
except Exception:
|
|
pass
|
|
|
|
# 폴백: 규칙 기반 분석
|
|
if not llm_analysis:
|
|
tips = [
|
|
"유휴 서버 자원을 정기적으로 점검하고 불필요한 서버는 폐기하세요.",
|
|
"소프트웨어 라이선스 사용 현황을 분기마다 감사하여 미사용 라이선스를 해지하세요.",
|
|
"유지보수 계약을 통합·재협상하여 규모의 경제를 활용하세요.",
|
|
"네트워크 대역폭 사용 패턴을 분석하여 과잉 회선을 축소하세요.",
|
|
"스토리지 티어링(hot/warm/cold)을 적용하여 보관 비용을 절감하세요.",
|
|
]
|
|
llm_analysis = "\n".join(f"{i+1}. {t}" for i, t in enumerate(tips))
|
|
|
|
return {
|
|
"year": year,
|
|
"month": month,
|
|
"total_amount": total,
|
|
"by_category": by_cat,
|
|
"optimization": llm_analysis,
|
|
"llm_used": "ollama/llama3",
|
|
"disclaimer": "AI 분석은 참고용입니다. 실제 절감 가능 여부는 현장 검토 후 판단하세요.",
|
|
}
|