Personal project · Backend · 2024.07 – 현재
운영 중 · 로컬 단일 호스트 · 9개 서비스

sec-rss-trace

공개 정보인 미국 공시와 뉴스를, 직장인이 제때 읽을 수 있는 크기로 줄이고 — 시장이 실제로 반응했는지까지 확인하는 시스템.

~7,000종목
미국 전 종목 1분봉을 상시 수집
1,000만행/일
토큰 1개 · 초당 3~10회 한도 안에서
33,255
알림 뒤 30분~3일 수익률로 라벨링한 사후 평가
84%
급등 1차 신호 중 거래량 확인으로 걸러낸 비율
717tests
단위 테스트 · CI 게이트 · 실응답 재생 픽스처
데모 영상
데모 1 · 주요 웹 기능 동작YouTube에서 열기 ↗
데모 2 · 설정 화면 (설명 자막 포함)YouTube에서 열기 ↗
01 · 문제

공개 정보인데, 읽을 시간이 없다

미국 상장사는 중요한 일이 생기면 감독기관(SEC)에 문서를 제출해야 하고, 그 문서는 누구나 볼 수 있습니다. 증시 뉴스도 마찬가지입니다. 문제는 양입니다 — 하루에도 수천 건이 쌓이고, 그중 주가를 움직일 만한 것은 극소수입니다. 낮에 일하는 사람은 그 극소수를 골라낼 시간이 없습니다.

그래서 두 가지를 원했습니다. 첫째, 내가 원하는 종류의 사건만 — 초거대 기업의 계약·투자, 인수합병, 정부 계약 같은 것 — 피드로 받기. 둘째, 그 알림이 실제로 의미가 있었는지 시장의 반응으로 되돌아가 확인하기. 알림을 보내는 것으로 끝나면 "내 판단이 맞았는지"를 영영 알 수 없기 때문입니다.

SEC 공시와 뉴스는 공개 정보이나 직장인은 제때 읽을 수 없습니다. 초거대 기업의 계약·투자 같은 원하는 사건을 피드로 받고, 시장이 실제로 반응했는지 확인하는 도구를 만들었습니다.

— 프로젝트를 시작한 문제의식

2024년 7월에 SEC 문서를 LLM으로 분류해 Slack으로 보내는 작은 봇으로 시작했고, 2년에 걸쳐 시세 수집 → 급등 알림 → 공시 보유 추적 → 사후 평가로 확장되었습니다. 지금은 아홉 개의 서비스가 한 호스트에서 상시 가동됩니다.

02 · 한눈에

세 갈래 입력, 세 갈래 출력

시스템은 왼쪽에서 오른쪽으로 읽으면 됩니다. 문서와 시세가 들어오고, 가운데에서 걸러지거나 계산되고, 사람에게는 Slack과 대시보드로, 시스템 자신에게는 사후 평가로 나갑니다.

입력 처리 (Python · AsyncIO) 출력 SEC EDGAR 공시 (RSS) 8-K · 13F · 13D/G — 2초 폴링 미국 증시 뉴스 StockTitan · GlobeNewswire · WSJ · TipRanks 토스증권 Open API 현재가(4초/전종목) · 1분봉 · 거래량 문서 선별 — LLM zero-shot 분류 키워드·티커·시총 1차 필터 → JSON 스키마 응답 시세 수집 + 급등 탐지 합성봉+백필 하이브리드 · z-score → RVOL 2단계 공시 보유 조립 13F + 13D/G → 운용사별 보유 수량 변화 MySQL · Redis · QuestDB · Meilisearch Slack 알림 사건 유형별 채널 · 거래량 확인된 급등만 웹 대시보드 실시간 피드(SSE) · 차트 · 검색 · 관리 후행평가 · 주간 브리핑 알림 뒤 수익률 라벨 → 정밀도·재현율 13F·13D/G 기사 → 감시 종목 등록 / 급등의 원인 뉴스 역조인 시세·시총
그림 1 · 전체 흐름. 공시와 뉴스는 LLM 선별을 거쳐 Slack·대시보드로 나가고, 같은 기사가 시세 감시의 트리거가 됩니다. 반대로 시세에서 잡힌 급등은 뉴스 DB를 역으로 뒤져 원인을 붙입니다. 모든 알림은 나중에 후행평가로 되돌아옵니다.

서비스 구성

같은 Docker 이미지를 진입점만 바꿔 띄웁니다. 코드는 호스트에서 읽기 전용으로 마운트되어, 배포는 "재시작"입니다.

