본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 24. 12:14

Python으로 견고한 EPUB 파싱 및 재구축 파이프라인을 구축한 방법

요약

LectuLibre의 번역 엔진 구축 과정에서 겪은 EPUB 파싱 및 재구축 파이프라인의 기술적 도전 과제를 다룹니다. 기존 ebooklib 라이브러리의 성능 및 네임스페이스 처리 한계를 분석하고, 이를 해결하기 위한 하이브리드 접근 방식을 제안합니다.

핵심 포인트

  • EPUB의 복잡한 XHTML 구조와 비표준 마크업 처리의 어려움
  • ebooklib 사용 시 발생하는 성능 저하 및 메모리 사용량 문제
  • XML 네임스페이스 누락으로 인한 유효성 검사 실패 해결 필요성
  • 메타데이터 관리를 위한 ebooklib과 정밀 파싱을 위한 lxml의 하이브리드 활용

LectuLibre의 번역 엔진을 구축하면서 깨진 마크업, 임베디드 폰트, 네임스페이스 혼란을 처리하는 과정

LectuLibre에서 우리는 EPUB 도서의 정확한 시각적 구조를 보존하면서 책 전체를 번역해야 했습니다. 핵심 과제는 EPUB를 파싱(Parsing)하고, 번역 가능한 모든 텍스트를 추출하여 LLM(Large Language Model)으로 보낸 다음, 이미지, CSS, 폰트 및 레이아웃을 건드리지 않고 번역된 콘텐츠로 책을 재조립하는 것이었습니다. 이는 보기보다 훨씬 어려운 일이었습니다. 우리가 이를 어떻게 해결했는지, 무엇이 문제였는지, 그리고 무엇을 배웠는지 소개합니다.

문제점: EPUB는 혼돈의 ZIP 파일이다

EPUB는 XHTML, CSS, 이미지, 그리고 몇 가지 XML 제어 파일(container.xml 및 OPF 매니페스트 등)을 포함하는 ZIP 아카이브입니다. 이론적으로는 깔끔한 형식이지만, 실제 환경의 EPUB는 엉망진창입니다:

  • 유효하지 않은 마크업, 닫히지 않은 태그 또는 누락된 네임스페이스(Namespace) 선언이 포함된 XHTML.
  • 그대로 통과시켜야 하는 임베디드 폰트(Embedded fonts), SVG 챕터 및 MathML.
  • 문장 단위의 번역이 필요한, 여러 인라인 요소에 걸쳐 분할된 텍스트(<b>Hello</b> <i>World</i>).
  • EPUB 3 사양은 방대하며, 많은 도서가 이 사양을 거의 따르지 않는 도구들에 의해 생성되었습니다.

우리는 수동 개입 없이 90% 이상의 도서를 처리할 수 있고, 대화형 웹 서비스에 충분히 빠르며, 필연적으로 받게 될 가장 망가진 입력값에서도 살아남을 수 있는 파이프라인이 필요했습니다.

첫 번째 시도: 고수준 라이브러리 접근 방식

우리는 EPUB 파일을 읽고 쓰기 위한 전용 Python 라이브러리인 ebooklib을 사용했습니다. 이 라이브러리는 아이템(문서, 이미지, 스타일시트), 스파인(Spine), 목차 및 메타데이터를 포함하는 EpubBook이라는 훌륭한 객체 모델을 제공합니다. 책을 열고 모든 XHTML 파일을 가져오는 코드는 기만적일 정도로 간단해 보입니다:

import ebooklib
from ebooklib import epub

...

