GameStop이 eBay에 $55.5B 인수 제안 — 밈주식 M&A 뉴스 모니터 만든 기록

목차

M&A 알림 봇을 굴리던 채널에 ‘GameStop이 eBay에 약 $55.5B 인수 제안’이라는 메시지가 떴다. 다른 채팅방보다 한참 뒤였다. 시세는 이미 출렁였고 SNS는 한 시간 전부터 시끄러웠다. 모니터링 스크립트를 다시 들여다본 계기다.

그런데, 밈주식이 M&A 카드를 꺼내든 사례 자체가 흥미롭다. 이 글의 초점은 다른 데 있다. 진위 검증이 아니라 ‘이런 트렌딩 뉴스가 떴을 때 어떻게 빨리 잡고 어떻게 분류할 것인가’다. 프론트엔드만 만지다가 백엔드로 넘어온 지 2년째인 시선에서, 회사에서 바로 굴릴 수 있는 형태로 짜봤다.

왜 기존 알림 도구로는 부족했나

운영 중인 채널에는 Google Alerts, 매체별 RSS, 사내 Bloomberg 푸시가 한꺼번에 들어온다. 한 채널에 모아두고 쓰고 있었다. 이번에 GameStop의 eBay 인수 제안 보도가 늦게 도착한 데에는 두 가지 이유가 있었다.

따라서, 첫째, 매체가 헤드라인을 던지는 시점과 RSS 피드에 반영되는 시점 사이에 갭이 있다. 큰 매체일수록 늦는 경향이 있다. 둘째, 키워드 알림은 의미를 모른다. "GameStop"과 "eBay"가 같은 헤드라인에 등장한다고 해서 인수 제안 뉴스라는 보장이 없다. 단순한 비교 기사일 수도 있고, 지난 사건의 회고 기사일 수도 있다.

게다가, 기존 도구가 채워주지 못한 빈 칸을 정리하면 이렇다.

  • 지연 시간: 5분 이내 도달이 목표
  • 의미 기반 분류: 단순 키워드 매칭이 아니라 헤드라인+본문을 보고 판단
  • 신뢰도 표시: ‘소식통 인용 추측 보도’와 ‘공식 발표’는 다르게 처리해야 한다
  • 중복 제거: 같은 사건을 5개 매체가 따로 알리는 일이 흔하다
  • 비용 통제: 매일 수백 건 분류해도 한 달 청구서가 무서우면 안 된다

실제로, 다섯 개를 동시에 만족하는 사내 도구는 없었다. 그래서 만들었다.

아키텍처 — 단순하게 가자

즉, 복잡하게 갈 이유가 없었다. 아래가 전체 그림이다.

graph LR
    A[RSS / 뉴스 API] --> B[Lambda 5분 cron]
    B --> C[Claude Sonnet 4.5 분류기]
    C --> D[DynamoDB 중복 제거]
    D --> E[Slack Webhook]
    D --> F[MCP 서버]
    F --> G[Claude Desktop]

그런데, 스택은 AWS Lambda(5분 cron) + DynamoDB + Slack Webhook + Claude API. 분류만 LLM이 한다. 인덱싱이나 RAG는 없다. 매번 헤드라인+본문 일부를 그대로 분류기에 넣는다.

처음에는 EventBridge + SQS + Step Functions까지 만들려다가 멈췄다. 5분 cron 하나면 충분한 일에 큐를 넣으면 디버깅만 어려워진다. 백엔드 전환 2년차의 학습 중 하나가 ‘필요할 때 도입하기’였다. 프론트에서 라이브러리 그냥 깔고 시작하던 습관이 가장 빨리 깨진 부분이기도 하다.

Claude API로 뉴스 분류하기

핵심 코드는 짧다. 분류 프롬프트와 호출이 전부다. 자세한 SDK 사용법은 Anthropic 공식 문서를 참고하면 된다.

import anthropic
import json
from typing import TypedDict

client = anthropic.Anthropic()