서비스하는 일포트
titan · gnw · wsj · tipranks뉴스 수집 → LLM 분류 → 저장 → Slack. 빅뉴스면 시세 감시 등록
thirteenSEC 13F · 13D/G 실시간 감지 + 보유 현황 조립·변동 알림8001
sec-watch악재성 공시(희석·상장폐지 위험 등) 3단계 스캔·구독
price-watch토스 API 호출 전담 — 전 종목 시세 수집 · 급등 탐지 · 후행평가 라벨링8005
webapi대시보드 백엔드 — 읽기 전용, SSE 실시간 스트림, 주간 브리핑8003
titan_api기사 REST · 피드백 · 키워드 관리
web-gateway (Caddy)정적 SPA(React) + API 리버스 프록시, 단일 origin8080
Python 3.10 · AsyncIOFastAPIaiohttpSQLAlchemy 2.0 · MySQLQuestDBRedisMeilisearchOpenAI · GeminiReact 19 · Vite · TypeScriptDocker Compose · CaddyGitHub Actionspytest

코드를 묶는 네 가지 원칙

  • 봇은 뼈대를 공유한다. RSS 폴링 → 필터 → 본문 추출 → 분류 → 저장 → 알림의 순서는 한 기반 클래스가 정하고, 소스마다 단계만 바꿉니다(Template Method). 새 소스 추가는 클래스 하나입니다.
  • LLM 공급자는 인터페이스 뒤에. ask / ask_json 두 메서드만 보이고 OpenAI·Gemini 구현이 그 뒤에 있어, 비용·한도·품질에 따라 바꿔 끼웁니다.
  • DB 세션은 문맥이 나른다. 서비스 계층이 세션 객체를 들고 다니지 않도록, 비동기 문맥 변수(contextvars)에 현재 트랜잭션을 두고 @transactional이 있으면 재사용·없으면 새로 여는 전파 규칙을 구현했습니다 — Spring의 ThreadLocal 트랜잭션과 같은 발상을 asyncio에 옮긴 것.
  • 핵심 로직은 I/O를 주입받는 순수 함수. 급등 판정·리미터·갭 선택 같은 결정은 시각과 데이터를 인자로 받아 토스·DB 없이 단위 테스트합니다.
03 · 접근 — 문서

수천 건 중 "읽을 가치가 있는 것"만 남기기

분류기는 예시 데이터로 학습시킨 모델이 아니라, LLM에 판단 기준을 글로 주고 분류하게 하는 zero-shot 방식입니다. 대신 대답은 자유 문장이 아니라 정해진 JSON 형식으로만 받습니다 — article_type(사건 유형 번호), summary(한국어 요약), ticker. 형식이 계약이라 파싱이 깨지지 않고, OpenAI와 Gemini를 같은 인터페이스 뒤에 두고 바꿔 쓸 수 있습니다.

LLM은 비싸고 느리므로 그 앞에 값싼 1차 필터를 둡니다. 키워드가 없거나, 티커가 없거나, 시가총액이 극단적으로 작은 문서는 LLM에 보내지 않습니다.

개장 직전 3초에 기사 40~50건

본장 개장 직전에는 기사가 몰립니다. 시가총액 조회에 쓰는 라이브러리는 동기 방식이라 그대로 두면 파이프라인 전체가 그 호출에 묶였고, asyncio.to_thread로 블로킹 호출만 스레드에 넘겨(네트워크 대기 중에는 GIL이 풀리므로 스레드가 유효합니다) 비동기 흐름을 유지했습니다. 당일 기사가 계속 쌓이는 뉴스 페이지는 장 막판에 파싱만 수 초가 걸려, 응답의 ETag를 기억했다가 If-None-Match로 다시 묻고 304면 파싱을 건너뜁니다.

RSS 항목 본문 HTML 추출 1차 필터 (코드) 키워드 · 티커 추출 시가총액 조회 통과분만 LLM zero-shot 분류 { article_type, summary, ticker, company_name } OpenAI / Gemini — 같은 인터페이스 메이저 빅딜 · 의미있는 뉴스 Slack 강채널 + 시세 감시 등록 실적 발표 과거 재무 공시와 결합 분석 13F · 13D/G 과거 보유 현황과 diff → 지분 변동 알림 잡음 · 저시총 저장만 (후행평가의 대조군이 된다) 코드 승격 규칙: 관심 티커 일치, 저시총, 한 기사 안 시총 격차 50배 이상 → 빅뉴스
그림 2 · 문서 선별 파이프라인. LLM은 "어떤 사건인가"만 답하고, 무엇을 알릴지는 코드가 정합니다. 잡음으로 분류된 기사도 버리지 않고 저장합니다 — 뒤에서 "놓친 것"을 세는 대조군이 되기 때문입니다.

사건 유형

유형처리
메이저 빅딜초거대 기술기업(M7)의 투자·계약, FDA 3상 결과Slack 강채널 · 시세 감시 등록 · 급등 알림 후보
의미있는 뉴스대형 거래, 정부 계약, FDA 2상, 자사주 매입Slack · 시세 감시 등록
M&A 루머인수합병 협의 보도 (확정 전)Slack 별도 채널
실적 발표 · 예정분기 실적, 발표 일정과거 재무 공시와 묶어 별도 채널
잡음배당, 채권 발행, 유상증자 공지저장만 — 알림 없음

