2026-03-28
브라우저에서 돌아가는 1.7MB 형태소 분석기를 만들었다 — Garu 개발기
시작은 벡터 검색이었다
블로그에 검색 기능을 붙이고 싶었다. 벡터 검색을 쓰려고 했는데, 무료 Neon DB에서는 pg_bigm도 안 되고, 한국어 형태소 분리도 지원을 안 했다. 서버 사이드에서 형태소 분석기를 돌리자니 무료 인프라 환경에서는 부담이 크고.
그래서 생각한 게, 아예 웹 브라우저에서 형태소 분석을 직접 돌리면 어떨까? 클라이언트에서 분리하고, 검색하고, DB에 저장하는 것까지 다 처리하면 서버 의존성이 사라지잖아. 근데 찾아보니까 브라우저에서 실용적으로 돌아가는 한국어 형태소 분석기가 없었다.
기존 형태소 분석기(Kiwi, MeCab-ko, Komoran)는 전부 서버 환경 전제. 모델 크기만 해도 20~50MB. 브라우저에서 돌리기엔 현실적으로 불가능했다.
그래서 직접 만들기로 했다. 조건은 네 가지:
- 모델 크기 수 MB 이내 (gzip으로 1MB 미만)
- 행렬 연산 없이 실시간 처리
- 기존 분석기에 준하는 정확도
- 서버 통신 없이 완전한 오프라인 동작
이렇게 시작한 게 Garu(가루) 프로젝트다. "텍스트를 곱게 갈아주는" 분석기.
두 번의 실패
Phase 1: BiLSTM 지식 증류
첫 번째 시도는 Kiwi의 BiLSTM+CRF 모델을 작은 BiLSTM으로 지식 증류하는 거였다. 음절 단위 인코딩에 INT8 양자화까지 해서 4.65MB, F1 88%까지는 만들었다.
근데 LSTM 추론에는 본질적으로 행렬 곱셈이 필요하다. JavaScript/WASM에서 행렬 연산은 네이티브 대비 수십 배 느리고, 배치 처리도 안 된다. 실시간으로 쓸 수가 없었다.
교훈: 신경망은 아무리 압축해도 브라우저 제약과 근본적으로 충돌한다. 행렬 연산을 완전히 없애야 한다.
Phase 2: 자소 단위 시퀀스 라벨링
Phase 1의 교훈을 반영해서, 한국어를 자소(ㄱ, ㅏ, ㄴ 등)로 분해한 뒤 자소 단위로 시퀀스 라벨링을 시도했다. 자소 어휘 크기가 약 50토큰이니까 임베딩 테이블이 엄청 작아질 거라고 생각했다.
결과는 처참했다. 1.2M 문장 학습에 시간이 극도로 오래 걸렸고(자소 시퀀스 길이가 음절 대비 2~3배), 수렴 자체가 안 됐다. 자소 수준의 패턴으로는 형태소 경계를 잡기에 너무 저수준이었다.
교훈: 자소 분해는 음절 수준의 의미 정보를 파괴한다. 형태소 분석에는 음절 이상의 단위가 맞다.
세 번째 시도: 코드북 + 비터비
두 번의 실패에서 제약이 명확해졌다.
- 행렬 연산 없어야 함 (Phase 1 교훈)
- 음절 이상 단위 사용 (Phase 2 교훈)
- 모델 크기 수 MB 이내
이 세 가지를 다 만족하는 방법으로, 완전히 비신경망 기반의 코드북 + 비터비 아키텍처를 설계했다. 기존 분석기(Kiwi) 출력을 대규모 코퍼스(kowikitext, 1.2M 문장)에서 수집하고, 접미사 코드북 + 내용어 FST 사전 + 트라이그램 비용 테이블로 압축해서 래티스 기반 비터비 디코딩을 하는 구조다.
신경망이 아예 없다. 학습 파라미터 0개. 전부 룩업과 1차원 배열 인덱싱으로만 동작한다.
76.1%에서 95.3%까지
초기 구현의 F1은 76.1%였다. Kiwi가 87.9%니까 11.7%p 격차. 여기서부터 체계적으로 올려갔다.
학습 데이터 전환 (+6%p)
가장 큰 개선은 데이터 소스를 바꾼 거였다. Kiwi 출력에서 코드북을 뽑는 대신, NIKL 모두의 말뭉치 골드 데이터에서 직접 추출했다. Kiwi와 NIKL 사이에는 분절 기준 차이가 꽤 있었는데, 골드 데이터를 쓰니까 이런 불일치가 싹 없어졌다. 이거 하나로 +6%p. 전체 최적화에서 제일 큰 기여도였다.
파라미터 튜닝 (+2%p)
비터비 비용 함수의 파라미터를 그리드 서치로 최적화했다. morpheme_penalty를 3.0에서 0.25로 12배 낮췄는데, 이게 의외로 효과가 컸다. 기존 값이 코드북의 다형태소 패턴을 너무 페널티 줘서, 긴 접미사 패턴이 제대로 활용이 안 되고 있었다.
OOV 분류 보정 (+2%p)
미등록어 품사 분류를 NIKL 기준에 맞게 세부 조정했다. 따옴표/괄호를 SW에서 SS로, 물결표를 SO로, 구분자를 SP로 분리하는 식이다. 이런 사소해 보이는 보정이 +2%p나 됐다.
위키백과 고유명사 제거 (+0.4%p, -55% 크기)
재밌는 발견이었다. 위키백과에서 뽑은 350K 고유명사 엔트리를 전부 빼니까 오히려 정확도가 올랐다. 모델 크기는 55% 줄었고. "더 많은 사전 = 더 높은 정확도"가 항상 성립하지는 않더라.
그 외 세부 최적화
다중 품사 FST(+0.3%p), 희소 트라이그램 양자화(-270KB, 무손실), 단어 바이그램(+0.2%p). 이런 최적화를 거쳐 코드북 모델만으로 F1 90.9%까지 올렸다.
스마트 어절 캐시 (90.9% → 93.8%)
코드북+비터비가 ~91%에서 수렴하자, 완전히 다른 관점으로 접근했다. 한국어 어절은 높은 빈도로 반복되고, 동일 어절의 분석 결과는 96%의 일관성을 보인다. 이걸 이용해서 비터비가 틀리는 어절만 선별해서 캐시하는 전략을 썼다.
단순 고빈도 캐시가 아니라, 학습 데이터에서 비터비를 돌리고 정답과 비교해서 교정 효과가 있는 어절만 골라 넣었다. 10,000 엔트리, 328KB로 +2.9%p.
문맥 기반 후처리 규칙 (93.8% → 95.1%)
비터비는 래티스 안에서만 최적 경로를 찾기 때문에, 어절 간 문맥에 의존하는 품사 판별에는 한계가 있다. 이걸 보완하기 위해 7종의 후처리 규칙을 추가했다.
- 보조용언(VX) 교정: "~고 있다", "~어 있다"의 있/없/하/보/주를 VV/VA에서 VX로
- 접속조사(JC) 교정: 명사 사이의 과/와/이랑을 JKB에서 JC로
- 보격조사(JKC) 교정: 되다/아니다 앞의 가/이를 JKS에서 JKC로
- 의존명사(NNB) 교정: 관형사형 어미 뒤의 수/것/데/바를 NNG에서 NNB로
- 접미사(XSN/XSV/XSA) 교정: 명사 뒤의 성/적/화, 하/되를 적절한 품사로
추가 모델 용량은 0KB. 코드 로직만으로 +1.3%p를 올렸다. 특히 VX(+10.8%p), JC(+66.2%p) 같은 특정 품사에서 극적인 개선이 있었다.
문장 단위 비터비 (95.1% → 95.3%)
기존에는 어절 단위로 비터비를 돌렸는데, 어절 경계를 넘어서는 동음이의어 해소가 안 되는 문제가 있었다. 비터비를 문장 단위로 확장해서 어절 간 품사 전이까지 고려하게 했다. +0.2%p.
최종 결과
| 시스템 | 모델 크기 | F1 (NIKL MP) | 브라우저 |
|---|---|---|---|
| Kiwi | ~40MB | 87.9% | 비실용적 |
| MeCab-ko | ~50MB | ~85% | 불가 |
| Garu | 1.7MB | 95.3% | 93KB WASM |
Kiwi 대비 모델 크기 1/23, 정확도 +7.4%p. gzip 전송 시 모델+WASM 합쳐서 약 1MB. npm install garu-ko로 서버/브라우저 모두 사용 가능.
시도했지만 안 됐던 것들
뭐든 잘 된 것만 쓰면 재미가 없으니까, 안 됐던 것도 적어본다.
- 모호성 테이블 (-2.7%p): 대안 품사를 더 많이 넣었더니 비터비가 오히려 헤맸다.
- 자기 학습 루프 (0%p): 빈도 부스팅 셀프 러닝을 시도했는데, 수렴 안 하고 진동만 했다.
- 퍼셉트론 재순위 (+0.1%p): 남은 오류의 58%가 분절 오류인데, 재순위로는 분절 경계를 교정 못 한다.
- 분해 선호도 페널티 (-0.2%p): 과분절이 오히려 늘었다.
기술 스택
- 코어 엔진: Rust (코드북 분석기, FST 사전, 비터비 디코더)
- 브라우저 바인딩: wasm-pack + wasm-bindgen
- 트레이닝 파이프라인: Python (코드북 추출, 평가, 그리드 서치)
- API 래퍼: TypeScript (npm 패키지)
앞으로의 과제
- 분절 오류 해소: 잔여 오류의 상당 부분이 분절 오류다. 경량 2-layer 1D CNN 재순위기(503KB, int8)와의 앙상블 실험에서 F1 95.8%를 달성했지만, WebGPU 성숙도를 기다리고 있다.
- 교차 도메인 성능: 뉴스→구어 같은 도메인 전환 시 8%p 하락. 도메인 적응 전략이 필요하다.
- OOV 처리 강화: 신조어나 고유명사에 대한 자모 분해 기반 형태소 추측 규칙 도입.
- N-best 디코딩: 현재 1-best만 지원. 후속 태스크 활용을 위해 N-best 확장 예정.
"텍스트를 곱게 갈아주는" 형태소 분석기라는 이름값을 하려면 아직 갈 길이 좀 남아있다.
GitHub: ongjin/garu | npm: garu-ko