프로젝트 목록

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 리포트종목 상세 — 실시간 시세 + AI 요약AI 요약 카드 — 근거 인용 마커거래 내역관심 종목

기술 스택

영역기술
AI ServerPython 3.12 · FastAPI · LangChain · pgvector · OpenAI (embedding) · Anthropic Claude Haiku 4.5 (요약) · Sonnet 4.6 + Opus 4.7 (judge) · Cohere Rerank v3.5 · Langfuse
BackendKotlin · Spring Boot 3.5 · Spring Security · JPA · Flyway · WebSocket · JWT
FrontendNext.js 16 (App Router · React 19) · TypeScript · Tailwind · Zustand · sonner
DatabasePostgreSQL 16 + pgvector · Redis
RealtimeKIS Open API WebSocket · Spring WebSocket (/ws/market, /ws/orders) · Redis Pub/Sub
InfraAWS EC2 (t4g.small · Graviton) · ECR · S3 · Route53 · Caddy(TLS) · Docker · Terraform · GitHub Actions OIDC
TestingTestcontainers (Postgres + Redis) · MockMvc · Vitest · jsdom · ruff

전체 시스템 아키텍처

Tick 전체 시스템 아키텍처 — Next.js · Kotlin 백엔드 · Python AI 서버 · pgvector · Redis · KIS 실시간

핵심 포인트:

  • 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 ChatAnthropiccache_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 토큰
  • KakaoOAuth2UserServiceOAuth2SuccessHandler → 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 실시간 체결) → 파싱 → domain PriceTick 방출.
  • 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 헤더 + 429 RATE_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 / News 3개 컨트롤러에 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.tsnext/headerscookies() 를 사용 → 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.tsisAccessFresh() 는 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 + docker awslogs driver + 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.

단계변경groundednesshalluc_sum
1. BaselineNaver only0.8838
2. +DART공시 소스 추가0.8416
3. +Hybriddense+sparse+RRF0.9084
4. +RerankCohere v3.50.8667 (후퇴)
5. +Medianjudge 3회 median0.8897
6. +σRetryσ 크면 재판정0.9094
7. +Cite+Denoise인용 마커 강제 + DART blacklist0.9193
8. +MultiJudgeSonnet 4.6 + Opus 4.70.9420

핵심 관찰. 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층 완화.

  1. N회 median (judge_repeat=3) — outlier 완화. NAVER 사례 grounded=0.0 재발 방지.
  2. σ 기반 자동 재판정 — 3회의 표준편차가 judge_std_threshold_grounded=0.15 를 넘으면 extras 4회 추가 → 총 7회 median. 편차 작으면 3회로 종료해 비용 유지.
  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.ktstasks.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=3app/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 층에서 조정.


성과

항목BeforeAfter
RAG hallucination (골든셋 15종목)8건 (baseline)0건 (multi-judge 최종)
RAG groundedness_mean0.8830.942 (+6.7%)
RAG citation_accuracy_mean0.9000.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 Key0개 (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 로 시작해서 필요 시점에 리팩토링. "나중에 확장 가능한 구조" 는 처음부터 완벽할 필요 없다.