← 프로젝트

StockPulse — 주식 이상 탐지 · AI 분석 파이프라인

  • 2026.03
  • 개인 프로젝트 · 설계부터 운영까지
  • Kafka · MSA
  • Kubernetes · ArgoCD
  • TimescaleDB

개요

장이 열려 있는 동안 주가가 갑자기 튀면, 투자자가 가장 먼저 궁금한 건 “왜?“예요. StockPulse는 미국 68종목과 한국 40종목의 1분봉을 계속 지켜보다가 급등락이 나오면, 그 움직임이 종목 하나의 일인지, 업종 전체인지, 시장 전체인지 먼저 나눠요. 그다음 관련 뉴스를 모아 LLM이 원인을 한국어 · 영어로 정리하고, 대시보드와 Slack으로 보내요.

수집부터 알림까지 서비스 8개가 Kafka 토픽 7개(DLQ 2개 포함)로 이어진 이벤트 기반 구조예요. 온프레미스 쿠버네티스 클러스터에 ArgoCD GitOps로 배포해 운영했고, 혼자 설계하고 만들고 운영한 개인 프로젝트예요.

아키텍처

스크롤을 내리면 왼쪽 설명을 따라 오른쪽 다이어그램이 움직여요. 노드에 마우스를 올리면 사양이 보여요.

한눈에 보기

StockPulse는 외부 데이터(시세 · 뉴스 · LLM)를 받아 온프레미스 쿠버네티스 안의 서비스들이 Kafka로 주고받으며 처리하고, 결과를 브라우저와 Slack으로 보내요. 배포는 아래쪽 GitOps 흐름이 맡아요.

services ×8topics ×7Kafka ×3

1시세가 들어오는 길

미국 68종목은 stock-collector가 NYSE 장중에만 60초마다 yfinance에서 1분봉을 가져와 stock.raw.us로 보내요. 장이 닫히면 개장까지 잠들어요.

stock.raw.usNYSE calendar

한국 40종목은 kis-bridge가 한국투자증권 WebSocket으로 실시간 체결을 받아 1분봉으로 묶어 stock.raw.kr로 보내요. 토큰은 만료 전에 갱신하고, 끊기면 5→60초 백오프로 다시 붙어요.

stock.raw.krKIS H0STCNT0

2급등락 탐지와 분류

anomaly-detector는 두 토픽을 함께 읽어, 등락률이나 Z-score가 임계값을 넘는 최근 5분 이내의 bar만 골라내요. 한국은 상하한 ±30%를 고려해 임계값을 더 높게 둬요.

% OR Z-scorelast 5 min

ETF를 섹터 체온계로 써요. 같은 방향으로 움직인 섹터 ETF가 3개 이상이면 MARKET, 해당 섹터 ETF나 같은 섹터 종목이 함께 움직이면 SECTOR, 아니면 INDIVIDUAL. 결과는 anomaly.detected로 나가요.

INDIVIDUALSECTORMARKET

3원인 분석

news-fetcher가 anomaly.detected를 받아, 섹터 키워드와 티커로 NewsAPI · Google News · Naver RSS에서 최근 3일 뉴스를 언어별로 최대 4건 모아 news.fetched로 보내요.

anomaly.detectednews.fetched

ai-analyzer가 Groq LLM으로 한국어 · 영어 분석을 써요. 분류에 따라 프롬프트 초점을 기업 이슈 · 섹터 이슈 · 매크로로 바꾸고, 429가 오면 안내된 시간만큼 기다렸다 다시 불러요. 끝내 실패하면 news.fetched.dlq로 보내요.

llama-3.3-70bcircuit breaker 5×/60snews.fetched.dlq

4사람에게 닿기까지

api가 analysis.completed를 받아 TimescaleDB에 저장하고, WebSocket /ws/live로 열려 있는 대시보드에 바로 밀어줘요. 브라우저는 nginx Ingress를 거쳐 들어와요.

analysis.completed/ws/live

