자체 호스팅 AI 번역 엔진을 구축했습니다 — 무엇이 작동했고, 무엇이 실패했으며, 결정 뒤에 숨겨진 수치들
요약
실시간 웹 페이지 번역을 위한 오픈 소스 자체 호스팅 엔진인 Transdom 구축 경험을 공유합니다. PyTorch에서 CTranslate2(int8 양자화)로 추론 엔진을 전환하여 속도를 6배 향상시키고 메모리를 절감한 기술적 의사결정 과정을 다룹니다.
핵심 포인트
- Transdom은 Python/FastAPI 서버와 JS 클라이언트 라이브러리로 구성된 자체 호스팅 엔진임
- int8 양자화를 적용한 CTranslate2 도입으로 추론 속도를 약 6배 향상시킴
- 양자화 과정에서 모델 크기를 절반 이하로 줄이면서도 번역 품질을 유지함
- 가정이 아닌 벤치마크를 통한 데이터 기반의 기술적 의사결정 강조
저는 단순히 튜토리얼을 따라가는 대신 실제 프로젝트를 구축하며 응용 AI (Applied AI)를 공부해 왔습니다. 이 포스트는 제가 가장 자랑스럽게 생각하는 프로젝트인 Transdom에 관한 것입니다. Transdom는 AI 모델을 사용하여 모든 웹 페이지를 실시간으로 번역하는 오픈 소스(open-source) 자체 호스팅(self-hosted) 엔진입니다. 단어당 API 과금이 발생하지 않으며, 제3자가 귀하의 데이터를 보유하지도 않습니다.
하지만 이 포스트는 단순히 "내가 무엇을 만들었는지 보세요"라는 식의 글이 아닙니다. 이것은 "내가 무엇을 측정했고, 무엇을 틀렸으며, 무엇을 되돌렸는지"에 대한 포스트입니다. 왜냐하면 그 부분이 바로 튜토리얼에서 보통 생략되는 AI 구축의 핵심이기 때문입니다.
Repo: github.com/hjdesigner/transdom
npm: npm install transdom
Transdom이 실제로 하는 일
두 가지 요소가 필요하며, 둘 다 필수적입니다. 서버를 호출할 클라이언트 라이브러리가 없다면 번역할 내용이 없기 때문입니다:
1. 서버 실행 (Python + FastAPI, Docker를 통해 자체 호스팅 — 데이터와 인프라 비용을 직접 소유합니다):
git clone https://github.com/hjdesigner/transdom
cd transdom
docker compose up --build
2. 페이지에 클라이언트 추가. transdom.js (React hook 포함)는 페이지의 DOM을 스캔하여 텍스트를 실행 중인 서버로 보내고, MutationObserver를 통해 프레임워크의 리렌더링(re-render)으로 나중에 추가된 콘텐츠를 포함하여 번역된 내용을 다시 교체합니다.
npm install transdom
import { Transdom } from "transdom";
const transdom = new Transdom({
...
이것이 통합의 전부입니다. 흥미로운 모든 일은 서버 측에서 일어나며, 그것이 제가 이야기하고 싶은 부분입니다.
결정 1: 양자화 (quantization), 그리고 왜 "측정"이 "가정"보다 나은가
원래 서버는 PyTorch를 통해 번역 모델을 직접 실행했습니다. 작동은 했지만, 느리고 무거웠습니다. 놀라운 일은 아닙니다. PyTorch는 유연성을 위해 구축되었지, 단일 모델 유형을 최대한 빠르게 서빙하기 위해 구축된 것이 아니기 때문입니다.
저는 추론 엔진(inference engine)을 **int8 양자화 (int8 quantization)**를 적용한 CTranslate2로 전환했습니다. 이는 모델 가중치를 32비트 부동 소수점(32-bit floats)에서 8비트 정수(8-bit integers)로 줄이는 기술로, 약간의 수치 정밀도를 희생하는 대신 메모리를 크게 절감하고 속도를 크게 향상시킵니다.
저는 이것이 도움이 될 것이라고 가정하지 않았습니다. 직접 벤치마크(benchmark)를 수행했습니다:
| 엔진 (Engine) | 15개 번역 소요 시간 |
|---|---|
| PyTorch (float32) | ~5.76s |
| CTranslate2 (int8) | ~0.87s |
대략 **6배의 속도 향상 (6x speedup)**이 있었으며, 디스크 상의 모델 크기도 ~465MB에서 ~226MB로 줄어들었습니다. 또한 소규모의 수동 작성 참조 세트(hand-written reference set)를 사용하여 두 엔진을 문장 단위로 비교하며 BLEU/chrF를 이용한 품질 검사(quality check)도 수행했습니다. 양자화(quantized)된 버전은 품질을 잃지 않았습니다. 제 테스트 세트에서는 오히려 점수가 미세하게 더 높게 나왔습니다 (한 문장은 float32 버전보다 더 자연스러운 구절을 생성했습니다). 양자화는 추측이 아닌 수치로 증명된 명백한 승리였습니다.
결정 2: 시맨틱 캐싱 (semantic caching) — 그리고 이것이 실제로 효과를 발휘하는 지점
단순한(naive) 캐시는 동일한 문자열만 인식합니다. 하지만 실제 UI 텍스트는 미세한 변형과 함께 반복됩니다: _"You have successfully logged in"_과 _"You have logged in successfully"_는 의미는 같지만 서로 다른 문자열입니다.
저는 문장 임베딩 (sentence embeddings, all-MiniLM-L6-v2)과 코사인 유사도 (cosine similarity)를 사용하는 두 번째 캐시 계층을 추가했습니다. 모든 번역된 문자열은 임베딩됩니다. 새로운 요청이 들어오면 캐시된 임베딩과 비교하며, 유사도가 임계값(threshold)을 넘으면 모델을 다시 실행하는 대신 캐시된 번역을 재사용합니다.
def find_similar_translation(source_lang, target_lang, embedding):
best_match, best_score = None, 0.0
for (cached_pair, _), entry in semantic_cache.items():
...
위의 두 문장을 대상으로 테스트했을 때: 유사도 점수는 0.9892로, 제가 설정한 임계값인 0.92를 훨씬 상회했으며 캐시된 번역이 올바르게 재사용되었습니다. 또한 의도적으로 유사하지만 다른 문장인 _"Welcome to our website"_와 _"Welcome to the website"_를 테스트했습니다. 소유격의 의미가 다르기 때문에 시스템은 이들을 병합하기를 올바르게 거부했습니다 (점수가 임계값 미만). 임계값은 공식에서 도출된 것이 아니라, 실제 쌍들을 테스트하고 경계선이 어디에 위치해야 하는지 관찰하며 결정되었습니다.
결정 3 (흥미로운 부분): 데이터와 마주하며 살아남지 못한 가설
나중에 저렴한 호스팅 티어(hosting tier)를 위해 서버를 충분히 가볍게 만들려고 시도할 때, 저는 시맨틱 캐시(semantic cache)가 서버의 기본 메모리 사용량에 가장 큰 기여를 하고 있다고 의심했습니다. sentence-transformers가 의존성(dependency)으로 PyTorch를 불러오기 때문이었습니다. 이를 비활성화하는 것이 명백한 해결책처럼 보였습니다.
저는 이를 기능 플래그(feature flag)로 만들고, 제가 찾을 수 있는 모든 혼란 변수(버퍼링된 로그, 잘못된 .env 파일, 오래된 컨테이너 등)를 제거한 후, docker stats를 사용하여 해당 기능의 온/오프 상태에 따른 유휴 RAM(idle RAM)을 측정했습니다:
| 시맨틱 캐시 (Semantic cache) | 유휴 RAM (Idle RAM) |
|---|---|
| 활성화 (Enabled) | ~378 MB |
| 비활성화 (Disabled) | ~354 MB |
약 6%의 차이였습니다. 가설이 틀렸습니다. 실제 기본 비용은 임베딩 모델(embedding model)이 아니라 CTranslate2와 transformers의 오버헤드(overhead)에서 발생했습니다. 저는 기능을 활성화된 상태로 유지했습니다. 작은 메모리 절약 효과가 이미 유용하다고 증명된 캐싱 레이어(caching layer)를 포기할 만큼의 가치는 없었기 때문입니다. 그리고 최적화가 성공한 척하는 대신, README에 이 _부정적인 결과(negative result)_를 기록했습니다.
이것은 그 어떤 단일 기능보다도, 제가 실제 "AI 엔지니어링 (AI engineering)" 기술이라고 지목하고 싶은 습관입니다. 즉, 작동하는 코드를 작성하는 것이 아니라, 특정 코드 조각이 추가하는 복잡성을 감수할 가치가 있는지 아는 것, 그리고 그 답이 "아니오"일 때조차 기꺼이 그 결과를 공개하는 것입니다.
번역을 견뎌내는 용어 사전 — 가짜 고유명사로 해결하기
사이트에는 절대 번역되면 안 되는 특정 용어(브랜드 이름)나, 항상 동일한 방식으로 번역되어야 하는 용어(모델이 생성하고 싶은 대로가 아니라 항상 "Entrar"라고 표시되어야 하는 "로그인" 버튼 등)가 필요합니다. 까다로운 점은, 그 용어가 긴 문장 _내부_에 있을 때 어떻게 되느냐 하는 것입니다.
저의 첫 번째 시도는 텍스트를 모델로 보내기 전에 XVARX0, XVARX1과 같은 플레이스홀더(placeholder) 토큰으로 용어를 마스킹(masking)한 다음, 나중에 실제 값을 복원하는 방식이었습니다. 문장 안에 플레이스홀더가 하나 있을 때는 작동했습니다. 하지만 두 개가 되면 번역이 엉망이 되었습니다. 모델이 생소한 영숫자 토큰 주변에서 일관성(coherence)을 잃는 것처럼 보였습니다.
해결책: 대신 용어들을 _가짜 고유명사(fake proper nouns)_로 교체하는 것이었습니다. Zurpaflex, Woblinka, Trencivo와 같이 만들어낸 단어이지만 이름 형태를 띤 단어들입니다. 번역 모델은 고유명사를 손대지 않고 그대로 유지하려는 경향이 매우 강하며(이는 말 그대로 모델이 학습된 방식의 일부입니다), 따라서 이름 형태의 토큰은 기호가 많은 플레이스홀더(placeholder)보다 번역 과정에서 훨씬 더 안정적으로 살아남습니다. 한 문장에 여러 개가 있더라도 마찬가지입니다.
"Click Login or Sign up to use Transdom"
→ "Clique em Entrar ou Criar conta para usar o Transdom"
단 한 번의 패스(pass)로 세 개의 용어 사전(glossary) 용어가 모두 보존되고 정확하게 대체되었습니다.
시도했지만 의도적으로 출시하지 않은 두 가지
자동 언어 감지 (Automatic language detection). 분류 모델(xlm-roberta-base-language-detection)을 테스트해 보았는데, 전체 문장에 대해서는 성능이 좋았으나(95% 이상의 신뢰도), Transdom이 실제로 가장 많이 번역하는 짧은 UI 문자열에 대해서는 성능이 저조했습니다. "Login", "Home"은 50-65%의 신뢰도를 기록했고, "hey"는 스와힐리어로 오분류되었습니다. 자체 호스팅된 배포 환경은 이미 자신의 사이트 소스 언어를 알고 있기 때문에, 자동 감지는 실제로는 거의 존재하지 않는 문제를 해결하기 위해 거대한 모델, 지연 시간(latency), 그리고 새로운 실패 모드(failure mode)를 추가하는 꼴이었습니다. 저는 이 실험을 증거를 삭제하는 대신, 문서화된 의도적인 미채택 사례로 리포지토리(repo)에 남겨두었습니다.
"더 똑똑한" 번역을 위한 로컬 LLM (A local LLM for "smarter" translation). 텍스트 내부의 {{templateVariables}}를 보존하는 것과 같은 예외 상황을 처리하기 위해 Ollama를 탐색해 보았습니다. 그러다 깨달았습니다. transdom.js는 프론트엔드 프레임워크가 {{variables}}를 실제 값으로 모두 해결한 후의 **렌더링된 DOM (rendered DOM)**을 읽는다는 사실을 말입니다. LLM이 해결했을 문제는 이 아키텍처에서는 존재하지 않았습니다. 존재하지도 않는 문제를 프롬프팅(prompting)으로 해결할 수는 없습니다. 해결책은 더 큰 솔루션을 구축하는 것이 아니라, 전제가 틀렸음을 인식하는 것이었습니다.
보안에 대해서도 솔직하게
README에는 무엇이 포함되어 있는지(IP당 속도 제한 (per-IP rate limiting), 페이로드 크기 제한 (payload size limits), CORS)와 무엇이 포함되어 있지 않은지(분산 남용 (distributed abuse), 요청당 컴퓨팅 비용을 고려하지 않은 요청 기반 제한 (request-based limits not accounting for per-request compute cost), 내장된 인증 없음 (no built-in auth), TLS 없음 (no TLS))가 명확하게 명시된 섹션이 있습니다. 자체 호스팅 (Self-hosting)을 한다는 것은 운영자가 이러한 책임의 일부를 물려받는다는 것을 의미합니다. 그렇지 않은 척하는 것은 나중에 누군가가 어려운 방식으로 이를 깨닫게 될 뿐입니다.
이 프로젝트가 실제로 나에게 가르쳐준 것
서버, 클라이언트 라이브러리 (client library), React 훅 (React hook)이 포함된 npm 패키지, 테스트, Docker, 랜딩 페이지에 이르기까지 Transdom을 엔드 투 엔드 (end to end)로 구축하는 과정은 AI 엔지니어가 실제로 매일 다루는 거의 모든 계층을 접하게 해주었습니다. 하지만 전이 가능한 기술 (transferable skill)은 단일 기술이 아니었습니다. 그것은 바로 루프 (loop)였습니다:
- 구체적이고 반증 가능한 가설을 세웁니다 ("양자화 (quantization)를 하면 더 빠르고 크기가 작아질 것이다")
- 실제로 측정합니다
- 데이터가 뒷받침되면 채택하고, 그렇지 않으면 폐기합니다 — 그리고 어떤 일이 일어났고 그 이유가 무엇인지 기록합니다. 이 루프야말로 제가 같은 길을 시작하려는 누구에게나 특정 라이브러리보다 더 연습하라고 권하고 싶은 것입니다.
저장소 (Repo)는 완전히 오픈 소스 (MIT)입니다: github.com/hjdesigner/transdom. 만약 직접 시도해 보시거나 개선할 가치가 있는 부분을 발견하신다면, 진심으로 의견을 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기