"조용히 실패"하는 것을 붙잡기

이 파이프라인에서 가장 아팠던 장애는 오류가 나는 것이 아니라 아무 일도 안 일어나는 것이었습니다. 2025년에 SEC가 공시 양식 이름을 바꾸면서(SC 13DSCHEDULE 13D) 일부 공시가 조용히 걸러졌고, 13F 알림 경로는 페이지 구조를 잘못 읽어 5개월간 한 건도 나가지 않았는데 DB는 다른 경로로 채워져 있어 누구도 눈치채지 못했습니다.

고친 방법은 코드 수정보다 테스트 방식의 전환이었습니다. 실제 SEC 응답을 그대로 저장해 두고 네트워크 없이 재생하는 record/replay 픽스처로 장애를 재현했고, 같은 픽스처가 회귀 테스트로 남아 재발을 막습니다. 응답이 비어 있으면 픽스처 기록 자체를 거부합니다 — "영원히 통과하는 가짜 초록불"이 가장 위험하기 때문입니다.

04 · 접근 — 시세

토큰 하나로 7,000종목을 따라가기

"시장이 반응했는가"를 보려면 가격이 필요합니다. 토스증권 Open API를 쓰는데, 제약이 셉니다 — 클라이언트당 인증 토큰이 하나(새로 받으면 이전 것이 무효)이고, 초당 3~10회가 한도입니다. 그래서 토스를 부르는 프로세스를 price-watch 하나로 못박았습니다. 대시보드 백엔드조차 토스를 직접 부르지 않고, price-watch가 저장해 둔 것만 읽습니다.

4.1버스트를 없애자 429가 사라졌다

한도를 지키려고 라이브러리의 속도 제한기를 썼는데도 429(요청 과다) 오류가 3분에 118건씩 쏟아졌습니다. 원인은 제한기의 기본 동작이었습니다. 잠시 쉬었다 재개하면 1초 안에 한도의 두 배를 한꺼번에 내보내는 "버스트"가 허용되는데, 서버는 그 순간을 초과로 봅니다. 단서는 경로별 차이였습니다 — 순서대로 도는 확정봉 요청(/candles)의 429는 줄었는데, 4초마다 묶어 쏘는 현재가 요청(/prices)만 그대로였습니다. 버스트가 큰 쪽만 남는다는 뜻입니다. 제한기를 버스트 없이 1건씩 균등 간격으로 바꾸고 15%의 여유를 두자 429는 0건이 되었습니다.

라이브러리 기본 (버스트 허용) 0s1s2s3s ← 순간 10건: 한도(5/s) 초과 → 429 유휴 뒤 재개할 때마다 반복 · 3분 동안 429 × 118 무버스트 (용량 1 · 간격 1/eff, eff = 한도 × 0.85) 0s1s2s3s 같은 초당 건수, 몰림 없음 → 429 = 0
그림 3 · 요청 간격의 차이. 서버 한도는 "초당 N건"이지만 판정은 순간 밀도로 이뤄집니다. 처리량을 15% 양보하는 대신 재시도로 낭비되던 예산이 사라져, 실제 유효 처리량은 오히려 늘었습니다.

4.2실시간성과 정확성을 분리한 하이브리드 수집

7,000종목의 1분봉을 하나하나 요청하면 한 바퀴에 수십 분이 걸립니다. 그래서 두 경로를 겹칩니다. 현재가는 200종목씩 묶어 4초마다 전 종목을 훑고, 그 값으로 1분봉을 합성합니다(즉시 쓸 수 있지만 거래량이 없음). 그 뒤 확정봉 요청이 순서대로 돌며 거래량과 정확한 값으로 덮어씁니다. 같은 (시각, 종목) 키에 두 번 써도 마지막 값 하나만 남는 저장소의 upsert 성질 덕에 순서가 꼬여도 결과가 같습니다. 합성과 보정을 합쳐 하루 약 1,000만 행을 쓰고, 보관되는 확정봉은 하루 약 140만 개(정규장+연장장)입니다. 시계열은 최근 365일을 보관합니다(2026-08-30 실측: 2025-08-29 ~ 2026-08-29, 최근 7일 활성 6,784종목).

한 분(minute) 안에서 10:31:00 10:32:00 현재가 폴링 · 4초마다 (200종목 묶음) 합성봉 (synth) — 즉시 O·H·L·C 있음 / volume 없음 확정봉 백필 (/candles) — 순서대로 한 바퀴 거래량 포함 · 정확한 O·H·L·C · 휴면 종목은 커서를 앞으로 먼저 저장 QuestDB pw_candles — 키 (ts, symbol, interval) DEDUP UPSERT: 같은 키는 마지막 값만 남는다 fill_type = 'confirmed' · 완전성의 기준 합성봉은 "지금 보이는 값", 확정봉은 "나중에 맞춰진 값" — 둘 다 같은 자리에 쓴다
그림 4 · 합성봉 + 확정봉 하이브리드. 폭(전 종목·즉시)은 폴링이, 깊이(거래량·정확도)는 백필이 맡습니다. 합성봉을 "거래량이 비어 있음"으로 추론하지 않고 명시적 fill_type으로 표시한 것이 데이터 완전성 판정의 기준이 되었습니다.
무결성은 "관측하고 복구"하는 것