notifier는 같은 결과를 섹터 · 이벤트 유형 필터로 거른 뒤 Slack으로 보내요. 3번 재시도해도 실패하면 analysis.completed.dlq로 옮겨요.

3 retriesanalysis.completed.dlq

재시작했을 때의 동작은 단계마다 달라요. 탐지 · 뉴스 수집 · 알림은 earliest로 놓친 메시지를 다시 처리하고, LLM 분석과 api는 latest로 두어 밀린 메시지에 LLM 비용이 한꺼번에 나가거나 같은 신호가 다시 뜨지 않게 했어요.

earliestlatest

5매일 다시 배우는 모델

ml-trainer CronJob이 한국 25종목의 다음 날 방향을 예측해요. 매일 전날 예측을 채점하고, 30일 정확도가 52% 아래거나 모델이 7일을 넘으면 다시 학습해요. 매주 한 번은 Optuna로 튜닝하고 walk-forward로 검증해요.

LGB 0.4 · XGB 0.35 · Cat 0.25Purged CV

6Git 푸시에서 배포까지

main에 푸시하면 self-hosted runner의 GitHub Actions가 바뀐 경로를 서비스에 매핑해 변경된 서비스만 빌드하고, Harbor에 stock/<svc>:<sha7>로 올려요.

path → service mapimages ×9

Actions가 매니페스트의 이미지 태그를 바꿔 커밋하면([skip ci]로 루프 방지), ArgoCD가 60초마다 저장소를 확인해 selfHeal · prune으로 클러스터를 Git과 똑같이 맞춰요. 이미지는 Harbor에서 받아 오고, 비밀값은 Sealed Secrets로 암호화해 Git에 둬요.

selfHeal · pruneServerSideApply

7지켜보는 눈 — 관측

Prometheus가 15초마다 api와 anomaly-detector의 지표, 쿠버네티스 · 노드 지표를 모으고, Grafana 대시보드 하나에서 Kafka lag · API p95 지연 · 탐지 건수 · ML 정확도를 봐요.

anomaly_detected_totalml_prediction_accuracy
한눈에 보기1 / 14
외부 데이터온프레미스 쿠버네티스 · namespace stock사용자CI/CD · GitOpsKAFKA ×3yfinance미국 1분봉 · 60초KIS OpenAPI한국 실시간 체결NewsAPI · RSS영문 · 한국어 뉴스Groq LLMllama-3.3-70bSlack이상 신호 알림브라우저실시간 대시보드stock-collectoryfinance · 장중만kis-bridge틱 → 1분봉 집계news-fetcher언어별 최대 4건ai-analyzer한/영 원인 분석notifier필터 · 재시도anomaly-detector% · Z · ETF 분류apiFastAPI · WebSocketTimescaleDBhypertable · 압축frontendNext.js 14Ingressnginx · MetalLBml-trainerCronJob ×3PrometheusGrafana · 15초 수집Sealed Secrets시크릿 복호화GitHubmain 브랜치Actionsself-hosted · 변경분Harbor사설 레지스트리ArgoCDselfHeal · prune

직접 해보기

녹화한 시세가 Kafka 파이프라인을 흐르며 급등락 탐지와 분류가 진행되는 데모

다음 업데이트에서 공개해요.

운영 기록

ArgoCD가 영원히 OutOfSync

증상
배포가 끝나도 일부 리소스가 계속 OutOfSync로 표시되고, 큰 매니페스트는 적용 자체가 실패했어요.
원인
StatefulSet의 volumeClaimTemplates와 CronJob의 status처럼 클러스터가 채우는 필드가 Git과 계속 달랐고, 큰 리소스는 client-side apply가 남기는 last-applied annotation 한도(262KB)를 넘었어요.
조치
해당 필드를 ignoreDifferences로 비교에서 빼고, ServerSideApply=true로 바꿔 annotation 없이 적용하게 했어요.

