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

임기호·2026년 5월 25일

댓글필터

목록 보기
2/3

1편에서는 왜 만들었는지와 무엇을 만들 건지를 정리했다.

이번에는 실제로 브라우저 안에서 댓글을 어떻게 분류하고, 본문과 댓글을 어떻게 LLM에 넘겼는지에 대한 기록이다.

처음 정한 방식이 그대로 간 건 거의 없었다.

댓글필터


처음에는 감성 분석이면 될 줄 알았다

가장 먼저 붙인 건 다국어 BERT 감성 분류 모델이었다.

bert-base-multilingual-uncased-sentiment

댓글을 긍정 / 부정 / 중립 정도로 나누면 읽을 만한 댓글도 어느 정도 걸러낼 수 있을 거라고 생각했다.

금방 틀렸다는 걸 알았다.

예를 들어 이런 댓글이 있었다.

철렁했습니다.
놀랐어요.

모델은 이런 반응을 부정으로 잡았다.

이상한 결과는 아니다. 이 모델은 별점 리뷰 같은 감성 데이터를 기준으로 학습된 모델이었다. 문제는 모델이 아니라 내가 시키는 일이었다.

내가 알고 싶은 건:

이 댓글이 긍정적인가?

가 아니었다.

이 댓글을 굳이 읽을 가치가 있는가?

였다.

질문 자체가 달랐다.


분류 대신 비슷한 댓글을 찾기로 했다

그래서 분류 모델을 버리고 paraphrase-multilingual-MiniLM-L12-v2로 바꿨다.

이 모델은 댓글에 라벨을 붙여주지 않는다. 문장을 384차원의 벡터로 만들어준다.

그다음은 내가 정한다.

읽을 만한 댓글 예시와 버릴 댓글 예시를 미리 만들어두고, 새로운 댓글이 어느 쪽과 더 가까운지만 보는 방식이다.

SHOW 예시는 15개를 만들었다.

저도 비슷한 일을 겪었는데...
개인적으로 이 부분은 좀 다르게 생각해요.

경험을 공유하거나 자기 의견이 있는 댓글들이다.

HIDE 예시는 10개였다.

놀랐어요!
감사합니다 잘 봤어요.
ㅎㅎ 좋은 글이네요.

짧은 반응이나 내용이 거의 없는 댓글들이다.

분류 코드는 단순하다.

const bestShow = showScores.reduce((a, b) =>
  a.sim > b.sim ? a : b
);

const bestHide = Math.max(...hideScores);

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,
    };

새 댓글과 SHOW/HIDE 예시의 코사인 유사도를 구하고 어느 쪽에 더 가까운지 본다.

이 방식이 마음에 들었던 건 모델을 다시 학습할 필요가 없다는 것이었다.

잘못 걸러진 댓글이 나오면 예시를 고치면 된다.

모델 재학습 X
예시 수정 O

분류 기준을 코드와 데이터 수준에서 내가 계속 조절할 수 있었다.


모델을 돌리기 전에 버릴 건 버렸다

그렇다고 댓글을 전부 임베딩 모델에 넣지는 않았다.

굳이 모델까지 돌릴 필요가 없는 댓글이 꽤 많았다.

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 };
}

15자보다 짧거나, ㅎㅎ, ㅋㅋ, ㅠㅠ 같은 문자만 있으면 바로 제외한다.

스팸 키워드는 결국 네 개만 남았다.

구독
방문해주세요
놀러오세요
이웃추가

처음에는 더 많이 넣었다.

URL이 있으면 스팸으로 잡아보기도 했고, "저는", "제가" 같은 인칭 표현도 규칙에 써봤다.

오탐이 훨씬 많았다.

규칙을 정교하게 만들려고 할수록 정상 댓글까지 같이 잘렸다.

결국 남은 기준은 단순했다.

확실한 것만 규칙으로 자르고, 애매한 건 모델에 넘긴다.


분류는 됐는데 요약은 할 수 없었다

여기까지는 댓글 하나를 보여줄지 말지를 판단하는 문제였다.

그런데 만들다 보니 하고 싶은 게 늘어났다.

이 글 3줄로 요약해줘
댓글 반응은 어때?
사람들이 가장 많이 이야기한 건 뭐야?