CLASSIFY_PROMPT = """너는 M&A 뉴스 분류기다. 아래 뉴스를 보고 JSON으로 답하라.

필드:
- type: "official_announcement" | "rumor" | "analysis" | "irrelevant"
- impact: "high" | "medium" | "low"
- entities: 티커 또는 회사명 리스트 (예: ["GME", "EBAY"])
- summary_ko: 한국어 한 문장 요약
- confidence: 0.0 ~ 1.0
- needs_verification: bool (소식통 인용이거나 공식 IR 미확인이면 true)

헤드라인: {headline}
본문 일부: {body}

JSON만 출력하라. 다른 설명 금지.
"""

class Classification(TypedDict):
    type: str
    impact: str
    entities: list[str]
    summary_ko: str
    confidence: float
    needs_verification: bool

def classify_news(headline: str, body: str) -> Classification:
    msg = client.messages.create(
        model="claude-sonnet-4-5",  # 2026-05 기준 사용한 모델
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": CLASSIFY_PROMPT.format(
                headline=headline,
                body=body[:2000]  # 본문은 앞 2000자만
            )
        }],
    )
    text = msg.content[0].text.strip()
    # 코드 블록으로 감싸서 오는 경우 제거
    if text.startswith("```"):
        text = text.split("```")[1]
        if text.startswith("json"):
            text = text[4:]
    return json.loads(text.strip())

또한, needs_verification 필드를 둔 이유는 운영하면서 깨달았다. ‘관계자에 따르면’, ‘X에 정통한 소식통’ 같은 표현은 LLM이 잘 잡아낸다. 이걸 플래그로 들고 다니면 후속 검증을 자동화하기 쉽다.

한국어 요약 품질 비교

그래서, 처음에는 GPT-4o로 시작했다가 Sonnet으로 옮겼다. 같은 헤드라인 30건을 두 모델에 던져보니 한국어 요약의 어색함이 체감상 달랐다. 다만 30건은 통계적으로 충분한 표본이 아니므로 일반화는 곤란하다. 이 시스템 한정의 체감일 뿐이다.

모델 한국어 요약 체감 구조화 출력 안정성 비고
Claude Sonnet 4.5 자연스러운 편 안정적 2026-05 기준 사용 모델
GPT-4o 무난한 편 안정적 비교용으로 30건 테스트
Gemini 2.5 Pro 길게 풀어쓰는 경향 안정적 비교용으로 30건 테스트

실제로, 표 안의 가격은 일부러 뺐다. 가격 정책은 자주 바뀌고, 작성 시점 가격을 단정하면 정보가 빠르게 낡는다. 각 공식 가격표를 직접 확인하는 편이 안전하다.

프롬프트에 안 넣은 것

특히, 엔티티 정규화(GME → GameStop Corp.)는 프롬프트에 안 넣었다. 모델이 자기 판단으로 표기를 바꾸기 시작하면 후속 매핑이 깨진다. 티커 리스트는 별도 매핑 테이블로 푼다. 프롬프트는 분류만, 정규화는 코드에서 한다.

MCP 서버로 감싸서 재사용하기

Lambda 안에서만 돌리면 임시 분석에 쓰기 불편하다. ‘GameStop eBay 보도 진짜인지 30분 전부터 흐름 보여줘’ 같은 요청을 Claude Desktop에서 바로 던지고 싶어서 MCP 서버로 한 겹 감쌌다. MCP 자체 사양은 Model Context Protocol 공식 사이트에 정리되어 있다.

from mcp.server.fastmcp import FastMCP
from typing import Literal
from datetime import datetime, timedelta

mcp = FastMCP("mna-monitor")

@mcp.tool()
def fetch_recent_alerts(
    severity: Literal["high", "medium", "all"] = "high",
    hours: int = 24,
    ticker: str | None = None,
) -> list[dict]:
    """최근 N시간 동안의 M&A 알림을 조회한다."""
    cutoff = datetime.utcnow() - timedelta(hours=hours)
    items = ddb_query(
        IndexName="severity-time-index",
        KeyConditionExpression="severity = :s AND ts > :t",
        ExpressionAttributeValues={
            ":s": severity if severity != "all" else None,
            ":t": int(cutoff.timestamp()),
        },
    )
    if ticker:
        items = [i for i in items if ticker in i["entities"]]
    return items