합성봉은 거래가 없는 분을 채우지 못하고, 백필은 한 바퀴에 지연이 있어 "완전 무결"은 처음부터 불가능합니다. 대신 확정봉 개수로 일자별 완전성을 히트맵으로 보이고, 부분 결손을 찾아 다시 채우는 자가치유 큐를 돌립니다. 2026년 7월 감사에서 이 보장이 네 군데서 깨져 98,682건(종목·일)의 결손 부채가 드러났고, 큐를 다시 설계해 상환했습니다.

05 · 접근 — 급등

가격이 먼저 말할 때, 원인을 거꾸로 찾는다

알림은 두 방향으로 갑니다. 뉴스 → 반응: 빅뉴스가 들어오면 그 종목을 일정 시간 지켜보다 기준가 대비 X% 오르면 알립니다(원인은 이미 손에 있음). 반응 → 원인: 뉴스가 없어도 전 종목에서 급등을 잡고, 자체 뉴스 DB를 거꾸로 뒤져 원인을 붙입니다. 둘 다 "가격만으로 발사하면 오탐이 너무 많다"는 같은 문제를 만납니다.

5.1종목마다 다른 "평소"를 기준으로

어떤 종목은 하루 1%가 큰 움직임이고 어떤 종목은 5%가 평범합니다. 그래서 수익률을 그대로 쓰지 않고 종목별 평소 변동폭으로 나눈 z-score를 씁니다. 평균과 변동폭은 지수이동평균으로 흐르듯 갱신하되, 방금 터진 급등이 자기 분모를 부풀리지 못하게 직전 값으로 채점하고 극단값은 잘라서 넣습니다. 개장·마감 무렵은 시장 전체가 출렁이므로 시간대별 변동성 프로파일로 나눠 보정하고, 변동폭이 0에 가까운 종목의 z가 폭발하지 않도록 절대 수익률 하한을 AND로 겁니다.

5.2거래량은 나중에 온다 — 2단계 확인

현재가 응답에는 거래량이 없습니다. 그래서 가격으로 먼저 "잠정 급등"을 잡고(1단계), 그 종목의 확정봉을 우선 받아 상대 거래량 RVOL이 임계 이상인 것만 "거래량 확인"으로 승격합니다(2단계). Slack은 2단계를 통과한 것만 받습니다. 실측으로 1단계 신호의 84%가 여기서 걸러졌습니다.

전 종목 ~7,000 4초마다 현재가 종목별 μ·σ 갱신 (EWMA) 시간대 프로파일 f(tod) 유동성·최소가·묵은가격 게이트 1단계 · 가격 z_adj = z / f(tod) ≥ Z AND |r| ≥ 절대 하한 잠정 급등 → 피드 · 백필 우선 큐 2단계 · 거래량 RVOL = 급등분 거래량 / 평소 확정봉 도착 뒤 판정 (30초 주기) 16% Slack 승격 + 원인 뉴스 첨부 84%는 억제 (목록엔 회색으로 남김) 뉴스 DB 역조인: 발사 −30분~ 후보 없으면 "원인 미확인"으로 정직하게
그림 5 · 급등 2단계 깔때기. 가격은 즉시, 거래량은 지연 확인. 사람에게 가는 인터럽트(Slack)는 2단계를 통과한 것으로 제한하고, 나머지는 피드에만 남겨 "왜 안 울렸는지"를 나중에 볼 수 있게 했습니다.
06 · 접근 — 공시

누가 무엇을 얼마나 들고 있는지 조립하기

대형 운용사와 기업의 보유 종목은 분기마다 13F로, 특정 회사 지분이 5%를 넘으면 13D/G로 공개됩니다. 두 양식은 형식도 주기도 다르지만, 합치면 "이 운용사가 이 종목을 지난 공시 대비 얼마나 늘리거나 줄였는가"를 알 수 있습니다. 새 공시가 들어오면 SEC 문서 목록에서 XML 원본을 찾아 파싱하고, 직전 조립 상태와 비교해 수량 변화만 알립니다.

처음에는 HTML 표를 긁었는데 표 구조가 바뀔 때마다 깨졌습니다. XML 원본으로 바꾸면서 XSL 미리보기(겉은 XML 주소지만 실제로는 HTML)를 잘못 고르는 함정, 새 공시를 먼저 저장하고 나서 "직전 보유"를 조회해 변화가 항상 0으로 나오던 순서 버그를 함께 잡았습니다 — 모두 실제 SEC 페이지 픽스처로 재현한 뒤 고쳤습니다.