이건 임베딩 모델로 할 일이 아니었다.

생성이 필요했다.

그래서 두 번째 모델로 WebLLM과 Qwen2.5-1.5B-Instruct-q4f16_1-MLC를 붙였다.

구조가 이렇게 됐다.

댓글 하나 분류
    ↓
MiniLM 임베딩

본문 요약 / 댓글 분석
    ↓
Qwen 1.5B

작은 작업까지 전부 LLM에 맡기지 않고 필요한 곳에만 썼다.


900MB 받고 나서 실패하면 너무 늦다

WebLLM은 WebGPU가 있어야 한다.

처음에는 모델 초기화를 그냥 시도했다.

그러면 지원하지 않는 환경에서도 모델 다운로드부터 시작할 수 있다.

모델이 거의 900MB라서 이건 꽤 나쁜 경험이었다.

그래서 다운로드 전에 잘라냈다.

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

별것 아닌 코드인데 효과는 컸다.

안 되는 환경에서는 안 된다는 걸 가능한 한 빨리 알려주는 게 낫다.


모델을 페이지마다 다시 올릴 수는 없었다

WebLLM 엔진도 처음에는 React 훅 안에서 관리할 생각이었다.

그런데 페이지가 다시 마운트될 때마다 900MB짜리 모델을 GPU에 다시 올리는 구조는 말이 안 됐다.

그래서 엔진을 컴포넌트 밖으로 뺐다.

let _engine: unknown = null;

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

useRef가 아니라 모듈 레벨의 let으로 둔 이유가 이것이다.

React 컴포넌트의 생명주기와 모델의 생명주기를 분리했다.

페이지가 바뀌어도 이미 만들어진 엔진은 다시 쓴다.


32K면 다 넣을 수 있었다. 문제는 그다음이었다

Context window는 32K로 잡았다.

context_window_size: 32768

Qwen 2.5가 지원하는 범위 안에서 본문과 댓글을 꽤 많이 집어넣을 수 있었다.

처음에는 이걸 장점으로만 봤다.

본문 전체와 댓글 100개가 들어간다면 그냥 다 넣고 물어보면 되지 않을까 싶었다.

들어가긴 했다.

잘 답하는 건 별개의 문제였다.

본문이 길어지면 1.5B 모델이 질문과 상관없는 부분까지 답변에 끌고 들어왔다.

컨텍스트 창에 들어갈 수 있다는 것과, 그 안에서 필요한 정보를 제대로 골라내는 건 다른 문제였다.


한 번에 답시키지 않았다

그래서 질문을 두 번에 나눴다.

첫 번째 단계에서는 답을 만들지 않는다.

질문과 관련 있는 부분만 찾는다.

Stage 1

질문과 관련 있는 구절만 뽑아라.
최대 5개.

그 결과만 두 번째 단계에 넣는다.

Stage 2

위에서 뽑은 구절만 가지고 답해라.

설정도 약간 다르게 했다.

Stage 1
temperature 0.1

Stage 2
temperature 0.2 ~ 0.4

첫 단계는 최대한 보수적으로 자료를 고르고, 두 번째 단계에서만 실제 문장을 만든다.

소형 모델이 본문 전체를 보면서 여기저기 내용을 섞는 현상이 눈에 띄게 줄었다.

다만 모든 요청에 이걸 적용하면 오히려 손해였다.

본문이 짧은데 한 번 더 추출 단계를 거치면 중요한 문장이 Stage 1에서 빠지는 경우가 있었다.

그래서 길이가 일정 수준을 넘을 때만 사용했다.

const STAGE1_THRESHOLD = 2000;

const needsStage1 =
  postBody.length > STAGE1_THRESHOLD;

긴 글에만 두 단계 추론을 사용한다.


"본문 요약"을 시켰는데 댓글을 요약했다

다음 문제는 컨텍스트였다.

본문과 댓글을 전부 넣어둔 상태에서:

3줄로 요약해줘.

라고 하면 가끔 댓글 내용까지 섞어서 답했다.

모델 입장에서는 이상할 게 없었다.

내가 본문도 줬고 댓글도 줬으니 둘 다 참고한 것이다.

그래서 컨텍스트를 세 가지로 나눴다.