@mcp.tool()
def classify_url(url: str) -> dict:
    """임의의 뉴스 URL을 즉석에서 분류한다."""
    article = fetch_article(url)
    return classify_news(article.headline, article.body)

if __name__ == "__main__":
    mcp.run()

실제로, claude_desktop_config.json에 등록하면 끝이다.

{
  "mcpServers": {
    "mna-monitor": {
      "command": "uv",
      "args": ["run", "--directory", "/abs/path/mna-monitor", "server.py"]
    }
  }
}

따라서, 이렇게 해두면 Claude Desktop에서 "지난 6시간 동안 GameStop 관련 high 시그널 알림 보여줘"라고 하면 fetch_recent_alerts 툴이 자동 호출된다. 별도 대시보드를 짜지 않아도 된다.

이처럼, (여담이지만 이 부분이 MCP의 가장 큰 매력 같다. 임시 대시보드를 안 짜도 되는 것.)

운영하면서 본 함정

같은 사건을 여러 매체가 동시에 보도

해시 기반 dedup으로 부족하다. URL은 다 다르고, 헤드라인도 살짝씩 다르다. 처음엔 단순히 (entities, hour bucket)로 묶었는데 영어 매체와 한국어 매체가 같은 사건을 다르게 부르는 케이스를 놓쳤다.

그런데, 지금은 다국어 임베딩 모델로 헤드라인을 인코딩한 뒤, 같은 엔티티가 등장하고 코사인 유사도가 0.85 이상이면 같은 사건으로 묶는다. 이 임계값은 운영 데이터를 보면서 0.78 → 0.82 → 0.85로 올렸다. 더 올리면 같은 사건을 분리하는 false split이 늘어난다.

‘소식통 보도’와 ‘공식 발표’ 구분

LLM의 type 분류는 완벽하지 않다. needs_verification 플래그를 따로 둔 이유다. 이 플래그가 true면 SEC EDGAR API와 회사 IR 페이지를 추가로 호출해서 보도 시점 직전·직후의 공식 자료가 있는지 확인한다. 없으면 알림에 ‘미검증’ 라벨을 붙인다.

GameStop의 eBay 인수 제안처럼 화제성이 큰 보도일수록 이 단계가 중요하다. 추측 단계의 보도가 사실로 굳어가는 과정에서 시장이 한 번씩 헛발질을 한다.

비용은 단조롭게 늘어난다

그러나, 분류 호출 한 건에 입력+출력 합쳐 약 700~900 토큰이다. 하루 평균 200건이면 한 달 호출량이 약 6,000건. 가격 자체는 작성 시점에 직접 확인하는 편을 추천한다(정책이 자주 바뀐다). 분류기 호출이 단조롭게 늘어난다는 점은 변하지 않는다. 월 한도 알림과 일일 호출 카운터를 처음부터 박아두는 편이 나중에 덜 놀란다.

따라서, :::tip 가장 흔한 실수: 본문 전체를 모델에 넘긴다. 본문은 길면 수만 자다. 분류 목적에는 헤드라인 + 앞 2,000자면 충분하다. 입력 토큰을 1/5로 줄일 수 있다. :::

재시도와 멱등성

LLM 호출은 가끔 실패한다. 5xx, 타임아웃, JSON 파싱 실패. 재시도는 무조건 들어가야 하지만 그 전에 멱등성 키를 박아놓아야 한다. 같은 기사를 두 번 분류해도 알림이 두 번 가면 안 된다. 운영 중인 시스템은 (article_url_hash) 단일 키로 DynamoDB에 PutItem 시 ConditionExpression="attribute_not_exists(pk)"를 넣어 둔다.

프론트→백엔드 전환자 시선에서

따라서, 같은 도메인 문제도 시점이 다르다는 걸 매번 느낀다. 프론트에서 뉴스 리스트를 만지면 관심사는 ‘리스트 가상화’, ‘스와이프’, ‘읽음/안읽음 상태’, ‘낙관적 업데이트’ 같은 것들이다. 백엔드로 넘어오니 같은 화면 뒤에 ‘5분 이내 도달’, ‘중복 제거’, ‘재시도’, ‘비용’, ‘DLQ’, ‘TTL’ 같은 단어가 들어선다.