별도의 sec-watch는 반대편을 봅니다. 신주·전환사채 발행처럼 기존 주주 지분 가치를 줄이는 희석 위험, 상장폐지·회계 문제 같은 강한 악재를 8-K 항목 번호와 양식 종류로 3단계 등급을 매겨 구독 종목에 즉시 알립니다.

07 · 검증

알림이 맞았는지, 시장에 되묻기

분류기가 "중요"라고 한 기사 뒤에 주가가 실제로 움직였는가? 반대로 "잡음"이라고 버린 기사 뒤에 움직인 것은 없는가? 전 종목 1분봉을 갖고 있으니 이 질문에 모든 기사에 대해 답할 수 있습니다.

기사를 받은 시각의 직전 봉을 기준가로 잡고, 30분·2시간·1일·3일 뒤 종가로 수익률을 계산해 저장합니다. 같은 창의 나스닥 100 ETF(QQQ) 수익률도 함께 저장해, 시장 전체가 오른 날의 상승을 뉴스의 공으로 돌리지 않게 합니다. 24시간 안의 최고·최저(런업·드로다운)도 기록하는데, "30분 만에 +5% 갔다가 제자리"는 종가로 보면 0%지만 알림으로는 적중이기 때문입니다. 2026년 8월에는 gs-quant의 이벤트 스터디에서 관점을 빌려 수신 전 2시간 창도 추가했습니다 — 기사가 오기 전에 이미 오른 종목은 "새어 나간 뉴스"라 알림 가치가 낮습니다.

사전 창 (−2h) 런업 · 드로다운 창 (24h 최고/최저) −2h 수신 (기준가) +30m +2h +1d +3d → final 각 시점: 수익률 % · QQQ 동일 창 수익률 · 성숙하면 점진적으로 채움 (라벨은 저장하지 않고 조회 때 임계값으로 계산) 1일 뒤 · 임계 +5% 기준 (2026-08-30 실측, 31,976건) +5% 이상 올랐다 그만큼 못 올랐다 "중요" 분류 "잡음" 분류 적중 1,087 hit 과잉 5,213 over — 알렸는데 조용했다 놓침 2,618 miss — 버렸는데 움직였다 정상 무시 23,058 quiet 정밀도 17.3% 재현율 29.3% 기저율 11.6% → 리프트 1.5× 임계 3%: P 24 / R 25 · 10%: P 8 / R 36
그림 6 · 후행평가. 위: 한 기사에 대해 재는 시점들. 아래: 분류 × 결과의 2×2 행렬. 숫자는 이 페이지를 만든 날 대시보드가 보여준 값 그대로입니다.
숫자를 솔직하게 읽으면

"중요" 분류의 17%만 다음 날 5% 이상 올랐습니다. 아무 기사나 골랐을 때(11.6%)보다 1.5배 나은 정도이고, 상승의 71%는 분류기가 놓쳤습니다. 이 분류기는 주가 예측기가 아니라 "읽을 가치" 필터이고, 적중은 시총 3천억 원 미만 초소형주에 몰려 있습니다(1,087건 중 735건). 이 표가 있기 때문에 프롬프트를 바꿨을 때 좋아졌는지 나빠졌는지를 감이 아니라 두 비율의 차이 검정으로 말할 수 있습니다.

7.1주간 브리핑 — 숫자는 코드가, 해석은 LLM이

매주 4분면 원자료를 사람이 훑는 대신, 코드가 검증한 사실만 모은 블록(기간 집계, 임계별 정밀도·재현율, 시장 수익률과 등락 종목 비율, 섹터별 중앙값, 놓침·과잉 상위 10건과 그 맥락)을 LLM에 주고 "이번 주 분류기가 어디서 새는가"를 구조화된 형식으로 받습니다. LLM은 주입된 숫자만 인용하고 계산은 하지 않습니다. 프롬프트를 바꾼 전후 기간의 비교(A/B)도 같은 방식으로, 유의성은 코드가 두 비율 z-검정으로 계산해 넣습니다.

7.2데이터가 틀리면 전략도 틀린다 — PEAD 검증

실적 발표 뒤 주가가 며칠간 같은 방향으로 표류한다는 현상(PEAD)을 검증하려고 별도 서비스를 만들었습니다. 핵심은 "발표 시점에 사람들이 실제로 본 숫자"인데, 무료 벤더 데이터는 나중에 조용히 수정됩니다. 그래서 발표 시점 그대로를 보존한 아카이브와 SEC 원본(XBRL)으로 대조했더니, 어닝 서프라이즈의 방향 자체가 표본의 최대 21%에서 뒤집혔습니다(허용 2%). 벤더 데이터를 부적합으로 판정하고 원본 공시 기반 신호로 바꿨습니다. 가설과 판정 기준은 결과를 보기 전에 사전 등록해, 결과를 보고 기준을 옮기는 일을 스스로 막았습니다.

08 · 운영

2년을 혼자 돌리며 배운 것