ArgoCD는 selfHeal과 prune이 켜져 있어서, 차이가 남아 있으면 계속 동기화를 시도해요. 클러스터가 채워 넣는 필드 때문에 생긴 차이는 아무리 동기화해도 사라지지 않아 대시보드가 늘 OutOfSync였고, 진짜 차이가 생겨도 알아보기 어려웠어요. 비교에서 뺄 필드를 명시하고, 재시도는 5회(5초부터 최대 3분)로 제한하고, 삭제는 PruneLast로 마지막에 하도록 정리했어요.

배운 점 GitOps에서 "Git과 같다"의 기준에서 클러스터가 관리하는 필드는 빼야 해요. 비교 대상과 적용 방식을 함께 설계해요.

브로커 3대가 작은 노드의 메모리를 넘본다

증상
브로커 3대의 힙을 합치면 6Gi에 가까워, 작은 워커 노드에서는 OOMKill이 날 수 있었어요.
원인
스테이트풀 워크로드의 기본 JVM 설정이 작은 노드 기준으로는 컸고, 기동 · 종료 시간도 고려돼 있지 않았어요.
조치
브로커 · ZooKeeper 힙을 -Xmx256m으로 제한하고, 재시작 때 __consumer_offsets 로딩을 기다리도록 startupProbe를 최대 630초로, 정상 종료를 위해 terminationGracePeriodSeconds를 90초로 늘렸어요.

Kafka는 브로커 3대(RF 3, min ISR 2)로 운영해요. 메모리와 함께 기동 · 종료 시간도 맞췄어요. 브로커가 뜰 때는 __consumer_offsets를 읽는 시간이 필요한데, probe가 그보다 먼저 실패로 판정하면 파드가 다시 시작되며 같은 일을 반복할 수 있어요. 그래서 startupProbe로 충분한 시간을 주고, 내려갈 때도 controlled shutdown이 끝날 때까지 기다리게 했어요.

배운 점 스테이트풀 워크로드는 "뜨는 시간"과 "내려가는 시간"까지가 리소스 설계예요.

1분봉 배치가 Kafka 메시지 한도를 넘었다

증상
미국 68종목의 1분봉을 한 메시지로 묶어 보내자 메시지 크기 한도에 걸렸어요.
원인
수집기가 조회 기간 전체(로컬 기본 5일치)의 분봉을 배치 메시지 하나에 담았어요.
조치
쿠버네티스 배포에서는 조회 기간을 1일로 줄이고, 메시지 한도는 10MB로 맞췄어요.

수집기는 60초마다 전 종목의 분봉을 key batch로 한 번에 보내요. 로컬 compose에서는 5일치를 보냈지만, 클러스터 설정에서는 메시지 크기 초과를 막으려고 1일치만 보내도록 했어요.

배운 점 "배치 하나 = 메시지 하나" 설계는 데이터가 늘면 한도에 부딪혀요. 커질 수 있는 메시지는 쪼갤 단위를 먼저 정해요.

설계 결정

단계마다 다른 consumer offset 전략

배경
파이프라인 단계마다 재시작했을 때 바라는 동작이 달라요. 놓친 메시지를 다시 처리해야 하는 단계도, 다시 처리하면 곤란한 단계도 있어요.
검토한 대안
  • 모든 consumer를 earliest로
  • 모든 consumer를 latest로
선택 이유
탐지 · 뉴스 수집 · 알림은 earliest로 두어 재시작해도 놓친 메시지를 다시 처리해요. LLM 분석과 api는 latest로 두어, 재시작 때 밀린 메시지에 LLM 비용이 한꺼번에 나가거나 같은 신호가 대시보드에 다시 뜨지 않게 했어요.
트레이드오프
latest 단계는 꺼져 있던 동안의 메시지를 건너뛰어요. 분석이 빠진 신호는 대시보드의 "과거 재분석"으로 다시 돌릴 수 있어요.

ETF를 섹터 체온계로 쓰는 이상 신호 분류