그런데, 프론트 출신이 백엔드를 처음 잡을 때 잘 빠뜨리는 항목을 체크리스트로 만들어뒀다.

  • 멱등성: 같은 입력이 두 번 들어와도 결과가 한 번만 발생하는가
  • 시간대: 모든 타임스탬프가 UTC로 저장되는가, 출력 시점에만 변환하는가
  • 재시도 backoff: 지수 백오프 + jitter가 들어가 있는가
  • DLQ: 결국 실패한 메시지는 어디로 가는가
  • TTL: DynamoDB 항목이 영원히 쌓이지 않는가
  • 비용 알림: API 비용이 임계값을 넘기면 알림이 오는가
  • 로깅: 분류 결과 + 입력 토큰 수 + 응답 시간이 같이 남는가

반면, 이 중 멱등성 하나만 빠뜨려도 운영자에게 알림이 두 번 가는 일이 생긴다. 시간대는 한 번 잘못 박으면 9시간씩 어긋난 데이터가 쌓인다(KST 기준).

회사에서 도입할 때 체크리스트

신입이 이 시스템을 받아서 굴린다고 가정하고 정리해뒀다. 순서대로 따라가면 하루 안에 돌릴 수 있는 수준이다.

  1. 소스 정하기: RSS 4~6개 + 뉴스 API 1개. 너무 많이 넣으면 노이즈 비율이 폭증한다.
  2. API 키와 비용 알림: Anthropic 콘솔에서 키 발급, 월 한도와 알림 임계값을 먼저 설정.
  3. 저장소: DynamoDB 테이블 1개 (PK = article_hash, sort = ts), TTL 활성화.
  4. 러너: AWS Lambda + EventBridge 5분 cron. PoC라면 GitHub Actions cron이 더 빠르다.
  5. 분류기: 위의 classify_news 함수. 프롬프트는 회사 도메인에 맞게 수정.
  6. 알림 채널: Slack Webhook + 백업으로 이메일.
  7. MCP 서버: 선택 사항. 임시 분석을 자주 한다면 추가하면 된다.
  8. 검증 단계: needs_verification=true면 SEC EDGAR / 회사 IR 페이지를 자동 호출.
  9. 모니터링: CloudWatch로 호출 수, 실패율, p95 응답 시간 대시보드.
  10. 러너북: 분류기 모델을 바꿀 때, 프롬프트를 바꿀 때, 새 소스를 추가할 때 절차를 문서로 남겨둔다.

반면, 이 중 9번과 10번이 자주 빠진다. 만든 사람이 운영도 같이 하는 동안엔 머릿속에 있어서 괜찮지만, 누군가 인수받을 때 무너진다.

주의 사항 몇 가지

  • 진위 확인은 시스템이 대신 못한다. high impact 알림이 떴어도 사람이 한 번 더 봐야 한다. 특히 GameStop처럼 화제성이 강한 종목은 추측 보도가 사실보다 빠르게 퍼진다.
  • 모델 응답을 그대로 신뢰해서 자동 매매 트리거에 연결하면 안 된다. 이건 정보 알림 시스템이지 트레이딩 시스템이 아니다.
  • 프롬프트를 자주 바꾸면 과거 알림과 새 알림의 분류 기준이 달라진다. 프롬프트 버전 필드를 알림 레코드에 같이 박아둬라.
  • 모델 버전이 바뀌면 같은 프롬프트라도 출력 톤이 미세하게 달라질 수 있다. 모델 ID도 레코드에 같이 적어둔다.

가격 임팩트 분석(보도 직후 시세가 얼마나 움직였는지 자동으로 묶기)은 아직 못 붙였다. 시세 API 비용과 라이선스 문제 때문에 다음 단계로 미뤄둔 상태다.

마지막

개인적으로는 이런 알림 시스템에 무거운 RAG를 붙이기보단, 분류 정확도를 올리는 명확한 프롬프트와 후속 검증 단계를 두 단계로 나누는 쪽이 더 낫다고 본다. GameStop의 eBay 인수 제안 같은 보도가 또 나올 때, 5분 안에 잡혀야 의미가 있다.

관련 글