개인 프로젝트지만 상시 가동 시스템이라 장애는 실제로 납니다. 사건마다 증상·원인·증거(재현 쿼리와 수치)·조치·재발 방지를 한 문서로 남기는 장부를 두었습니다. 벤더 응답은 나중에 바뀌므로 "그때 그 값이 틀렸다"는 증거를 사고 시점에 남겨야 합니다.

  1. 2026-06-04
    거래량 구멍 — 하루치 정규장 거래량 86%가 비었다
    개장일 교란과 "합성봉을 거래량 없음으로 추론"하던 사각지대. 큐를 통해 역방향 재수집, 확정봉 1,745,049개 복원. 이후 명시적 fill_type으로 전환.
  2. 2026-06-05
    시계열 DB 쓰기 폭주 — 호스트가 멈추고 재부팅
    과거 분을 다시 쓰는 백필이 하루 단위 파티션의 꼬리를 통째로 재작성(디스크 쓰기 425MB/s, I/O 대기 40%). 파티션을 시간 단위로 재설계해 5~7MB/s, I/O 대기 0%, 부하 20→5.5. 재설계는 무손실 라이브 마이그레이션으로 — 쓰기를 잠시 멈추고 시간순으로 새 테이블에 복사(정렬돼 있어 순서 꼬임 없음) → 건수 검증 → 이름 교체. 쓰기가 싼 LSM 계열 DB로 갈아타는 것도 검토했지만 읽기 증폭·컴팩션 비용을 새로 떠안는 일이라, DB를 바꾸는 대신 워크로드를 DB에 맞췄습니다.
  3. 2026-06 → 07
    우아하지 못한 종료 — docker stop이 매번 10초
    셸이 PID 1이라 종료 신호가 파이썬에 닿지 않았음. exec로 바꾸고 형성 중인 합성봉을 종료 시 flush. 10s → 0.88s, 재부팅 후 1분 공백 제거.
  4. 2026-07-08
    "자가치유가 결국 채운다"는 보장의 균열 감사
    14일 창 밖 고아, 일시 오류를 영구 스킵, 한 방문 페이지 상한이 스스로 부분일을 만드는 것 — 네 가지 결함. 98,682건(종목·일) 부채를 확인하고 백로그 슬롯 + 감사 큐로 상환.
  5. 2026-08-16
    수정주가 오염 — 한 시계열에 두 가격 기준
    벤더 API의 기본값(수정주가)을 오해해 액면분할 종목의 과거 봉이 뒤섞임. 시세 계통 감사 6건과 함께 해결: 분할 계수 장부를 두고 읽기 시점에 보정, 오염 봉 1,124 → 10, 오염 라벨 150 → 57. 신뢰 불가한 81일은 CSV로 명시.
  6. 2026-08-22
    13F 알림이 5개월간 한 건도 없었다
    DB는 다른 경로로 채워져 아무도 몰랐던 무음 장애. 실제 SEC 페이지 픽스처로 원인 세 가지를 재현해 회귀 테스트로 고정.

품질 장치

  • 테스트 717건(76파일) — 시세·급등·리미터의 핵심 로직은 I/O를 주입받는 순수 함수로 분리해 토스·DB 없이 회귀 검증. 실제 SEC 응답은 record/replay 픽스처로 재생.
  • CI 게이트 — 테스트가 깨지면 이미지가 빌드되지 않습니다. 네트워크·LLM이 필요한 테스트는 마커로 분리해 CI가 항상 초록을 유지하도록 했습니다.
  • 스키마 재현성 — 기동 시 자동 생성 + 컬럼 변경은 alembic 마이그레이션. 마이그레이션 전 코드가 돌아도 새 기능만 건너뛰는 fail-soft.
  • 추적 가능한 로그 — RSS 항목 하나가 파싱→LLM→DB→Slack을 지나는 동안 한 상관관계 ID를 달고 다닙니다. 웹 요청은 응답 헤더로 같은 ID를 돌려줍니다.
  • 회복탄력성 — 수집 경로는 VPN 사이드카 10대로 송신 IP를 분산하고, 429를 받은 경로는 잠시 풀에서 빼두었다가 되돌립니다. 한 경로가 막혀도 전체가 멈추지 않습니다.
  • 데이터 감사 — 2026년 8월 시세 계통 전수 감사(발견 6건 전부 해결), 그리고 "값이 틀렸다는 증거를 남기는" 보정 장부.
09 · 화면

대시보드와 데모

모든 결과는 React 대시보드에서 봅니다. 새 기사는 SSE로 밀려오고, 연결이 끊긴 사이의 것은 재연결 때 DB에서 보충해 빠짐이 없습니다. 브라우저는 토스를 호출하는 서비스에 절대 닿지 않습니다 — 게이트웨이가 읽기 전용 백엔드만 노출합니다.

