From a4655d234ff8cd20263197ba5350e035b20ac062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 7 Jun 2026 23:17:57 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20=EB=A1=9C=EC=BB=AC=20=EA=B0=80?= =?UTF-8?q?=EC=83=81=ED=99=98=EA=B2=BD=C2=B7=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?DB=20gitignore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b037f76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +env/ + +# 로컬 테스트 아티팩트 +test.db +test_*.db +*.sqlite3 From fb32d4439c613003da5e3a6c76aa3046d24956ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 7 Jun 2026 23:18:10 +0900 Subject: [PATCH 2/7] =?UTF-8?q?perf:=20DB=20=EC=BB=A4=EB=84=A5=EC=85=98=20?= =?UTF-8?q?=ED=92=80=EC=9D=84=20=EC=9B=8C=EC=BB=A4=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/database.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/core/database.py b/core/database.py index e73316b..2aa71e0 100644 --- a/core/database.py +++ b/core/database.py @@ -7,13 +7,26 @@ # SQLite인 경우에만 check_same_thread 옵션 추가 connect_args = {} +engine_kwargs = { + "pool_pre_ping": True, # PostgreSQL 커넥션 끊김 자동 복구 +} if SQLALCHEMY_DATABASE_URL.startswith("sqlite"): connect_args["check_same_thread"] = False +else: + # 동기 라우트는 anyio 워커 스레드풀(기본 40개)에서 동시에 실행되고, + # 노드/VM 상태 조회는 서버별로 스레드 병렬 호출한다. 기본 풀(5+10)로는 + # 커넥션이 고갈되므로 워커 동시성에 맞춰 풀을 키우고 stale 커넥션을 재활용한다. + engine_kwargs.update( + pool_size=20, # 상시 유지 커넥션 + max_overflow=20, # 피크 시 추가 허용 (총 40 ≈ 워커 스레드 수) + pool_recycle=1800, # 30분마다 커넥션 재생성 (DB 측 idle timeout 대비) + pool_timeout=30, # 커넥션 확보 대기 타임아웃(초) + ) engine = create_engine( SQLALCHEMY_DATABASE_URL, connect_args=connect_args, - pool_pre_ping=True, # PostgreSQL 커넥션 끊김 자동 복구 + **engine_kwargs, ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) From dd6d3ee666e5be307622b25de737f7b0157f7bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 7 Jun 2026 23:18:10 +0900 Subject: [PATCH 3/7] =?UTF-8?q?perf:=20=EC=95=8C=EB=A6=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20(user=5Fid,=20created?= =?UTF-8?q?=5Fat)=20=EB=B3=B5=ED=95=A9=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/notification.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/models/notification.py b/models/notification.py index 98767ec..aa4c4f1 100644 --- a/models/notification.py +++ b/models/notification.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Index from sqlalchemy.orm import relationship from core.database import Base from core.timezone import now_kst @@ -7,6 +7,12 @@ class Notification(Base): __tablename__ = "notifications" + # 알림 목록 조회는 항상 "user_id = ? ORDER BY created_at DESC" 형태이므로 + # (user_id, created_at) 복합 인덱스로 필터 + 정렬을 한 번에 커버한다. + __table_args__ = ( + Index("ix_notifications_user_created", "user_id", "created_at"), + ) + id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) type = Column(String, nullable=False) # info, success, error From 6efd5658afdef85d6e20f0441a730f7d66f751da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 7 Jun 2026 23:18:10 +0900 Subject: [PATCH 4/7] =?UTF-8?q?perf:=20=EB=85=B8=EB=93=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EB=B3=91=EB=A0=AC=20Proxmox=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/proxmox_client.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/services/proxmox_client.py b/services/proxmox_client.py index a2ea02c..0d1976c 100644 --- a/services/proxmox_client.py +++ b/services/proxmox_client.py @@ -1,6 +1,7 @@ import logging import threading import time +from concurrent.futures import ThreadPoolExecutor from proxmoxer import ProxmoxAPI from fastapi import HTTPException @@ -11,6 +12,32 @@ _cache_lock = threading.Lock() _CACHE_TTL = 300 # 5분 +# 여러 노드(서버)의 Proxmox 상태를 동시에 조회하기 위한 공용 스레드풀. +# 각 서버는 서로 다른 Proxmox 연결(requests.Session)을 쓰므로 "서버 단위" 병렬은 안전하다. +# (동일 서버 내 호출은 세션을 공유하므로 각 작업 함수 내부에서는 순차 처리한다.) +_node_executor = ThreadPoolExecutor(max_workers=12, thread_name_prefix="pmox-node") + + +def gather_per_server(servers, fn): + """servers 각각에 대해 fn(server)를 스레드풀에서 병렬 실행한다. + + 반환: {server.id: 결과}. fn 이 예외를 던지면 해당 server.id 는 결과에서 + 제외된다(호출부에서 누락을 offline 등으로 처리). + + 주의: fn 내부에서는 이미 로드된 ORM 컬럼만 읽어야 한다(지연 로딩/refresh 금지). + SQLAlchemy Session 은 스레드 안전하지 않으므로 관계 접근은 호출 전에 끝낸다. + """ + if not servers: + return {} + results: dict[int, object] = {} + future_map = {_node_executor.submit(fn, s): s for s in servers} + for future, server in future_map.items(): + try: + results[server.id] = future.result() + except Exception as e: + logger.warning(f"[proxmox] 노드 병렬 조회 실패 (server_id={server.id}): {e}") + return results + def get_proxmox_for_server(server): """ From 1226c378e63e7ee9c2dcea26a9cb0ee7c25df88b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 7 Jun 2026 23:18:20 +0900 Subject: [PATCH 5/7] =?UTF-8?q?perf:=20VM=C2=B7=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=85=B8=EB=93=9C=20=EB=B3=91=EB=A0=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20N+1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/routes/monitoring.py | 57 +++++----- api/routes/vmcontrol.py | 237 +++++++++++++++++++++------------------ 2 files changed, 159 insertions(+), 135 deletions(-) diff --git a/api/routes/monitoring.py b/api/routes/monitoring.py index 744c01b..876b792 100644 --- a/api/routes/monitoring.py +++ b/api/routes/monitoring.py @@ -1,7 +1,7 @@ import logging from fastapi import APIRouter, Depends from sqlalchemy.orm import Session -from services.proxmox_client import get_proxmox_for_server +from services.proxmox_client import get_proxmox_for_server, gather_per_server from core.database import get_db from models.server import Server from models.user import User, UserRole @@ -12,8 +12,29 @@ router = APIRouter() +def _fetch_node_stats(server): + """단일 노드 상태 조회 (스레드풀에서 서버별 병렬 실행).""" + proxmox = get_proxmox_for_server(server) + node_status = proxmox.nodes(server.name).status.get() + + cpu_usage = node_status.get("cpu", 0) * 100 # 소수점(0.05)을 백분율(5%)로 + memory = node_status.get("memory", {}) + total_ram_gb = memory.get("total", 0) / (1024**3) + used_ram_gb = memory.get("used", 0) / (1024**3) + free_ram_gb = total_ram_gb - used_ram_gb + + return { + "status": "online", + "cpu_usage_percent": round(cpu_usage, 1), + "ram_total_gb": round(total_ram_gb, 1), + "ram_used_gb": round(used_ram_gb, 1), + "ram_free_gb": round(free_ram_gb, 1), + "uptime_seconds": node_status.get("uptime", 0), + } + + @router.get("/nodes") -async def get_system_stats( +def get_system_stats( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ @@ -33,33 +54,15 @@ async def get_system_stats( if not servers: return {"message": "등록된 활성 서버가 없습니다.", "stats": {}} - all_stats = {} + # 노드별 상태를 동시에 조회 (서버마다 독립 Proxmox 연결 → 스레드 안전) + stats_by_id = gather_per_server(servers, _fetch_node_stats) + all_stats = {} for server in servers: - try: - proxmox = get_proxmox_for_server(server) - - # 각 노드의 상태 조회 - node_status = proxmox.nodes(server.name).status.get() - - # 데이터 가공 - cpu_usage = node_status.get("cpu", 0) * 100 # 소수점(0.05)을 백분율(5%)로 - - memory = node_status.get("memory", {}) - total_ram_gb = memory.get("total", 0) / (1024**3) - used_ram_gb = memory.get("used", 0) / (1024**3) - free_ram_gb = total_ram_gb - used_ram_gb - - all_stats[server.name] = { - "status": "online", - "cpu_usage_percent": round(cpu_usage, 1), - "ram_total_gb": round(total_ram_gb, 1), - "ram_used_gb": round(used_ram_gb, 1), - "ram_free_gb": round(free_ram_gb, 1), - "uptime_seconds": node_status.get("uptime", 0), - } - except Exception as e: - logger.error(f"[monitoring] 노드 {server.name} 조회 실패: {e}") + result = stats_by_id.get(server.id) + if result is not None: + all_stats[server.name] = result + else: all_stats[server.name] = { "status": "offline", "error": "노드에 연결할 수 없습니다.", diff --git a/api/routes/vmcontrol.py b/api/routes/vmcontrol.py index 6af2600..ced86b2 100644 --- a/api/routes/vmcontrol.py +++ b/api/routes/vmcontrol.py @@ -1,12 +1,12 @@ -import asyncio import base64 import logging +import time from datetime import timedelta from core.timezone import now_kst from fastapi import APIRouter, HTTPException, status, Depends, Request -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from schemas.vm_schema import VMAction, VMCreate, VMResize, SnapshotCreateRequest -from services.proxmox_client import get_proxmox_for_server +from services.proxmox_client import get_proxmox_for_server, gather_per_server from models.server import Server from services.vm_service import create_vm, delete_vm from core.database import get_db @@ -22,68 +22,72 @@ @router.get("/nodes") -async def get_nodes( +def get_nodes( db: Session = Depends(get_db), current_user: User = Depends(get_current_active_admin), ): """Proxmox 노드 목록 조회 (관리자 전용)""" servers = db.query(Server).filter(Server.is_active == True).all() + + def fetch(server): + return get_proxmox_for_server(server).nodes(server.name).status.get() + + status_by_id = gather_per_server(servers, fetch) result = [] for server in servers: - try: - proxmox = get_proxmox_for_server(server) - node_status = proxmox.nodes(server.name).status.get() - result.append({"name": server.name, "status": "online", "detail": node_status}) - except Exception: + detail = status_by_id.get(server.id) + if detail is not None: + result.append({"name": server.name, "status": "online", "detail": detail}) + else: result.append({"name": server.name, "status": "offline", "detail": None}) return {"nodes": result} +def _fetch_node_resources(server): + """단일 노드의 리소스 사용량 조회 (스레드풀에서 서버별 병렬 실행).""" + proxmox = get_proxmox_for_server(server) + s = proxmox.nodes(server.name).status.get() + cpu_used = round(s.get("cpu", 0) * 100, 1) + mem = s.get("memory", {}) + mem_used_gb = round(mem.get("used", 0) / (1024 ** 3), 1) + mem_total_gb = round(mem.get("total", 0) / (1024 ** 3), 1) + # lvm-thin 스토리지 합산 (data, vm 등 여러 파티션) + disk_used = 0 + disk_total = 0 + try: + storages = proxmox.nodes(server.name).storage.get() + for st in storages: + if st.get("type") == "lvmthin": + disk_used += st.get("used", 0) + disk_total += st.get("total", 0) + except Exception: + pass + return { + "online": True, + "cpu_percent": cpu_used, + "mem_used_gb": mem_used_gb, + "mem_total_gb": mem_total_gb, + "disk_used_gb": round(disk_used / (1024 ** 3), 1), + "disk_total_gb": round(disk_total / (1024 ** 3), 1), + } + + @router.get("/nodes/resources") -async def get_nodes_resources( +def get_nodes_resources( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """노드별 리소스 사용량 조회 (배포 페이지용)""" servers = db.query(Server).filter(Server.is_active == True).all() - result = {} - for server in servers: - try: - proxmox = get_proxmox_for_server(server) - s = proxmox.nodes(server.name).status.get() - cpu_used = round(s.get("cpu", 0) * 100, 1) - mem = s.get("memory", {}) - mem_used_gb = round(mem.get("used", 0) / (1024 ** 3), 1) - mem_total_gb = round(mem.get("total", 0) / (1024 ** 3), 1) - # lvm-thin 스토리지 합산 (data, vm 등 여러 파티션) - disk_used = 0 - disk_total = 0 - try: - storages = proxmox.nodes(server.name).storage.get() - for st in storages: - if st.get("type") == "lvmthin": - disk_used += st.get("used", 0) - disk_total += st.get("total", 0) - except Exception: - pass - disk_used_gb = round(disk_used / (1024 ** 3), 1) - disk_total_gb = round(disk_total / (1024 ** 3), 1) - result[server.name] = { - "online": True, - "cpu_percent": cpu_used, - "mem_used_gb": mem_used_gb, - "mem_total_gb": mem_total_gb, - "disk_used_gb": disk_used_gb, - "disk_total_gb": disk_total_gb, - } - except Exception as e: - logger.warning(f"[NodeResources] {server.name} 조회 실패: {e}") - result[server.name] = {"online": False} - return result + res_by_id = gather_per_server(servers, _fetch_node_resources) + return { + server.name: res_by_id.get(server.id, {"online": False}) + for server in servers + } @router.get("/{node}/vms") -async def get_vms( +def get_vms( node: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), @@ -109,36 +113,38 @@ async def get_vms( @router.get("/admin/all-vms") -async def get_all_vms( +def get_all_vms( db: Session = Depends(get_db), current_user: User = Depends(get_current_active_admin), ): """전체 VM 목록 조회 — 노드별 그룹핑 (관리자 전용)""" servers = db.query(Server).filter(Server.is_active == True).all() - all_db_vms = db.query(Vm).all() + # owner 를 함께 로드(joinedload)해 vm.owner.email 접근 시 발생하던 N+1 제거 + all_db_vms = db.query(Vm).options(joinedload(Vm.owner)).all() - # DB VM을 server_id로 매핑 - vm_by_server: dict[int, list[Vm]] = {} + # DB VM을 server_id별 info dict 리스트로 매핑 (라이브 상태는 이후 병렬로 채움). + # ORM 속성 접근은 메인 스레드에서 끝내고, 워커 스레드에는 dict 만 넘긴다. + info_by_server: dict[int, list] = {} for vm in all_db_vms: - vm_by_server.setdefault(vm.server_id, []).append(vm) - - nodes = [] + info_by_server.setdefault(vm.server_id, []).append({ + "vmid": vm.hypervisor_vmid, + "name": vm.display_name or vm.name, + "node": None, # 아래에서 서버명으로 채움 + "status": "unknown", + "owner_email": vm.owner.email if vm.owner else None, + "internal_ip": vm.internal_ip, + "created_at": str(vm.created_at) if vm.created_at else None, + "expires_at": str(vm.expires_at) if vm.expires_at else None, + }) for server in servers: - node_vms = [] - for vm in vm_by_server.get(server.id, []): - info = { - "vmid": vm.hypervisor_vmid, - "name": vm.display_name or vm.name, - "node": server.name, - "status": "unknown", - "owner_email": vm.owner.email if vm.owner else None, - "internal_ip": vm.internal_ip, - "created_at": str(vm.created_at) if vm.created_at else None, - "expires_at": str(vm.expires_at) if vm.expires_at else None, - } + for info in info_by_server.get(server.id, []): + info["node"] = server.name + + def fetch(server): + proxmox = get_proxmox_for_server(server) + for info in info_by_server.get(server.id, []): try: - proxmox = get_proxmox_for_server(server) - vm_status = proxmox.nodes(server.name).qemu(vm.hypervisor_vmid).status.current.get() + vm_status = proxmox.nodes(server.name).qemu(info["vmid"]).status.current.get() info["status"] = vm_status.get("status", "unknown") info["cpu_usage"] = vm_status.get("cpu", 0) info["maxmem"] = vm_status.get("maxmem", 0) @@ -147,28 +153,37 @@ async def get_all_vms( info["uptime"] = vm_status.get("uptime", 0) except Exception: pass - node_vms.append(info) - nodes.append({"name": server.name, "vms": node_vms}) + + # VM이 있는 서버만 노드별로 병렬 조회 (서버마다 독립 연결 → 스레드 안전) + gather_per_server([s for s in servers if info_by_server.get(s.id)], fetch) + + nodes = [ + {"name": server.name, "vms": info_by_server.get(server.id, [])} + for server in servers + ] return {"nodes": nodes} @router.get("/my-vms") -async def get_my_vms( +def get_my_vms( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """현재 사용자가 소유한 모든 VM 목록 조회 (모든 노드 통합)""" - user_vms = db.query(Vm).filter(Vm.owner_id == current_user.id).all() + # server 를 함께 로드(joinedload)해 vm.server.name 접근 시 발생하던 N+1 제거 + user_vms = ( + db.query(Vm) + .options(joinedload(Vm.server)) + .filter(Vm.owner_id == current_user.id) + .all() + ) - # 서버별로 VM 그룹핑 → Proxmox 연결 재사용 + # 서버별로 info dict 그룹핑. ORM 접근은 메인 스레드에서 끝내고, + # 원래 VM 순서를 유지하기 위해 result 리스트에 같은 dict 참조를 담아둔다. from collections import defaultdict - server_vms: dict[int, list] = defaultdict(list) - for vm in user_vms: - server_vms[vm.server_id].append(vm) - + infos_by_server: dict[int, list] = defaultdict(list) + servers_by_id: dict[int, object] = {} result = [] - proxmox_cache: dict[int, any] = {} - for vm in user_vms: info = { "vmid": vm.hypervisor_vmid, @@ -179,29 +194,35 @@ async def get_my_vms( "created_at": str(vm.created_at) if vm.created_at else None, "expires_at": str(vm.expires_at) if vm.expires_at else None, } - try: - if vm.server_id not in proxmox_cache: - proxmox_cache[vm.server_id] = get_proxmox_for_server(vm.server) - proxmox = proxmox_cache[vm.server_id] - vm_status = proxmox.nodes(vm.server.name).qemu(vm.hypervisor_vmid).status.current.get() - info["status"] = vm_status.get("status", "unknown") - info["cpu_usage"] = vm_status.get("cpu", 0) - info["maxmem"] = vm_status.get("maxmem", 0) - info["mem_usage"] = vm_status.get("mem", 0) - info["maxdisk"] = vm_status.get("maxdisk", 0) - info["uptime"] = vm_status.get("uptime", 0) - uptime = vm_status.get("uptime", 0) - info["provisioning"] = ( - vm_status.get("status") == "running" and 0 < uptime < 180 - ) - except Exception: - pass + infos_by_server[vm.server_id].append(info) + servers_by_id[vm.server_id] = vm.server result.append(info) + + def fetch(server): + proxmox = get_proxmox_for_server(server) + for info in infos_by_server[server.id]: + try: + vm_status = proxmox.nodes(server.name).qemu(info["vmid"]).status.current.get() + info["status"] = vm_status.get("status", "unknown") + info["cpu_usage"] = vm_status.get("cpu", 0) + info["maxmem"] = vm_status.get("maxmem", 0) + info["mem_usage"] = vm_status.get("mem", 0) + info["maxdisk"] = vm_status.get("maxdisk", 0) + uptime = vm_status.get("uptime", 0) + info["uptime"] = uptime + info["provisioning"] = ( + vm_status.get("status") == "running" and 0 < uptime < 180 + ) + except Exception: + pass + + # 노드별로 병렬 조회 (서버마다 독립 연결 → 스레드 안전) + gather_per_server(list(servers_by_id.values()), fetch) return result @router.get("/{node}/vms/{vmid}/status") -async def get_vm_status( +def get_vm_status( node: str, vmid: int, db: Session = Depends(get_db), @@ -224,7 +245,7 @@ async def get_vm_status( command="test -f /home/ubuntu/ok.txt && echo OK || echo NOTYET" ) pid = result.get("pid") - await asyncio.sleep(1) + time.sleep(1) out = proxmox.nodes(node).qemu(vmid).agent("exec-status").get(pid=pid) stdout = base64.b64decode(out.get("out-data", "")).decode(errors="ignore") provisioning = "OK" not in stdout and 0 < uptime < 180 @@ -256,7 +277,7 @@ async def get_vm_status( @router.get("/{node}/vms/{vmid}/metrics") -async def get_vm_metrics( +def get_vm_metrics( node: str, vmid: int, timeframe: str = "hour", @@ -310,7 +331,7 @@ async def get_vm_metrics( @router.put("/{node}/vms/{vmid}/resize") -async def resize_vm( +def resize_vm( node: str, vmid: int, body: VMResize, @@ -372,7 +393,7 @@ async def resize_vm( @router.post("/{node}/vms/{vmid}/extend") -async def extend_vm( +def extend_vm( node: str, vmid: int, db: Session = Depends(get_db), @@ -405,7 +426,7 @@ async def extend_vm( @router.post("/{node}/vms/{vmid}/action") @limiter.limit("10/minute") -async def control_vm( +def control_vm( request: Request, node: str, vmid: int, @@ -440,7 +461,7 @@ async def control_vm( @router.post("/create", status_code=status.HTTP_201_CREATED) @limiter.limit("5/minute") -async def create_vm_endpoint( +def create_vm_endpoint( request: Request, vm_config: VMCreate, db: Session = Depends(get_db), @@ -464,7 +485,7 @@ async def create_vm_endpoint( @router.get("/{node}/vms/{vmid}/snapshots") -async def list_snapshots( +def list_snapshots( node: str, vmid: int, db: Session = Depends(get_db), @@ -484,7 +505,7 @@ async def list_snapshots( @router.post("/{node}/vms/{vmid}/snapshots") -async def create_snapshot( +def create_snapshot( node: str, vmid: int, body: SnapshotCreateRequest, @@ -521,7 +542,7 @@ async def create_snapshot( @router.post("/{node}/vms/{vmid}/snapshots/{snapname}/rollback") -async def rollback_snapshot( +def rollback_snapshot( node: str, vmid: int, snapname: str, @@ -541,7 +562,7 @@ async def rollback_snapshot( @router.delete("/{node}/vms/{vmid}/snapshots/{snapname}") -async def delete_snapshot( +def delete_snapshot( node: str, vmid: int, snapname: str, @@ -561,7 +582,7 @@ async def delete_snapshot( @router.get("/{node}/vms/{vmid}/auto-snapshot") -async def get_auto_snapshot( +def get_auto_snapshot( node: str, vmid: int, db: Session = Depends(get_db), @@ -573,7 +594,7 @@ async def get_auto_snapshot( @router.put("/{node}/vms/{vmid}/auto-snapshot") -async def toggle_auto_snapshot( +def toggle_auto_snapshot( node: str, vmid: int, db: Session = Depends(get_db), @@ -588,7 +609,7 @@ async def toggle_auto_snapshot( @router.delete("/{node}/vms/{vmid}") @limiter.limit("5/minute") -async def delete_vm_endpoint( +def delete_vm_endpoint( request: Request, node: str, vmid: int, From 1e001a85ff174c50c8d4d826b70a2bfec64e102e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 7 Jun 2026 23:18:20 +0900 Subject: [PATCH 6/7] =?UTF-8?q?perf:=20FAQ=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20user=20joinedload=20=EB=A1=9C=20N+1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/routes/faq.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/api/routes/faq.py b/api/routes/faq.py index 3e1aaff..b5f9369 100644 --- a/api/routes/faq.py +++ b/api/routes/faq.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from pydantic import BaseModel, field_validator from typing import Optional @@ -55,18 +55,20 @@ class Config: # ── Endpoints ──────────────────────────────────────────────── @router.get("", response_model=list[FaqQuestionResponse]) -async def get_faq_questions( +def get_faq_questions( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """FAQ 질문 목록 조회 — 본인 질문만 (관리자는 전체)""" from models.user import UserRole + # user 를 함께 로드(joinedload)해 q.user.email 접근 시 발생하던 N+1 제거 + base_query = db.query(FaqQuestion).options(joinedload(FaqQuestion.user)) if current_user.role == UserRole.ADMIN: - questions = db.query(FaqQuestion).order_by(FaqQuestion.created_at.desc()).all() + questions = base_query.order_by(FaqQuestion.created_at.desc()).all() else: questions = ( - db.query(FaqQuestion) + base_query .filter(FaqQuestion.user_id == current_user.id) .order_by(FaqQuestion.created_at.desc()) .all() @@ -86,7 +88,7 @@ async def get_faq_questions( @router.post("", response_model=FaqQuestionResponse) -async def create_faq_question( +def create_faq_question( body: FaqQuestionCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -110,7 +112,7 @@ async def create_faq_question( @router.put("/{question_id}/answer", response_model=FaqQuestionResponse) -async def answer_faq_question( +def answer_faq_question( question_id: int, body: FaqAnswerCreate, admin: User = Depends(get_current_active_admin), @@ -137,7 +139,7 @@ async def answer_faq_question( @router.delete("/{question_id}") -async def delete_faq_question( +def delete_faq_question( question_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), From 4db0ff8dd1585f2127793d85f9985eab107b0083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 7 Jun 2026 23:18:20 +0900 Subject: [PATCH 7/7] =?UTF-8?q?perf:=20=EB=B8=94=EB=A1=9C=ED=82=B9=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=8F=99=EA=B8=B0=20def=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A3=A8=ED=94=84=20=EC=B0=A8=EB=8B=A8=20=ED=95=B4?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/routes/auth.py | 22 +++++++++++----------- api/routes/firewall.py | 14 +++++++------- api/routes/network.py | 2 +- api/routes/notifications.py | 8 ++++---- api/routes/oauth.py | 4 ++-- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/api/routes/auth.py b/api/routes/auth.py index 943dcfe..6b05406 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -326,7 +326,7 @@ async def signup_project(body: ProjectSignupRequest, db: Session = Depends(get_d @router.post("/verify") @limiter.limit("10/minute") -async def verify_email( +def verify_email( request: Request, body: VerifyCodeRequest, db: Session = Depends(get_db) ): """ @@ -421,7 +421,7 @@ async def verify_email( @router.get("/pending-approvals") -async def get_pending_approvals( +def get_pending_approvals( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -452,7 +452,7 @@ async def get_pending_approvals( @router.post("/approve/{user_id}") -async def approve_project_owner( +def approve_project_owner( user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -475,7 +475,7 @@ async def approve_project_owner( @router.post("/reject/{user_id}") -async def reject_project_owner( +def reject_project_owner( user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -568,7 +568,7 @@ async def resend_code( @router.post("/login", response_model=Token) @limiter.limit("10/minute") -async def login( +def login( request: Request, db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends(), @@ -624,7 +624,7 @@ async def login( @router.post("/refresh") -async def refresh_token( +def refresh_token( request: Request, body: RefreshRequest = None, db: Session = Depends(get_db), @@ -668,7 +668,7 @@ async def refresh_token( @router.get("/me") -async def read_users_me(current_user: User = Depends(get_current_user)): +def read_users_me(current_user: User = Depends(get_current_user)): """현재 로그인된 사용자의 정보를 조회합니다.""" return { "id": current_user.id, @@ -764,7 +764,7 @@ async def request_password_reset( @router.post("/password-reset/confirm") @limiter.limit("5/minute") -async def confirm_password_reset( +def confirm_password_reset( request: Request, body: PasswordResetConfirm, db: Session = Depends(get_db) ): """비밀번호 재설정 2단계: 인증 코드 확인 + 해당 role 계정의 비밀번호만 변경.""" @@ -839,7 +839,7 @@ async def confirm_password_reset( @router.put("/change-password") -async def change_password( +def change_password( body: ChangePasswordRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), @@ -926,7 +926,7 @@ async def upload_avatar( @router.delete("/avatar") -async def delete_avatar( +def delete_avatar( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -945,7 +945,7 @@ async def delete_avatar( @router.post("/logout") -async def logout(): +def logout(): """로그아웃 — httpOnly 쿠키 삭제.""" response = JSONResponse(content={"message": "로그아웃 되었습니다."}) _clear_auth_cookies(response) diff --git a/api/routes/firewall.py b/api/routes/firewall.py index 7312f92..000c891 100644 --- a/api/routes/firewall.py +++ b/api/routes/firewall.py @@ -15,7 +15,7 @@ @router.get("/{vmid}/rules") -async def get_firewall_rules( +def get_firewall_rules( vmid: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), @@ -34,7 +34,7 @@ async def get_firewall_rules( @router.post("/{vmid}/rules") -async def add_firewall_rule( +def add_firewall_rule( vmid: int, rule: FirewallRule, db: Session = Depends(get_db), @@ -58,7 +58,7 @@ async def add_firewall_rule( @router.delete("/{vmid}/rules/{pos}") -async def delete_firewall_rule( +def delete_firewall_rule( vmid: int, pos: int, db: Session = Depends(get_db), @@ -80,7 +80,7 @@ async def delete_firewall_rule( # ── 커스텀 포트 할당 (30000~39999) ─────────────────────────────────────────── @router.get("/{node}/{vmid}/ports") -async def get_custom_ports( +def get_custom_ports( node: str, vmid: int, db: Session = Depends(get_db), @@ -109,7 +109,7 @@ async def get_custom_ports( @router.post("/{node}/{vmid}/ports", status_code=201) -async def add_custom_port( +def add_custom_port( node: str, vmid: int, body: VmPortCreate, @@ -177,7 +177,7 @@ async def add_custom_port( @router.post("/{node}/{vmid}/ports/defaults/restore") -async def restore_default_ports( +def restore_default_ports( node: str, vmid: int, db: Session = Depends(get_db), @@ -237,7 +237,7 @@ async def restore_default_ports( @router.delete("/{node}/{vmid}/ports/{port_id}") -async def delete_custom_port( +def delete_custom_port( node: str, vmid: int, port_id: int, diff --git a/api/routes/network.py b/api/routes/network.py index 8f7f74d..cd0481f 100644 --- a/api/routes/network.py +++ b/api/routes/network.py @@ -9,7 +9,7 @@ @router.get("/{node}/{vmid}/ports") -async def get_forwarded_ports( +def get_forwarded_ports( node: str, vmid: int, db: Session = Depends(get_db), diff --git a/api/routes/notifications.py b/api/routes/notifications.py index 64c979b..6716db1 100644 --- a/api/routes/notifications.py +++ b/api/routes/notifications.py @@ -9,7 +9,7 @@ @router.get("") -async def get_notifications( +def get_notifications( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -34,7 +34,7 @@ async def get_notifications( @router.patch("/{notification_id}/read") -async def mark_as_read( +def mark_as_read( notification_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), @@ -52,7 +52,7 @@ async def mark_as_read( @router.post("/read-all") -async def mark_all_as_read( +def mark_all_as_read( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -65,7 +65,7 @@ async def mark_all_as_read( @router.delete("/{notification_id}") -async def delete_notification( +def delete_notification( notification_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), diff --git a/api/routes/oauth.py b/api/routes/oauth.py index 310fd13..ba759bf 100644 --- a/api/routes/oauth.py +++ b/api/routes/oauth.py @@ -69,7 +69,7 @@ def _generate_pkce() -> tuple[str, str]: @router.get("/authorize") @limiter.limit("10/minute") -async def oauth_authorize(request: Request): +def oauth_authorize(request: Request): """DataGSM OAuth 인증 시작 — 사용자를 DataGSM 로그인 페이지로 리다이렉트""" state = secrets.token_urlsafe(32) verifier, challenge = _generate_pkce() @@ -253,7 +253,7 @@ class TokenExchangeRequest(BaseModel): @router.post("/exchange") -async def exchange_temp_code(body: TokenExchangeRequest): +def exchange_temp_code(body: TokenExchangeRequest): """임시 코드를 JWT 토큰으로 교환 (1회용) — httpOnly 쿠키에 설정""" entry = _token_store.pop(body.code, None) if not entry or entry["expires"] < time.time():