포스트

플랫폼 위의 첫 앱 — 뉴스로 기업 관계도를 그리는 이슈 링커, 그리고 가족에게 열기

매일 밤 뉴스와 공시에서 "어느 회사가 어느 회사와 무슨 관계인가"를 뽑아 출처가 달린 그래프로 만듭니다. 4B 모델의 정밀도는 0.73, 기준은 0.8이었습니다. 모델을 바꾸는 대신 무엇을 그리지 않을지 정했습니다.

플랫폼 위의 첫 앱 — 뉴스로 기업 관계도를 그리는 이슈 링커, 그리고 가족에게 열기

이 글의 독자: 지난 글에서 이어지는 시리즈의 마지막 편입니다. 앞 글을 안 읽으셨어도 따라올 수 있게 썼습니다.


들어가며 — 플랫폼은 그 위에 무언가 올라가야 증명된다

지금까지 만든 것은 플랫폼입니다. 게이트웨이, 인증, GPU 대기열, RAG, 관측, 백업. 그런데 플랫폼은 그 위에서 돌아가는 애플리케이션이 있어야 제대로 시험됩니다. 회사에서도 사내 LLM 게이트웨이의 진짜 고객은 다른 팀의 서비스입니다.

그래서 첫 애플리케이션을 정했습니다. 저는 경제 뉴스를 매일 봅니다. 그런데 뉴스는 사건 단위로 흘러가고, “이 회사와 저 회사가 어떤 관계인지”는 머릿속에서 직접 이어야 합니다.

이슈 링커: 매일 뉴스와 공시를 모아서, AI 반도체 쪽 기업·지표·정책 사이의 관계를 근거 문장과 출처가 달린 그래프로 만든다. 아침마다 “어제 무엇이 바뀌었는지”를 요약해 보낸다.

처음부터 정한 선이 하나 있습니다. 주가나 매매 신호는 다루지 않습니다. 증권사 API는 계좌 주인의 개인 거래용이라 데이터를 남에게 보여 줄 수 없고, 이건 투자 도구가 아니라 관계를 읽는 도구입니다.

링커는 게이트웨이의 한 고객입니다. 자기 API 키를 받아 게이트웨이를 통해 임베딩과 추출을 하고, 똑같이 rate limit과 대기열을 거치고, 똑같이 사용량이 기록됩니다. 플랫폼이 “다른 서비스”를 받아들이는지 시험하는 셈입니다.


Step 0 — 오늘 나올 용어 4개

용어뜻왜 중요한가
카탈로그추적할 대상(기업, 지표, 정책) 목록과 각각의 별칭기사에서 무엇을 찾을지 정함. 43개로 시작
Claim기사 하나에서 뽑은 “A가 B에 대해 무엇을 한다”는 문장 하나근거 문장을 원문 그대로 인용해야 함
Edge여러 claim을 모은 A↔B 관계 하나그래프에 실제로 그려지는 선
Precision (정밀도)그려진 것 중 실제로 맞는 비율사람이 보는 것. 이 글의 주인공

Step 1 — 모으기: 30분마다 (결정 1)

처음엔 하루 한 번 밤에 모으려 했습니다. 그런데 RSS 피드는 하루치도 안 들고 있습니다. 밤에 한 번 읽으면 낮의 기사를 놓칩니다. 그래서 수집은 30분마다, GPU를 쓰는 일(추출)만 밤에 하기로 나눴습니다.

출처무엇비고
GDELT전 세계 뉴스 메타데이터검색 API는 두 네트워크에서 모든 요청에 429. 대신 15분마다 올라오는 원본 파일을 직접 읽음
RSS연합뉴스, 코리아헤럴드, 한경 글로벌 (영문판)robots.txt와 이용 조건 먼저 확인
FRED / ECOS미국 CPI·고용·금리, 한국 수출·기준금리지표 발표를 날짜가 있는 “이슈”로
OpenDART / EDGAR한국·미국 공시공급 계약, 투자 공시

네이버 뉴스 검색은 쓰지 않았습니다. 신규 신청이 닫혔고, 약관상 검색 결과를 AI로 요약하는 것이 금지되어 있다고 보고되어 있습니다.

