나만의 Chrome Extension 만들기

김재연·2026년 3월 7일
post-thumbnail
배경

카카오뱅크에 입사하게 되었다.

이런저런 과정이 있었고, 직무가 SRE로 바뀌게 되었다.

SRE를 하다 보니 기술을 보다 깊게 파야 하는 경우가 많았고, 그에 따라 공식 문서를 많이 보게 되었다.

모두가 알듯이 공식 문서는 대부분 영어(원문)로 이루어져 있다.

문제 상황

영어를 그리 잘하지는 않기에, Google Translate 기능을 활용해서 번역을 보고 있었는데 번역의 질이 좋지 않으니 원문으로 보았다가, 번역본으로 보았다가를 반복했다.

굉장히 불편한 삶이었다.

원래 사용하던 익스텐션은 몰입형 번역이었다.

원문과 번역본을 나란히 보여주는 방식이라, 굉장히 편리했다.

하지만 카뱅에서 이를 사용하기엔 무리가 있었다.

아무래도 도메인 특성으로 인해, 그리고 회사의 규모로 인해 사내에서 열람하는 문서 혹은 데이터들을 외부로 보내면 안 된다.

몰입형 번역 익스텐션은 사내에서 보는 문서의 데이터를 외부 서버로 보낼 위험이 크기 때문에 해당 익스텐션 설치가 허락되지 않았고, 불편한 삶이 이어졌다.

어느 날 구글 번역기가 정말 말을 너무 안 들어가지고 답답함이 최고조에 달해, 이 익스텐션을 내가 직접 만들어야겠다고 다짐했다.

크롬 익스텐션이 뭔데?

크롬 익스텐션은 Chrome 브라우저의 기능을 확장해주는 작은 프로그램이다.

구조는 생각보다 굉장히 단순하다.

my-extension/
├── manifest.json   ← 익스텐션의 메타데이터, 권한, 진입점 정의
├── popup.html      ← 익스텐션 아이콘 클릭 시 뜨는 UI
├── popup.js        ← popup의 동작 정의
└── content.js      ← 실제 웹 페이지에 inject되어 동작하는 스크립트

이 중에서 핵심은 content.js다.

이 스크립트가 실제 페이지에 inject되어 DOM을 읽고, 번역 결과를 원문 아래에 삽입해주는 역할을 한다.

manifest.json에는 이렇게 권한을 정의해준다.

{
  "manifest_version": 3,
  "name": "Local Translator",
  "version": "1.0",
  "permissions": ["activeTab", "scripting"],
  "action": {
    "default_popup": "popup.html"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}
번역 모델 선택 - M2M

번역 모델로는 Meta에서 만든 M2M-100을 선택했다.

이유는 단순하다.

  • 로컬에서 돌릴 수 있다.
  • 외부 API 없이 완전히 내부에서만 동작한다.
  • 다국어를 지원한다.

외부 서버로 데이터가 나가면 안 된다는 조건이 있었기 때문에, 로컬에서 완전히 돌아가는 모델이 필수였다.

M2M은 transformers 라이브러리를 통해 간단하게 로드할 수 있다.

from transformers import M2M100ForConditionalGeneration, M2M100Tokenizer

model = M2M100ForConditionalGeneration.from_pretrained("facebook/m2m100_418M")
tokenizer = M2M100Tokenizer.from_pretrained("facebook/m2m100_418M")
로컬 서버 구성

Chrome 익스텐션은 브라우저 내에서 동작하고, Python 모델은 별도 프로세스에서 동작하기 때문에 둘 사이의 통신이 필요하다.

짜피 Chrome에서 localhost로 통신이 가능하기 때문에, FastAPI로 간단하게 서버를 하나 띄워줬다.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class TranslateRequest(BaseModel):
    text: str
    src_lang: str = "en"
    tgt_lang: str = "ko"

@app.post("/translate")
def translate(req: TranslateRequest):
    tokenizer.src_lang = req.src_lang
    encoded = tokenizer(req.text, return_tensors="pt")
    generated_tokens = model.generate(
        **encoded,
        forced_bos_token_id=tokenizer.get_lang_id(req.tgt_lang)
    )
    return {"result": tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)[0]}

그러면 content.js에서는 이렇게 호출하기만 하면 된다.

const response = await fetch("http://localhost:8000/translate", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: paragraph, src_lang: "en", tgt_lang: "ko" }),
});
const data = await response.json();
청크 단위 병렬 번역

처음엔 페이지 전체 텍스트를 한 번에 번역 요청했다.

당연히 느렸다.

그래서 두 가지 개선을 적용했다.

1. 문단(paragraph) 단위로 청크를 나눈다

페이지의 <p> 태그를 기준으로 텍스트를 분리했다.

const paragraphs = document.querySelectorAll("p");

2. 청크를 병렬로 번역한다

const translateParagraph = async (p) => {
  const original = p.innerText.trim();
  if (!original) return;

  const response = await fetch("http://localhost:8000/translate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: original }),
  });
  const data = await response.json();

  const translated = document.createElement("p");
  translated.innerText = data.result;
  translated.style.color = "#888";
  translated.style.fontSize = "0.9em";
  p.after(translated);
};

await Promise.all([...paragraphs].map(translateParagraph));

Promise.all을 활용해 각 문단 번역을 병렬로 날려주었다.

결과는 성공적이었다.

원문 아래에 번역본이 나란히 붙는, 내가 원하던 그 형태가 완성됐다.

배운 것

결과보다 과정에서 배운 것이 더 크다.

옛날 같았으면 생각만 하고 그만뒀을 것이다.

"익스텐션 만드는 거 복잡하지 않나?", "번역 모델 어떻게 붙이지?", "로컬 통신 되나?" 같은 물음표들이 실행을 막았을 것이다.

하지만 AI가 있으니 빠른 실행이 가능했다.

구조를 모르면 물어보고, 코드가 막히면 물어보고, 에러가 나면 물어봤다.

그렇게 생각에서 완성품까지 가는 시간이 극적으로 줄었다.

이제 진짜 그런 세상이 다가온 것 같다.

상상이 현실이 되는 세상. 거기로 조금 더 다가가기 위해 많은 노력을 해야 할 것 같다.

profile
끊임없이 '성장'하는 개발자 김재연입니다.

0개의 댓글