""" 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 분석은 참고용입니다. 실제 절감 가능 여부는 현장 검토 후 판단하세요.", }