대부분의 RAG 데모는 "청크 가져와서 LLM에 던지기"에서 끝난다. FinSight-AI는 그 너머의 "AI 시스템이 실무에서 깨지지 않으려면 무엇을 해야 하는가"를 코드로 보여준다. Spring Boot 3.3 + Java 17이 백엔드 중심이고, Python(FastAPI) AI 서비스는 임베딩·리랭킹·생성을 담당하는 사이드카로 분리된다. AI 응답이 안 와도 결정론적 폴백으로 데모가 계속 돌아가는, 시연에 최적화된 설계가 특징이다.
"RAG 한 방"이 아니라 장기 실행 워크플로우 + 단일 비행 락 + 데이터 스냅샷 해시 캐시 + pgvector 하이브리드 검색 + RAG 회귀 평가를 한 레포 안에 통합한 백엔드 엔지니어링 쇼케이스. 그래서 풀스택 백엔드 + AI 인프라를 동시에 공부하기 좋은 교재다.
2026년 들어 LLM 활용이 PoC를 넘어 실서비스로 들어가면서, "모델 호출 자체가 아니라 그 주변 인프라가 무너진다"는 문제가 부상했다. 모델 응답 타임아웃, 중복 비용 청구, 캐시 불일치, 인덱싱 부분 실패 — 이런 운영 문제를 체계적으로 보여주는 오픈소스 레포는 의외로 드물다. FinSight-AI는 이 빈틈을 정확히 노린다.
dataSnapshotHash로 데이터 변경 감지langchain·llamaindex는 라이브러리이고 운영 문제는 사용자에게 맡긴다. dify·langgenius는 워크플로우 UI는 있지만 Spring Boot 같은 백엔드 엔지니어링 문맥은 약하다.
FinSight-AI는 "Java/Spring 백엔드 엔지니어가 AI 시스템을 시연 가능한 형태로 만든다면 어떻게 짤까"에 대한 구체적 답안이다. 별 261개 + TrendShift 23위는 그만큼 "취업/이직 포트폴리오"로서의 호소력이 높다는 시그널. 실제로 docs 폴더에 resume-and-interview.md가 있다.
| 구성요소 | 버전·역할 |
|---|---|
| Java | 17 — Spring Boot 3.x 요구사항 |
| Spring Boot | 3.3 — REST API, 워크플로우 오케스트레이션, 메트릭 엔진 |
| Spring JDBC (JdbcTemplate) + Flyway | 스키마 마이그레이션 자동화, JSONB·pgvector 컬럼 수동 매핑 |
| Spring Boot Actuator + Micrometer | /actuator/health·/actuator/prometheus로 운영 신호 노출 |
| Spring AMQP | RabbitMQ 비동기 워크플로우, dead-letter queue 처리 |
| Lettuce / Spring Data Redis | Redis Lua 단일 비행 락, AI 리포트 캐시 |
| Maven | 빌드·테스트, Testcontainers 통합 |
| 구성요소 | 버전·역할 |
|---|---|
| FastAPI + Uvicorn | 임베딩(/embed)·리랭킹(/rerank)·생성(/generate-answer)·종목 분석(/analyze-stock) 엔드포인트 제공 |
| Ollama (선택) | qwen2.5:7b 모델로 로컬 LLM 호출. 미설치 시 결정론적 룰 기반 폴백 |
| Pydantic | 요청/응답 스키마 검증 |
별도 SPA 프레임워크 없음. Spring Boot가 / 루트에서 정적 파일을 서빙하는 단순한 대시보드. Vanilla JS로 REST API를 호출해 워크플로우 상태·메트릭·검색 증거·타임라인·그래프·평가 결과를 표시한다. 의도적으로 "백엔드 중심" 프로젝트라는 정체성을 지키기 위한 선택.
| 서비스 | 이미지 | 역할 |
|---|---|---|
| postgres | pgvector/pgvector:pg16 | JSONB + 전문검색 + pgvector 코사인 인덱스 |
| redis | redis:7-alpine | Lua 단일 비행 락, 리포트 캐시 |
| rabbitmq | rabbitmq:3-management | 비동기 워크플로우 큐 + DLQ |
| elasticsearch | elasticsearch:8.15.3 | (확장 영역) 추가 전문검색 백엔드 |
| minio | minio:latest | (확장 영역) S3 호환 객체 스토리지 |
| ai-service | FastAPI 컨테이너 | 임베딩/리랭킹/생성 |
| backend | Spring Boot 컨테이너 | API + 워크플로우 + 메트릭 |
postgres,rabbitmq,redis,prod를 모두 켜면 DB·MQ·캐시가 살아 있는 완전체. 아무것도 안 켜면 in-memory 리포지토리 + 로컬 락 + 결정론 폴백으로 동작한다. "프로파일 하나에 인프라 의존성 하나를 매핑한다"는 패턴은 그대로 베껴서 쓸 만한 설계.어느 입구로 들어와도 Spring Boot 게이트웨이가 받고, 그 아래에서 워크플로우 오케스트레이터와 하이브리드 검색이 동작한다. AI 호출은 FastAPI 사이드카로 분리되고, 상태·증거·캐시는 PostgreSQL+Redis+RabbitMQ 셋이 나눠 든다.
한 번의 종목 분석은 "데이터 수집 → 지표 계산 → 문서 인덱싱 → 인텔리전스 빌드 → AI 분석"의 5단계로 쪼개진다. 각 단계는 WorkflowTask 한 행에 status + stage + attempts로 영속화되어, 어느 단계에서 멈췄는지·몇 번 재시도했는지가 항상 추적 가능하다.
[*] → CREATED
CREATED → LEASE_ACQUIRED ← Redis Lua 락 획득 시점
LEASE_ACQUIRED → INGESTING_DATA ← Stage 1: 데이터 수집
INGESTING_DATA → METRIC_CALCULATING ← Stage 2: 재무지표 + 리스크 시그널
METRIC_CALCULATING → DOCUMENT_INDEXING ← Stage 3: 청크 + 임베딩 인덱싱
DOCUMENT_INDEXING → INTELLIGENCE_BUILDING ← Stage 4: 타임라인 + KG
INTELLIGENCE_BUILDING → AI_ANALYZING ← Stage 5: LLM 호출 + 리포트
AI_ANALYZING → SUCCEEDED | FAILED
FAILED → RECOVERING → LEASE_ACQUIRED ← 재시도 회로
FAILED → DEAD_LETTER ← 최대 시도 초과
LEASE_ACQUIRED → LEASE_WAIT ← 다른 워커가 락을 들고 있음
status(CREATED/RUNNING/FAILED/SUCCEEDED/DEAD_LETTER)는 "워크플로우 시스템 입장에서의 생사 여부"를 표현한다. stage(INGESTING/METRIC/INDEX/INTEL/AI)는 "리서치 파이프라인 입장에서 어디까지 갔는가"를 표현한다.
두 축을 분리하면 "AI_ANALYZING 단계에서 FAILED인 작업만 골라 재시도"같은 운영 쿼리가 자연스러워진다. 설계 단계에서 "여러 관심사를 한 컬럼에 욱여넣지 않는다"는 원칙의 좋은 예시.
같은 종목(예: 600519=귀주 마오타이)을 2개 인스턴스가 동시에 분석하려고 하면, LLM 호출이 중복되어 비용이 두 배로 새거나 cache stampede가 발생한다. 이를 막기 위해 FinSight는 Redis Lua 스크립트로 원자적 락 + 펜싱 토큰 발급을 한 방에 처리한다.
-- 의사 코드 (실제 구현 참고)
-- KEYS[1]: lease key (예: "lease:stock:600519")
-- ARGV[1]: caller_id, ARGV[2]: TTL_ms
local existing = redis.call("GET", KEYS[1])
if existing then
return {0, existing} -- 락 실패: 누가 들고 있는지 반환
end
local token = redis.call("INCR", "fencing:counter")
redis.call("SET", KEYS[1], ARGV[1] .. ":" .. token, "PX", ARGV[2])
return {1, token} -- 락 성공: 펜싱 토큰 반환
AI 응답을 캐시하면 빠르고 싸지만, "데이터가 바뀌었는데 옛 결론을 그대로 돌려주는" 사고가 가장 무섭다. FinSight는 캐시 키를 단순 프롬프트 해시가 아니라 세 겹으로 묶는다.
| 해시 | 의미 | 변경 트리거 |
|---|---|---|
contextHash | 프롬프트 문맥(질문 + 시스템 지시)의 지문 | 질문 텍스트, 모델 버전 변경 |
dataSnapshotHash | 시장 데이터·지표·리스크·증거의 통합 해시 | 새 공시, 지표 재계산, 새 증거 청크 |
reportVersion | 종목별 단조 증가 버전 | 매번 새 리포트 생성 시 +1 |
캐시 조회 시 (symbol, contextHash, dataSnapshotHash)가 모두 일치해야만 적중. 데이터가 바뀌면 dataSnapshotHash가 바뀌어 자동으로 미스가 되고 새 리포트가 생성된다. "캐시 무효화는 어렵다"는 격언에 대한 매우 깔끔한 답안.
순수 벡터 검색은 "현금흐름 위험" 같은 정확한 용어 매칭에 약하고, 순수 키워드 검색은 "재무 건전성 우려" 같은 의미적 유사 표현을 놓친다. FinSight는 PostgreSQL 하나로 두 채널을 모두 돌리고 결과를 머지한다.
// 의사 코드: HybridRetrievalGateway
List<Chunk> keywordHits = chunkRepo.fulltextSearch(symbol, query); // GIN + ts_vector
List<Chunk> vectorHits = chunkRepo.vectorSearch(symbol, embedding); // pgvector 코사인
List<Chunk> merged = dedupAndRerank(keywordHits, vectorHits);
return merged.stream().map(this::toEvidence).toList();
docs/architecture.md → 전체 그림 5분 컷docs/design-agent-workflow.md → 상태머신과 락 설계 이유backend/workflow/ → 실제 코드와 위 설계 매핑backend/retrieval/HybridRetrievalGateway → 키워드+벡터 머지 로직backend/stock/StockAiAnalysisService → 캐시 키 + 폴백 분기db/migration/V*.sql → JSONB·pgvector 스키마 실제 정의하나의 코드베이스를 "노 도커 in-memory"부터 "풀 프로덕션"까지 돌릴 수 있는 비결. 각 인프라를 별도 프로파일에 매핑한 다음, @Profile("redis")로 빈을 갈아끼우는 패턴을 익혀라.
실습: 자신의 사이드 프로젝트에 local·postgres·redis 프로파일을 도입하고 빈 두 벌을 준비해 보기.
Elasticsearch + Pinecone + RDBMS를 세 벌 운영하는 대신, PostgreSQL 16 + pgvector 확장만으로 하이브리드 검색을 구현하는 법. 인프라 단순화의 모범 사례.
실습: CREATE EXTENSION vector; 후 384차원 컬럼에 코사인 인덱스 만들고 <=> 연산자로 top-k 검색해 보기.
SETNX + EXPIRE 두 명령은 원자성이 깨진다는 함정. Lua 스크립트 한 번 호출 = 한 트랜잭션이라는 사실, 그리고 펜싱 토큰의 역할을 코드로 체득.
실습: Lettuce eval()로 락 획득 Lua를 짜고, 두 스레드가 경쟁하는 JUnit 테스트로 1명만 이기는지 검증.
Spring AMQP의 x-dead-letter-exchange·x-dead-letter-routing-key 설정으로 실패 메시지를 격리하고, 회수 스케줄러로 재처리하는 패턴.
실습: 일부러 실패하는 컨슈머를 만들어 메시지가 DLQ로 흘러가는 것을 RabbitMQ 관리 UI(:15672)에서 확인.
"체감상 좋아진 것 같다"를 벗어나 RAG 품질을 숫자로 추적하는 법: hit rate, evidence coverage, hallucination risk, citation presence를 측정해 회귀 테스트로 굳히는 흐름.
실습: 자신의 RAG 프로젝트에 10개 정답 케이스를 만들고, CI에서 점수가 떨어지면 빌드를 깨도록 임계값 설정.
"AI 부분은 Python이 편하고, 트랜잭션·MQ·캐시는 Spring이 편하다"는 현실적 절충. REST 경계로 두 언어를 분리하고, 사이드카가 죽어도 폴백으로 데모를 살리는 패턴.
실습: 자신의 Spring 앱에서 FastAPI 엔드포인트를 WebClient로 호출하고, 타임아웃·서킷브레이커를 Resilience4j로 추가.
V1__init.sql → V2__add_chunks.sql 같은 버전 마이그레이션을 운영. "DB 콘솔에서 ALTER TABLE 친다"는 안티패턴을 버리는 출발점.
실습: Flyway로 컬럼 하나 추가 + 백필 SQL 한 쌍을 짜고, 롤백 시나리오까지 검토.
/actuator/health로 도커 컴포즈 healthcheck를 묶고, /actuator/prometheus로 Grafana 대시보드 연결. AI 호출 비용·실패율을 카운터로 노출하는 출발점.
실습: 자신의 앱에 finsight.ai.calls.total·finsight.cache.hit.ratio 같은 메트릭을 추가하고 Prometheus에서 PromQL 한 줄 작성.
| 항목 | 요구사항 |
|---|---|
| OS | macOS / Linux / WSL2 |
| Java | JDK 17 (OpenJDK 권장) |
| Maven | 3.9+ (또는 포함된 mvnw) |
| 메모리 | 2GB 이상 (in-memory 리포지토리 + 결정론 폴백 모드) |
| 디스크 | ~500MB |
| 항목 | 요구사항 |
|---|---|
| Docker | 20.10+ / Docker Compose v2 |
| 메모리 | 8GB 이상 권장 (Elasticsearch 512MB heap, JVM 백엔드, AI 서비스 등) |
| 디스크 | ~3GB (postgres+es+minio 볼륨 포함) |
| 포트 | 8080(backend), 8001(ai-service), 5432, 6379, 5672, 15672, 9200, 9000, 9001 |
| 항목 | 요구사항 |
|---|---|
| Ollama | 설치 + ollama serve 가동 |
| 모델 | ollama pull qwen2.5:7b (~4.7GB) |
| 메모리 | qwen2.5:7b는 8GB+ 권장, GPU 있으면 빠름 |
| 네트워크 | FastAPI 사이드카 → Ollama: host.docker.internal:11434 |
FastAPI 사이드카의 /analyze-stock은 Ollama 호출에 실패하면 결정론적 룰 기반 응답을 돌려준다(aiGenerated=false 플래그 포함). 덕분에 발표·이력서 시연에서 "왜 인터넷이 끊겼지" 같은 사고가 안 난다. AI 시스템 발표 시 가장 자주 깨지는 부분을 미리 막아둔 영리한 설계.
cd backend && mvn spring-boot:run 후 http://localhost:8080 접속.
대시보드에서 워크플로우 한 건을 직접 트리거하고, GET /api/workflows/summary 응답을 그대로 캡처해보자. in-memory 모드라도 상태 머신 흐름은 동일하다.
./scripts/run-full-stack.sh 한 방으로 7개 컨테이너가 뜬다. docker compose ps로 healthcheck 상태 확인.
특히 RabbitMQ 관리 UI(http://localhost:15672, finsight/finsight)에서 큐가 어떻게 만들어졌는지 살펴보자.
./scripts/quick-demo.sh로 종목 600519 리포트 생성 → 같은 요청 한 번 더 → 캐시 히트 확인.
이어서 POST /api/metrics/recalculate/600519로 지표 재계산 → 같은 분석 요청 → 이번엔 캐시 미스가 나고 새 reportVersion이 부여되는지 검증.
psql로 접속해 document_chunks 테이블의 임베딩 컬럼을 확인.
임의의 384차원 벡터를 만들어 SELECT * FROM document_chunks ORDER BY embedding <=> '[...]'::vector LIMIT 5;로 직접 top-k 검색. 그리고 EXPLAIN ANALYZE로 IVFFlat 인덱스가 실제로 사용되는지 확인.
EvaluationCaseCatalog에 자신의 평가 케이스 3개를 추가하고 POST /api/evaluations/rag/run으로 점수를 뽑는다.
GitHub Actions에서 mvn test가 평가 점수 임계값 80점 이하면 빌드를 깨도록 게이트를 만든다. LLM 평가 회귀 게이트라는 모던한 CI 패턴을 직접 체득.
JUnit에서 100개 스레드가 동시에 같은 종목 분석을 요청하도록 코드를 짜고, 실제로 LLM 호출이 1번만 일어나는지(나머지는 락 미획득 분기로 빠지는지) 검증.
이어서 Redis를 일부러 끄고, 로컬 폴백 락으로도 같은 결과가 나오는지 비교. "분산 락 코드는 단일 노드에서도 안전해야 한다"는 원칙을 손에 익힌다.
FinSight 백엔드를 노 도커 모드로 띄우고 코드 산책. application.yml·application-postgres.yml·application-rabbitmq.yml이 어떻게 합성되는지, @Profile 빈이 어떻게 갈아끼워지는지 추적. Actuator 엔드포인트를 모두 호출해본다.
Flyway 마이그레이션 파일을 따라 읽으며 document_chunks·workflow_tasks·stock_analysis_reports 스키마 분석. GIN 인덱스 / IVFFlat 인덱스 / B-tree 차이를 EXPLAIN으로 확인. pg_stat_statements로 자주 도는 쿼리 추출.
Martin Kleppmann의 "How to do distributed locking" 글을 읽고, Redlock 비판 논쟁을 정리. FinSight의 LeaseService 코드와 비교. Redis 7의 FUNCTION 명령(영구 Lua 등록) 도입까지 실험.
Spring AMQP의 RabbitListener·RetryTemplate·DLQ 라우팅 패턴을 따라 짜보고, FinSight의 RabbitWorkflowListener와 비교. Kafka로 같은 시나리오를 구현해보면 두 시스템의 의미적 차이가 드러난다.
Anthropic의 "Contextual Retrieval" 블로그, Pinecone 가이드, LangChain의 RAG 패턴을 정리. FinSight의 DocumentChunker(섹션 메타 + 오버랩)와 HybridRetrievalGateway를 따라 짠 다음, 자신의 코퍼스에 적용.
RAGAS·TruLens 같은 표준 라이브러리를 익히고, FinSight의 RagEvaluationService 메트릭 8종(hit rate, evidence/answer coverage, hallucination, consistency, calibration, latency, citation)을 비교. 자신의 시스템에 맞는 평가셋 + 회귀 임계값을 CI에 박는다.
Micrometer로 LLM 호출 카운터·지연시간 히스토그램·캐시 hit ratio를 노출하고 Grafana 대시보드 구성. OpenTelemetry trace로 "한 종목 분석이 5단계 워크플로우를 어떻게 통과했는지" 시각화. AI 비용 알람도 추가.
FinSight의 docs/resume-and-interview.md 패턴을 그대로 흉내내, 자신만의 AI 인프라 프로젝트를 한 줄 요약 + 시연 GIF + 핵심 패턴 3개로 정리. "AI 모델 호출"이 아니라 "AI 시스템 운영"이라는 단어로 본인 정체성을 새롭게 포지셔닝.
vector(384) 같은 컬럼 타입과 <=>(코사인 거리) 같은 연산자를 제공한다. 별도 벡터 DB 없이 RDBMS 하나로 RAG를 굴릴 수 있다.createIfAbsent로 DB에 박아 중복 워크플로우 생성을 막는다.x-dead-letter-exchange로 설정.DocumentChunker로 섹션 메타 + 오버랩으로 분할해 저장하고, 답변마다 어느 청크가 인용됐는지를 trace에 기록한다.postgres·rabbitmq·redis·prod를 조합해 한 코드베이스가 다양한 실행 모드를 가질 수 있다.V1__init.sql·V2__add_index.sql 같은 버전 파일로 관리하는 도구. 앱 시작 시 자동으로 적용. "DB 콘솔에서 손으로 ALTER" 안티패턴 방지.ollama pull qwen2.5:7b로 모델 다운로드 후 REST API(:11434)로 호출. FinSight는 선택적 백엔드로 통합./actuator/health·/actuator/metrics·/actuator/prometheus로 Liveness·메트릭·Prometheus scrape 출력을 한 줄 설정으로 노출.