8 min read

Local AI를 이용한 EN/KOR 자막 생성기 개발기: 시행착오와 기술적 돌파구

Local AI를 이용한 EN/KOR 자막 생성기 개발기: 시행착오와 기술적 돌파구

로컬 AI로 구축하는 유튜브 한글 자막 번역 파이프라인: 시행착오와 최적화의 기록

영어 유튜브 영상을 한글 자막으로 즐길 수 있도록 돕는 사이드 프로젝트를 진행하고 있습니다. 로컬 AI를 이용해 자막 생성 파이프라인을 구축하며 겪었던 수많은 시행착오와 기술적 해결 과정을 공유하고자 합니다. 이 글은 단순히 '돌아가는' 코드를 넘어, 속도, 정확도, 그리고 비용(로컬 LLM) 사이의 균형을 찾아가는 여정에 대한 기록입니다.


Phase 1: 시작은 미약하게 (기능 구현과 리팩토링)

처음에는 기능 구현에만 급급했습니다. yt-dlp로 오디오를 추출하고, faster-whisper로 텍스트 변환(STT)을 한 뒤, GPT-4o API로 번역하는 단순한 구조였습니다. 프롬프트 설정이나 전/후처리, 비용 계산도 생략한 채 "일단 돌아가게 만들자"는 목표로 main.py라는 단일 파일에 모든 로직을 집어넣었습니다.

하지만 기능이 추가될수록 코드는 복잡해졌고, 유지보수를 위해 구조적 재설계가 필요했습니다. 각 기능이 명확한 책임을 갖도록 다음과 같이 모듈을 분리했습니다.

  • downloader.py: 유튜브 URL 처리 및 오디오 추출
  • transcriber.py: Whisper 모델 관리 및 오디오 세그먼트 생성
  • translator.py: LLM 통신 및 프롬프트 관리
  • utils.py, config.py: 공통 유틸리티 및 설정 중앙화

이 리팩토링을 통해 main.py는 각 모듈을 조립하는 오케스트레이터(Orchestrator) 역할만 맡게 되었습니다. 파이프라인이 복잡해질수록 복잡도를 낮추는 작업이 병행되어야 프로젝트를 지속적으로 개선할 수 있다는 점을 깨달았습니다.

Phase 1.1: Local AI로의 전환 (Gemma 2 도입)

구조가 잡힌 뒤, 비용 절감과 독립적인 환경 구축을 위해 GPT-4oOllama 기반의 로컬 모델로 대체했습니다. 선택한 모델은 **Gemma 2 (9B)**입니다. Google의 오픈 웨이트 모델로, 다국어 성능이 뛰어나다는 점이 매력적이었습니다.

물론 성능 자체는 GPT-4o가 우월합니다. 하지만 10분 분량의 영상을 번역할 때마다 발생하는 누적 비용과, 테스트 한 번에도 비용을 신경 써야 하는 심리적 부담감이 컸기에 로컬 LLM 도입은 필수적이었습니다.


Phase 2: 기술적 난관과 아키텍처 수정

2.1 타임스탬프 할루시네이션 (Drift 현상)

번역된 문장의 타임스탬프를 맞추는 과정은 이 프로젝트의 가장 큰 난관이었습니다. 처음에는 LLM에게 "문장을 합쳐서 번역하고, 합쳐진 문장의 시작과 끝 시간도 계산해서 JSON으로 응답해줘"라고 요청했습니다.

하지만 LLM은 숫자에 약했습니다. 10.5초가 되어야 할 끝 시간을 10.8초로 반환하거나, 전혀 엉뚱한 수치를 생성하여 영상과 자막의 싱크가 점차 어긋나는 Drift 현상이 발생했습니다. 결론은 하나였습니다. "LLM은 문맥 추론만, 계산은 코드가 책임진다."

id-based 매핑 방식적용

  1. ID 부여: 음성 변환(STT) 단계에서 생성된 모든 세그먼트에 고유 ID(0, 1, 2...)를 부여합니다.
  2. 요청 최적화: LLM에게는 번역과 함께 "이 번역에 사용된 원본 세그먼트의 start_idend_id만 알려줘"라고 요청합니다.
  3. 코드 단 매핑: LLM이 반환한 ID를 바탕으로, 코드에서 원본 세그먼트의 타임스탬프를 직접 찾아 매핑합니다.
