2편 - Transformers.js와 WebLLM으로 브라우저에서 댓글을 분석한다

임기호·2026년 5월 25일

댓글필터

목록 보기
2/3

Transformers.js와 WebLLM으로 브라우저에서 댓글을 분석한다 — 댓글필터 개발기 (2)

1편에서는 왜 / 무엇을 만드는지를 다뤘다. 이 글은 어떻게 돌리는지에 대한 기록이다. 결정의 이유, 잘못 짚었던 지점, 그래서 어떻게 갈아엎었는지를 위주로.

https://merbl-filter.vercel.app/posts


Phase 1 — 분류 모델 대신 임베딩 모델

처음엔 BERT 다국어 모델 (bert-base-multilingual-uncased-sentiment) 로 [긍정 / 부정 / 중립] 식 감성 분류를 돌렸다. 잘 안 됐다. 이 모델이 별점 리뷰로 학습된 거라 "철렁했습니다", "놀랐어요" 같은 충격받은 독자 반응까지 죄다 부정으로 찍어버렸다. 사실 처음부터 도구가 다른 일을 하고 있던 거다 — 내가 원한 건 "읽을만한 댓글이냐" 였지 "좋은 평이냐" 가 아니었다.

그래서 paraphrase-multilingual-MiniLM-L12-v2 로 갈아엎었다. 이건 분류 모델이 아니라 문장 임베딩 모델이다. 댓글을 384차원 벡터로 만들고, 미리 준비해둔 예시 세트와의 코사인 유사도로 점수를 매기는 방식.

예시 세트가 곧 모델

분류 라벨을 모델에 학습시키는 대신 예시 25개를 직접 작성해서 박아뒀다.

  • SHOW 예시 15개"저도 비슷한 일을 겪었는데..." (경험공유), "개인적으로 이 부분은 좀 다르게 생각해요" (의견있음) 같은 글
  • HIDE 예시 10개"놀랐어요!", "감사합니다 잘 봤어요", "ㅎㅎ 좋은 글이네요" 같은 짧은 반응
// 댓글 임베딩 vs 예시 임베딩 — 가장 가까운 쪽을 본다
const bestShow = showScores.reduce((a, b) => (a.sim > b.sim ? a : b));
const bestHide = Math.max(...hideScores);

// -1~1 범위의 차이를 0~100으로 정규화
const raw = (bestShow.sim - bestHide + 1) / 2;
const score = Math.round(raw * 100);

return score >= 50
  ? { label: 'worth_reading', tag: bestShow.tag, score }
  : { label: 'noise', tag: 'noise', score };

학습 단계가 필요 없으니 예시만 손보면 분류 기준이 바로 바뀐다. 잘못 걸러진 댓글이 보이면 그걸 예시에 추가하기만 하면 됨. 이게 분류 모델 대비 가장 큰 실용적 차이였다.

임베딩 전에 거르는 게 먼저

모든 댓글을 모델에 다 통과시킬 필요는 없다. "굳이 모델 돌릴 필요 없는 것들" 은 정규식으로 먼저 쳐낸다.

// 15자 미만, ㅎㅋㅠ만으로 구성, 노골적 스팸 키워드 → 사전 차단
if (text.length < 15) return { label: 'noise', score: 5 };
if (/^[ㅎㅋㅠㅜㅡ~^.!?\s]+$/.test(text)) return { label: 'noise', score: 5 };
if (SPAM_KEYWORDS.some((k) => text.includes(k))) return { label: 'spam', score: 0 };

스팸 키워드는 "구독", "방문해주세요", "놀러오세요", "이웃추가" 4개로 좁혔다. 처음엔 URL 정규식, 인칭 대명사("저는/제가") 같은 것도 넣었는데 오탐이 많아서 다 뺐다. "규칙은 명백한 것만" 이 결국 답이었다.

Phase 2 — WebLLM 셋업

Phase 2는 WebLLM + Qwen2.5-1.5B-Instruct-q4f16_1-MLC 조합. 댓글 단위 분류는 Phase 1로 충분하지만, 본문 요약댓글 반응 분석 같은 생성이 필요한 일은 LLM이 들어가야 한다.

WebGPU 사전 감지로 fail-fast

WebLLM은 WebGPU를 요구한다. 없으면 그냥 못 돌리는데, 그걸 모르고 모델을 받기 시작하면 900MB를 다운로드한 뒤 에러가 난다. 그래서 다운로드 전에 미리 차단한다.

if (typeof navigator === 'undefined' || !('gpu' in navigator)) {
  setPhase2Error('이 기기는 WebGPU를 지원하지 않습니다.');
  return;
}

이 한 줄이 있고 없고가 사용자 경험 차이가 크다. 모바일에서 잠깐 들어왔다가 모델 받느라 데이터만 까먹는 일이 없어진다.

엔진은 모듈 레벨 싱글톤

let _engine: unknown = null; // ← 모듈 레벨

