본문 바로가기
Project/🚀 AI Finance 2026

#6 [모듈1] 데이터 분석 - 개발문서

by 로띠 2026. 2. 19.

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에 적재된다.

M0: 데이터 수집
NewsFetcher
Naver API → 뉴스 기사
DisclosureFetcher
DART API → 공시 제목
▼                            ▼
M1: NLP 분석
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 테이블
▼                            ▼
M2: 피처 엔지니어링
뉴스 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>)와 엔티티(&quot;)가 포함된다. 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> 주가 &quot;급등&quot;"
# → "삼성전자 주가  급등 "

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만 저장됨