Published on

크롬에 내장된 AI를 이용하여 한국어로 번역하기 컴포넌트 개발

Authors
  • avatar
    Name
    Yumi Yang
    Twitter

크폼에서는 영문 페이지의 경우 마우스 우클릭시 한국어(으)로 번역하는 기능이 있다. 영문으로 되어있는 페이지를 한국어로 번역하는 기능을 locale을 사용하지 않고, 한국어로 번역하기 버튼을 추가해서 번역할 수 있는 기능을 실험적으로 만들어봤다.

우선 가장 먼저 떠올랐던건 구글의 translate API를 사용하는 거였는데, 당연히 비용이 발생해서 다른 방법을 찾아봤다. 크롬이 제공하는 온디바이스 번역 모델(신경망 언어모델)을 브라우저가 자동 다운로드해서 사용할 수 있는 방법이 있었다.

내장된 AI를 사용한 번역을 참고했다. 비용이 없긴 하지만, 크롬(138 이상 지원, 25년 6월 24일 릴리즈)만 가능하고 아직 실험적 API라 갑자기 지원을 하지 않을수도 있는 단점이 있긴 했다. 그래도 여러개의 언어를 지원하고 다운로드가 빠르고 한번만 다운 받으면 오프라인에서도 사용가능하다는 장점이 있어서 한번 간단하게 컴포넌트로 만들어 적용했다.