배경
종목 하나가 급등했을 때, 그게 그 회사만의 일인지 업종 전체의 움직임인지에 따라 원인도, 찾아볼 뉴스도 달라요.
검토한 대안
  • 종목 단위로만 판정하고 원인 추론은 LLM에 맡기기
선택 이유
섹터마다 ETF를 체온계로 두고, 같은 방향으로 움직인 섹터 ETF가 3개 이상이면 MARKET, 해당 섹터 ETF나 같은 섹터 종목이 함께 움직이면 SECTOR, 아니면 INDIVIDUAL로 나눠요. 분류에 따라 LLM 프롬프트의 초점도 기업 이슈 · 섹터 이슈 · 매크로로 바꿔요.
트레이드오프
분류 품질이 ETF 구성과 임계값에 달려 있어요. 임계값은 시장마다 따로 조정해야 해요(한국은 상하한 ±30%를 고려해 더 높게).

외부 API 실패는 기다리고, 끊고, DLQ로 격리

배경
LLM과 Slack 같은 외부 API는 언제든 느려지거나 429를 돌려줘요. 메시지 하나의 실패가 파이프라인 전체를 막으면 안 돼요.
검토한 대안
  • 실패한 메시지를 성공할 때까지 계속 재시도
  • 실패한 메시지 버리기
선택 이유
ai-analyzer는 429 응답에 적힌 대기 시간을 읽어 기다렸다 다시 부르고, 연속 5번 실패하면 60초 동안 호출을 끊어요(서킷브레이커). 그래도 실패한 메시지는 원래 토픽 · 에러 · 시각을 담아 news.fetched.dlq로 보내요. notifier도 Slack 3회 재시도 후 analysis.completed.dlq로 보내요.
트레이드오프
DLQ에 쌓인 메시지를 다시 흘려보내는 도구는 아직 없어요.

Kafka가 없어도 도는 폴백 경로

배경
Kafka · 수집기 · 탐지기가 모두 떠 있어야만 동작하면, 인프라 일부가 내려갔을 때 서비스 전체가 멈춰요. 로컬에서 기능을 확인할 때도 전체 스택이 필요해져요.
검토한 대안
  • Kafka 없이는 기동하지 않기
선택 이유
KAFKA_BOOTSTRAP_SERVERS가 비어 있으면 api가 APScheduler로 한 시간마다 일봉 기반 파이프라인(탐지 → 뉴스 → 분석 → 저장)을 직접 돌려요. 두 경로는 같은 탐지 · 분석 모듈(core/)을 써서 로직이 갈라지지 않아요.
트레이드오프
폴백은 일봉 기준이라 실시간성이 없어요. 운영에서는 스트리밍 경로가 기본이에요.

이미지 태그 커밋 + ArgoCD로 배포하는 GitOps

배경
서비스 9개의 이미지를 빌드해 클러스터에 반영하는 과정을 손으로 하면, 지금 무엇이 어느 버전으로 떠 있는지 추적하기 어려워요.
검토한 대안
  • CI에서 kubectl apply로 직접 배포
선택 이유
CI는 변경된 서비스만 빌드해 Harbor에 올리고, 매니페스트의 이미지 태그를 커밋 SHA로 바꿔 커밋해요([skip ci]로 루프 방지). ArgoCD가 그 커밋을 보고 selfHeal · prune으로 클러스터를 Git과 같게 맞춰요. 무엇이 배포돼 있는지는 Git 기록이 답해 줘요.
트레이드오프
태그 커밋이 저장소 기록에 쌓이고, ArgoCD 쪽 제약(annotation 한도, 클러스터가 채우는 필드)을 따로 다뤄야 했어요(운영 기록 참고).

비밀값도 Git에 — Sealed Secrets

배경
GitOps로 모든 매니페스트를 Git에 두면, 비밀값을 어디에 둘지가 문제예요.
검토한 대안
  • kubectl로 Secret을 손으로 생성
  • 외부 시크릿 저장소(Vault 등)