이 방식은 많은 깔끔한 EPUB에서 작동합니다. 하지만 100권의 퍼블릭 도메인(Public-domain) 도서로 스트레스 테스트를 진행했을 때, 우리는 빠르게 한계에 부딪혔습니다:

  • 성능 (Performance): ebooklib은 내부적으로 xml.dom.minidom을 사용합니다. 수많은 XHTML 파일이 포함된 20MB 크기의 도서를 읽는 데 6초 이상이 소요되었고, 다시 쓰는 데는 훨씬 더 오랜 시간이 걸렸습니다. 전체 DOM (Document Object Model)이 메모리에 유지되기 때문에 메모리 사용량이 1GB 이상으로 급증하곤 했습니다.
  • 네임스페이스 처리 (Namespace handling): 일부 EPUB3 도서는 모든 곳에서 명시적인 XHTML 네임스페이스(<html xmlns="http://www.w3.org/1999/xhtml">)를 사용합니다. ebooklib의 XML 직렬화 (serialization) 과정에서 이러한 네임스페이스가 때때로 누락되어, 유효성 검사 (validation)에 실패하는 출력이 생성되었습니다.
  • 세밀한 텍스트 탐색 불가 (No fine‑grained text traversal): 마크업 (markup)을 보존하면서 표시되는 텍스트만 교체하기 위해서는, 결국 우리가 직접 XHTML을 파싱해야만 했습니다.

분명히, 실제 콘텐츠 조작을 위해서는 더 낮은 수준 (lower-level)의 도구가 필요했습니다.

하이브리드 솔루션: 메타데이터를 위한 ebooklib, XHTML을 위한 lxml

우리는 다음과 같은 하이브리드 아키텍처 (hybrid architecture)로 결정했습니다:

  • ebooklib을 사용하여 EPUB의 _구조 (structure)_를 읽고 씁니다: 매니페스트 (manifest), 스파인 (spine), 목차 (TOC), 그리고 바이너리 파일 (이미지, 폰트) 등이 해당됩니다. 이를 통해 ZIP 파일 조작 및 OPF 생성 로직을 직접 다시 구현해야 하는 수고를 덜 수 있었습니다.
  • 모든 XHTML 파일에 대해서는 lxml.etree를 사용하여 콘텐츠를 파싱합니다 (lxml.etree는 빠르고, 네임스페이스를 인식하며, 손상된 마크업으로부터 복구할 수 있습니다). 트리 (tree)를 순회하며 번역 가능한 텍스트 세그먼트 (text segments)를 추출하고, 이를 번역한 다음, 다시 트리에 번역된 내용을 주입 (inject)합니다.

다음은 핵심 추출 로직입니다:

from lxml import etree

def extract_translatable_blocks(html_bytes):
...

우리가 element.textelement.tail을 모두 추적한다는 점에 주목하십시오. 이는 매우 중요한데, <p><b>Hello</b> <i>World</i></p>와 같은 HTML에서

트리가 수정되면, 네임스페이스 (namespace)를 보존하며 다시 직렬화 (serialize)합니다:

