1 왜 구조화된 로깅인가
Phase C-5는 LLM Agent가 누적한 대화에서 인사이트를 뽑는 단계다. C21~C23이 잘 돌려면 C20이 잘 설계되어 있어야 한다 — 분석 단계에서 누락 필드를 메우거나 raw 텍스트만으로 추정하는 것은 비용이 폭발한다.
| 다운스트림 | C20에서 필요한 것 |
|---|---|
| C21 의도 분류·토픽 | query·response 텍스트 + 시간순서 + 사용자 segment |
| C22 응답 품질 평가 | thumbs_up·feedback 텍스트 + run_id + retrieval citations |
| C23 피드백 루프 | run_id로 prompt·model·reranker 추적 + 결과 매핑 |
| C16 Bandit | run_id·arm·reward (delayed feedback 매칭) |
| C19 실험 분석 | exp_id·arm·user_id·timestamp + primary/guardrail metrics |
| 보안·감사 | user_id·timestamp·tool_calls·data_accessed |
이 모든 요구사항을 만족하는 단일 스키마를 설계하는 것이 본 편의 목표.
2 발화 단위 — 4계층
계층:
- query = 한 번의 질문·답변 쌍 (turn)
- session = 같은 사용자의 연속된 질의 (timeout 30분 등)
- conversation = 한 주제로 묶인 N개 query (사용자 명시 또는 추론)
- user_history = 한 사용자의 전체 누적 대화
각 계층의 사용처:
| 계층 | 사용 |
|---|---|
| query | A/B·Bandit 보상 단위, 검정 단위, 의도 분류 단위 |
| session | 사용자 행동 패턴, RFM 추출 |
| conversation | 다중 turn 대화 품질, 멀티스텝 플래닝 평가 |
| user_history | 개인화·세그멘테이션 토대 |
스키마는 query 단위가 기본 — 다른 계층은 query를 group_by로 만든다.
3 Raw·Structured·Feature 3계층 스키마
3.1 Raw — 원본 그대로
# app/logging/raw.py — 모든 입력·출력의 정직한 사본
from pydantic import BaseModel
from datetime import datetime
from typing import Any
class RawLog(BaseModel):
log_id: str # UUID
timestamp: datetime
log_type: str # "user_query" | "llm_response" | "tool_call" | "feedback"
payload: dict[str, Any] # 원본 — 가공 없음
correlation_id: str # 같은 query의 모든 raw log를 묶는 IDraw는 절대 수정하지 않는다 — 손실 없는 원본 보존.
3.2 Structured — 분석 친화적
class StructuredQuery(BaseModel):
run_id: str # query 단위 단일 ID
user_id: str
session_id: str
timestamp: datetime
# 질의
query_text: str
query_lang: str # "ko" | "en" | ...
query_tokens: int
# 응답
response_text: str
response_tokens: int
latency_ms: int
# 시스템 결정
model: str # "gpt-4o-2024-08-06"
prompt_version: str # "v3.2"
reranker_id: str | None
retrieval_collections: list[str]
retrieved_doc_ids: list[str]
# 실험 컨텍스트
experiments: dict[str, str] # {"exp_001": "treatment", "exp_007": "control"}
segment: dict[str, str] # C17 세그먼트 라벨
# 비용·관측성
cost_usd: float
trace_id: str | None # OpenTelemetry trace
# 피드백 (delayed)
thumbs_up: int | None = None # null = 아직 안 옴
feedback_text: str | None = None
feedback_timestamp: datetime | None = None이 스키마가 분석 파이프라인의 단일 진실이다. raw → structured 변환은 ETL job (정해진 시점에 실행).
3.3 Feature — 머신러닝 친화적
class FeatureRow(BaseModel):
run_id: str
user_id: str
timestamp: datetime
# 사용자 임베딩
user_embedding: list[float] # 64 dim
segment_one_hot: list[int] # 26 dim (C17 결과)
# 질의 임베딩·메타
query_embedding: list[float] # 768 dim
query_intent_class: str # C21 의도 분류 결과
query_topic_cluster: int # C21 토픽 클러스터
# 응답 품질 신호
citation_count: int
has_disclaimer: bool
response_quality_score: float | None # C22 자동 평가
# 보상 (Bandit·실험용)
reward: float | None # thumbs_up 또는 implicit signalfeature 계층은 매일·매시간 갱신되며 모델 학습에 직접 들어가는 형태다.
4 저장 3계층 — Hot·Warm·Cold
[ Hot 7일 ] 실시간 모니터링·Bandit feedback
PostgreSQL · Redis stream
↓ (daily ETL)
[ Warm 90일 ] 분석·실험 보고서·세그먼트 갱신
ClickHouse · BigQuery · DuckDB
↓ (daily ETL)
[ Cold 1년+ ] 감사·재현·대규모 retraining
S3 · Azure Blob (Parquet)
| 계층 | 형식 | 인덱스 | 사용 |
|---|---|---|---|
| Hot | row store (PG·Redis) | run_id·user_id·exp_id | 실시간 조회 |
| Warm | column store (Clickhouse·BQ·DuckDB) | timestamp·exp_id | OLAP 분석 |
| Cold | object store + Parquet | hive partition (날짜·exp_id) | 대규모 batch |
3계층 분리 이유: - Hot은 쓰기 최적, 분석 쿼리는 비효율 - Warm은 read-heavy OLAP — column store + 압축으로 비용 절감 - Cold는 장기 보관 — 가격 가장 저렴, 접근은 느려도 무방
MINERVA 규모(분기당 수백만 query)에서 단일 PG로 모두 처리하면 인덱스 비대해지고 백업·복원 비용 폭증. 분리가 필수.
5 PII 처리·보존 정책
5.1 식별
| 필드 | 카테고리 | 처리 |
|---|---|---|
| user_id | 식별자 | hashing 또는 surrogate key |
| query_text | 사용자 콘텐츠 | PII 탐지·마스킹 |
| response_text | 시스템 출력 | 모델이 PII 노출 가능 — 동일 처리 |
| feedback_text | 사용자 콘텐츠 | 동일 |
| user_embedding | 파생 | 역추적 가능 — 식별자로 분류 |
5.2 마스킹
# app/logging/pii.py — 정규식 + LLM 결합
import re
PATTERNS = {
"email": r"[\w\.-]+@[\w\.-]+\.\w+",
"phone": r"\d{2,3}-\d{3,4}-\d{4}",
"credit_card": r"\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}",
"ssn_kr": r"\d{6}-\d{7}",
}
def mask_pii(text: str) -> str:
for name, pattern in PATTERNS.items():
text = re.sub(pattern, f"[{name.upper()}]", text)
return text
def mask_pii_llm(text: str) -> str:
"""LLM 기반 — 정규식이 못 잡는 인명·주소·고유명사."""
prompt = f"다음 텍스트에서 인명·주소·계좌·전화 등 식별 가능 정보를 [MASKED]로 치환:\n{text}"
return llm_call(prompt)raw 계층은 마스킹 전·후 둘 다 보관 (감사 시 원본 필요). structured·feature는 마스킹 후만.
5.3 보존 기간
# config/retention.yaml
raw:
retention_days: 30 # 짧게 — PII 위험 최소화
encryption: aes_256
access_log: true # 누가 raw 접근했는지
structured:
retention_days: 730 # 2년 — 실험·분석에 충분
pii_masked: true
feature:
retention_days: 1095 # 3년 — 모델 retraining
pii_masked: true
embeddings_only: true # raw 텍스트 없음
cold_anonymized:
retention_days: -1 # 무기한
user_id: hashed # 재식별 불가능 수준GDPR·국내 개인정보법 — 사용자 삭제 요청 시 모든 계층에서 제거하는 절차 필수. 이를 위해 user_id가 모든 계층에서 추적 가능해야 한다.
6 인덱싱과 검색
6.1 시계열·OLAP
-- ClickHouse warm storage
CREATE TABLE structured_query (
run_id String,
timestamp DateTime,
user_id String,
session_id String,
query_text String,
response_text String,
model LowCardinality(String),
prompt_version LowCardinality(String),
cost_usd Float32,
latency_ms UInt32,
thumbs_up Nullable(Int8),
experiments Map(String, String),
segment Map(String, String)
) ENGINE = MergeTree
ORDER BY (timestamp, user_id)
PARTITION BY toYYYYMM(timestamp);Map·LowCardinality로 실험 컨텍스트와 모델 메타를 효율 저장. 일반 OLAP 쿼리(시간별 latency·실험별 thumbs_up·세그먼트별 cost)가 ms 수준.
6.2 검색 (Full-Text + Vector)
# Elasticsearch / OpenSearch 또는 PG full-text
# query_text·response_text·feedback_text 검색용
# 또는 vector store (Qdrant·Pinecone·Milvus)
# 의미 기반 — "이 사용자가 한 질문과 비슷한 다른 사용자 질문 찾기"C21 토픽 클러스터링은 vector store에서 효율적. C22 응답 품질 평가는 full-text + 정규식 결합.
7 다운스트림 시스템 통합
7.1 Bandit feedback (C16)
# delayed feedback이 도착하면 Bandit update
async def on_feedback_received(run_id: str, thumbs_up: int):
log = await db.structured.find_one(run_id=run_id)
if log.experiments:
for exp_id, arm in log.experiments.items():
await bandit_router(exp_id).update(arm, log.context, reward=thumbs_up)
await db.structured.update(run_id, {"thumbs_up": thumbs_up,
"feedback_timestamp": datetime.utcnow()})7.2 실험 분석 (C19)
# C19 단계 7 자동 분석이 warm storage 직접 쿼리
def analyze_experiment(exp_id: str) -> dict:
df = clickhouse.query(f"""
SELECT user_id, segment['department'] as dept,
experiments['{exp_id}'] as arm,
thumbs_up, latency_ms, cost_usd
FROM structured_query
WHERE has(experiments, '{exp_id}')
AND timestamp >= '...' AND timestamp < '...'
""")
return run_analysis_pipeline(df)7.3 세그먼트 갱신 (C17)
8 MINERVA 적용
app/logging/
├── raw.py # raw schema + write to PG/Redis
├── structured.py # structured schema + ETL from raw
├── feature.py # feature schema + daily build
├── pii.py # 마스킹·정규식·LLM
├── retention.py # 정책·삭제 job
└── correlation.py # OpenTelemetry trace_id
scripts/
├── etl_raw_to_structured.py # 매시간 cron
├── etl_structured_to_feature.py # 매일 cron
└── retention_cleanup.py # 매일 cron — 만료 row 삭제
infra/
├── postgres # hot
├── clickhouse # warm
└── azure_blob # cold (Parquet)
06편 JSONL 메트릭이 raw 계층 일부, 08-1 streaming observability가 trace_id 연결. 본 편은 그 위에 표준화된 분석 토대를 얹는 것.
9 자주 발생하는 함정
9.1 Sampling Bias
스토리지 비용 회피로 1% sampling만 저장 → 작은 세그먼트·드문 의도가 누락.
해법: - 세그먼트별·의도별 stratified sampling - 모든 실험 컨텍스트는 항상 100% 보관 - raw 30일 + structured 100% 2년이 비용·가치 균형점
9.2 Async Loss
LLM 응답이 비동기로 stream 중 timeout·연결 끊김 → 일부 token 로깅 누락.
해법: - 매 token마다 raw write (Redis Stream) - ETL이 is_complete flag 검증 - 불완전한 query는 별도 카테고리로 분석 (응답 품질 평가에서 제외)
9.3 Schema Drift
스키마를 자주 바꾸면 OLAP 쿼리·다운스트림 시스템이 깨짐.
해법: - Schema Registry (예: Avro·Protobuf) + 버전 관리 - 신규 필드는 nullable로 추가 - 제거는 deprecation 단계 (6개월 후 진짜 제거)
9.4 Trace 끊김
OpenTelemetry trace_id가 일부 단계에서 누락 → 분석 시 같은 query의 단계별 latency 추적 불가.
해법: - BaseAgent 계약(02-0편)이 trace_id 전파를 강제 - ETL에서 trace 누락 row를 알림
9.5 Storage Cost Explosion
raw 텍스트를 모두 PG에 저장 → 90일 후 TB 단위, vacuum·backup 부담.
해법: - raw는 30일 PG (또는 Redis Stream + 30일 후 만료) → 그 이후는 cold만 - structured는 column store에 압축 보관 - 임베딩은 vector store에 별도 (PG에 768 dim float를 행마다 저장하면 폭증)
10 정리
| 영역 | 핵심 |
|---|---|
| 단위 | query·session·conversation·user_history 4계층 |
| 스키마 | raw (원본)·structured (분석)·feature (ML) 3계층 |
| 저장 | hot 7일 · warm 90일 · cold 1년+ |
| PII | raw에 원본·마스킹 둘 다, structured 이상은 마스킹만 |
| 보존 | 카테고리별 retention.yaml, GDPR 사용자 삭제 절차 |
| 인덱스 | OLAP은 ClickHouse, 검색은 ES + vector store |
| 다운스트림 | Bandit feedback·실험 분석·세그먼트 갱신·품질 평가의 단일 토대 |
| 함정 | sampling bias·async loss·schema drift·trace 끊김·storage 폭발 |
11 응용 분야
| 시나리오 | 본 편 절 |
|---|---|
| 모든 query가 어느 실험에 속했는지 사후 추적 | structured.experiments 필드 + warm storage |
| 사용자 삭제 요청 처리 | retention.py + 모든 계층 삭제 절차 |
| 의도 분류 학습 데이터 추출 | feature 계층 + query_intent_class 라벨 |
| 응답 품질 자동 평가 | structured·feature + C22 evaluator |
| 비용 분석 (모델·세그먼트별) | warm OLAP + cost_usd column |
| 인시던트 사후 분석 | trace_id로 raw 단계별 추적 |
12 관련 주제
선행 학습 (선수)
- 06편 A/B 메트릭 JSONL — raw 계층 일부 토대
- 08-1편 streaming observability — trace_id·timing 토대
- C17 세그멘테이션 — segment 필드 정의
후속 (Phase C-5)
- C21 의도 분류·토픽 — structured 계층 위에서 분석
- C22 응답 품질 평가 — feature 계층의 quality_score 채움
- C23 피드백 루프 — 분석 결과를 Bandit·프롬프트에 반영
Cross-reference (Engineering)
- Engineering: structured logging — raw 계층 구현 토대
- Engineering: JSON Schema — schema registry 토대
- Engineering: Python CLI — ETL 스크립트