선택 이유
수동 실행 워크플로가 GitHub Secrets로 .env를 만들고, kubeseal로 암호화한 SealedSecret 6개를 커밋해요. 클러스터 안의 컨트롤러만 복호화할 수 있어서 Git에 올려도 안전해요. 컨트롤러도 ArgoCD Application으로 관리해요.
트레이드오프
컨트롤러 키를 잃으면 모든 SealedSecret을 다시 만들어야 해서, 키 백업이 운영 절차에 들어가야 해요.

인프라 · 비용

git pushmainActionsself-hosted · 변경분Harborstock/<svc>:<sha>태그 커밋[skip ci]ArgoCDselfHeal · prune클러스터namespace stock

온프레미스 쿠버네티스

  • 네임스페이스 stock 하나에 Deployment 12개, StatefulSet 3개(Kafka ×3 · ZooKeeper · TimescaleDB), DaemonSet 2개, CronJob 6개, Job 3개를 올렸어요.
  • 외부 노출은 MetalLB(L2)가 준 IP 하나에 nginx Ingress로 경로를 나눠요. /api · /ws · /auth는 FastAPI로, 나머지는 Next.js로 가요. WebSocket은 타임아웃을 1시간으로 늘렸어요.
  • 저장소는 NFS 정적 PV(Retain)와 nfs-subdir-external-provisioner를 함께 쓰고, 무거운 워크로드(Kafka · DB · API · ML)는 node-tier=heavy 노드로 보내요.
  • 이미지는 사설 Harbor에 두고, 자체 서명 인증서는 DaemonSet이 각 노드의 containerd에 배포해요.

Git 푸시에서 배포까지

self-hosted runner의 GitHub Actions가 바뀐 경로를 서비스에 매핑해 변경된 서비스만 빌드해요. Harbor에 stock/<svc>:<sha7>로 올린 뒤 매니페스트 태그를 바꿔 커밋하면, ArgoCD가 그 커밋을 60초 주기로 확인해 클러스터에 반영해요. 비밀값은 Sealed Secrets로 암호화해 Git에 둬요.

데이터 보존과 비용

  • TimescaleDB hypertable은 하루 지난 청크를 자동 압축하고, 시세는 30일 · 분석은 90일이 지나면 청크 단위로 지워요. 행 단위 DELETE보다 훨씬 가볍게 정리돼요.
  • 매일 도는 CronJob이 청크 정리 · VACUUM ANALYZE · 용량 리포트를, 매주 도는 CronJob이 NFS 사용률(80% 경고)과 Redis AOF 정리를 맡아요.
  • Kafka는 7일 또는 15GiB까지만 보관해요.

내 기여

이 섹션은 준비 중이에요.

한계 · 회고

알고 있는 한계와, 다시 한다면 바꿀 부분이에요.

  • API가 사실상 단일 인스턴스예요. WebSocket 연결 관리가 프로세스 메모리에 있어서, API를 여러 대로 늘리면 일부 브라우저에 알림이 가지 않아요. HPA는 최대 3대로 잡혀 있지만, Redis pub/sub로 브로드캐스트를 옮기기 전까지는 1대로 운영해야 해요.
  • CI에 테스트가 없어요. 변경된 서비스만 빌드하는 파이프라인은 갖췄지만, 빌드 전에 도는 테스트 단계가 없어서 회귀를 사람이 잡아야 해요.
  • 파티션 수를 정하지 않았어요. 토픽을 자동 생성에 맡겨 파티션 수가 명시돼 있지 않아서, consumer를 늘려도 병렬 처리 효과가 제한적이에요.
  • 관측의 빈칸. ai-analyzer는 스크레이프 대상에 있지만 메트릭 서버가 없고, kafka-exporter가 배포되지 않아 대시보드의 Kafka lag 패널이 비어 있을 수 있어요. Redis도 배포만 돼 있고 쓰이지 않아요.
  • CronJob 시간대. timeZone: Asia/Seoul을 지정해 놓고 스케줄은 UTC로 환산해 적어서, 학습 작업이 의도보다 9시간 이르게 돌아요.