Project
Tick
한국 주식 뉴스를 RAG로 요약하고 실시간 시세·모의 매매를 함께 제공하는 사이드 프로젝트. 1인 풀스택으로 백엔드·AI 서버·프론트·인프라를 모두 설계·구현하며, LLM 요약의 hallucination을 정량 evals로 8→0까지 제거.
- 기간
- 2026.06 ~ 진행 중
- 역할
- 1인 풀스택 — AI Server (RAG · Evals) / Backend (Spring Boot 헥사고날) / Frontend (Next.js) / Infra (Terraform · AWS)
- 기술 스택
- FastAPILangChainClaude Sonnet 4.6 / Opus 4.7pgvectorKotlinSpring BootNext.jsPostgreSQLRedisTerraform · AWS
주요 기능
- RAG hallucination 8→0 · groundedness 0.883→0.942 (골든셋 15종목 8단계 iteration)
- Multi-judge (Sonnet 4.6 + Opus 4.7) + N회 median + σ 자동 재판정으로 판정기 자체 안정성까지 정량화 (disagreement 0.071)
- Hybrid Retrieval (dense + sparse + RRF) + 신선도 감쇠 + Cohere Rerank + Query Rewrite (Haiku)
- AI 서버·백엔드 물리 분리 + 양쪽 모두 헥사고날 — embedding/llm/retriever/reranker/cache/trace 전부 swappable
- KIS 실시간 시세를 백엔드가 대신 붙어 프론트 fan-out — /ws/market 공개, /ws/orders JWT + memberId 라우팅
- GitHub Actions OIDC + SSM SendCommand로 정적 AWS Key 0개, SSH 22 포트 닫음
프로젝트 개요
한국 주식의 최근 뉴스를 LLM 으로 요약해 급등/급락 이유를 알려주고, KIS Open API 실시간 시세 위에서 모의 매매까지 이어지는 통합 서비스. AI 서버(Python/FastAPI RAG) · Backend(Kotlin/Spring) · Frontend(Next.js) · 인프라(Terraform) 를 1인이 전 계층 설계·구현했습니다.
| 기간 | 2026.06 ~ 진행 중 |
| 역할 | 1인 풀스택 — AI Server (RAG · Evals) / Backend (Spring Boot 헥사고날) / Frontend (Next.js) / Infra (Terraform · AWS) |
서비스 화면
서비스화면 펼쳐보기