수집 봇들 titan · gnw · wsj · … VPN 사이드카 ×10 빅뉴스 price-watch :8005 토스 호출 전담 · 단일 인스턴스 폴링 · 백필 · 급등 탐지 후행평가 라벨러 토큰 1개 ↔ 토스 API 쓰기 MySQL · 원장 Redis · hot · pub/sub QuestDB · 1분봉 Meilisearch · 검색 색인 읽기만 MySQL → 색인 sync 30초 · 새 기사는 SSE로 즉시 webapi :8003 REST · SSE 브리지 색인 sync · 브리핑 Caddy :8080 React SPA + 프록시 basic auth ↑ 브라우저 (같은 origin) 규칙: 토스를 부르는 주체는 하나 — 둘이면 서로의 토큰을 무효화한다. 사용자 트래픽은 토스 한도를 소비하지 않는다.
그림 7 · 컨테이너 토폴로지. 쓰기와 읽기의 주체를 분리했습니다. 틸 점선은 Redis pub/sub으로 급등·기사 이벤트가 SSE로 흘러가는 경로, 회색 점선은 webapi가 MySQL을 30초마다 Meilisearch 검색 색인으로 동기화하는 경로입니다.

화면 목록

  • 기사 피드 (미국 · 실시간) — 사건 유형별 트리, 검색(Meilisearch — 대문자로 치면 티커 모드), 북마크, 기사 카드 안 미니 차트(뉴스 수신 시각을 마커로, 확정 구간은 실선·미확정 합성봉 구간은 점선 — API 제약을 숨기지 않음)
  • 가격 알림 — watch/글로벌 급등 피드, 거래량 확인 배지, 원인 뉴스 펼치기
  • 후행평가 — 4분면 매트릭스, 임계 스윕(3/5/10% × 호라이즌), 지표 전환(원시·시장보정·런업·사전 창)
  • 주간 브리핑 — 월 캘린더(일별 적중률), 브리핑 상세, A/B 비교 생성
  • 13F 보유 현황 · SEC watch(희석·악재 스캔) · 시세 관리자(완전성 히트맵, 급등 임계 라이브 튜닝) · 키워드 · 알림 설정 · 기술 문서
10 · 한계

정직 조항과 다음 할 일

  • 확장의 상한이 토큰에 있다. 토스 토큰이 하나라 시세 수집은 단일 인스턴스여야 하고, 백필 처리량은 그 한도가 정합니다. 수평 확장은 구조적으로 막혀 있습니다.
  • 합성봉은 거래가 없는 분을 채우지 못한다. 완전성은 보장이 아니라 관측·복구의 문제이고, 히트맵과 자가치유 큐가 그 한계를 다룹니다.
  • 사후 평가의 시간은 거래일이 아니라 달력이다. 주말을 낀 3일은 실제로 하루치 거래일 수 있습니다. 상장폐지된 종목은 마지막 가용 봉으로 종결되어 생존 편향이 있습니다 — 학습용으로 쓸 때는 상태 필터가 필수입니다.
  • 분류 정밀도는 낮다. 17%는 "읽을 가치" 필터로는 쓸모 있어도 매매 신호로는 부족합니다. 사전 창(−2h) 라벨을 알림 등급에 반영하는 것이 다음 실험입니다.
  • 티커는 바뀐다. 티커 개명·재사용 이력이 없어 과거 기사와 종목의 매핑이 어긋날 수 있습니다. 식별자 유효 구간 테이블이 후보에 있습니다.
부록

용어 사전

본문의 † 표시에 마우스를 올리거나 키보드로 초점을 맞추면 같은 설명이 뜹니다.

