Python으로 EPUB 파일 파싱 및 재구축하기: AI 도서 번역기 구축을 통한 교훈
요약
AI 도서 번역 서비스 구축 과정에서 겪은 EPUB 파일 파싱 및 재구축 기술적 과제를 다룹니다. Python의 ebooklib과 BeautifulSoup을 활용하여 XHTML 구조와 메타데이터를 보존하며 텍스트를 추출하는 방법을 설명합니다.
핵심 포인트
- EPUB는 XHTML, CSS, XML 등을 포함하는 복잡한 ZIP 아카이브 구조임
- ebooklib과 BeautifulSoup(lxml-xml 파서)을 조합하여 정교한 파싱 가능
- 번역 시 마크업과 이미지 등 리소스를 보존하며 텍스트 노드만 처리하는 것이 핵심
- content.opf의 spine 요소를 통해 도서의 올바른 읽기 순서 유지 가능
메타데이터, 서식, 또는 우리의 정신 건강을 잃지 않고 수천 개의 EPUB를 처리하기 위해 ebooklib와 Beautiful Soup을 어떻게 사용했는지에 대하여.
꿈: 클릭 한 번으로 끝나는 도서 번역
AI 기반 도서 번역 서비스인 LectuLibre를 구축하기 시작했을 때, 우리는 핵심 과제가 번역 그 자체는 아닐 것이라는 점을 알고 있었습니다. Claude나 DeepSeek와 같은 LLM(대규모 언어 모델)은 텍스트를 처리하는 데 매우 뛰어납니다. 진짜 골칫거리는 도서의 _컨테이너(container)_인 EPUB 파일이었습니다. 사용자가 EPUB를 업로드하면 우리가 번역하고, 사용자는 완벽하게 서식이 맞춰진 번역된 도서를 다운로드하는 방식입니다. 간단해 보이나요? 우리도 그렇게 생각했습니다—실제로 시도해 보기 전까지는 말이죠.
EPUB는 보기보다 복잡한 형식입니다. 이는 XHTML 챕터, CSS, 이미지, 폰트, 그리고 몇 가지 XML 제어 파일(특히 content.opf 및 toc.ncx)을 포함하는 ZIP 아카이브입니다. 책을 번역하려면 다음을 수행해야 합니다:
- 주변 마크업 (markup)을 보존하면서 XHTML 파일에서 모든 텍스트를 추출합니다.
- 태그, 앵커 (anchor), 이미지는 건드리지 않고 텍스트 노드 (text nodes)만 번역합니다.
- 동일한 메타데이터, 스파인 순서 (spine order), 리소스를 사용하여 EPUB를 재구축합니다.
이는 웹 스크래핑 (web scraping)과 문서 조립 (document assembly) 사이 어딘가에 위치한 파싱 (parsing) 및 재구조화 문제입니다. 우리가 Python을 사용하여 이 문제를 어떻게 해결했는지, 그리고 그 과정에서 무엇을 배웠는지 소개합니다.
왜 그냥 Calibre를 사용하지 않나요?
Calibre는 전자책의 맥가이버 칼과 같지만, 이는 데스크톱 애플리케이션이지 우리가 FastAPI 서비스에 내장할 수 있는 라이브러리가 아닙니다. 우리는 가볍고, 스크립트 작성이 가능하며, 오픈 소스인 무언가가 필요했습니다. 이때 EPUB 파일을 읽고 쓸 수 있는 순수 Python 라이브러리인 ebooklib가 등장했습니다. 이 라이브러리는 제 역할을 수행하지만, 우리가 발견했듯이 수천 개의 실제 EPUB 파일을 투입했을 때만 나타나는 특이한 점들이 있습니다.
우리의 파싱 파이프라인: ebooklib + Beautiful Soup
ebooklib을 사용한 기본 워크플로우는 다음과 같습니다:
from ebooklib import epub
book = epub.read_epub('book.epub')
그 한 줄의 코드는 ZIP 압축을 해제하고, XML을 파싱하여 Book 객체를 제공합니다. 하지만 문제는 세부 사항에 있습니다. 저희가 최종적으로 도달한 실제 프로덕션 코드(production code)는 다음과 같습니다:
import ebooklib
from ebooklib import epub
from bs4 import BeautifulSoup
...
저희는 BeautifulSoup 파서(parser)로 lxml-xml을 사용했는데, 이는 EPUB의 XHTML이 HTML이 아닌 XML인 경우가 많기 때문입니다. 기본 HTML 파서를 사용하면 <br/>와 같은 셀프 클로징 태그(self-closing tags)가 손상되었습니다. 미묘하지만 결정적인 선택이었습니다.
OPF 스파인(Spine) 탐색하기
content.opf는 <spine> 요소를 통해 읽기 순서를 정의합니다. Ebooklib은 이를 book.spine으로 표현합니다. 각 스파인 아이템은 매니페스트(manifest) 아이템을 가리키는 idref를 가지고 있습니다. 따라서 책을 순서대로 훑으려면 다음과 같이 합니다:
spine_order = []
for spine_item in book.spine:
item = book.get_item_with_id(spine_item[0])
...
하지만 일부 EPUB는 존재하지 않는 ID를 가리키는 스파인 항목을 가지고 있다는 것을 발견했습니다. 저희의 접근 방식은 다음과 같습니다: 해당 항목을 조용히 건너뛰고 경고를 로그로 남기는 것입니다. 엄격함보다는 견고함(Robustness)을 택했습니다.
마크업을 깨뜨리지 않고 제자리에서 번역하기
텍스트를 추출한 후, 저희는 이를 청크(chunks) 단위로 LLM에 보냅니다(토큰 제한을 준수하며). 까다로운 부분은 모든 태그, 클래스(class), 속성(attribute)을 온전하게 유지하면서 원본 텍스트를 번역된 버전으로 교체하는 것입니다.
저희는 BeautifulSoup 트리를 순회하며 NavigableString 노드만 교체합니다:
def translate_soup(soup: BeautifulSoup, translation_map: dict) -> BeautifulSoup:
for text_node in soup.find_all(string=True):
stripped = text_node.strip()
...
이 방식은 단순한 경우에는 잘 작동하지만, 번역 결과로 인해 문단 수가 변하는 경우(예: 하나의 <p>가 두 개가 되는 경우) 곧바로 문제에 직면했습니다. 해결책은 무엇이었을까요? 문단 경계에서 분할하고 수동으로 재조립함으로써 번역된 내용을 다시 엮는 방식이었습니다. 완벽하지는 않지만, 95%의 케이스를 처리할 수 있습니다.
EPUB 재구축하기: 정신을 잃지 않고 다시 쓰기
모든 soup 객체가 번역되면, EPUB를 재구축합니다:
def save_epub(book, chapters, output_path):
for ch in chapters:
item = book.get_item_with_id(ch['id'])
...
단순해 보이지만, ebooklib의 write_epub 함수는 매니페스트 (manifest)와 스파인 (spine)을 업데이트하지 않고 항목을 추가하거나 제거할 경우 유효하지 않은 아카이브를 생성할 수 있습니다. 우리의 규칙은 다음과 같습니다: 항목을 절대 추가하거나 제거하지 마십시오. 오직 기존의 XHTML 문서만 수정합니다. 이렇게 하면 기존의 content.opf를 보존하고 UUID 불일치를 방지할 수 있습니다.
메모리에 관한 조언
수백 개의 이미지가 포함된 200MB 용량의 EPUB를 단순하게 로드하면 메모리 사용량이 1GB 이상으로 급증할 수 있습니다. 우리는 이미지 대신 문서 항목만 선택적으로 로드하여 이를 방지합니다:
book = epub.read_epub(filepath, options={'ignore_ncx': False, 'expand_css': False})
# 필요하지 않은 경우 이미지나 폰트를 로드하지 마세요
for item in book.get_items():
...
이 방식을 통해 대용량 파일에서 메모리 사용량을 60% 줄였습니다.
동시성 처리하기
우리의 FastAPI 백엔드에서는 업로드를 비동기적으로 처리합니다. 파일을 임시 위치로 스트리밍한 다음, 번역 워커 (worker)에게 전달합니다. 번역 단계는 CPU 집약적 (CPU-bound)이고 메모리 소모가 크기 때문에, 제한된 수의 워커 (최대 cpu_count())를 가진 ThreadPoolExecutor를 사용합니다. 이를 통해 VPS의 응답성을 유지하고 OOM (Out Of Memory) 킬을 방지합니다.
잘못된 형식의 EPUB와 씨름하기
실제 환경의 EPUB는 엉망인 경우가 많습니다. 우리는 다음과 같은 문제들을 겪었습니다:
content.opf누락 → ZIP 파일 내의 모든.opf파일을 찾는 방식으로 대체- Non-UTF-8 인코딩 (encodings) → 디코딩 전
chardet을 사용하여 인코딩 감지 - 매니페스트 (manifest) 내의 순환 참조 (Cyclic references) → 파싱 루프에 방문 기록 세트 (visited set) 추가
- 빈 스파인 (spine) → 모든 XHTML 파일을 읽기 순서로 취급
@import가 포함된 CSS → Kindle이 임포트 (import)를 지원하지 않으므로 핵심 스타일을 인라인 (inline) 처리- CDN에서 로드되는 외부 폰트 → 로컬 폴업 (fallback) 폰트로 교체
- 문서 전체를 감싸고 있는 CDATA 섹션 → XML을 전처리하여 이를 해제(unwrap)해야 함
우리는 이러한 문제들을 점검하고 가능한 경우 자동으로 복구(auto-heal)를 시도하는 사전 검증(preflight validation) 단계를 구현했습니다. 까다로운 사례의 경우, 사용자에게 "특이한(quirky)" 파일을 업로드했음을 알리고, 우리의 AI가 최선을 다해 처리할 것이라고 안내합니다.
목차의 난제 (The Table of Contents Conundrum)
많은 번역기가 내비게이션(navigation)을 간과합니다. 초기에는 toc.ncx (또는 EPUB3의 경우 nav.xhtml)를 건드리지 않고 그대로 두었으나, 사용자들이 목차가 여전히 원문 언어로 되어 있다고 불평했습니다. 현재 우리는 동일한 LLM을 사용하되 더 단순한 컨텍스트를 적용하여 내비게이션 레이블(<navLabel> 요소)을 번역하는 별도의 단계를 개발하고 있습니다. 문제는 일부 레이블이 단순한 숫자이거나 장(chapter)의 약어라는 점이며, 과도한 번역(over-translating)은 사용자 경험을 해칩니다. 우리는 자연어(natural language)를 포함하는 레이블만 번역하는 하이브리드 접근 방식을 탐색하고 있습니다.
성능 및 확장성 수치 (Performance and Scalability Numbers)
우리의 백엔드는 4코어 VPS에서 실행되는 FastAPI 앱입니다. 일반적인 번역 워크플로우 소요 시간은 다음과 같습니다:
- 파싱 (Parsing): 300페이지 분량의 소설 기준 0.2~0.5초.
- 번역 (LLM 호출): 30~50초 (단연코 가장 큰 병목 구간).
- EPUB 재구축 (Rebuilding EPUB): 복잡도에 따라 0.5~1.5초.
우리는 LLM API의 속도 제한(rate limit)이나 메모리 압박에 도달하기 전까지 8개의 동시 번역을 처리할 수 있습니다. 우리의 워커 풀(worker pool)은 asyncio를 사용하여 이를 관리하며, 세마포어(semaphore)를 통해 병렬성을 제한합니다.
공포의 코퍼스로 진행하는 테스트 (Testing with a Corpus of Horrors)
성능 퇴보(regression)를 방지하기 위해, 우리는 의도적으로 손상된 파일을 포함하여 500개의 참조용 EPUB 코퍼스(corpus)를 구축했습니다. 모든 배포 전에는 다음과 같은 통합 테스트(integration tests)를 실행합니다:
- 스파인(spine) 순서가 유지되는지 확인.
- XHTML 파일의 개수가 변하지 않았는지 검증.
- 일부 샘플에 대해 번역된 결과물을 사람이 검토한 골드 스탠다드(gold standard)와 비교.
epubcheck(IDPF 도구)를 사용하여 출력된 EPUB 검증.
이 과정은 라이브러리 업데이트로 인해 파싱 동작이 변경될 때마다 여러 차례 위기를 모면하게 해주었습니다.
교훈 (Lessons Learned)
- EPUB을 절대 신뢰하지 마세요. XML이 유효하지 않을 수 있다고 항상 가정해야 합니다.
- 관심사의 분리 (Separation of concerns): 파싱 (Parsing), 번역 (Translation), 재구축 (Rebuilding)을 각각 별도의 테스트 가능한 함수로 유지하세요.
- ebooklib은 훌륭하지만 완벽하지는 않습니다. 미디어 오버레이 (Media overlays)나 EPUB3 오디오와 같은 고급 기능을 다루려면 ZIP 파일을 직접 조작해야 할 수도 있습니다.
- 메모리가 중요합니다. 필요하지 않은 바이너리 블롭 (Binary blobs)을 로드하지 마세요.
- 코퍼스 (Corpus)로 테스트하세요. 우리는 모든 배포 전에 실행할 500개의 참조 EPUB 세트(손상된 파일 포함)를 유지 관리하고 있습니다.
- 내비게이션 (Navigation)도 콘텐츠의 일부입니다. 책은 번역하면서 목차 (TOC)를 번역하지 않으면 일관성 없는 경험을 초래합니다.
향후 계획: PDF 및 스트리밍
PDF 파싱은 우리의 로드맵에서 다음 단계이며, 완전히 다른 종류의 과제들이 기다리고 있습니다 (우리는 pymupdf를 눈여겨보고 있습니다). 하지만 EPUB 파이프라인을 통해 전자책은 문서보다는 웹 페이지에 더 가깝다는 것을 배웠으며, 웹 네이티브 도구 (BeautifulSoup, CSS 인라이닝 (CSS inlining))를 사용하는 것이 효과적이라는 점을 깨달았습니다.
만약 여러분도 비슷한 것을 구축하고 있다면, 여러분의 접근 방식이 궁금합니다. ebooklib을 계속 사용하셨나요, 아니면 직접 구현하셨나요? RTL (Right-to-Left) 언어는 어떻게 처리하시나요? 댓글을 통해 토론에 참여해 주세요.
—
LectuLibre는 lectulibre.com에서 프라이빗 베타 서비스 중입니다. 우리는 책을 사랑하고 언어가 장벽이 되어서는 안 된다고 믿기에 이 파이프라인을 구축했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기