아래 파일은 간단하게 구현해본 화면과 코드이다. 타겟 언어를 지원하는지 확인하고, 지원하면 바로 한국어로 번역 버튼을 누를 때 모델을 다운로드하고 번역도 같이 진행하도록 했다. translate complete
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <title>Chrome 온디바이스 번역: 상태/진행률 데모</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style>
      body {
        font-family:
          system-ui,
          -apple-system,
          Segoe UI,
          Roboto,
          sans-serif;
        padding: 24px;
        line-height: 1.6;
      }

      .row {
        margin: 12px 0;
      }

      .card {
        border: 1px solid #ddd;
        border-radius: 12px;
        padding: 16px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
      }

      .btn {
        padding: 10px 14px;
        border-radius: 10px;
        border: 0;
        cursor: pointer;
      }

      .btn:disabled {
        opacity: 0.6;
        cursor: not-allowed;
      }

      .btn-primary {
        background: #111;
        color: #fff;
      }

      .status {
        font-weight: 600;
      }

      .mono {
        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
      }

      .progress-wrap {
        height: 12px;
        background: #f1f1f1;
        border-radius: 999px;
        overflow: hidden;
        margin-top: 8px;
      }

      .progress-bar {
        height: 100%;
        width: 0%;
        background: #0b5;
        transition: width 0.2s ease;
      }

      .log {
        background: #fafafa;
        border: 1px dashed #ddd;
        padding: 12px;
        border-radius: 10px;
        max-height: 200px;
        overflow: auto;
        white-space: pre-wrap;
      }

      .badge {
        display: inline-block;
        padding: 2px 8px;
        border-radius: 999px;
        background: #eee;
        font-size: 12px;
      }
    </style>
  </head>

  <body>
    <h1>Chrome 온디바이스 번역 데모</h1>
    <div class="row card">
      <div>소스 → 타겟 언어</div>
      <div class="row">
        <label>source: <input id="srcLang" value="en" class="mono" style="width:90px" /></label>
        <label style="margin-left:12px;"
          >target: <input id="tgtLang" value="ko" class="mono" style="width:90px"
        /></label>
        <button id="checkBtn" class="btn btn-primary" style="margin-left:12px;">
          지원 상태 확인
        </button>
      </div>
      <div class="row">
        상태: <span id="status" class="status">-</span>
        <span id="statusBadge" class="badge" style="margin-left:8px; display:none;"></span>
      </div>
      <div class="row">
        모델 다운로드 진행률
        <div class="progress-wrap">
          <div id="bar" class="progress-bar"></div>
        </div>
        <div id="barText" class="mono" style="font-size:12px; margin-top:6px;">0%</div>
      </div>
    </div>

    <div class="row card">
      <div style="display:flex; gap:8px; align-items:center;">
        <button id="translateBtn" class="btn btn-primary">한국어로 번역</button>
        <button id="origBtn" class="btn">원문으로 보기</button>
        <span
          id="readyBadge"
          class="badge"
          title="Translator 인스턴스 준비여부"
          style="display:none;"
          >ready</span
        >
      </div>
      <div class="row">
        <strong>번역 대상 텍스트</strong>
        <p id="sample">
          Hello! This is a short sample paragraph to demonstrate on-device translation with Chrome's
          Translator API. It should become Korean after you click the button above.
        </p>
      </div>
    </div>

    <div class="row card">
      <div><strong>이벤트 로그</strong></div>
      <div id="log" class="log"></div>
    </div>

    <script>
      ;(function () {
        const $ = (id) => document.getElementById(id)
        const logEl = $('log')
        const statusEl = $('status')
        const badgeEl = $('statusBadge')
        const bar = $('bar')
        const barText = $('barText')
        const readyBadge = $('readyBadge')

        let translator = null
        let origText = null
        let currentAvailability = null

        function log(msg) {
          const ts = new Date().toLocaleTimeString()
          logEl.textContent += `[${ts}] ${msg}\n`
          logEl.scrollTop = logEl.scrollHeight
        }

        function setStatus(text, code) {
          statusEl.textContent = text
          badgeEl.style.display = 'inline-block'
          badgeEl.textContent = code || ''
        }

        function setProgress(pct) {
          const clamped = Math.max(0, Math.min(100, Math.round(pct)))
          bar.style.width = clamped + '%'
          barText.textContent = clamped + '%'
        }

        function featureSupported() {
          return typeof window.Translator !== 'undefined'
        }

        async function checkAvailability() {
          if (!featureSupported()) {
            setStatus('브라우저가 Translator API를 지원하지 않습니다.', 'unsupported')
            log('Translator API not found (Chrome 138+ 전용 / 실험적 기능)')
            return null
          }
          const sourceLanguage = $('srcLang').value.trim() || 'en'
          const targetLanguage = $('tgtLang').value.trim() || 'ko'
          try {
            const a = await Translator.availability({ sourceLanguage, targetLanguage })
            currentAvailability = a // 예: 'available', 'after-download', 'unavailable'
            const mapToKorean = {
              available: '즉시 사용 가능 (모델 이미 있음)',
              'readily-available': '즉시 사용 가능 (모델 이미 있음)',
              'after-download': '다운로드 후 사용 가능 (모델 필요)',
              downloadable: '다운로드 후 사용 가능 (모델 필요)',
              unavailable: '현재 사용 불가 (언어/환경 미지원)',
            }
            setStatus(mapToKorean[a] || `알 수 없음: ${a}`, a)
            log(`availability(${sourceLanguage}${targetLanguage}) = ${a}`)
            return a
          } catch (e) {
            setStatus('상태 확인 중 오류', 'error')
            log('availability() error: ' + (e?.message || e))
            return null
          }
        }

        async function ensureTranslator() {
          if (translator) return translator
          if (!featureSupported()) throw new Error('API 미지원')

          const sourceLanguage = $('srcLang').value.trim() || 'en'
          const targetLanguage = $('tgtLang').value.trim() || 'ko'

          // monitor 콜백에서 downloadprogress 이벤트 감시
          const t = await Translator.create({
            sourceLanguage,
            targetLanguage,
            monitor(monitor) {
              monitor.addEventListener('downloadprogress', (e) => {
                // e.loaded, e.total 등이 제공될 수 있음(브라우저 빌드에 따라 다름)
                const loaded = e?.loaded ?? 0
                const total = e?.total ?? 1
                const pct = total ? (loaded / total) * 100 : loaded % 100
                setProgress(pct)
                log(`downloadprogress: ${Math.round(pct)}% (loaded=${loaded}, total=${total})`)
              })
              monitor.addEventListener('downloadcomplete', () => {
                setProgress(100)
                log('downloadcomplete')
              })
              monitor.addEventListener('error', (err) => {
                log('monitor error: ' + (err?.message || err))
              })
            },
          })
          translator = t
          readyBadge.style.display = 'inline-block'
          log('Translator.create() ready')
          return translator
        }

        async function translateNow() {
          const p = $('sample')
          if (!origText) origText = p.textContent
          const t = await ensureTranslator()
          // 짧은 텍스트는 translate(), 긴 텍스트는 translateStreaming() 권장
          const out = await t.translate(p.textContent)
          p.textContent = out
          log('translate() done')
        }

        function restoreOriginal() {
          if (!origText) return
          $('sample').textContent = origText
          log('restore original text')
        }

        // UI 바인딩
        $('checkBtn').addEventListener('click', checkAvailability)
        $('translateBtn').addEventListener('click', async () => {
          $('translateBtn').disabled = true
          try {
            // 상태를 먼저 확인해 UX 개선
            const a = currentAvailability ?? (await checkAvailability())
            if (a === 'unavailable') {
              alert('현재 환경/언어쌍은 온디바이스 번역을 지원하지 않습니다.')
              return
            }
            await translateNow()
          } catch (e) {
            alert('번역 중 오류: ' + (e?.message || e))
          } finally {
            $('translateBtn').disabled = false
          }
        })
        $('origBtn').addEventListener('click', restoreOriginal)

        // 초기 상태
        setProgress(0)
        log('페이지 로드 완료. 언어 설정 후 "지원 상태 확인"을 눌러보세요.')
      })()
    </script>
  </body>