기술 스택
| 영역 | 기술 |
|---|---|
| AI Server | Python 3.12 · FastAPI · LangChain · pgvector · OpenAI (embedding) · Anthropic Claude Haiku 4.5 (요약) · Sonnet 4.6 + Opus 4.7 (judge) · Cohere Rerank v3.5 · Langfuse |
| Backend | Kotlin · Spring Boot 3.5 · Spring Security · JPA · Flyway · WebSocket · JWT |
| Frontend | Next.js 16 (App Router · React 19) · TypeScript · Tailwind · Zustand · sonner |
| Database | PostgreSQL 16 + pgvector · Redis |
| Realtime | KIS Open API WebSocket · Spring WebSocket (/ws/market, /ws/orders) · Redis Pub/Sub |
| Infra | AWS EC2 (t4g.small · Graviton) · ECR · S3 · Route53 · Caddy(TLS) · Docker · Terraform · GitHub Actions OIDC |
| Testing | Testcontainers (Postgres + Redis) · MockMvc · Vitest · jsdom · ruff |
전체 시스템 아키텍처
핵심 포인트:
- AI 서버와 백엔드가 물리적으로 분리 — LLM 호출(2-4초)이 CRUD/시세 응답 시간을 잠식하지 않음. 백엔드는 순수 프록시.
- 헥사고날(ports/adapters) 을 백엔드/AI 서버 양쪽 모두 적용 — embedding · llm · retriever · reranker · cache · trace · broadcast · archive 가 전부 swappable.
- RAG 정량 평가(evals) — 골든셋 15종목 × judge 모델 2세대(Sonnet 4.6 + Opus 4.7) 로 8단계 iteration, hallucination 8→0.
- 실시간 시세는 KIS→백엔드→프론트 fan-out, 주문 이벤트는 memberId 라우팅. Redis Pub/Sub 모드 스위치로 수평 확장 가능.
구현 내용
AI Server (RAG · Python)
1. 헥사고날 아키텍처 — 모든 어댑터 swappable
app/ports/ 에 7개 인터페이스(Embedding · Llm · Retriever · Reranker · SummaryCache · Trace · QueryRewrite · SectorFallback) 를 정의하고 app/adapters/ 에 각각의 구현체.
- 환경변수 유무에 따라
deps.py가 자동 선택 — Cohere key 없으면RerankerPort = None, Redis host 비어있으면NoOpSummaryCacheAdapter, Langfuse key 없으면NoOpTraceAdapter. - 로컬 개발자가 유료 API 없이 파이프라인 전체를 돌릴 수 있고, prod 에선 스위치만 켜면 활성.
- 신규 어댑터 추가 (예: BGE reranker) 는
RerankerPort하나 구현 +deps.py한 줄 배선.
2. Hybrid Retrieval — Dense + Sparse + RRF
HybridNewsRetrieverAdapter — 두 랭킹을 Reciprocal Rank Fusion (K=60) 로 병합.
- Dense — pgvector cosine 거리 (
ne.embedding <=> query_vec). 자연어 의미 유사도. - Sparse — Postgres tsvector +
ts_rank_cd(...)전문검색. 고유명사/숫자/희귀 term. - RRF 병합 — 두 랭킹의
1/(K+rank)합. 상위top_k*3을 rerank 후보로. - Cohere Rerank v3.5 — 선택적 (
COHERE_API_KEY존재 시), 최종 top-K.
→ 한국 주식 뉴스는 종목명·수치 정확 매칭이 자주 필요(dense 만으론 놓침) + 자연어 이유 설명은 dense 가 강점. hybrid 도입 후 hallucination 8→4.
3. 신선도 감쇠 — RRF 위에 지수 감쇠 곱
HybridNewsRetrieverAdapter._apply_freshness_decay() — RRF 병합 이후 2^(-age_days / half_life) 를 곱한다.
- 반감기 기본 3일 (
retrieval_freshness_half_life_days, 0 이면 비활성). - 14일 윈도우 내에서 최신 뉴스가 우선. 이 항목이 없으면 오래된 뉴스와 2시간 전 뉴스가 동일 순위.
- 유닛 테스트: decay OFF 시 원본 RRF 순서 유지, ON 시 오래된 뉴스가 뒤로 밀림 확인.
4. Query Rewriting — Haiku 로 다변량 쿼리 → variant 축 RRF
HaikuQueryRewriteAdapter — 종목별 base_query 를 실적/이벤트/섹터 관점으로 2~3개 재작성.
- 원본 쿼리는 종목명+심볼로 고정되어 있어 종목별 이슈(임상, M&A, 규제) 다양성 반영이 어려움.
- variant 각각에 hybrid 검색 → variant 간 RRF 로 병합.
- 실패 / 키 미설정 시 NoOp 어댑터로 base_query 그대로 (기본).
→ 소형주 등 검색 recall 이 부족한 종목에서 관점 다변화로 top-K 커버리지 향상.
5. Citation Span — LLM 요약 근거를 뉴스 인덱스로 추적
application/citation_parser.py — 프롬프트가 강제한 [뉴스 N번] 마커를 파싱해 sources 배열 인덱스로 변환.
- 프롬프트: "key_reasons 의 모든 항목에
[뉴스 N번]마커 필수, 여러 뉴스면[뉴스 1번, 3번]". - 파서: 범위 밖 인덱스 무시 (환각 방어), 중복 dedup, 단일/다중 마커 모두 지원.
- 응답 스키마:
key_reasons: [{text, source_indices: [1,3]}]+sources: [{news_id, title, url, ...}]. - 프론트 카드가 각 근거 옆에
#1#3배지 → 클릭 시 원문 링크로 이동.
→ 사용자가 요약의 어느 문장이 어떤 뉴스에서 나왔는지 눈으로 검증 가능.
6. 섹터 Fallback — 뉴스 0건 종목 방어
PgSectorFallbackAdapter — 종목 직접 뉴스가 0건이면 stock_master 조인으로 같은 섹터 최신 뉴스 top-K 반환.
- KOSDAQ 소형주 등 뉴스 인덱스가 얕은 종목이 "판단 보류" 만 반환하던 문제 해결.
- 프롬프트 헤더에 "⚠️ 직접 뉴스 부족 → 섹터 뉴스로 대체" 명시하고 key_reasons 를 섹터/업종 동향 관점으로만 작성하게 강제.
- 사용자가 "이 요약은 섹터 뉴스 기반" 임을 인지하도록 UX 로 노출.
7. 정량 Evals — Golden Set × Multi-Judge (Sonnet 4.6 + Opus 4.7)
evals/run_eval.py — 골든셋 15종목(대형 8 / 중형 4 / KOSDAQ 3) 을 순회하며 요약 생성 → LLM judge 로 채점.
- 지표:
groundedness·citation_accuracy·hallucination_count·coverage. - N회 병렬 판정 → 지표별 median (
judge_repeat=3). 같은 입력에도 판정이 흔들리는 stochasticity 완화. - σ 기반 자동 재판정 — median 3회의 표준편차가 threshold 를 넘으면 extras 4회 추가 → 총 7회 median. 편차가 작은 종목은 3회로 종료해 비용 유지.
- Multi-judge (Sonnet 4.6 + Opus 4.7) — 두 세대의 Claude 를 병렬로 판정 후 median-of-medians. 두 모델 median 값 차이를
disagreement_mean으로 기록해 systematic bias 를 계량. - 결과 파일
evals/results/*.json+EVAL_REPORT.md로 8단계 iteration 추이 문서화.
→ 판정기 자체의 신뢰도까지 정량화 (disagreement=0.071 = 두 모델 median 이 매우 일치).
8. Multi-Judge 를 eval 전용으로 물리 격리
evals/eval_settings.py — judge_repeat / opus_judge_model / disagreement threshold 등 eval 전용 설정을 별도 파일로 분리.
- prod 요약 use case 가 실수로 참조하면 요청당 최대 7회 LLM 판정 호출로 비용 폭발 위험.
- 물리적 격리 (
app/아래에서 import 금지) 로 사고 원천 차단. grep -r judge_multi_enabled app/= 0건 확인.
9. Langfuse 관측 + prompt cache
LangfuseTraceAdapter — span("summarize_stock") 안에 embed → retrieve → llm 호출 트리, metadata(symbol, news_count, query_variants, sector_fallback).
- LangChain
ChatAnthropic의cache_control="ephemeral"로 system prompt 캐싱 활성 — 반복 요청 시 input 토큰 대폭 절감. - Langfuse key 없으면
NoOpTraceAdapter로 자동 fallback — 로컬 개발자가 신경 안 써도 됨.
Backend (Kotlin · Spring Boot 헥사고날)
10. 도메인별 headed 헥사고날 — 9개 모듈
order, account, member, auth, stock, market, watchlist, news, ai — 모듈 각각이 adapter/{web|persistence|external|in/websocket|out/broadcast|scheduler} + application/{port|usecase} + domain 구조.
- 도메인 규칙은 도메인 안 (
Account.withdraw(),Holding.applyBuy(),Order.filledSell()). - Service 계층은 얇게 — 트랜잭션과 use case 조합만.
- 외부 어댑터가 도메인을 참조하는 방향 고정 — 도메인은 Spring/JPA 를 모른다.
11. Kakao OAuth2 + JWT + Refresh 토큰
KakaoOAuth2UserService→OAuth2SuccessHandler→ JWT access(60분) + refresh(14일) 발급.JwtAuthenticationFilter로 stateless (세션 X, CSRF 비활성).- Refresh 토큰은 raw 는 HttpOnly 쿠키, DB 엔 SHA-256 hash 만 저장.
MemberProvisioner가 첫 로그인 시 member + account 자동 생성.- 프론트 middleware 가 access 만료 감지 →
/api/v1/auth/refresh로 자동 갱신 후 원래 페이지 유지.
12. KIS Open API 실시간 시세 어댑터
KisWebSocketFeedAdapter — 한국투자증권 실시간 WebSocket 을 백엔드가 대신 붙어서 프론트에 fan-out.
- approval_key 발급 → subscribe (
H0STCNT0실시간 체결) → 파싱 → domainPriceTick방출. - PINGPONG 10초 timeout 감지 후 지수 backoff 재접속 (최대 60초).
- 세션당 tr_key 40개 제한 (KIS 규격) 을 초과하면 프론트 구독 요청 reject.
- KIS 실시간 데이터 파싱 형식(
caret-delimited문자열) 을 유닛 테스트로 격리 검증 (KisWebSocketFeedAdapterParseTest).
13. 프론트 WebSocket — 시세와 주문 이벤트 분리
/ws/market — 심볼별 subscribe/unsubscribe 프로토콜, 공개 (인증 불필요).
/ws/orders — memberId 로 서버 라우팅, HTTP handshake 시 JWT 검증.
- BroadcastPort / OrderEventPublisherPort 를 정의해 handler 가 concrete adapter 가 아닌 port 에 의존.
- 스위치 (
tick.{market|orders}.broadcast.mode = inprocess | redis) —inprocess는 단일 JVM in-memory fan-out,redis는 Pub/Sub 으로 다중 인스턴스에 전파 후 각자 local 세션 delivery. - 프론트는
marketWs.ts/ordersWs.ts싱글톤 매니저 — idle 5초 close, 지수 backoff 재접속, 심볼별 refcount. - 주문 체결 시 sonner toast +
router.refresh()로 orders/portfolio 서버 컴포넌트 자동 갱신.
14. Watchlist 기반 자동 데이터 수집 스케줄러
NewsCollectionScheduler (기본 6시간마다) + AiEmbeddingScheduler (매일 03:30 KST).
- 대상 심볼: 전체 사용자 watchlist union —
SELECT DISTINCT symbol FROM watchlist. - StockMaster 전체를 도는 대신 관심 대상만 스캔 → 외부 API (Naver / DART) 쿼터 절감.
- 로컬 개발 실수 방지 위해 opt-in 기본 OFF (
TICK_SCHEDULE_NEWS_COLLECT_ENABLED=true로 활성). - 한 종목 실패가 배치 전체를 막지 않도록 종목별 try/catch 격리.
15. Rate Limit — Redis 고정 창 + 애노테이션
@RateLimited(bucket, limit, windowSec) 애노테이션 + RateLimitInterceptor.
- AI 요약 5/min (bucket=ai_summary, LLM 비용 방어), 임베딩 3/min, 기본 60/min.
- Redis INCR + EXPIRE(1st only). Redis 다운 시 fail-open (서비스 전체 429 잠금 방지).
- Anonymous 요청은 skip — SecurityFilter 가 먼저 401 로 막음.
- 초과 시
Retry-After헤더 + 429RATE_LIMIT_EXCEEDED.
16. 뉴스 원본 S3 아카이빙 (선택)
NewsArchivePort (S3 / NoOp) — 뉴스 저장 직전에 원본 body 를 S3 에 업로드하고 news.archive_url 저장.
- key =
news/{yyyy}/{MM}/{dd}/{symbol}/{contentHash}.txt. - 실패 시 null 반환 → 뉴스 저장 흐름은 계속 (아카이빙은 감사/원문 재확인 목적, RAG 경로 무영향).
- 기본 비활성 (
tick.news.archive.enabled=false) — 로컬에서 AWS 인증 없이 동작. - Terraform
news_archive.tf로 bucket + IAM + 30일 IA + 365일 만료 lifecycle 정의.
17. 통합 테스트 — Testcontainers × MockMvc × 실제 JWT
IntegrationTestBase — @SpringBootTest + Testcontainers(pgvector/pg16 + redis) + @TestPropertySource 로 JWT secret 주입.
- 실제
JwtProvider로 토큰 발급 →JwtAuthenticationFilter통과 → 컨트롤러까지 도달. Order/Account/News3개 컨트롤러에 401/400/404/200 + 입금 흐름 커버.TestDataSeeder— JdbcTemplate 으로 member + account 직접 seed (JPA 우회, 부작용 최소화).- 총 64 tests, 실행 40초, 0 fail.
- Testcontainers 는 shade 된 docker-java 의 기본 API 버전이 1.32 인데 최신 Docker/OrbStack 은 1.40 요구 →
systemProperty("api.version", "1.43")로 강제.
Frontend (Next.js 16 · React 19)
18. Server / Client 컴포넌트 물리 분리 — apiFetch 오염 차단
services/apiFetch.ts 는 next/headers 의 cookies() 를 사용 → server-only.
services/aiSummaryClient.ts (브라우저) / aiSummaryService.ts (서버) 로 client fetcher 를 별도 파일로 분리.
- client component (예:
LazyAiSummary) 가 실수로apiFetch를 import 하면 client 번들이 server 코드까지 끌고 들어와 Next.js build 가 깨짐. - 파일 자체를 나눠 컴파일러가 잘못된 import 를 조기에 잡음.
19. WebSocket 싱글톤 매니저 — 재접속 backoff + refcount
marketWs.ts / ordersWs.ts — 브라우저 탭당 물리 커넥션 1개 유지.
- 최초 subscribe 시 lazy open, 마지막 리스너 해제 후 5초 idle → close.
- 커넥션 끊기면 1.5초 → 30초 지수 backoff 로 재접속, 활성 심볼 전부 재구독.
- SSR 안전 —
typeof window === 'undefined'가드. - Vitest + jsdom + MockWebSocket 으로 subscribe/tick 라우팅/idle-close 8개 시나리오 유닛 테스트.
20. 주문 실시간 알림 — sonner + router.refresh()
OrderEventsListener client component 를 (app) 그룹 layout 에 마운트.
/ws/orders이벤트 수신 시 sonner toast (매수/매도, 실현손익 부호별 문구).router.refresh()로 현재 페이지의 서버 컴포넌트만 재조회 → orders/portfolio 자동 갱신 (전체 페이지 리로드 X).- (app) 그룹 밖 (로그인/공개 페이지) 은 리스너가 안 붙는다.
21. Middleware — access token freshness + auto-refresh
middleware.ts — protected 경로 진입 시 access 만료 여부 판정.
- access 유효 → 통과.
- access 만료 & refresh 있음 →
/api/v1/auth/refresh호출 → 새 access 쿠키를 request headers 에도 주입해 서버 컴포넌트가 새 값 사용 → 응답 헤더에 set-cookie forward. - 둘 다 실패 →
/login?redirect={원경로}. lib/jwt.ts의isAccessFresh()는 exp 클레임만 확인 (서명 검증은 백엔드).
Infra (Terraform · AWS)
22. 단일 EC2 (t4g.small · Graviton) + Caddy 자동 TLS
- ARM64 인스턴스 t4g.small (2 GiB, ~$12/월) + gp3 30 GiB.
- Caddy 가 :80/:443 종료 + Let's Encrypt 자동 갱신. Spring
forward-headers-strategy: native로 원본 스킴 복원. - Docker 로 backend (blue/green 두 컨테이너) + frontend + postgres + redis 기동.
- CD 가 backend-a → backend-b 순차 재시작 + health check 로 rolling 무중단.
- Elastic IP 고정 → 카카오 콘솔 redirect URI 안정.
23. GitHub Actions OIDC — 정적 AWS 키 0
iam_github_oidc.tf — OIDC provider + IAM role, sub 클레임을 repo:leesangeok/Tick:refs/heads/main 로 한정.
- ECR push · SSM SendCommand · GetCommandInvocation 만 허용.
- GitHub Actions secrets 에 AWS Access Key 0개.
- CD 는 SSM Run Command 로 EC2 에
docker pull && restart실행. SSH 22 포트 닫힘.
24. Postgres 자동 백업 → S3 (매일 03:30 KST)
backup.tf — user_data cron 이 pg_dump 후 S3 로 업로드.
- 30일 STANDARD_IA 전환, 90일 만료.
- 인증은 EC2 instance profile — 로컬에 액세스키 없이 백업 자동.
- CI 에서 dry-run (
terraform validate/fmt) 통과 후 main merge 로 apply.
25. RDS + CloudWatch + News Archive S3 (Terraform only)
rds.tf · cloudwatch.tf · news_archive.tf — 코드는 있고 apply 는 별도 이관 계획 필요.
- RDS Postgres 16.4 (db.t4g.micro · pgvector · Multi-AZ subnet group · EC2 SG 로만 5432 허용 · Secrets Manager 관리 비밀번호).
- CloudWatch log group
/tick/dev/app+ dockerawslogsdriver + error metric filter. - News archive S3 bucket + IAM (30일 IA / 365일 만료).
- 이관 순서 (RDS 생성 → pg_dump/restore → CREATE EXTENSION vector → SPRING_DATASOURCE_URL 교체) 를 파일 상단 주석에 명시.
트러블슈팅
1. RAG hallucination 을 8→0 으로 만든 8단계 iteration
증상. Baseline (Naver 뉴스 + Sonnet single judge) 에서 15종목 요약 중 8건이 뉴스에 없는 사실을 확정 표현으로 서술. 예: "삼성전자 — HBM 가격 인상을 예고" (뉴스는 수익성 역전만 언급), "NAVER — 4GW AI 데이터센터" (뉴스엔 4GW/8GW 혼재).
원인. 소스 다양성 부족(네이버만) + retrieval 소음 + 판정기 stochasticity 가 겹쳐 있음.
해결 — 8단계 실측 iteration.
| 단계 | 변경 | groundedness | halluc_sum |
|---|---|---|---|
| 1. Baseline | Naver only | 0.883 | 8 |
| 2. +DART | 공시 소스 추가 | 0.841 | 6 |
| 3. +Hybrid | dense+sparse+RRF | 0.908 | 4 |
| 4. +Rerank | Cohere v3.5 | 0.866 | 7 (후퇴) |
| 5. +Median | judge 3회 median | 0.889 | 7 |
| 6. +σRetry | σ 크면 재판정 | 0.909 | 4 |
| 7. +Cite+Denoise | 인용 마커 강제 + DART blacklist | 0.919 | 3 |
| 8. +MultiJudge | Sonnet 4.6 + Opus 4.7 | 0.942 | 0 ⭐ |
핵심 관찰. 4단계 (Rerank 도입) 에서 오히려 후퇴 — sparse retrieval 이 이미 소음(DART 임원공시 등)을 섞어놨기 때문. 7단계에서 DART report_nm blacklist 로 소음 제거하니 rerank 가 정상 작동. retrieval 개선과 데이터 품질은 함께 봐야 한다는 교훈.
남는 원칙. LLM 요약 품질은 지표 하나(mean 하나)로 측정하면 안 된다. groundedness / citation-accuracy / hallucination_count / coverage 를 함께 보고, judge 자체의 stochasticity 도 정량화해야 개선 방향이 보인다.
2. LLM judge 자체가 흔들리는 stochasticity — median + σ retry + cross-model
증상. 같은 요약을 Sonnet 4.6 에게 3번 채점시키면 groundedness 가 0.6 → 0.95 로 뒤엎힘. 삼성바이오는 grounded=0.0 outlier 가 반복되어 mid tier mean 을 훼손.
원인. LLM judge 도 결정론적이지 않고, 특히 애매한 문장(투자 권유 표현 경계, 뉴스에서 부분 뒷받침) 에서 판정이 크게 흔들림.
해결 — 3층 완화.
- N회 median (
judge_repeat=3) — outlier 완화. NAVER 사례grounded=0.0재발 방지. - σ 기반 자동 재판정 — 3회의 표준편차가
judge_std_threshold_grounded=0.15를 넘으면 extras 4회 추가 → 총 7회 median. 편차 작으면 3회로 종료해 비용 유지. - Cross-model — Sonnet 4.6 + Opus 4.7 두 세대 median-of-medians. 두 모델 median 차이 (
disagreement_mean=0.071) 를 별도 지표로 기록해 systematic bias 계량.
결과. 8단계 최종 halluc_sum 0, disagreement_mean 0.071 (매우 일치). 판정기 자체 신뢰도가 정량화됨.
남는 원칙. LLM 을 판정에 쓰는 순간, 판정기 자체의 안정성도 데이터로 관리해야 한다. 지표에 σ 를 함께 기록하지 않으면 "지표가 오르는데 뭘 바꿔야 오르는지 모른다" 는 상태에 빠진다.
3. Cohere Rerank 도입이 오히려 hallucination 을 늘리던 문제
증상. 3단계 hybrid 도입으로 halluc 4까지 내렸는데, 4단계에 Cohere Rerank v3.5 를 붙이니 halluc 7 로 후퇴.
원인. Rerank 후보를 top_k*3 으로 늘렸는데, 이 후보군에 DART 소음 공시 (임원 특정증권보유상황보고서 등) 가 다량 포함되어 있었다. Reranker 는 관련성 점수를 매기지 소음 필터 역할은 안 함 → 결국 rerank 가 소음을 상위로 끌어올림.
해결 (7단계).
- 백엔드
DartDisclosureAdapter.include-types에서 D(지분)/E(기타) 제거 → 142건 → 85건 (40% 감소). report_nm에 "특정증권", "임원" 포함 시 필터.- Rerank 는 그대로, 입력 데이터 품질만 개선.
결과. rerank 가 정상 작동 → 7단계 halluc 3.
남는 원칙. 파이프라인 후단(rerank) 이 후퇴할 때 후단만 봐선 원인이 안 나온다. 후단은 전단의 데이터 품질을 그대로 승계한다.
4. HTTP/2 h2c upgrade 로 백엔드 → ai-server 요청 body 유실
증상. Kotlin 백엔드가 Spring RestClient 로 ai-server (uvicorn) 의 /ai/summary 호출 시, POST body 가 서버에 도달할 때 비어있음. 로컬에서 curl 로는 정상.
원인. JDK HttpClient 의 기본값이 HTTP/2. uvicorn/starlette 은 h2c upgrade 요청을 잘못 처리해 body 를 잃어버림.
해결. RestClient.builder().requestFactory(JdkClientHttpRequestFactory(HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build())) 로 HTTP/1.1 강제.
남는 원칙. 내부 서비스 간 통신은 프로토콜 version 을 명시. "기본값" 이 상대 서버 구현에 따라 다르게 해석되면 디버그가 어려워진다.
5. Testcontainers 가 최신 Docker 데몬을 못 알아보던 문제
증상. GitHub Actions 는 통과하는데 로컬 (OrbStack, Docker 29.x) 에선 Server API version 1.32 requested, but the server minimum API version is 1.40 로 컨테이너 기동 실패.
원인. Testcontainers 가 shade 한 docker-java 의 기본 요청 버전이 1.32. OrbStack/Docker 29 는 1.40 이상만 허용.
해결. build.gradle.kts 의 tasks.withType<Test> { systemProperty("api.version", "1.43") } 로 강제.
tasks.withType<Test> {
useJUnitPlatform()
systemProperty("api.version", "1.43")
}남는 원칙. OSS 라이브러리가 shade 한 트랜지티브 의존성 버전은 자동 upgrade 되지 않는다. 로컬/CI 환경 편차는 system property 로 우회하되, 이유를 코드 주석에 남긴다.
6. Multi-judge 가 prod 코드에 스며들어 요청당 7배 비용 폭발 위험
증상. eval 파이프라인에서 도입한 judge_multi_enabled / judge_repeat=3 이 app/config/settings.py 에 있음. 언젠가 실수로 prod use case 가 참조하면 요청당 최대 7회 LLM 호출로 비용 폭발.
원인. eval-only 설정과 prod-shared 설정이 같은 파일에 섞여 있으면 언젠가 사고 난다.
해결.
evals/eval_settings.py별도 파일 신설. judge_repeat / judge_multi_enabled / disagreement threshold 이관.app/config/settings.py에는 shared (anthropic_api_key, retrieval_top_k) 만 유지.grep -r "judge_multi_enabled" app/= 0건 확인 커밋 시점에 검증.
남는 원칙. "실수하면 비용이 폭발하는 설정" 은 물리적으로 격리한다. 코드 리뷰에 의존하지 말고, import 로 자연스럽게 막는다.
7. Redis 캐시 스키마 변경 시 옛 값이 downstream 을 깨뜨리던 문제
증상. AiSummary.key_reasons: list[str] 에서 list[KeyReason(text, source_indices)] 로 도메인을 확장하니, 옛 캐시 값을 그대로 deserialize 하면 downstream 에서 kr.text 접근 시 AttributeError.
원인. JSON 캐시는 스키마가 없어 하위 호환을 자동 보장 못 함.
해결.
RedisSummaryCacheAdapter._to_result()에서KeyReason(text=kr["text"], source_indices=...)로 명시 재구성.- 옛 형식 (list[str]) 은
kr["text"]접근 시 예외 → 상위try/except (KeyError, TypeError)가 잡아 cache miss 로 처리 → 재생성. - 캐시 무효화 배포 없이도 자연스러운 마이그레이션.
남는 원칙. 캐시 스키마 변경 시 옛 값을 읽을 수 없게 만들되(예외 유발) 에러 로그가 아닌 miss 로 처리되도록 설계한다.
8. 오래된 뉴스가 최신 급등락 이유를 압도하던 신선도 문제
증상. 14일 윈도우 내 오늘 급등 종목을 요약하는데 13일 전 뉴스가 상위에 랭크되어 "오늘의 이유" 가 아니라 "지난 주 이슈" 요약이 나옴.
원인. RRF + Cohere Rerank 는 시간 무관 랭킹. 14일 윈도우 내면 오래된 뉴스와 2시간 전 뉴스가 동일 점수.
해결. HybridNewsRetrieverAdapter._apply_freshness_decay() — RRF 병합 이후 2^(-age_days / half_life) 곱. 반감기 3일 (0 이면 비활성).
검증. 유닛 테스트로 3개 뉴스(0일/3일/6일) 를 역순으로 넣어도 decay 적용 후 최신 순서로 정렬되는지 확인.
남는 원칙. 시간 감쇠는 랭킹 파이프라인의 최상단이 아닌 병합 이후에 곱하는 것이 자연스럽다. 하위 retriever 는 순수 관련성만 담당, 시간 편향은 use case 층에서 조정.
성과
| 항목 | Before | After |
|---|---|---|
| RAG hallucination (골든셋 15종목) | 8건 (baseline) | 0건 (multi-judge 최종) |
| RAG groundedness_mean | 0.883 | 0.942 (+6.7%) |
| RAG citation_accuracy_mean | 0.900 | 0.949 (+5.4%) |
| Judge disagreement_mean (cross-model σ) | — | 0.071 (매우 일치) |
판정기 안정성 (count_retried) | — | 0/15 (σ retry 발동 없음) |
| Backend 통합/유닛 테스트 | 41건 (unit only) | 64건 (integration + unit, 0 fail) |
| Frontend 테스트 | 0건 | 8건 (WS 매니저 vitest) |
| CI 대상 | backend + ai-server + infra | + frontend (tsc + lint + vitest) |
| 실시간 시세 프론트 커넥션 | 컴포넌트당 개별 | 탭당 1개 (싱글톤 + refcount) |
| /ws/orders 실시간 알림 | 폴링 | push + router.refresh() (0 폴링) |
| 뉴스 수집 방식 | 수동 API 트리거 | watchlist 자동 스케줄러 (Naver/DART API 쿼터 절약) |
| AI 요약 rate limit | 없음 | 5/min per user (LLM 비용 방어) |
| Multi-judge 를 prod 가 참조할 위험 | 같은 settings 파일 | eval 전용 모듈로 물리 격리 (0건) |
| 정적 AWS Access Key | — | 0개 (GitHub Actions OIDC) |
| EC2 SSH 22 포트 | — | 닫힘 (SSM SendCommand 만) |
| RDS/CloudWatch/S3 아카이빙 | 코드 미준비 | Terraform validate/fmt 통과 (apply 별도) |
※ RAG 지표는 ai-server/evals/results/*.json + EVAL_REPORT.md 실측. 골든셋 15종목 × 3~7회 판정 median.
회고
- 정량 evals 없이는 RAG 개선을 못 한다 는 걸 몸으로 배웠다. "요약이 좋아졌다" 는 감상이 아니라 groundedness / hallucination / disagreement 4개 지표로 말할 수 있어야 다음 단계가 보인다. 특히 LLM judge 자체의 σ를 측정 하기 시작한 순간부터 개선 방향이 "지표를 올리는 것" 에서 "판정을 안정화하는 것" 으로 분리됐다.
- retrieval 개선과 데이터 품질은 함께 봐야 한다. Cohere Rerank 도입이 오히려 후퇴한 경험 (4→7) 이 결정적 — reranker 는 관련성만 매기지 소음을 걸러주지 않는다. DART blacklist 로 소음을 제거한 뒤 rerank 를 재활성해야 시너지.
- 헥사고날 port/adapter 는 신규 어댑터 추가 비용을 낮춘다. Cohere key 없으면 rerank 자동 스킵, Redis host 비어있으면 NoOp 캐시 fallback — 이 스위치가 로컬 개발자에게 "일단 돌려보자" 를 허락한다. 실제 이걸로 팀원 없이 혼자 8단계 iteration 을 돌릴 수 있었다.
- "실수하면 비용이 폭발하는 설정" 은 물리 격리 한다. Multi-judge 설정을
evals/eval_settings.py로 분리한 결정은 코드 리뷰가 없는 1인 개발에서 특히 중요. import path 자체가 사고를 막는다. - Redis 캐시 스키마 변경은 miss 로 처리되게 설계. 하위 호환 코드를 유지하려는 유혹을 피하고, 옛 값이 자연스레 실패하도록 두되 상위에서 miss 로 재생성. 배포 시 캐시 flush 필요 없음.
- 실시간 WebSocket 을 handler / broadcast port / 로컬 세션 registry 세 층으로 분리 한 것이 Redis Pub/Sub 스위치를 코드 변경 없이 가능하게 했다. 처음부터 이걸 계획하기 어렵고, InProcess 로 시작해서 필요 시점에 리팩토링. "나중에 확장 가능한 구조" 는 처음부터 완벽할 필요 없다.