첫날 GDELT에서 11만 7천 개 기사가 들어왔습니다. 카탈로그 기업을 제목에서 이름으로 부르는 기사만 남기자 401개. 그 과정에서 별칭 규칙이 계속 늘었습니다.

  • “Intel”은 애매합니다. 회사가 아닌 뜻으로도 제목에 나오니, 그것만으로는 남기지 않습니다.
  • 빅테크는 혼자서는 의미가 없습니다. “Apple”만 나오는 기사는 이 주제(AI 반도체)와 상관없는 경우가 대부분이라, 카탈로그의 다른 대상과 같이 나올 때만 남깁니다.
  • “CPI”만으로는 어느 나라 CPI인지 모릅니다.
  • 쇼핑 할인 기사는 아예 걸러냅니다. 회사 이름은 나오지만 관계는 없습니다.

같은 사건을 여러 매체가 쓴 기사는 게이트웨이의 임베딩으로 하나의 이야기(story)로 묶습니다.


Step 2 — 뽑기: 4B 모델은 얼마나 정확한가

밤 02:30, 링커는 이야기마다 한 번씩 게이트웨이를 불러 관계를 뽑습니다. 출력은 JSON 스키마로 강제하고, 관계 종류는 정해진 목록(경쟁, 협력, 공급, 투자, 수요를 끌어냄, 영향)에서만 고릅니다.

평가 세트는 첫날의 이야기 157개입니다. 라벨은 AI 코딩 도우미(Claude)가 프롬프트와 같은 규칙으로 달았고, 제 검수는 아직 일부만 했습니다. 이 점은 아래 숫자를 읽을 때 감안해 주세요.

시도정밀도재현율
프롬프트 v1, 모델 출력 그대로0.210.70
v1 + 검사 규칙 (관계 종류, 자기 자신과의 관계 금지, 근거는 원문 그대로)0.380.70
v2 (관계별 허용 대상, 방향 예시) + 검사0.460.66
v2 + 제목에 나온 대상만 보여 주기, 근거가 양쪽 이름을 다 포함해야 함0.450.40
v3 (부정 규칙: 주가 움직임, 리뷰, 순위 목록은 관계가 아님. “대부분의 제목에는 관계가 없다”)0.730.54
v3, 생각 모드—0.02

몇 가지가 인상적이었습니다.

보이지 않는 것을 주면 지어냅니다. GDELT는 기사 본문에 나온 조직 이름도 알려 줍니다. 그런데 모델은 제목만 봅니다. 본문에만 있는 이름을 후보로 주자, 모델은 제목과 엮어서 관계를 만들어 냈습니다. “ASML과는 아직 계약 없음”이라는 제목에서 ASML이 Intel에 공급한다를 뽑는 식으로요.

“없다”고 말해도 된다고 알려 줘야 합니다. v3에서 가장 효과가 컸던 한 줄은 “대부분의 제목에는 관계가 없다”였습니다. 모델은 무언가를 뽑으라고 하면 무언가를 뽑습니다.

생각 모드는 쓸 수 없었습니다. 103개 중 94개에서 3,000토큰을 다 쓰도록 생각만 하다가 답을 못 냈습니다.

남은 오류는 대부분 방향이었습니다. “Oracle expands chip supply deal with Nvidia”를 Oracle이 Nvidia에 공급한다로 읽습니다.


Step 3 — 0.73 < 0.8, 그래서 무엇을 할 것인가 (결정 2)

설계할 때 기준을 정해 두었습니다. 사람 검토 없이 그래프에 그리려면 정밀도 0.8. 0.73은 못 미칩니다.

선택지는 둘이었습니다. 더 큰 모델(GPU 업그레이드, 클라우드, 맥미니의 큰 모델)을 쓰거나, 4B를 유지하고 여러 독립 매체가 같은 관계를 말할 때만 그리는 것. 하드웨어를 사기 전에 두 번째를 먼저 재 보기로 했습니다.

GDELT 30일치를 다시 읽었습니다(기사 11,888개, 이야기 7,534개). 여기서 나온 관계(edge) 161개에 전부 라벨을 달았습니다.

몇 곳이 말했나정밀도
기사 1개0.22
매체 2곳0.41
매체 3곳 이상0.68
2곳 이상 전체0.57

0.57. 기대보다 훨씬 낮았습니다. 이유를 보니 당연했습니다. 틀린 해석이 제목을 따라 반복됩니다. 같은 “Oracle expands chip supply deal with Nvidia”를 세 매체가 비슷한 제목으로 쓰면, 같은 오류가 세 번 나옵니다. 독립된 증거가 아니라 같은 오류의 복사본이었던 겁니다.