# LLM이 start_id와 end_id를 반환하면 코드가 매핑
new_start_time = original_segments[start_id].start
new_end_time = original_segments[end_id].end
sequenceDiagram
    participant Batch as 📦 Batch Data (Python)
    participant LLM as 🤖 Gemma 2 (LLM)
    participant Parser as 🧩 Logic (Python)
    participant Result as 🎬 Final Subtitle

    Note over Batch: 원본 세그먼트 데이터<br/>(정확한 시간 정보 보유)
    Batch->>LLM: 1. 텍스트와 ID만 전송<br/>{ "0": "Hello", "1": "World" }
    
    Note over LLM: 🧠 "문맥을 보니 합치는 게 좋겠군!"<br/>(시간 계산 X, 내용 번역 O)
    
    LLM-->>Parser: 2. 번역 결과와 ID 범위 반환<br/>{ "0-1": "안녕하세요, 여러분" }
    
    Note over Parser: 🔑 Key Parsing: "0-1"<br/>Start ID: 0, End ID: 1
    
    Parser->>Batch: 3. 실제 시간 조회 (Lookup)
    Batch-->>Parser: ID 0 Start: 10.0s<br/>ID 1 End: 15.2s
    
    Note over Parser: 4. 정확한 시간 매핑
    
    Parser->>Result: { Start: 10.0s, End: 15.2s, Text: "안녕하세요..." }
    
    Note right of Result: ✅ 싱크

이 방식을 통해 소수점 단위까지 정확한 원본 타임스탬프를 보존할 수 있게 되었습니다.

2.2 Semantic Chunking의 시도와 실패

품질을 높이기 위해 문장을 주제(Topic) 단위로 묶어 번역하는 'Semantic Chunking'을 시도했습니다. 하지만 Gemma 2(9B)에게는 주제 분류 자체가 너무 무거운 작업이었고, 분류 단계에서 오류가 발생하면 뒤따르는 번역과 싱크가 연쇄적으로 무너지는 오류 전파(Error Propagation) 문제가 발생했습니다. 결국 "복잡성은 엔지니어링의 적"이라는 교훈을 얻고 시스템을 다시 단순화했습니다.


Phase 3: 세부 튜닝 (Gemma 2 & Whisper)

파이프라인의 전체 성능은 결국 '가장 약한 고리'에서 결정됩니다. 로컬 LLM의 성능을 극대화하기 위해 각 단계를 미세 튜닝했습니다.

Gemma 2 튜닝: 최적의 알갱이 크기(Granularity) 찾기

  1. XML 구조화: 자연어 요청 대신 <context>, <input>, <constraints> 태그를 사용해 입력을 구조화하니 지시 이행력이 급상승했습니다.
  2. JSON 후처리기: JSON 응답에 마크다운 기호(```json)나 불필요한 쉼표가 섞여도 정규표현식(Regex)으로 정제하여 파싱 에러를 방지했습니다.
  3. 배치 크기 최적화: Gemma 2는 한 번에 10문장 이상을 처리하면 내용을 빼먹거나 요약해버리는 증상이 있었습니다. 실험 끝에 배치 크기를 2문장으로 줄여 정확도를 확보했습니다.

Whisper 튜닝: 환각 제거와 전처리

  1. initial_prompt 설정: 모델에게 완벽한 구두점이 찍힌 예시 문장을 제공하여, 문장 시작 누락을 방지하고 구두점 정확도를 높였습니다.
  2. VAD(Voice Activity Detection) 조정: min_silence_duration_ms를 튜닝해 불필요한 잡음이나 숨소리가 자막으로 생성되는 것을 차단했습니다.
  3. 8초 강제 분할: Whisper가 간혹 뱉는 10초 이상의 긴 세그먼트는 번역 모델에 부하를 줍니다. word_timestamps=True를 활용해 8초가 넘는 세그먼트는 단어 단위로 분석해 강제로 분할했습니다.

Phase 4: 최적화 (Optimization)

병렬 처리 (Parallelism)

  • Transcription: ffmpeg로 오디오를 2분 단위로 분할한 뒤 ThreadPoolExecutor를 통해 병렬로 전사(STT)합니다. M3 Pro 기준 워커 4개 설정시 속도가 약 2~2.5배 향상되었습니다.
  • Translation: OLLAMA_NUM_PARALLEL 설정을 통해 여러 개의 모델 인스턴스를 띄워 병렬 번역을 진행했습니다. (M3 Pro 18GB 환경에서 2개까지 안정적)

슬라이딩 윈도우 (Context Window)

배치 단위로 번역하면 문맥이 끊기는 문제가 있습니다. 이를 해결하기 위해 현재 배치 전후의 문장을 '참고용 컨텍스트'로 함께 제공하는 슬라이딩 윈도우 기법을 적용했습니다. LLM에게 "앞뒤는 참고만 하되, 가운데 문장만 번역하라"고 지시하여 문맥의 자연스러움을 유지했습니다.


마무리: 모델의 약점을 보완하는 엔지니어링

이번 프로젝트를 통해 깨달은 것은, 성능의 핵심은 '똑똑한 모델' 하나가 아니라 '모델의 약점을 보완하는 엔지니어링'에 있다는 점입니다.

  • 계산은 코드가 (ID Mapping)
  • 속도는 병렬 처리로 (Concurrency)
  • 실수는 꼼꼼한 검증 로직으로 (Validation Logic)

LLM은 만능이 아닙니다. 특히 정밀한 데이터 처리가 필요한 작업에서는 LLM의 추론 능력과 전통적인 프로그래밍 로직을 결합하는 하이브리드 접근 방식이 가장 강력한 해답이 된다는 것을 배웠습니다.