모드모델에 주는 내용
post본문
comments댓글
all본문 + 댓글

본문 요약이면 post.

댓글 반응 분석이면 comments.

둘을 비교하거나 종합하는 질문이면 all.

시스템 프롬프트도 각각 다르게 만들었다.

post에는 댓글을 사용하지 말라고 넣고, comments에는 본문의 내용을 추측하지 말라고 넣었다.

사용자가 직접 바꿀 수도 있지만, 대부분은 질문에서 먼저 판단한다.

detectContext(input)

LLM에게 아무거나 다 보여주고 잘 알아서 하길 기대하는 것보다, 필요한 것만 보여주는 쪽이 결과가 안정적이었다.


temperature도 하나로 쓰지 않았다

처음에는 생성 설정도 하나로 통일하려 했다.

실제로 써보니 요약과 의견 분석에 같은 값을 사용할 이유가 없었다.

const isSummary =
  /요약|핵심|정리|3줄/.test(input);

const temperature =
  isSummary ? 0.2 : 0.4;

요약은 낮게 잡았다.

표현의 다양성보다 원문을 벗어나지 않는 게 중요했다.

댓글 분석이나 코멘트는 조금 높였다.

여기서는 똑같은 문장만 반복하는 것보다 어느 정도 표현 변화가 있는 쪽이 나았다.


소형 모델은 같은 말을 자꾸 했다

Qwen 1.5B를 쓰면서 눈에 띈 문제가 하나 더 있었다.

같은 단어나 문장 구조를 반복했다.

~입니다.
~입니다.
~입니다.

이런 식이었다.

그래서 penalty를 추가했다.

frequency_penalty: 0.6,
presence_penalty: 0.4,

완전히 없어지지는 않았지만 반복은 눈에 띄게 줄었다.


프롬프트도 중복되기 시작했다

post, comments, all 프롬프트를 따로 만들다 보니 공통 규칙이 계속 복사됐다.

예를 들면:

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

처음에는 각 프롬프트에 따로 넣었다.

한 군데 고치고 다른 프롬프트는 안 고치는 일이 생겼다.

그래서 공통 부분은 COMMON_RULES로 뺐다.

모드별 프롬프트에는 그 모드에서만 필요한 내용만 남기고 마지막에 공통 규칙을 붙인다.

별것 아닌 리팩터링인데 프롬프트가 늘어나기 시작하니 이게 훨씬 관리하기 편했다.


지금 구조

결국 처음 생각했던 것보다 역할이 많이 나뉘었다.

댓글
 │
 ├─ 명백한 짧은 반응 / 스팸
 │      ↓
 │    규칙으로 제거
 │
 └─ 애매한 댓글
        ↓
     MiniLM 임베딩
        ↓
 SHOW / HIDE 예시와 비교


본문 / 댓글에 대한 질문
        ↓
   컨텍스트 선택
 post / comments / all
        ↓
   긴 글이면 Stage 1
   관련 구절만 추출
        ↓
      Qwen
        ↓
      답변

처음에는 브라우저에서 모델 하나 돌리면 끝날 거라고 생각했다.

실제로는 모델을 무엇에 쓰지 않을지를 정하는 일이 더 많았다.

명백한 댓글은 정규식으로 자르고, 댓글 분류는 작은 임베딩 모델에 맡기고, 문장 생성이 필요한 요청만 LLM으로 보낸다.

LLM에 넘길 때도 전체 데이터를 무조건 주지 않고 질문에 맞는 컨텍스트만 고른다.

지금은 이 구조가 가장 납득이 간다.


아직 제일 마음에 안 드는 건 본문 요약 품질이다.

다음으로는 한국어에 더 잘 맞는 모델을 바꿔보는 것과, 긴 글을 잘라 검색한 뒤 필요한 부분만 넘기는 RAG-lite 방식을 테스트해볼 생각이다.

그런데 그 전에 더 기본적인 문제가 하나 있었다.

Worker가 아예 뜨지 않았다.

모델 코드도 프롬프트도 전부 만들어놓고 "AI 모델 다운로드" 버튼을 누르면 Worker 초기화 실패만 떴다.

그 원인을 찾는 데 며칠을 썼다.

3편 — Web Worker 세 번 막힌 기록

0개의 댓글