1 개요
분산 시스템에서 “상태(state)”는 여러 계층에 분산된다. MINERVA는 다음 네 계층에 걸쳐 상태를 관리한다.
| 계층 | 보관 위치 | 범위 | 수명 |
|---|---|---|---|
| React 컴포넌트 | useState |
현재 렌더 트리 | 탭 세션 |
| 브라우저 영구 저장소 | localStorage |
현재 origin | 수동 삭제 전까지 |
| 백엔드 메모리 | _agent_cache |
프로세스 | 프로세스 재시작 전까지 |
| 파일 시스템 | JSONL 로그 | 디스크 | 영구 (수동 rotation 필요) |
각 계층을 실제 코드와 함께 분석한다.
2 프론트엔드 상태
2.1 컴포넌트 내부 상태 (useState)
frontend/src/pages/QnaChatbot.tsx는 다음 상태를 선언한다.
// 현재 열려 있는 대화의 메시지 목록
const [messages, setMessages] = useState<ChatMessage[]>([]);
// SSE 스트리밍 진행 여부 (버튼 비활성화·로딩 UI에 사용)
const [streaming, setStreaming] = useState(false);
// 전체 대화 목록 (사이드바 히스토리)
const [conversations, setConversations] = useState<Conversation[]>([]);
// 현재 선택된 대화 ID
const [activeId, setActiveId] = useState<string | null>(null);ChatMessage 인터페이스는 렌더링에 필요한 모든 메타데이터를 포함한다.
2.2 사이드바 파라미터 상태
사이드바에서 조절하는 설정도 useState로 관리된다.
const [documentId, setDocumentId] = useState<string>("표준화");
const [sourceFilter, setSourceFilter] = useState<string>("all");
const [modelDeployment, setModelDeployment] = useState<string>("");
const [temperature, setTemperature] = useState<number>(0.7);
const [keepHistory, setKeepHistory] = useState<boolean>(true);
const [forceArm, setForceArm] = useState<string>("");이 값들은 handleSend()가 호출될 때 agent_params로 직렬화되어 백엔드에 전달된다. 별도 저장 로직이 없으므로 페이지 새로 고침 시 초기값으로 리셋된다.
2.3 스트리밍 처리 흐름
handleSend()는 SSE 스트림을 소비하며 메시지를 점진적으로 업데이트한다.
async function handleSend(text: string) {
setStreaming(true);
// placeholder assistant 메시지 추가 (isStreaming: true)
setMessages(prev => [
...prev,
{ role: "user", content: text },
{ role: "assistant", content: "", isStreaming: true },
]);
for await (const event of streamEvents(req)) {
if (event.type === "token") {
// 마지막 메시지(assistant)에 토큰 누적
setMessages(prev => {
const next = [...prev];
const last = next[next.length - 1];
last.content += event.text ?? "";
return next;
});
} else if (event.type === "done") {
// 최종 Response로 메타데이터 업데이트
setMessages(prev => {
const next = [...prev];
const last = next[next.length - 1];
last.isStreaming = false;
last.citations = event.response?.citations;
last.runId = event.response?.run_id;
last.latencyMs = event.response?.latency_ms;
last.ttftMs = event.response?.ttft_ms;
last.armId = event.response?.arm_id;
last.model = event.response?.model;
return next;
});
} else if (event.type === "error") {
// 에러 메시지로 교체
setMessages(prev => {
const next = [...prev];
next[next.length - 1] = {
role: "assistant",
content: `오류: ${event.error}`,
isStreaming: false,
};
return next;
});
}
}
setStreaming(false);
}setMessages를 매 토큰마다 호출하기 때문에 렌더링 빈도가 높다. React 18의 자동 배칭이 일부를 묶어주지만, 빠른 스트림에서는 프레임 드롭이 발생할 수 있다.
3 localStorage 영구 저장소
3.1 대화 저장·복원
페이지 컴포넌트가 직접 localStorage를 호출하지 않고, frontend/src/lib/conversations.ts(저장·로드·삭제)와 frontend/src/lib/anonymousUser.ts(브라우저당 고정 익명 ID)로 도메인 로직이 분리되어 있다. 이 분리 덕분에 Records/Tests 페이지에서도 같은 함수를 재사용한다.
// frontend/src/lib/conversations.ts
function saveConversations(convs: Conversation[]): void {
localStorage.setItem("minerva_conversations", JSON.stringify(convs));
}
function loadConversations(): Conversation[] {
const raw = localStorage.getItem("minerva_conversations");
if (!raw) return [];
try {
return JSON.parse(raw) as Conversation[];
} catch {
return [];
}
}// frontend/src/lib/anonymousUser.ts
function getAnonymousUserId(): string {
let uid = localStorage.getItem("minerva_anon_uid");
if (!uid) {
uid = `anon-${crypto.randomUUID().slice(0, 8)}`;
localStorage.setItem("minerva_anon_uid", uid);
}
return uid;
}마운트 시 가장 최근 대화를 자동 복원한다. 익명 ID는 sticky_hash 기반 A/B 실험 할당의 키로도 쓰인다.
useEffect(() => {
const saved = loadConversations();
if (saved.length > 0) {
setConversations(saved);
const last = saved[saved.length - 1];
setActiveId(last.id);
setMessages(last.messages);
}
}, []);messages 상태가 변경될 때마다 현재 대화를 localStorage에 동기화한다.
3.2 localStorage 설계 특성
| 항목 | 설명 |
|---|---|
| 스토리지 키 | "minerva_conversations" 단일 키 |
| 직렬화 | JSON.stringify(Conversation[]) — 전체 재직렬화 |
| 용량 한계 | 브라우저별 5~10MB; 대화가 많아지면 QuotaExceededError |
| 서버 동기화 없음 | 사용자가 브라우저를 바꾸거나 시크릿 모드를 사용하면 히스토리 소실 |
| 사용자 식별 | getAnonymousUserId() — localStorage 기반 UUID, 브라우저 지워지면 교체 |
4 백엔드 Agent 캐시
4.1 캐시 구조
백엔드는 HTTP 요청마다 새로운 스레드(또는 코루틴)에서 처리되는 무상태(stateless) 설계다. 그러나 QnaChatbotAgent 초기화 비용이 크기 때문에 프로세스 레벨 캐시를 유지한다.
# src/services/api/routers/qna_chatbot.py
_agent_cache: dict[tuple[Optional[str], Optional[str]], QnaChatbotAgent] = {}
_agent_cache_lock = threading.Lock()캐시 키는 (experiment_name, arm_id) 튜플이다. 실험 없는 기본 요청은 (None, None)으로 단일 인스턴스를 공유한다.
4.2 캐시 조회 로직
def _build_agent(user_id=None, force_arm=None):
config, exp_name, arm_id, overrides = resolve_config_for_user(
"qna_chatbot", user_id=user_id, force_arm=force_arm,
)
key = (exp_name, arm_id)
# 빠른 경로: lock 없이 먼저 확인 (대부분의 요청)
agent = _agent_cache.get(key)
if agent is not None:
return agent, exp_name, arm_id, overrides
# 느린 경로: cache MISS → lock으로 보호한 후 생성
with _agent_cache_lock:
agent = _agent_cache.get(key) # double-check
if agent is None:
agent = QnaChatbotAgent(
documents=DEFAULT_DOCUMENTS,
documents_metadata=DEFAULT_DOCUMENTS_METADATA,
default_document=DEFAULT_DOCUMENT_ID,
config=config,
)
_agent_cache[key] = agent
return agent, exp_name, arm_id, overridesdouble-checked locking 패턴을 사용한다. Python의 GIL이 단순 dict 조회를 thread-safe하게 만들어주지만, cache MISS 후 생성 과정은 lock으로 보호한다.
4.3 Retriever 내부 벡터 캐시
ParentChunkRetriever는 검색 결과의 child 벡터를 Document.metadata[_CHILD_VECTOR_KEY]에 임시로 실어서 cosine 재계산 시 embed_documents API 호출을 절약한다.
# src/core/rag/retriever.py
_CHILD_VECTOR_KEY = "_child_vector_cache"
def _hybrid_search_with_vectors(self, query, k):
results = vs.client.search(...) # Azure SDK 직접 호출
docs = []
for result in results:
vector = result.get(FIELDS_CONTENT_VECTOR)
if vector is not None:
metadata[_CHILD_VECTOR_KEY] = vector # 메타데이터에 임시 저장
docs.append(Document(page_content=..., metadata=metadata))
return docs, query_vec이 캐시는 단일 요청 수명(Document 객체 lifetime)만 유지된다. 다음 요청에서는 재사용되지 않는다. 즉 리퀘스트 스코프 캐시이고, agent 캐시(프로세스 스코프)와는 다른 계층이다.
캐시 효과:
| 시나리오 | embed_query | embed_documents |
|---|---|---|
_hybrid_search_with_vectors 성공, 인덱스 retrievable=True |
0회 (재사용) | 0회 (캐시) |
_hybrid_search_with_vectors 성공, 인덱스 retrievable=False |
0회 | 1회 |
| 표준 래퍼 폴백 | 1회 | 1회 |
| reranker 사용 (flashrank/cross_encoder) | - | - (벡터 불필요) |
4.4 Compressor 캐시
reranker compressor는 ParentChunkRetriever 인스턴스 내부에 _compressor로 lazy 캐시된다.
def _get_compressor(self):
if self._compressor is None and self._reranker_type in ("flashrank", "cross_encoder"):
from core.rag.reranker import get_reranker
self._compressor = get_reranker(
reranker_type=self._reranker_type,
model=self._reranker_config.get("model"),
top_n=self._reranker_config.get("top_n", 5),
)
return self._compressorFlashRank 모델은 ~50MB, cross-encoder(BAAI/bge-reranker-v2-m3)는 ~1GB. 첫 요청에서만 로드되고, 이후 같은 retriever 인스턴스에서 재사용된다.
4.5 Agent 내부 lazy 캐시
QnaChatbotAgent 자체도 내부에 지연 초기화 캐시를 갖는다.
class QnaChatbotAgent:
def __init__(self, ...):
self._retrievers: dict[str, object] = {} # 문서별 retriever
self._prompt = None # PromptTemplate
self._llm = None # LLM 인스턴스
def _ensure_initialized(self) -> None:
"""최초 요청에서만 실행, 이후 no-op."""
if self._prompt is None:
self._prompt = self._load_prompt_from_path(...)
if self._llm is None:
self._llm = get_llm(...)문서별 retriever는 _get_retriever(document_id)에서 별도로 lazy 초기화된다. 워밍업 시 기본 문서 외 retriever도 미리 초기화한다.
4.6 캐시 설계 한계
현재 구현에서 발견되는 한계는 다음과 같다.
TTL 없음: 캐시에 만료 정책이 없다. 실험 설정이 변경되어도 프로세스를 재시작하기 전까지 이전 config를 가진 agent가 계속 사용된다.
캐시 무효화 없음: A/B 실험의 overrides가 수정되면 (exp_name, arm_id) 키는 동일하므로 캐시가 hit된다. 변경 사항이 반영되려면 프로세스 재시작이 필요하다.
무한 증가: 실험/arm 조합이 늘어날수록 캐시가 커진다. 현재는 수동 관리 외 방법이 없다.
멀티 프로세스 비공유: uvicorn --workers 2 이상으로 실행 시 프로세스별 캐시가 분리된다. 동일 사용자의 요청이 다른 프로세스에 라우팅되면 cold start가 발생한다.
FAISS vs Azure AI Search 캐시 부담의 차이: Vector Store provider에 따라 프로세스 캐시 크기가 크게 달라진다. FAISS는 인덱스 자체를 메모리에 적재하므로 워커당 수백 MB가 추가되지만, Azure AI Search는 인덱스가 외부 서비스에 있어 프로세스 캐시는 retriever 객체와 임베딩 모델만 보유한다. 멀티워커 스케일아웃 시 메모리 비용 추정에서 이 차이를 반드시 분리해야 한다.
4.7 Warmup이 캐시에 미치는 영향
FastAPI lifespan에서 warmup()이 실행되면 cold-start 비용이 기동 시점으로 이동한다.
# src/services/api/main.py
@asynccontextmanager
async def lifespan(app: FastAPI):
if os.getenv("WARMUP_ON_STARTUP", "true").lower() in ("true", "1", "yes"):
from services.api.routers.qna_chatbot import warmup as warmup_qna
warmup_qna()
from services.api.routers.data_standardizer import warmup as warmup_ds
warmup_ds()
yieldwarmup_qna()가 다음 캐시를 초기화한다.
# src/services/api/routers/qna_chatbot.py
def warmup() -> None:
agent, exp_name, arm_id, _ = _build_agent(user_id=None, force_arm=None)
# → _agent_cache[(None, None)] 채워짐 (또는 default 실험 arm)
query = Query(text="warmup", experiment_id=exp_name, arm_id=arm_id)
for event in agent.stream(query):
if event.type == "done":
break
# → agent._prompt, agent._llm 채워짐
# → agent._retrievers["표준화"] 채워짐 (default 문서)
# → ALBERT 분류기 등 외부 모델 메모리 적재
for doc_id in DEFAULT_DOCUMENTS:
if doc_id != DEFAULT_DOCUMENT_ID:
agent._get_retriever(doc_id) # 추가 문서 retriever도 lazy 초기화
# → agent._retrievers["인실리코"], ["DAMA"] 채워짐4.8 동시성 시나리오 — 캐시 경쟁
double-checked locking이 보장하는 것과 보장하지 않는 것을 정리한다.
agent = _agent_cache.get(key) # 락 없는 빠른 경로
if agent is None:
with _agent_cache_lock: # 락 안에서 재확인
agent = _agent_cache.get(key)
if agent is None:
agent = QnaChatbotAgent(...) # 비싸고 시간 오래 걸림
_agent_cache[key] = agent보장됨: 동일 키에 대해 QnaChatbotAgent 인스턴스가 두 번 생성되지 않는다.
보장되지 않음: 다른 키 두 개가 동시에 cache MISS면 두 스레드가 각각 다른 QnaChatbotAgent를 동시에 초기화한다. 두 인스턴스가 모두 _get_retriever()에서 set_active_config(self.config)를 호출하면 글로벌 _active_config가 경쟁한다.
# Thread A (key="exp_v1", "arm_a") # Thread B (key="exp_v2", "arm_b")
agent_a = QnaChatbotAgent(config=cfg_a) agent_b = QnaChatbotAgent(config=cfg_b)
agent_a._get_retriever("표준화") agent_b._get_retriever("DAMA")
└─ set_active_config(cfg_a) └─ set_active_config(cfg_b)
┌──── 경쟁 ────┐
▼ ▼
_active_config = cfg_b (마지막 호출이 이김)
chatbot_retriever()는 get_active_config() → cfg_b를 잘못 사용할 수 있음이 경쟁은 warmup이 두 agent를 순차로 초기화할 때는 발생하지 않지만, 새 실험을 도입한 직후 첫 실 요청들이 동시에 들어오면 발생할 수 있다.
5 JSONL 로그 (감사 상태)
JSONL 파일은 “쓰기 전용 상태”다. 조회 UI 없이 파일로만 누적된다.
5.1 실행 로그
# src/core/metrics_logger.py
_LOG_PATH = Path("data/runtime/runs.jsonl")
_write_lock = threading.Lock()
def log_run(record: dict) -> None:
with _write_lock:
with _LOG_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")각 레코드에는 다음 필드가 포함된다.
{
"timestamp": "2026-05-05T10:23:45.123456",
"run_id": "a1b2c3d4...",
"agent_name": "qna_chatbot",
"user_id": "anon-xxxx",
"session_id": null,
"query": "표준화 원칙은 무엇인가요?",
"answer": "표준화 원칙은...",
"total_time_ms": 1823,
"ttft_ms": 312,
"success": true,
"model_name": "gpt-4.1",
"citation_count": 3,
"experiment_name": null,
"arm_id": null,
"extras": null
}5.2 사용자 피드백 로그
feedback_logger.py가 runs.jsonl과 별도 파일로 사용자 피드백을 append한다. run_id가 두 파일을 잇는 외래키 역할을 한다.
# src/core/feedback_logger.py
_FEEDBACK_PATH = Path("data/runtime/feedback.jsonl")
def log_feedback(run_id: str, agent_name: str, helpful: Optional[bool], comment: Optional[str] = None):
record = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"run_id": run_id,
"agent_name": agent_name,
"helpful": helpful, # True / False / None (코멘트만)
"comment": comment,
}
with _write_lock:
with _FEEDBACK_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")분리하는 이유: 응답 시점에는 helpful을 모르므로 사후에 별도 라우터(POST /feedback)에서 추가된다. runs.jsonl을 사후 UPDATE하면 append-only 이점이 깨지므로 두 파일을 분리하고, 분석 시 run_id로 join한다.
5.3 실험 로그
A/B 실험별 별도 JSONL에 arm 배정 결과가 기록된다.
5.4 로그 설계 특성
| 항목 | 현재 구현 |
|---|---|
| 파일 형식 | JSONL (줄 단위 JSON) |
| 동시 접근 | threading.Lock() — 단일 프로세스 내 thread-safe |
| Rotation 정책 | 없음 (무한 증가) |
| 조회 방법 | 파일 직접 열기 또는 grep |
| 복수 프로세스 | 동일 파일에 여러 프로세스가 쓰면 lock 충돌 가능 |
6 상태 계층 간 상호작용
사용자 입력
│
▼
[React useState] ← 탭 세션 동안만 유지
│ 메시지 변경 감지
▼
[localStorage] ← 브라우저에 영구 저장 (lib/conversations.ts, anonymousUser.ts)
│ HTTP POST /stream (user_id 포함)
▼
[FastAPI Router] ← 무상태 (요청 범위)
│ _build_agent()
▼
[_agent_cache] ← 프로세스 수명 동안 유지
│ agent.stream()
▼
[runs.jsonl] ← 디스크 영구 기록 (응답 시점)
▲
│ run_id FK
[feedback.jsonl] ← 디스크 영구 기록 (사용자 피드백 사후 추가)
[experiments/{exp}.jsonl] ← 디스크 영구 기록 (arm 배정)
7 설계 평가
7.1 강점
- 백엔드 무상태 설계로 수평 확장이 원칙적으로 가능하다.
- localStorage 기반 히스토리가 서버 DB 없이 대화 영속성을 제공한다.
- double-checked locking agent 캐시가 cold start 비용을 효과적으로 흡수한다.
7.2 개선 여지
프론트엔드-백엔드 상태 불일치: 백엔드는 대화 상태를 저장하지 않는다. 사용자가 localStorage를 지우면 백엔드 로그에는 기록이 남아있어도 대화 복원이 불가능하다.
Agent 캐시 TTL 없음: 실험 설정 변경 시 캐시를 무효화할 방법이 없다.
매 토큰 setState: 스트리밍 중 setMessages를 매 청크마다 호출한다. 긴 응답에서 불필요한 렌더링이 반복된다. useRef로 누적 후 디바운스하는 방식을 고려할 수 있다.
localStorage 단일 키: 대화 수가 늘어나면 전체를 매번 재직렬화한다. IndexedDB 전환 또는 키 분리가 필요하다.
8 모니터링 가이드
각 상태 계층의 건강성을 점검하는 핵심 지표를 정리한다.
8.1 프로세스 메모리
# Linux
ps -o pid,rss,vsz,cmd -p $(pgrep -f "uvicorn services.api.main")
# Windows
Get-Process python | Where-Object { $_.MainWindowTitle -match "uvicorn" } |
Select-Object Id, WorkingSet64, VirtualMemorySize64기준선: - 워밍업 직후: 기본 (~200MB) + ALBERT 모델 (~200MB) + Embedding cache + parent_store ≈ 600MB~1GB - 실험 arm 1개 추가 시: +400MB (별도 retriever, parent_store)
8.2 Agent 캐시 점검
현재 라우터에 디버그 엔드포인트가 없다. 운영 중 캐시 상태를 확인하려면 다음과 같은 헬퍼 엔드포인트를 추가하는 것을 권장한다.
8.3 localStorage 용량
브라우저 콘솔에서 직접 확인 가능하다.
const data = localStorage.getItem("minerva_conversations");
console.log(`size: ${data.length} bytes (${(data.length / 1024).toFixed(1)} KB)`);
console.log(`conversations: ${JSON.parse(data).length}`);
console.log(`avg messages: ${
JSON.parse(data).reduce((s, c) => s + c.messages.length, 0) / JSON.parse(data).length
}`);브라우저별 상한: - Chrome/Edge: ~10MB - Firefox: ~10MB - Safari: ~5MB
평균 메시지 ~500자 + citations ~3KB 가정 시 ~3KB/턴. 100턴 대화 100개 ≈ 30MB → 이미 한계 초과. IndexedDB 전환 시점.
8.4 JSONL 로그 크기
레코드당 평균 크기 추정 (record_from_response() 기준): - timestamp + run_id + agent_name + ids ≈ 200B - query (사용자 질문, 평균 50자) ≈ 100B - answer (답변, 평균 2000자) ≈ 4KB - 메타 + 휴리스틱 플래그 ≈ 200B
1줄 ≈ 4.5KB. 일 1000요청 가정 시 일 4.5MB, 연 1.6GB. PostgreSQL 마이그레이션 시점 산정 기준이 된다.
9 정리
MINERVA의 상태는 네 계층에 분산된다. React useState가 렌더링 상태를, localStorage가 사용자 히스토리를, _agent_cache가 초기화 비용이 큰 객체를, JSONL이 감사 로그를 각각 담당한다. 이 분산 구조는 서버 DB 없이도 UX를 제공하지만, 계층 간 동기화 보장이 없어 상태 불일치가 발생할 수 있다. 다음 포스트에서는 이 구조에서 에러가 어떤 경로로 전파되는지 분석한다.
다음: 에러 전파 경로 분석