NiceGUI + FastAPI + 4-Agent Pipeline 아키텍처에 대한 면밀한 확장성 분석입니다.
48,000줄 코드 전수 분석을 기반으로 병목 지점, 프레임워크 한계, 단계별 해결 방안을 제시합니다.
현재 아키텍처
FLOPI는 두 개의 독립 서비스로 구성되며, HTTP로 통신합니다.
4-Agent Pipeline 흐름
현재 한계치
코드 분석 기반 추정치입니다. 실제 환경에서는 네트워크/LLM 응답 시간에 따라 변동됩니다.
자원 추가 vs 설계 변경
위 한계치 중 일부는 자원(DB 풀, LLM 키, 메모리)을 늘리면 즉시 해결됩니다. 그러나 나머지는 아무리 자원을 투입해도 코드 구조를 바꾸지 않으면 풀리지 않는 문제입니다.
| 문제 | 해결 |
|---|---|
| LLM rate limit | 키 로테이션 / 상위 플랜 |
| 메모리 부족 | RAM 추가 |
| DB 풀 분리 | Detection/API 전용 풀 분리 |
| 문제 | 이유 |
|---|---|
| 싱글 이벤트 루프 | 12코어여도 1코어만 사용. workers 늘리면 APScheduler 중복 실행 |
| NiceGUI 수평확장 | WebSocket 세션이 인스턴스에 바인딩 — 멀티 인스턴스 불가 |
| 태스크 유실 | 프로세스 죽으면 메모리 태스크 소실 — 영속성 설계 필요 |
| Scheduler 중복 | 워커 분리 없이 워커 수 늘리면 Detection 사이클 N배 실행 |
max=30), Detection Semaphore(20), LLM Semaphore(30)는 이미 적절히 튜닝되어 있습니다.
남은 과제는 설계 변경이 필요한 구조적 문제입니다.
싱글 이벤트 루프 — 왜 12코어인데 1코어만 쓸까?
FLOPI의 거의 모든 확장성 문제를 관통하는 핵심 원인입니다. 이 구조를 이해하면 나머지 이슈들이 왜 발생하는지 자연스럽게 이해됩니다.
1. asyncio 이벤트 루프란?
Python의 asyncio는 하나의 스레드에서 여러 작업을 번갈아 실행합니다.
실제로 동시에 2개가 돌아가는 게 아니라, "기다리는 시간"을 활용해 다른 작업을 처리하는 것입니다.
2. FLOPI에서의 문제
3. 구체적 병목 시나리오
await 구간(네트워크 I/O)은 괜찮습니다. 문제는 await 사이의 동기 코드입니다.
LLM 응답 JSON 파싱, 프롬프트 텍스트 조립, 로그 포매팅 등이 모두 이벤트 루프를 점유합니다.
개별로는 수십 ms에 불과하지만, 파이프라인 9라운드 × 동시 이상 8건 = 72회 누적되면 체감됩니다.
4. 왜 워커를 늘릴 수 없는가?
일반적인 Stateless API 서버는 워커를 늘리면 되지만, FLOPI는 스케줄러(APScheduler) + 상태 유지 UI(NiceGUI)가 같은 프로세스에 있어서 워커를 늘리는 것 자체가 불가능합니다.
5. 해결 방향
API는 멀티워커로 4코어 활용, 스케줄러는 단독 프로세스로 중복 실행 방지, NiceGUI는 현행대로 단일 프로세스. 프로세스 간 통신은 DB 큐 또는 Redis로 연결합니다.
Fire-and-Forget 태스크 유실 CRITICAL
워크플로우와 플레이북 트리거가 결과를 추적하지 않습니다.
# detection/scheduler.py
asyncio.create_task(trigger_event_workflows(...)) # 결과 추적 없음
asyncio.create_task(trigger_playbooks(...)) # 실패해도 모름
- 프로세스 크래시: 진행 중이던 워크플로우/플레이북 전부 소실 (메모리에만 존재)
- 예외 발생:
asyncio.create_task()의 예외가 조용히 무시됨 (unhandled exception in task) - 중복 실행: Workflow + Playbook이 동일 이상을 동시에 처리할 수 있음 (순서 보장 없음)
- 재시도 없음: LLM 호출 실패, DB 타임아웃 등에 대한 자동 재시도 로직 부재
jobs 테이블에 상태 추적 (pending → running → completed/failed) + 재시도 카운터 + 워커 폴링.
4-Agent 파이프라인 순차 블로킹 CRITICAL
파이프라인 내부(Diagnostician → Advisor → Validator)는 순차 실행이지만,
여러 이상 건의 파이프라인은 asyncio.create_task()로 동시에 뜰 수 있습니다.
문제는 DB 커넥션 풀(30개, 공유) + LLM Semaphore(30) + 단일 이벤트 루프를 공유하면서 발생하는 자원 경합입니다.
| Agent | LLM 호출 | 평균 시간 | 최악 |
|---|---|---|---|
| Diagnostician | 1~3 라운드 | 3~5초 | 15초 |
| Advisor | 1~3 라운드 | 3~5초 | 15초 |
| Validator | 1~3 라운드 | 3~5초 | 15초 |
| 합계 | 최대 9회 | 9~15초 | 45초 |
| 동시 이상 | LLM 호출 | DB 커넥션 압박 | 체감 속도 |
|---|---|---|---|
| 1~5건 | ~45회 | 여유 (풀 30개 중 일부) | 쾌적 (10~20초) |
| 8건 | ~72회 | Detection과 경합 시작 | 느려짐 (30~60초) |
| 15건+ | ~135회 | 풀 경합 + LLM Semaphore(30) 포화 | 수 분 지연, 타임아웃 발생 |
하드 리밋이 아니라 5건까지는 쾌적, 8건부터 성능 급락, 15건+는 시스템 불안정. DB 커넥션 풀 30개를 Detection(Semaphore 20) + 파이프라인 + API가 동시에 사용하면서 병목이 발생합니다. 에이전트 하나 실패 시 폴백 없이 해당 파이프라인 중단.
DB 커넥션 풀 경합 (단일 풀 공유) HIGH
Oracle 커넥션 풀이 max=30으로 설정되어 있지만, Detection과 API가 동일 풀을 공유하므로 동시 부하 시 경합이 발생합니다.
NiceGUI 프레임워크 한계 HIGH
NiceGUI는 Python만으로 빠르게 UI를 만들 수 있지만, 구조적으로 수평 확장이 불가능합니다.
1. 서버사이드 UI 상태
FLOPI는 30개 페이지가 있고, 각 페이지마다 state 딕셔너리 + UI 컴포넌트를 서버에 유지합니다.
특히 Workflow 캔버스 (SVG + 7개 이벤트 핸들러)가 가장 무겁습니다.
2. 싱글 프로세스, 싱글 코어
ui.run()이 내부적으로 Uvicorn을 단일 워커로 실행합니다.
멀티워커는 NiceGUI가 공식적으로 지원하지 않습니다 — WebSocket 세션이 특정 워커에 바인딩되기 때문입니다.
M4 Pro 12코어 중 NiceGUI는 1코어만 사용.
3. WebSocket 상시 연결
NiceGUI는 모든 클라이언트와 WebSocket을 상시 유지합니다. 이것이 @ui.refreshable, ui.timer(), emitEvent()의 작동 기반입니다.
| 사용자 수 | 탭 수 | 상시 WebSocket | 서버 영향 |
|---|---|---|---|
| 5명 | 2개 | 10개 | 안정 |
| 20명 | 2개 | 40개 | 메모리 주의 |
| 50명 | 2개 | 100개 | 불안정 |
4. 브로드캐스트 없음
# 모든 페이지에서 이런 식으로 개별 폴링
ui.timer(30.0, header_status.refresh) # 사용자마다 30초마다 API 호출
A가 이상 처리 완료해도 B 화면에 30초 후에야 반영. 사용자 50명이면 분당 100회 동일 API 중복 호출.
FastAPI 운영 이슈 HIGH
FastAPI 자체는 확장성이 뛰어나지만, FLOPI에서의 사용 방식에 문제가 있습니다.
1. Uvicorn 단일 워커
LLM 호출(Gemini)이 5~10초 동안 이벤트 루프를 점유하면, 같은 시간 동안 다른 API 응답도 지연됩니다.
workers=4로 늘리면? → APScheduler가 워커마다 중복 실행됩니다.
Detection 사이클이 4배로 돌아가면서 DB 부하 폭증 + 이상 중복 생성.
2. SSE 스트리밍 연결 점유
# AI Chat 스트리밍 — 응답 올 때까지 연결 유지
@router.post("/api/ai-chat/stream")
async def chat(body):
async def generate():
# LLM 응답 5~30초간 이 연결 유지
yield sse_event(...)
return EventSourceResponse(generate())
AI Chat 사용자 5명이 동시에 질문하면 SSE 연결 5개가 수십 초간 점유. 이 동안 DB 커넥션도 물고 있을 수 있어 풀 고갈에 기여.
NiceGUI vs SPA 비교
| UI 상태 위치 | 서버 메모리 |
| 사용자당 서버 부하 | WebSocket + Python 객체 |
| 멀티워커 | 불가 |
| 실시간 업데이트 | 내장 (WebSocket) |
| 수평 확장 | 매우 어려움 |
| 개발 속도 | 빠름 (Python만) |
| 적정 사용자 | ~30명 |
| UI 상태 위치 | 브라우저 |
| 사용자당 서버 부하 | Stateless (거의 없음) |
| 멀티워커 | 자유롭게 확장 |
| 실시간 업데이트 | 별도 구현 필요 (SSE/WS) |
| 수평 확장 | 쉬움 |
| 개발 속도 | 느림 (TS + Python) |
| 적정 사용자 | 수천 명 |
Detection 틱 60초 고정 MEDIUM
현재 60초마다 run_detection_cycle()이 실행되어 due 규칙들을 평가합니다.
규칙이 100개로 늘어나면 한 틱에 평가할 양이 폭증하고, 평가가 60초를 넘으면 다음 틱과 겹칩니다.
- 규칙 50개 × 평균 2초 = 100초 (순차) → Semaphore(20) 병렬화로 6초
- 규칙 100개 × 평균 2초 = 200초 (순차) → 병렬화로 10초 (여유 있음)
- 규칙 200개 × DB 느려져서 평균 5초 = 1000초 → 병렬화로 50초 (여유 있음)
- 규칙 200개 × DB 느려져서 평균 8초 = 1600초 → 병렬화로 80초 (틱 초과!)
UI 30초 폴링 MEDIUM
NiceGUI 대시보드의 모든 데이터가 30초 간격 HTTP 폴링으로 갱신됩니다. 사용자 50명 × 페이지 평균 2개 타이머 = 분당 200회 API 호출 (대부분 중복).
LLM 라운드 제한 MEDIUM
| 컨텍스트 | 최대 라운드 | 위험 |
|---|---|---|
| Detection Agent | 3회 | 복잡한 이상 → 불완전 진단 |
| AI Chat | 5회 | 다단계 도구 체인 중단 |
| 라운드당 타임아웃 | 없음 | 느린 도구가 전체 에이전트 블로킹 |
에이전트 수가 늘면 각각의 LLM 호출이 누적되어 Gemini API rate limit에 도달할 수 있습니다.
글로벌 상태 의존 MEDIUM
# 모듈 레벨 전역 변수들
_pool = None # Oracle 커넥션 풀 (싱글톤)
_thick_done = False # Thick client 초기화 플래그
_current_module = "" # LLM 호출자 컨텍스트
Uvicorn 멀티워커 전환 시 각 워커가 독립된 _pool을 생성합니다.
_current_module은 contextvars가 아니라 일반 변수이므로 동시 요청 간 충돌 가능.
Phase 1: 안정성 확보
# 현재: 단일 풀 max=30 (Detection + API 공유)
# 개선: Detection 전용 풀과 API 전용 풀 분리
detection_pool = create_pool_async(min=2, max=15)
api_pool = create_pool_async(min=2, max=20)
-- 새 테이블
CREATE TABLE job_queue (
id NUMBER GENERATED ALWAYS AS IDENTITY,
job_type VARCHAR2(50), -- 'workflow' | 'playbook'
payload CLOB, -- JSON (anomaly_id, params...)
status VARCHAR2(20), -- pending | running | completed | failed
retry_count NUMBER DEFAULT 0,
max_retries NUMBER DEFAULT 3,
created_at TIMESTAMP DEFAULT SYSTIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
error_msg VARCHAR2(1000)
);
# 에이전트당 타임아웃
result = await asyncio.wait_for(
diagnostician.run(anomaly),
timeout=30 # 30초 초과 시 TimeoutError
)
# 서킷브레이커 (연속 5회 실패 → 60초간 우회)
from pybreaker import CircuitBreaker
llm_breaker = CircuitBreaker(fail_max=5, reset_timeout=60)
Phase 2: 처리량 개선
현재 이상 10건이 들어오면 순차 처리 → 병렬화하면 동시 3~5건 처리 가능.
# 이상 건별 파이프라인 병렬 실행
pipeline_semaphore = asyncio.Semaphore(5)
async def run_with_limit(anomaly):
async with pipeline_semaphore:
return await pipeline.execute(anomaly)
await asyncio.gather(*[run_with_limit(a) for a in anomalies])
이상 발생/상태 변경 이벤트를 SSE로 대시보드에 즉시 전송. 비핵심 데이터는 5분 간격으로 완화하여 API 부하 80% 감소.
틱 간격 60초 → 30초 단축. 규칙 우선순위 기반 스케줄링: Critical 규칙 먼저, Low 규칙은 유휴 시간에 평가. 동일 데이터 소스 규칙을 그룹화하여 쿼리 1회로 다수 규칙 평가.
Phase 3: 멀티 인스턴스 확장
- AI Chat 세션 캐시 (메모리 → Redis)
- 워크플로우 실행 중복 방지를 위한 분산 락
- Tool Studio 결과 캐시 (TTL 5분)
DB 기반 Job Queue를 전용 메시지 큐로 교체. 워크플로우/플레이북을 별도 컨슈머 프로세스로 분리.
NiceGUI를 Next.js 등 SPA로 교체. FastAPI는 API 서버로 유지. 서버 Stateless 달성 → 수평 확장 자유. 단, 개발 비용이 가장 큼.
최종 판단
운영자 5~10명이 사용하는 FAB 내부 도구로서는 현재 아키텍처가 작동합니다. NiceGUI의 빠른 개발 속도가 큰 장점이고, 프로세스 분리(NiceGUI / FastAPI)도 잘 되어 있습니다.
에이전트와 규칙이 늘어나면 태스크 유실과 DB 풀 경합이 가장 먼저 터집니다. 이건 사용자 수와 무관하게, 시스템 자체의 안정성 문제입니다.
- DB 풀 분리 — Detection/API 전용 풀 (반나절 작업)
- Job Queue 테이블 + 워커 (1~2일 작업)
- 파이프라인 타임아웃 추가 (반나절 작업)
Phase 2~3를 순차적으로 진행하되, NiceGUI → SPA 전환 시점을 미리 정해두세요. 사용자 30명을 넘는 순간이 전환점입니다. FastAPI API는 그대로 유지하면서 프론트엔드만 교체하면 됩니다.
남은 이슈
| 영역 | 현재 상태 | 병목? | 리스크 |
|---|---|---|---|
| 태스크 내구성 | fire-and-forget | Yes | CRITICAL |
| 파이프라인 | 순차 블로킹 | Yes | CRITICAL |
| DB 풀 경합 | max=30, 단일 풀 (Detection/API 공유) | 규칙 증가 시 | HIGH |
| NiceGUI 확장 | 싱글 프로세스 + 서버 상태 | 30명+ | HIGH |
| FastAPI 워커 | 단일 워커 + Scheduler | 동시 요청 많을 때 | HIGH |
| Detection 틱 | 60초 고정 | 규칙 200개+ | MEDIUM |
| UI 폴링 | 30초 간격 | 사용자 50명+ | MEDIUM |
| LLM 라운드 | 3~5회 제한 | 복잡한 이상 | MEDIUM |
| 글로벌 상태 | 모듈 레벨 변수 | 멀티워커 시 | MEDIUM |
| ChromaDB | 로컬 디스크 | 문서 대량 시 | LOW |
해결 완료
| 영역 | 현재 값 | 설정 위치 | 런타임 변경 |
|---|---|---|---|
| DB 커넥션 풀 크기 | max=30 | config.py:24 / 환경변수 ORACLE_MAX_POOL | 환경변수 |
| Detection 동시성 | Semaphore(20) | detection/scheduler.py:19 / DB detection.max_concurrent | DB 설정 |
| LLM 동시 호출 | Semaphore(30) | core/llm/client.py:85 / DB llm.max_concurrent | DB 설정 |