SEC · EDGAR
미국 증권거래위원회. 상장사는 중요 사건을 SEC에 보고해야 하고, 그 문서는 EDGAR라는 공개 시스템에 올라옵니다. 이 프로젝트는 EDGAR의 RSS 피드를 2초마다 확인합니다.
zero-shot 분류
예시 데이터로 학습시키지 않고, "이런 기준이면 A, 저런 기준이면 B"라는 판단 기준을 글(프롬프트)로 주어 LLM이 분류하게 하는 방식. 기준을 바꾸는 비용이 낮은 대신 결과를 꾸준히 측정해야 합니다.
구조화 출력 (JSON 스키마)
LLM의 답을 자유 문장이 아니라 미리 정한 필드(예: 사건 유형 번호, 요약, 티커)를 가진 JSON으로만 받는 것. 형식이 계약이라 파싱 실패가 사라지고, 공급자를 바꿔도 호출 코드가 그대로입니다.
티커
거래소에서 종목을 부르는 짧은 기호(예: NVDA). 기사 본문에서 티커를 뽑아내야 어느 종목의 시세를 볼지 정할 수 있습니다.
시가총액
주가 × 발행 주식 수. 회사의 크기입니다. 같은 뉴스라도 초소형주에서는 주가가 크게 움직이고, 대형주에서는 거의 움직이지 않습니다.
record/replay 픽스처
외부 서버의 실제 응답을 한 번 녹음해 파일로 저장하고, 테스트할 때는 네트워크 없이 그 파일을 재생하는 기법. "그때 그 응답"으로 장애를 재현하고, 같은 파일이 회귀 테스트가 됩니다.
인증 토큰 (OAuth)
API를 부를 때 "나는 허가받은 클라이언트"임을 증명하는 열쇠. 토스 API는 클라이언트당 하나만 유효하고, 새로 발급하면 이전 열쇠가 즉시 무효가 됩니다.
속도 제한 · 429
서버가 "초당 N건까지"로 요청을 제한하는 것. 넘기면 HTTP 429(Too Many Requests)로 거절하고, 잠시 뒤 다시 시도해야 합니다. 거절된 요청은 예산 낭비입니다.
1분봉 · OHLCV
1분 동안의 시가(Open)·고가(High)·저가(Low)·종가(Close)·거래량(Volume)을 한 줄로 묶은 것. 차트의 막대 하나가 봉입니다.
멱등 upsert
같은 키로 여러 번 저장해도 결과가 한 번 저장한 것과 같은 성질. 순서가 꼬이거나 재시도해도 데이터가 중복되지 않아, 합성봉 위에 확정봉을 안전하게 덮어쓸 수 있습니다.
z-score
지금 값이 "평소 평균에서 표준편차 몇 개만큼 떨어져 있는가". 종목마다 평소 변동폭이 다르므로, 수익률을 그 종목의 변동폭으로 나눠야 비교가 됩니다.
시간대 변동성 보정
개장 직후와 마감 직전은 시장 전체가 평소보다 크게 출렁입니다. 그 시간대의 평균 출렁임으로 나눠 주지 않으면 매일 개장마다 급등 알림이 쏟아집니다.
RVOL (상대 거래량)
지금 거래량 ÷ 그 종목의 평소 1분 거래량. 가격만 튄 것과 실제로 사람들이 몰린 것을 구분합니다.
13F
일정 규모 이상의 기관 투자자가 분기마다 보유 주식을 공개하는 SEC 양식. 45일 지연이 있지만 "누가 무엇을 들고 있는가"를 볼 수 있는 공식 자료입니다.
13D · 13G
어떤 회사 지분을 5% 넘게 갖게 되면 제출하는 양식. 13D는 경영 관여 의도가 있을 때, 13G는 단순 투자일 때입니다. 2025년부터 SEC가 이름을 "SCHEDULE 13D/G"로 바꿨습니다.
8-K와 항목 번호
상장사가 중요한 사건을 즉시 보고하는 양식. 사건 종류마다 항목 번호가 정해져 있어(예: 4.02 재무제표 신뢰 불가, 2.03 채무 발생, 3.02 신주 발행) 번호만 봐도 성격을 알 수 있습니다.
두 비율 z-검정
"A 기간 정밀도 17%와 B 기간 20%의 차이가 우연인가"를 표본 수를 고려해 판정하는 통계 검정. 표본이 적으면 큰 차이도 우연일 수 있다는 것을 숫자로 말해 줍니다.
PEAD
Post-Earnings Announcement Drift. 실적이 예상보다 좋으면(나쁘면) 발표 뒤 며칠~몇 주간 주가가 같은 방향으로 계속 움직이는 경향. 오래 알려진 현상이지만 데이터가 정확해야 검증할 수 있습니다.
시점(PIT) 아카이브
Point-in-time. "그 날 사람들이 실제로 본 값"을 나중 수정과 분리해 보존한 기록. 벤더가 과거 값을 조용히 고치면, 수정된 값으로 과거를 평가하는 오류(look-ahead)가 생깁니다.
XBRL
SEC에 제출되는 재무제표의 기계 판독용 형식. 벤더 가공본이 아닌 원본 숫자를 그대로 읽을 수 있습니다.
상관관계 ID
하나의 작업(예: RSS 항목 하나)이 여러 단계를 지나는 동안 모든 로그 줄에 붙는 짧은 식별자. 장애가 났을 때 그 ID 하나로 전 과정을 grep 할 수 있습니다.
GIL
파이썬 인터프리터의 전역 잠금. CPU 계산은 스레드를 늘려도 한 번에 하나만 돌지만, 네트워크·디스크를 기다리는 동안은 잠금이 풀려 스레드가 병렬로 유효합니다. 동기 라이브러리를 스레드로 감싸는 근거입니다.
ETag · 304
서버가 응답에 붙이는 내용 지문. 다음 요청에 "이 지문 그대로면 안 보내도 된다"(If-None-Match)고 하면, 변한 게 없을 때 본문 없이 304만 돌아옵니다. 큰 페이지를 매번 다시 파싱하지 않게 해 줍니다.
SSE
Server-Sent Events. 서버가 브라우저에 한 방향으로 이벤트를 계속 밀어 넣는 표준 방식. 새로고침 없이 새 기사와 알림이 화면에 나타납니다.