</html>



간단한 아이디어로 쉽게 번역을 구현할 수 있었는데, 실제로 번역이 필요한 페이지에는 매핑할 데이터가 많았고, 데이터 형태는 배열이 많거나 깊은 경우도 있고, 텍스트가 굉장히 긴 것도 있었다. 그러다보니 번역에 시간도 꽤 걸린다고 느껴지는 상황도 있어서 번연 루틴을 병렬 구조로 변경했고, 동시성 6 정도까지는 안정적이라고 생각해서 최대 6개까지 await을 사용해서 번역했다. 그럼에도 불구하고 크롬이 뻗어버리는 경우가 생겼다. ㅋㅋㅋ

stop

왜 멈췄나? (크롬 탭이 “먹통”처럼 보이는 이유)

  1. 메모리 폭증
    • 큰 배열/긴 문자열을 JSON.stringify/parse로 백업 → 원본 + 문자열 + 파싱 결과중복 객체/문자열이 한꺼번에 생김
    • 번역 전처리(태그 파싱/직렬화), 청크 분할, 결과 문자열 등 임시 버퍼가 계속 만들어져 **GC(가비지 컬렉션)**가 자주/오래 멈춤(Stop-the-World)
    • V8은 문자열을 내부적으로 UTF-16로 다루고, 긴 문자열 결합/슬라이싱 시 추가 복사가 발생할 수 있었다.
  2. 이벤트 루프가 숨을 못 쉰다 (메인 스레드 독점)
    • 수백/수천 개 항목을 한 번에 순회하면서 문자열 처리·DOMParser/XMLSerializer·정규식 등 CPU 바운드 작업메인 스레드에서 오래 돌림.
    • await가 있어도 마이크로태스크 큐에 Promise가 폭주하면 렌더링/입력 이벤트가 뒤로 밀려 UI가 멈춘 것처럼 보인다.
  3. Angular Change Detection 과부하
    • 항목 하나 바꿀 때마다 CD 사이클이 돌고, 깊은 중첩이면 전체 트리 재평가 + DOM 업데이트가 반복됨
    • 특히 OnPush가 아니거나, 변경을 잘게 여러 번 내보내면 프레임 드랍/지연이 커진다고 한다.
  4. DOM/파서 비용
    • <xhtml:…> 조각을 매번 DOMParser → TreeWalker → XMLSerializer로 처리하면, 노드 수만큼 객체 할당/해제가 급증함

결론적으로 메모리 부족(OOM)으로 탭이 죽을 수도 있긴한데 그 전에 GC 연속 정지 + 이벤트 루프 기아로 멈춘 것처럼 보이는 경우가 더 흔한 상황이다.