그리고 불편한 사실 하나. 앞 단계의 0.73은 낙관적이었습니다. v3의 부정 규칙은 그 103개 이야기를 보면서 썼습니다. 시험 문제를 보고 공부한 셈입니다. 한 달치 뉴스에서 기사 하나짜리 관계는 22%만 맞았습니다.

프롬프트를 고친 데이터로 그 프롬프트를 평가하면 안 됩니다. 다음 프롬프트나 모델은 한 번도 보지 않은 세트로 재야 합니다.

그런데 관계 종류별로 나누자 다른 그림이 보였습니다.

관계 (2곳 이상)정밀도
경쟁 (competes)14 / 16
협력 (partners)9 / 10
둘 합계23 / 26 = 0.88
공급, 투자, 수요, 영향7 / 27 = 0.26

방향이 없는 관계는 맞고, 방향이 있는 관계는 틀립니다. “A와 B가 경쟁한다”는 어느 쪽에서 읽어도 같지만, “A가 B에 공급한다”는 방향을 맞혀야 하고, 4B는 그걸 자주 뒤집습니다.

그래서 결정은 모델을 바꾸는 게 아니라, 무엇을 그리지 않을지였습니다.

  • 그래프에는 경쟁과 협력만, 2곳 이상이 말할 때만 그린다. (정리 후 22 / 25 = 0.88)
  • 방향이 있는 관계는 버리지 않고 “검증되지 않음” 표시와 함께 타임라인에만 둔다.
  • 더 강한 모델이 생기면, 처음 보는 세트로 다시 잰다.

모델 없이 정확한 관계: 공시

공시는 다릅니다. 한국 DART의 공급 계약 공시에는 계약 상대방이 양식의 칸으로 적혀 있습니다. 그건 모델이 읽을 필요가 없습니다. 칸을 그대로 읽어서 관계로 만들었고, 3년치에서 10 / 10 맞았습니다. 회사가 직접 공시한 관계는 공시 하나만으로 그립니다.

가장 정확한 추출은 추출하지 않아도 되는 것입니다. 구조화된 데이터가 있으면 모델보다 먼저 그걸 쓸 것.

(작은 함정: OpenDART 서버는 JDK 24부터 기본으로 막힌 오래된 TLS 암호 방식만 제공했습니다. 링커만 그 방식을 다시 허용했습니다.)


Step 4 — 아침 브리핑은 모델이 쓰지 않는다 (결정 3)

매일 07:00에 “어제 무엇이 바뀌었는지”를 웹과 텔레그램으로 보냅니다. 새로 뽑힌 관계, 새로 나온 지표와 공시, 오늘 발표 예정인 지표.

이 브리핑에는 LLM을 쓰지 않았습니다. 저장된 관계와 이슈를 템플릿에 채울 뿐입니다. 모델이 쓰지 않으면 잘못 말할 수가 없습니다. 요약이 필요한 곳이 아니라 정확해야 하는 곳이니까요.

질문하는 기능(“뉴스에 묻기”)은 모델을 씁니다. 다만 구조를 이렇게 나눴습니다. 링커는 질문에 나온 대상을 찾아서 번호 붙은 근거(관계, 근거 문장, 지표, 공시)와 프롬프트를 만들어 브라우저에 돌려줍니다. 브라우저는 그걸 사용자 본인의 로그인 토큰으로 게이트웨이에 보냅니다. 그러면 사용량과 한도가 링커가 아니라 질문한 사람 앞으로 잡힙니다. 게이트웨이는 한 줄도 바꾸지 않았습니다.

그래프에 안 보이던 가장 큰 뉴스

며칠 써 보다가 이상한 걸 발견했습니다. 10월 31일, “Intel 주가 25% 급락” 같은 제목이 30개 넘게 쏟아졌는데, 링커 어느 페이지에도 없었습니다. 링커는 두 대상 사이의 관계를 보여 주는데, 이건 회사 하나에 일어난 사건이었으니까요.

그래서 “가장 많이 보도된 이야기”를 따로 만들었습니다. 지난 24시간의 이야기를 서로 다른 매체 수로 줄 세웁니다. 같은 사건의 이야기들은 임베딩 유사도 0.75 이상이면 합칩니다. 이 숫자도 쟀습니다. 10월 30~31일 데이터에서 같은 사건끼리는 0.76~0.92, 가장 가까운 무관한 쌍은 0.70이었습니다.


Step 5 — 가족에게 열기

마지막 단계는 기술보다 사람 쪽이었습니다. 이걸 가족이 쓸 수 있게 만드는 것.