def serialize_html(root_node):
    # lxml의 etree.tostring은 nsmap와 함께 트리를 전달하면 네임스페이스를 올바르게 처리합니다
    html_str = etree.tostring(
...

그 다음 ebooklib 아이템에 item.set_content(serialized_html.encode('utf-8'))를 호출하고 책을 다시 파일로 씁니다.

까다로운 문제 해결하기

1. 임베디드 폰트 및 바이너리 리소스 (Embedded Fonts and Binary Resources)

ebooklib은 이미지와 폰트를 ITEM_IMAGEITEM_OTHER로 투명하게 처리했습니다. 저희는 XHTML이 아닌 아이템에 대해서는 번역을 단순히 건너뜁니다. 하지만 일부 도서의 경우, 재구축 후에도 유효하게 유지되어야 하는 CSS 내의 폰트 페이스 (font-face) 선언에 의존한다는 것을 발견했습니다. 저희는 CSS를 수정하지는 않지만 (예를 들어 content: "Chapter 1"을 번역하는 것은 자살 행위나 다름없습니다), 각 CSS를 파싱하여 폰트 페이스 src URL을 확인하고, 재구축된 EPUB에서 이들이 상대 경로로 보존되는지 확인합니다.

2. epubcheck를 통한 검증

최종적인 무결성 검사 (sanity check)를 위해 재구축된 모든 EPUB를 epubcheck (공식 Java 검증기)로 실행합니다. 초기에는 출력 파일의 30%가 실패했습니다. 주로 ebooklib이 ZIP 파일 시작 부분의 mimetype 파일 항목을 누락시키거나, 저희가 실수로 xml:lang 속성을 제거했기 때문이었습니다. 저희는 항상 mimetype 파일을 가장 먼저 삽입하도록 쓰기 루틴을 패치했으며, 현재는 lxml 조작 과정에서 모든 XML 네임스페이스와 속성을 보존합니다.

3. 성능 튜닝 (Performance Tuning)

500페이지 분량의 소설을 처음부터 끝까지(파싱, 번역, 재구축) 처리하는 데 걸리는 시간은 대략 다음과 같습니다:

  • EPUB 읽기 및 XHTML 추출: CPU 기준 2~3초
  • 번역 API 호출: 8~12초 (LLM 지연 시간(latency)이 대부분을 차지)
  • XHTML 재구축 및 EPUB 쓰기: CPU 기준 3~4초

각 장(chapter)은 독립적이므로 asyncio를 사용하여 XHTML 파일 처리를 병렬화합니다 (lxml 작업을 위해 asyncio.to_thread 사용). 이를 통해 일반적인 도서의 실제 소요 시간(wall-clock time)을 약 10초까지 단축했으며, 이는 실시간 웹 서비스로서 수용 가능한 수준입니다.

거대한 DOM을 동시에 로드하는 것을 피함으로써 메모리 사용량은 약 150~200MB로 안정적으로 유지됩니다.

Lessons Learned (The Hard Way)

  1. 상위 수준 라이브러리(High-level libraries)는 훌륭한 시작점이지만, 결국에는 명세(spec)를 이해해야 합니다. ebooklib 덕분에 ZIP 컨테이너와 매니페스트(manifest) 작업에 소요될 몇 주간의 시간을 아낄 수 있었습니다. 하지만 특정 도서가 iBooks에서 열리지 않는 이유를 디버깅하기 위해서는 EPUB 3 명세를 읽고 OPF 파일을 한 줄씩 확인해야만 했습니다.
  2. 실제 환경의 쓰레기 데이터(garbage)로 테스트하세요. 우리는 Project Gutenberg, Standard Ebooks, 그리고 무작위 인디 출판물에서 가져온 100개의 퍼블릭 도메인(public-domain) EPUB 코퍼스(corpus)를 구축했습니다. 그중 약 15%는 심각하게 손상되어 있었습니다(예: 세 개의 <body> 시작 태그가 포함된 XHTML). lxmlrecover=True 옵션은 생명줄과 같았습니다.
  3. 재구축 후에는 항상 검증하세요. Calibre에서 책이 "괜찮아 보이더라도", 숨겨진 구조적 오류는 다른 리더기에서 문제를 일으킬 수 있습니다. CI(지속적 통합) 과정에서 epubcheck를 자동화하세요.
  4. 네임스페이스(Namespaces)는 당신의 하루를 망칠 수 있습니다. 파싱할 때는 항상 lxmlnsmap을 사용하세요. 기본 네임스페이스 접두사(prefix)를 당연하게 가정해서는 안 됩니다.
  5. 모든 것을 번역하지 마세요. CSS content, <pre> 포맷 블록, 그리고 수식은 그대로 두어야 합니다. 우리는 설정 가능한 허용 목록(allow-list)을 기반으로 요소를 필터링합니다.

Open Questions for the Community

우리는 여전히 우리의 파이프라인에 100% 만족하지 못하고 있습니다. ebooklib은 DOM 기반 접근 방식 때문에 대용량 파일에서 속도가 느립니다. zipfilelxml을 사용하여 ZIP과 OPF를 직접 다시 작성하면 더 빨라질 수 있겠지만, 작성해야 할 코드가 너무 많습니다. 오버헤드 없이 더 세밀한 제어(granular control)를 제공하는 다른 Python EPUB 라이브러리가 있을까요? ebooklib을 포크(fork)하여 XML 백엔드를 lxml로 교체하는 것이 의미가 있을까요? 여러분의 경험담(war stories)—특히 유사한 번역 또는 변환 파이프라인을 구축해 본 경험이 있다면—을 듣고 싶습니다.

이 기사는 LectuLibre 엔지니어링 팀에 의해 작성되었습니다. 우리는 AI 기반 도서 번역 서비스를 구축하고 있습니다. 여러분도 EPUB과 씨름하고 있다면, 함께 이야기해 봅시다!

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0