해결 방안

  1. 청크 + 휴식(Yield)
    • 큰 배열은 청크 단위로 처리하고 매 청크 끝마다 브라우저에 숨 좀 돌려주기
      const CHUNK = 50;
      for (let i = 0; i < keys.length; i += CHUNK) {
        await translatePathsInParallel(keys.slice(i, i+CHUNK), CONCURRENCY);
        await new Promise(r => requestAnimationFrame(() => r())); // 렌더/입력 처리 기회
      }
      
  2. 동시성 제한
    • concurrency를 2~6 사이로 제한. 나는 6으로 설정했는데 큰 배열이면 더 낮게(2~4) 설정
  3. 변경 합치기(Coalescing)
    • 진행률/상태 이벤트는 100~200ms 쓰로틀
    • 여러 텍스트를 바꾼 뒤에 한 번에 상위 참조 갱신(bumpRefAtPath) + markForCheck()
  4. 메모리 복제 줄이기
    • 백업을 JSON.stringify로 전체 값 저장하지 않고, “경로 → 원문 문자열”만 저장해서 복제 최소화
    • 긴 본문은 청크 번역하고 join도 큰 배열 한 번만
  5. 부분/지연 번역
    • references 같은 거대 배열은 기본 제외
    • 리스트는 보이는 구간만(on demand) 번역(가상 스크롤 + IntersectionObserver)
  6. DOM 파서 최소화
    • 태그 보존 번역은 필요한 필드에만 적용, 코드와 설명이 같이 있는 블럭이 있었는데 코드는 제외하고 텍스트만 번역함
    • 동일 필드 반복 처리 피하고(캐시), 가능한 텍스트노드만 변경
  7. Change Detection 최적화
    • 부모/상위 컴포넌트는 OnPush
    • 무거운 루프는 NgZone.runOutsideAngular()에서 돌리고, 결과 반영 시에만 zone.run()으로 들어와서 markForCheck()

이렇게 했더니 브라우저가 다운되는 문제는 사라졌고 가지고 있던 데이터 중에 가장 큰 데이터에서도 안정적이고 속도도 빠르게 돌아갔다. 앵귤러를 쓰고 있어서 서비스와 컴포넌트로 나눠서 개발했다. 서비스는 번역 엔진을 초기화하고 호출하는 기능이랑 단일 번역 기능을 구현해두고, 컴포넌트에서 번역할 대상을 스캔한 후에 깊이나 복잡도를 제한하고, 서비스에 있는 번역 기능을 병렬로 번역하도록 했다. 그리고 병렬로 번역하다보니 필드별로 진행률이 섞이는 경우가 있어 진행률이 늘어났다 줄어드는 경우가 생겼다. 어색하게 느껴져서 전체 필드 개수를 기반으로 누적 진행률을 표시하도록 해서 증가만 하도록 설정했다.

추가로 정규식을 여러개 쓰게 됐는데 유용해서 메모!

  1. 스캔 단계
    • isTranslatableString: [a-zA-Z] / looksLikeCodeOrId로 필터링
    • shouldExcludeKeyName: 키 이름 제외(정규식 대신 문자열 비교)
  2. 번역 여부 판단
    • 텍스트에 태그 있나 → /[<][^>]+>/
    • 한글/영문 비율 → /[가-힣]/, /[A-Za-z가-힣]/ (필요 시 태그 제거 후 판정: /<\/?[\w:-]+(?:\s+[^>]*?)?>/g)
  3. 청크 분할
    • 문단: /\n{2,}/
    • 문장: /(?<=[.!?])\s+/
  4. 경로 접근
    • 배열 인덱스 토큰: /^\[(\d+)\]$/
  5. 코드/URL 걸러내기
    • /^([A-Z0-9._-]{3,}|https?:\/\/|#include|function\s|\bclass\s)/i


그러나 결론은.... 우리 프로그램은 온프레미스 환경에서 사용하는 고객사가 많기 때문에 최신 크롬을 사용하지 못하는 경우가 많고, 폐쇄망에서 사용하는 경우도 많아서 해당 코드는 실험으로 끝났당. LLM이 있어서 즐겁고 빠르게 해봤다.