들어오는 길. Tailscale의 tailnet 초대가 아니라 노드 공유를 썼습니다. 가족은 제 네트워크 전체가 아니라 이 기계 하나만 보입니다. 계정은 제가 하나씩 만들고, 첫 비밀번호는 임시라서 첫 로그인 때 본인이 바꿉니다. 그래서 가족의 비밀번호는 본인만 압니다.

한국어. 웹 UI를 한국어 기본, 영어 전환 가능하게 바꿨습니다. 번역 라이브러리 대신 TypeScript 객체 두 개로 했습니다. ko가 en과 모양이 다르면 컴파일이 실패하니, 빠진 번역이 배포될 수 없습니다. 그리고 단어를 바꿨습니다. “그래프에 묻기”는 가족에게 아무 뜻이 없었습니다. “뉴스에 묻기”가 됐습니다.

화면. 기본은 어두운 화면이고, 선택지는 밝게/어둡게 둘뿐입니다. “기기 설정 따르기”가 헷갈린다는 말을 듣고 뺐습니다. 로그인 페이지(Keycloak)도 앱에서 고른 테마를 따라가게 했습니다.

밤새 리뷰. 가족에게 열기 직전의 밤, 게이트웨이·링커·웹·인프라를 다섯 갈래로 나눠 코드 리뷰를 돌렸습니다. 가장 중요한 발견은 보안이었습니다. 운영 대시보드(Grafana)의 비상용 관리자 비밀번호 인증이 웹 주소를 통해서도 열려 있었습니다. 원래는 노드 안에서만 쓰려던 것인데, nginx가 인증 헤더를 그대로 넘기고 있었습니다. 노드를 공유받은 사람이라면 시도해 볼 수 있는 상태였습니다. nginx에서 그 헤더를 지우고, 바깥에서는 막히고 안에서는 되는 것을 확인했습니다. 나머지는 가족이 처음 쓰는 날 질문을 잃어버릴 만한 것들이었습니다. 확인 없이 지워지는 대화, 다른 대화의 정지 버튼, 세션이 끝나면 사라지는 입력 중인 질문, 휴대폰에서 Enter가 줄바꿈이 안 되던 것.

방문자에게는. 포트폴리오를 보는 분들께 계정을 열어 드릴 수는 없습니다. 공개 로그인은 집 네트워크와 GPU 한 장을 아무에게나 여는 일이고, 공용 계정은 대화가 섞이고 사람별로 회수할 수 없습니다. 대신 앱 안의 “어떻게 만들었나” 페이지를 정적으로 떠서 이 블로그에 올렸습니다. 요청이 노드를 지나가는 길을 움직이는 지도로 보여 주고, 이 시리즈의 측정값들을 정리해 두었습니다.

👉 어떻게 만들었나 — Local LLM


시리즈를 마치며

6편 동안 한 일을 숫자로 줄이면 이렇습니다.

편핵심 숫자
1. 엔진동시 16명: vLLM 460 tok/s, Ollama의 6배
2. 게이트웨이동시 24개 → 16개 처리, 8개 대기 ≤ 5.4초, 전부 기록
3. RAG정답 조각을 받은 답 13 / 13 정확
4. 검색 품질Hit@5 0.88 → 0.96, 리랭커 15초 → 0.17초
5. 운영클라우드에서 복구 훈련 통과, 재부팅 후 KV cache 37k 고정
6. 이슈 링커정밀도 0.57 → 그리는 것을 줄여서 0.88

그리고 숫자보다 오래 남을 것 몇 가지.

  1. 잴 수 있는 것을 추정하지 않는다. 임베딩 속도도, 정밀도도, 추정은 10배씩 빗나갔습니다.
  2. 유명한 기법보다 내 데이터의 숫자. HyDE도, 이웃 조각도, 다수결도 제 데이터에서는 손해였습니다.
  3. 모델을 키우기 전에 문제를 줄일 수 있는지 본다. 방향 없는 관계만 그리기, 공시는 칸에서 읽기, 브리핑은 템플릿으로.
  4. 해 보지 않은 복구는 희망이다. 복구도, 알림도, 재부팅도 한 번씩은 실제로.

아직 열려 있는 것도 많습니다. 추출 라벨을 제가 직접 검수하는 일, 처음 보는 세트로 더 큰 모델을 재는 일, 가족이 실제로 어떻게 쓰는지 보는 일. 다음 글은 아마 그 이야기가 될 겁니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.