목차 — M1 데이터 분석 모듈
1. 모듈 개요
M1 데이터 분석 모듈은 M0에서 수집한 비정형 텍스트 데이터(뉴스 기사, 공시 제목)를 NLP로 정량화하는 모듈이다. 한국어 금융 특화 BERT 모델을 사용해 감성 점수(-1.0 ~ +1.0)를 산출하고, 공시 제목에서 유형과 중요도를 분류한다.
| 항목 | 수치 |
|---|---|
| NLP 모델 | snunlp/KR-FinBert-SC (HuggingFace, 3-class) |
| 분석 대상 | 뉴스 기사 (제목+본문) + DART 공시 제목 |
| 출력 피처 | 19개 (뉴스 5 + 공시 5 + 뉴스정제 3 + 대체데이터 6) → M2에서 ML 입력으로 사용 |
| 공시 분류 | 7개 유형 (실적/주요사항/지분/자본변동/정기보고/구조변경/기타) |
| 설계 패턴 | Singleton + Lazy Loading, 배치 추론, Graceful Degradation |
| 코드 규모 | 14개 파일, 약 2,100줄 (M0과 통합 구조) |
2. 아키텍처 — NLP 파이프라인
M1은 독립 모듈이 아니라 M0 수집 파이프라인 내부에 자연스럽게 통합되어 있다. 뉴스/공시 수집 직후 센티먼트 분석이 실행되며, 결과는 동일 트랜잭션에서 DB에 적재된다.
|
NewsFetcher Naver API → 뉴스 기사 |
DisclosureFetcher DART API → 공시 제목 |
|
SentimentAnalyzer
(Singleton) 뉴스: title + desc → score (-1.0~+1.0) 공시: report_nm → score (-1.0~+1.0) |
_classify_report() 공시 제목 → report_type + type_score (0.2~1.0) |
| news_sentiment 테이블 | dart_disclosure 테이블 |
뉴스 5 + 뉴스정제 3 + 공시 5 + 대체데이터 6 = 19개 피처 → M3 ML 입력
분석 흐름 요약
| 단계 | 입력 | 처리 | 출력 |
|---|---|---|---|
| 1. 텍스트 정제 | Naver API 원본 HTML | HTML 태그/엔티티 제거 (regex) | clean text |
| 2. 감성 분석 | title + description | KR-FinBert-SC 추론 (batch) | score, label, confidence |
| 3. 공시 분류 | DART report_nm | 키워드 규칙 매칭 (7유형) | report_type, type_score |
| 4. DB 집계 | 개별 기사/공시 점수 | 일별 평균/건수/표준편차 집계 | 10개 ML 피처 |
3. KR-FinBert-SC 센티먼트 분석기
3.1 모델 선택
한국어 금융 텍스트에 특화된 감성분석 모델 4가지를 비교 검토하고, snunlp/KR-FinBert-SC를 채택했다.
| 모델 | 도메인 | 분류 | 채택 |
|---|---|---|---|
| snunlp/KR-FinBert-SC | 한국어 금융 | 3-class (pos/neg/neutral) | O |
| ProsusAI/finbert | 영어 금융 | 3-class | - |
| GPT-4o-mini (API) | 범용 | 자유 | - |
| klue/bert-base | 한국어 범용 | fine-tune 필요 | - |
선택 이유: (1) 한국어 금융 도메인 사전학습, (2) API 비용 제로 (로컬 CPU 추론), (3) HuggingFace pipeline 2줄로 즉시 사용, (4) 학습 데이터 불필요 (pre-trained).
3.2 Singleton + Lazy Loading 구현
BERT 모델은 로딩에 3~5초가 소요되므로 Singleton 패턴 + 첫 호출 시 Lazy Loading으로 설계했다. 뉴스 분석과 공시 분석이 동일한 인스턴스를 공유한다.
class SentimentAnalyzer: # lazy loading singleton _instance: Optional["SentimentAnalyzer"] = None @classmethod def get_instance(cls) -> "SentimentAnalyzer": if cls._instance is None: cls._instance = cls() return cls._instance def _load_model(self): # 첫 analyze() 호출 시 1회만 실행 if self._pipeline is not None: return from transformers import pipeline as hf_pipeline self._pipeline = hf_pipeline( "sentiment-analysis", model="snunlp/KR-FinBert-SC", max_length=512, truncation=True, )
설계 포인트: from transformers import pipeline을 _load_model() 내부에 지연 임포트했다. transformers 라이브러리 자체의 import가 무거우므로 (torch/numpy 로딩), 서버 기동 시점에는 임포트하지 않고 실제 분석 요청이 들어올 때 로드한다.
3.3 점수 산출 로직
모델 출력(label + confidence)을 -1.0 ~ +1.0 범위의 단일 스칼라 점수로 변환한다.
# label → base score _LABEL_SCORE = { "positive": 1.0, "negative": -1.0, "neutral": 0.0, } # 최종 점수 = base_score * confidence # 예: positive (0.95) → +1.0 * 0.95 = +0.9500 # 예: negative (0.72) → -1.0 * 0.72 = -0.7200 # 예: neutral (0.88) → 0.0 * 0.88 = 0.0000 def analyze(self, texts: list[str], batch_size=32) -> list[dict]: self._load_model() outputs = self._pipeline(texts, batch_size=batch_size) results = [] for output in outputs: label = output["label"].lower() confidence = output["score"] sentiment_score = _LABEL_SCORE.get(label, 0.0) * confidence results.append({ "label": label, "confidence": round(confidence, 4), "sentiment_score": round(sentiment_score, 4), }) return results
| 입력 텍스트 | label | confidence | score |
|---|---|---|---|
| "삼성전자 분기 영업이익 사상 최대" | positive | 0.9512 | +0.9512 |
| "외국인 매도세 지속 코스피 하락" | negative | 0.8734 | -0.8734 |
| "카카오 정기주주총회 개최 안내" | neutral | 0.7891 | 0.0000 |
4. 뉴스 NLP 파이프라인
4.1 전처리 — HTML 정제
Naver News API 응답에는 HTML 태그(<b>, <br>)와 엔티티(")가 포함된다. BERT 입력 전에 regex로 제거한다.
# HTML 태그 / 엔티티 제거용 패턴 _TAG_RE = re.compile(r"<[^>]+>") _ENTITY_RE = re.compile(r"&[a-zA-Z]+;|&#\d+;") def _clean_html(text: str) -> str: text = _TAG_RE.sub("", text) text = _ENTITY_RE.sub(" ", text) return text.strip() # 변환 예시 # "<b>삼성전자</b> 주가 "급등"" # → "삼성전자 주가 급등 "
4.2 수집 → 분석 → 저장 파이프라인
NewsService.collect()가 전체 파이프라인을 오케스트레이션한다. 종목별 뉴스와 시장 전체 뉴스를 분리 수집하며, 둘 다 동일한 SentimentAnalyzer 인스턴스로 분석한다.
# NewsService.collect() 핵심 흐름 analyzer = SentimentAnalyzer.get_instance() # Singleton # ① 종목별 뉴스 수집 for code, name in codes: records = fetcher.fetch_stock_news(code, name) # query = "{종목명} 주가" (e.g. "삼성전자 주가") # ② 센티먼트 분석 (title + description 결합) texts = [r["title"] + ". " + (r["description"] or "") for r in records] sentiments = analyzer.analyze(texts) # ③ score/label 머지 for rec, sent in zip(records, sentiments): rec["sentiment_score"] = sent["sentiment_score"] rec["sentiment_label"] = sent["label"] # ④ 시장 전체 뉴스 (6개 키워드) # 키워드: "코스피", "코스닥", "증시", "주식시장", "한국은행 금리", "경기 전망" market_records = fetcher.fetch_market_news(market="KR") market_records = _analyze_records(analyzer, market_records) # ⑤ DB 저장 (Upsert on url+code) NewsRepository(session).upsert_articles(all_records)
4.3 종목 뉴스 vs 시장 뉴스
| 구분 | 검색 쿼리 | code 값 | 최대 건수 | 용도 |
|---|---|---|---|---|
| 종목 뉴스 | "{종목명} 주가" | "005930" 등 | 종목당 50~100건 | news_sentiment, news_volume 피처 |
| 시장 뉴스 | "코스피", "증시" 등 6개 | NULL | 키워드당 16건 | market_sentiment 피처 |
종목 뉴스와 시장 뉴스의 분리: DB에서 code IS NULL인 레코드가 시장 전체 뉴스다. Feature Engineer는 종목별 피처(news_sentiment)와 시장 피처(market_sentiment)를 각각 별도 쿼리로 집계한다. 이를 통해 "시장 전체가 부정적인데 이 종목만 긍정적" 같은 시그널을 ML 모델이 학습할 수 있다.
5. 공시 분류 + 감성 분석
5.1 7유형 키워드 분류기
DART 공시 제목에서 키워드 매칭으로 유형(report_type)과 중요도 가중치(type_score)를 판별한다. 실적 관련 공시가 가장 높은 가중치(1.0), 기타 공시가 가장 낮은 가중치(0.2)를 갖는다.
_TYPE_RULES = [
# (키워드 리스트, report_type, type_score)
(["영업(잠정)실적", "매출액또는손익구조", "실적"], "실적", 1.0),
(["합병", "분할", "영업양수도"], "구조변경", 0.9),
(["주요사항보고서", "소송"], "주요사항", 0.8),
(["지분", "임원ㆍ주요주주", "주식등의대량보유"], "지분", 0.7),
(["유상증자", "전환사채", "신주인수권"], "자본변동", 0.6),
(["사업보고서", "분기보고서", "반기보고서"], "정기보고", 0.5),
# 미매칭 → ("기타", 0.2)
]
def _classify_report(report_nm: str) -> tuple[str, float]:
for keywords, rtype, score in _TYPE_RULES:
for kw in keywords:
if kw in report_nm:
return rtype, score
return "기타", 0.2
| 유형 | type_score | 공시 제목 예시 |
|---|---|---|
| 실적 | 1.0 | 매출액또는손익구조30%이상변경 |
| 구조변경 | 0.9 | 합병등종료보고서 |
| 주요사항 | 0.8 | 주요사항보고서(영업양수도결정) |
| 지분 | 0.7 | 임원ㆍ주요주주특정증권등소유상황보고서 |
| 자본변동 | 0.6 | 유상증자결정 |
| 정기보고 | 0.5 | 사업보고서(2024.12) |
| 기타 | 0.2 | 증권신고서(채권-Loss Absorption at the Point) |
5.2 공시 제목 감성 분석
뉴스와 동일한 KR-FinBert-SC 모델로 공시 제목을 분석한다. 공시 제목은 뉴스보다 정형화되어 있으므로 neutral 비율이 높지만, 실적/구조변경 관련 공시는 명확한 긍정/부정 신호를 보인다.
# DisclosureService._analyze_sentiment() def _analyze_sentiment(records: list[dict]) -> list[dict]: analyzer = SentimentAnalyzer.get_instance() # 뉴스와 동일 인스턴스 texts = [r["report_nm"] for r in records] # 공시 제목만 분석 sentiments = analyzer.analyze(texts) for rec, sent in zip(records, sentiments): rec["sentiment_score"] = sent["sentiment_score"] rec["sentiment_label"] = sent["label"] return records
뉴스 vs 공시 분석 차이점: 뉴스는 title + ". " + description을 결합하여 분석하지만, 공시는 report_nm(제목)만 분석한다. 공시 본문은 DART API에서 별도 요청이 필요하고 응답이 크므로, 제목 기반 분석으로 효율과 속도를 우선했다.
5.3 이중 분석 — 분류 + 감성
공시는 규칙 기반 유형 분류(type_score)와 모델 기반 감성 분석(sentiment_score)을 동시에 적용받는다. 두 점수는 독립적인 피처로 M2에 전달된다.
# dart_disclosure 테이블 레코드 예시 { "code": "005930", "report_nm": "매출액또는손익구조30%(대규모법인15%)이상변경", "report_type": "실적", # ← 규칙 기반 분류 "type_score": 1.0, # ← 유형 가중치 "sentiment_score": 0.4213, # ← KR-FinBert-SC "sentiment_label": "positive", # ← 모델 판정 }
6. DB 집계 전략
ML 모델 학습에 사용되는 피처는 개별 기사/공시 단위가 아니라 일별 집계 값이다. Repository 계층에서 SQL 집계를 수행하며, PostgreSQL과 SQLite 양쪽 모두 호환되도록 구현했다.
6.1 뉴스 일별 집계
# NewsRepository.get_daily_sentiment() # 종목별: code == "005930" AND date BETWEEN start AND end SELECT date, AVG(sentiment_score) -- news_sentiment COUNT(id) -- news_volume STDDEV(sentiment_score) -- news_sentiment_std FROM news_sentiment WHERE code = ? AND date BETWEEN ? AND ? GROUP BY date # 시장 전체: code IS NULL (시장 뉴스) SELECT date, AVG(sentiment_score) -- market_sentiment COUNT(id) -- market_news_volume FROM news_sentiment WHERE code IS NULL AND market = ? GROUP BY date
6.2 Cross-DB 표준편차
SQLite에는 STDDEV() 함수가 없으므로, DB 종류에 따라 분기 처리한다.
def _stddev_expr(self): if settings.DB_TYPE == "sqlite": # SQLite: sqrt(|avg(x²) - avg(x)²|) avg_sq = func.avg(score * score) sq_avg = func.avg(score) * func.avg(score) return func.coalesce(func.sqrt(func.abs(avg_sq - sq_avg)), 0) else: # PostgreSQL: 내장 stddev() return func.coalesce(func.stddev(score), 0)
abs() 처리: 부동소수점 연산에서 avg(x²) - avg(x)²가 아주 작은 음수가 될 수 있다 (이론적으로는 항상 >= 0). abs()로 감싸 sqrt() 도메인 에러를 방지한다.
6.3 공시 롤링 윈도우 집계
공시 피처는 30일 롤링 윈도우로 집계한다. 60일 lookback(30일 + 이전 30일)을 사용하여 공시 빈도 변화율(volume_change)도 산출한다.
# Feature Engineer — 공시 피처 산출 for each date in df: last_30d = disclosures in [date-30, date] prev_30d = disclosures in [date-60, date-30] "disclosure_count_30d" = len(last_30d) "days_since_disclosure" = date - max(last_30d.date) "disclosure_sentiment" = avg(last_30d.sentiment_score) "disclosure_type_score" = avg(last_30d.type_score) "disclosure_volume_change" = (count_30d - count_prev) / count_prev
7. M2 피처 엔지니어링 연동
M1에서 생성된 데이터는 M2의 Phase 4(뉴스)와 Phase 5A(공시)에서 총 19개의 ML 피처로 변환된다.
| Phase | 피처 | 설명 | 값 범위 |
|---|---|---|---|
| Phase 4 (뉴스) |
news_sentiment | 종목별 일별 평균 감성 | -1.0 ~ +1.0 |
| news_volume | 종목별 일별 기사 건수 | 0 ~ N | |
| news_sentiment_std | 종목별 일별 감성 표준편차 | 0 ~ 1.0 | |
| market_sentiment | 시장 전체 일별 평균 감성 | -1.0 ~ +1.0 | |
| market_news_volume | 시장 전체 일별 기사 건수 | 0 ~ N | |
| Phase 5A (공시) |
disclosure_count_30d | 최근 30일 공시 건수 | 0 ~ N |
| days_since_disclosure | 마지막 공시 이후 일수 | 0 ~ 365+ | |
| disclosure_sentiment | 30일 공시 평균 감성 | -1.0 ~ +1.0 | |
| disclosure_type_score | 30일 공시 유형 가중치 평균 | 0.2 ~ 1.0 | |
| disclosure_volume_change | 공시 빈도 변화율 (30d vs 이전30d) | -1.0 ~ +N | |
| Phase 6B (뉴스 정제) |
news_relevance_ratio | 종목명이 제목에 포함된 기사 비율 | 0 ~ 1.0 |
| news_sentiment_filtered | 관련성 필터링된 기사 평균 감성 | -1.0 ~ +1.0 | |
| sector_news_sentiment | 동일 섹터 평균 뉴스 감성 | -1.0 ~ +1.0 | |
| Phase 7 (대체데이터) |
google_trend_score | Google Trends 검색량 (0~100 정규화) | 0 ~ 1.0 |
| google_trend_momentum | 5일 전 대비 검색량 변화율 | -1.0 ~ +N | |
| community_post_volume | 종토방 게시글 수 (log1p 정규화) | 0+ | |
| community_comment_volume | 종토방 댓글 수 (log1p 정규화) | 0+ | |
| community_engagement_ratio | 댓글/게시글 비율 (참여도) | 0+ | |
| alternative_activity_index | 트렌드 + 커뮤니티 종합 활동 지수 | 0 ~ 1.0 |
# feature_engineer.py — 피처 컬럼 정의 PHASE4_FEATURE_COLUMNS = PHASE3_FEATURE_COLUMNS + [ "news_sentiment", "news_volume", "news_sentiment_std", "market_sentiment", "market_news_volume", ] PHASE5_FEATURE_COLUMNS = PHASE4_FEATURE_COLUMNS + [ "disclosure_count_30d", "days_since_disclosure", "disclosure_sentiment", "disclosure_type_score", "disclosure_volume_change", # + Phase 5B 수급 피처 5개 (M1 영역 아님) ] PHASE6_FEATURE_COLUMNS = PHASE5_FEATURE_COLUMNS + [ # Phase 6A 섹터/상대강도 7개 (M2 영역) # Phase 6B 뉴스 정제 3개 ↓ "news_relevance_ratio", "news_sentiment_filtered", "sector_news_sentiment", ] PHASE7_FEATURE_COLUMNS = PHASE6_FEATURE_COLUMNS + [ "google_trend_score", "google_trend_momentum", "community_post_volume", "community_comment_volume", "community_engagement_ratio", "alternative_activity_index", ]
Graceful NULL: 뉴스/공시 데이터가 없는 날짜에도 ML 학습이 중단되지 않는다. 누락 피처는 NULL로 유지되며, RandomForest/XGBoost/LightGBM은 NULL을 자체적으로 처리하며, DL 모델(LSTM/Transformer)은 SimpleImputer(median)로 대체한다.
8. 코드 구조 & 통계
| 역할 | 파일 | 라인 | 핵심 클래스/함수 |
|---|---|---|---|
| 센티먼트 분석 | sentiment_analyzer.py | 97 | SentimentAnalyzer (Singleton) |
| 뉴스 수집 | news_fetcher.py | 191 | NewsFetcher, _clean_html() |
| 공시 수집+분류 | disclosure_fetcher.py | 213 | DisclosureFetcher, _classify_report() |
| 뉴스 서비스 | news_service.py | 187 | NewsService.collect(), _analyze_records() |
| 공시 서비스 | disclosure_service.py | 226 | DisclosureService._analyze_sentiment() |
| 뉴스 Repository | news_repository.py | 196 | get_daily_sentiment(), _stddev_expr() |
| 공시 Repository | disclosure_repository.py | 154 | get_disclosures_for_features() |
| 뉴스 모델 | models/news.py | 47 | NewsSentiment (SQLAlchemy) |
| 공시 모델 | models/disclosure.py | 89 | DartDisclosure, KrxSupplyDemand |
| 피처 엔지니어 | feature_engineer.py | ~100 (M1 부분) | _merge_news_features(), _merge_disclosure_features() |
| 트렌드 수집 | google_trends_fetcher.py | 160 | GoogleTrendsFetcher, 주간→일간 보간 |
| 커뮤니티 수집 | naver_community_fetcher.py | 191 | NaverCommunityFetcher, HTML 파싱 |
| 대체데이터 서비스 | alternative_service.py | 121 | AlternativeService.collect_all() |
| 대체데이터 모델 | models/alternative.py | 51 | AlternativeData (alternative_data 테이블) |
합계: 14개 파일, 약 2,100줄. M0(수집)과 물리적으로 같은 디렉터리에 위치하지만, 논리적으로 NLP 분석 책임만 담당한다. SentimentAnalyzer가 유일한 ML 모델 의존이며, 나머지는 규칙 기반 분류와 SQL 집계로 구성된다.
파일 트리 (M1 관련)
app/ ├── data_collector/ │ ├── sentiment_analyzer.py # KR-FinBert-SC Singleton (97줄) │ ├── news_fetcher.py # Naver API + HTML 정제 (191줄) │ ├── disclosure_fetcher.py # DART 공시 + 7유형 분류 (213줄) │ ├── google_trends_fetcher.py # Google Trends (pytrends, 160줄) │ └── naver_community_fetcher.py # Naver 종토방 크롤링 (191줄) ├── services/ │ ├── news_service.py # 수집→분석→저장 오케스트레이션 (187줄) │ ├── disclosure_service.py # 공시 센티먼트 연동 (226줄) │ └── alternative_service.py # 대체데이터 수집 오케스트레이션 (121줄) ├── repositories/ │ ├── news_repository.py # 일별 집계, Cross-DB stddev (196줄) │ ├── disclosure_repository.py # 공시/수급 CRUD (154줄) │ └── alternative_repository.py # 대체데이터 CRUD (83줄) ├── models/ │ ├── news.py # news_sentiment 테이블 (47줄) │ ├── disclosure.py # dart_disclosure + krx_supply_demand (89줄) │ └── alternative.py # alternative_data 테이블 (51줄) └── ml/ └── feature_engineer.py # _merge_news/disclosure_features (~100줄)
에러 핸들링 전략
| 에러 상황 | 처리 방식 | 결과 |
|---|---|---|
| 모델 로딩 실패 | ModelLoadError raise | 파이프라인 중단 (critical) |
| 추론 실패 | InferenceError raise | 호출측에서 catch |
| API 키 미설정 | available=False, skip | 빈 결과 반환 |
| 종목별 수집 실패 | warning 로그, continue | 해당 종목만 skip |
| 공시 센티먼트 실패 | warning 로그, 분류만 유지 | type_score만 저장됨 |
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #8 [모듈1-2] 금융 BERT 모델 메모리 관리 (0) | 2026.02.19 |
|---|---|
| #7 [모듈1-1] 한국어 금융 뉴스의 감성 sentiment 분석 (0) | 2026.02.19 |
| #5 [모듈0] 데이터 수집 - 개발문서 (0) | 2026.02.19 |
| #4 Alchemetric API 정의서 (0) | 2026.02.06 |
| #3 Alchemetric ERD (0) | 2026.02.03 |