export function useWebLLM() {
  // ...
}

React 컴포넌트가 마운트/언마운트될 때마다 900MB 모델을 GPU에 다시 올리면 죽는다. 엔진 인스턴스를 모듈 스코프에 박아두면 페이지 이동에도 유지된다. "훅 안에 useRef" 가 아니라 "모듈 안에 let" 인 이유.

32K context window

context_window_size: 32768

Qwen 2.5는 native 32K를 지원한다. 댓글 100개 + 본문까지 다 넣어도 입력은 들어간다. 이게 안 되면 청킹·요약 캐스케이드 같은 부가 로직이 필요해지는데, 그걸 안 해도 되는 게 컸다. 다만 들어가는 것잘 답하는 것은 다른 문제라서, 그게 다음 절(2-Stage 추론) 의 동기가 됐다.

본문이 길면 헛소리한다 → 2-Stage 추론

LLM에 본문 전체와 질문을 한 번에 던지면, 소형 모델은 질문과 무관한 구절까지 답변에 끌고 들어온다. 그래서 추론을 두 단계로 쪼갰다.

Stage 1 (temp 0.1, 짧게): "질문과 관련 있는 구절만 인용해라"
        ↓ 추출된 구절
Stage 2 (temp 0.2~0.4, stream): "이 구절로만 답해라"

Stage 1의 출력은 인용 구절 5개 이하로 제한하고, Stage 2에서는 그 구절만 컨텍스트로 사용한다. 환각이 줄어드는 게 체감으로 느껴진다.

다만 본문이 짧을 땐 Stage 1을 스킵한다.

const STAGE1_THRESHOLD = 2000;
const needsStage1 = postBody.length > STAGE1_THRESHOLD;

짧은 본문은 Stage 1을 거치면 오히려 중요 문장이 추출 단계에서 빠질 위험이 있다. 모델한테 두 번 일 시키는 비용보다 손실이 큼.

본문 요약인데 댓글이 섞인다 → 컨텍스트 모드 분기

"3줄 요약해줘" 라고 했는데 답변에 댓글이 섞여 나오는 일이 있었다. 본문이랑 댓글을 한 컨텍스트에 다 넣어두니 LLM이 뭘 요약해야 하는지 헷갈리는 것.

세 가지 모드로 쪼갰다.

모드컨텍스트쓰임
post본문만본문 요약, 핵심 정리
comments댓글만댓글 반응 분석, 스팸 찾기
all둘 다본문+댓글 종합

각 모드마다 시스템 프롬프트가 다르다. post 모드 프롬프트엔 "댓글은 무시하라", comments 모드 프롬프트엔 "본문 정보는 추측하지 마라" 가 들어간다. 모드는 사용자가 토글할 수도 있고, 키워드 감지(detectContext()) 로 자동 결정되기도 한다.

작은 디테일들

작업 유형에 따라 temperature 다르게

const isSummary = /요약|핵심|정리|3줄/.test(input);
const temperature = isSummary ? 0.2 : 0.4;

요약은 정확성이 우선이라 낮게, 분석·코멘트는 약간의 표현 다양성이 필요해서 높게. 일률적으로 0.7 같은 값을 쓰는 것보다 체감 품질이 명확히 좋았다.

반복 억제 페널티

frequency_penalty: 0.6
presence_penalty: 0.4

소형 모델이 같은 표현·같은 문장을 반복하는 경향이 있다. frequency_penalty 를 0.6 정도까지 올리니 "~입니다. ~입니다. ~입니다." 같은 패턴이 눈에 띄게 줄었다.

공통 규칙 한 덩이로

모든 시스템 프롬프트의 끝에 COMMON_RULES 한 덩이를 붙여놨다.

- 본문에 없는 사실은 만들지 마라
- 가짜 URL, 가짜 인용을 만들지 마라
- "분석해보겠습니다" 같은 메타 텍스트 금지
- 같은 문장을 반복하지 마라

이런 방어 규칙을 시스템 프롬프트마다 따로 적으면 동기화가 안 된다. 한 덩이로 빼고 모드별 프롬프트 끝에 합치는 식.

회고

아직 미흡한 부분:

  • 본문 요약 품질이 여전히 가장 큰 숙제다. 다음으로는 한국어 특화 모델 교체와 RAG-lite 청킹을 보고 있다.
  • 예시 세트는 지속적으로 모니터링하면서 잘못 걸린 댓글이 보일 때마다 추가·수정하는 식으로 계속 다듬어 갈 예정.

다음 편 예고

이 글은 모델·프롬프트 위주였다. 그런데 이 모든 게 동작하기 전에, 그냥 "Worker가 안 뜬다" 는 문제로 며칠을 날렸다. 다음 편은 그 기록이다.

3편 — Web Worker 3중 버그를 잡은 기록
webpack이 react-dom을 같이 묶는 함정, COEP/CORP, Blob URL과 WASM 경로

0개의 댓글