Python으로 EPUB 파일 파싱 및 재구축하기: 학습된 교훈
요약
AI 기반 도서 번역 서비스를 구축하며 겪은 EPUB 파일 파싱 및 재구축 과정의 기술적 도전과 해결책을 다룹니다. 복잡한 EPUB 구조를 유지하면서 LLM을 통해 텍스트를 번역하고 다시 패키징하는 방어적 코딩 전략을 소개합니다.
핵심 포인트
- EPUB의 구조적 특이점과 표준 미준수 파일 처리의 중요성
- ebooklib 라이브러리를 활용한 객체 지향적 EPUB 조작
- 번역 후 마크업 유지를 위한 정교한 텍스트 삽입 및 검증 프로세스
- 데이터 무결성을 위한 커스텀 유효성 검사 단계의 필요성
내비게이션과 메타데이터를 깨뜨리지 않고 AI 번역을 위해 복잡한 EPUB 구조를 처리하는 방법
LectuLibre에서 우리는 AI 기반 도서 번역 서비스를 구축했습니다. 사용자가 EPUB를 업로드하면, 우리의 파이프라인(pipeline)은 Claude 및 DeepSeek와 같은 LLM(Large Language Models)을 사용하여 텍스트를 번역합니다. 목차(table of contents), 내부 링크, 또는 스타일을 망가뜨리지 않으면서 유효한 EPUB를 파싱(parsing)하고 재구축(rebuild)해야 하는 상황이 오기 전까지는 매우 간단해 보입니다.
우리가 직면했던 실제 사례의 도전 과제, 도구(tooling)를 선택한 방법, 그리고 실제 EPUB 파일을 다룰 때 발견한 까다로운 문제점들을 공유하고자 합니다.
문제점: EPUB는 엉망진창인 ZIP 파일이다
EPUB는 본질적으로 XHTML, CSS, 이미지, 그리고 OPF 매니페스트(manifest)를 포함하는 ZIP 아카이브(archive)입니다. 이는 잘 정의된 표준(EPUB 3.2)이지만, 실제로 출판사들은 규칙을 어기는 파일들을 생성합니다. container.xml이 누락되거나, 번역 후 깨지는 인라인 스타일(inline styles), 그리고 파싱을 취약하게 만드는 구조적 특이점들이 존재합니다.
우리의 번역 프로세스는 다음과 같은 작업이 필요했습니다:
- 사용자가 제공하는 어떤 EPUB든 수용할 것.
- 정확한 구조를 유지하면서 모든 텍스트 콘텐츠를 추출할 것.
- 각 단락을 번역을 위해 LLM에 전송할 것.
- 번역된 텍스트를 원래의 XHTML 파일에 다시 삽입할 것.
- 모든 것을 새로운 유효한 EPUB로 다시 패키징(repackage)할 것.
4단계가 까다로운 부분입니다. 번역된 텍스트는 더 길거나 짧을 수 있고, 이스케이프(escaping) 처리가 필요한 문자를 포함할 수 있으며, 주변의 마크업(markup)은 온전하게 유지되어야 합니다.
우리의 접근 방식: 방어적 코딩(Defensive Coding)을 가미한 ebooklib 사용
우리는 여러 Python 라이브러리를 평가했습니다:
epub(pypub) – 너무 단순하며, 편집 지원 기능이 없음.lxml+ 수동 zip – 보일러플레이트(boilerplate) 코드가 너무 많음.ebooklib– 깔끔한 API를 갖춘 완전한 읽기/쓰기 기능 제공.
우리는 ebooklib을 선택했습니다. 이 라이브러리는 EPUB 구조의 객체 지향 모델 (object-oriented model)을 제공하며, 문서를 반복 (iterate)할 수 있게 해주고, 수정된 객체들로부터 새로운 EPUB을 작성할 수 있습니다. 단점은 문서화 (documentation)가 부족하고, 형식이 잘못된 (malformed) 파일에서 오류가 발생할 수 있다는 점입니다. 우리는 많은 검증 (validation) 단계를 추가해야 했습니다.
1단계: EPUB 로딩 및 검증
import ebooklib
from ebooklib import epub
...
하지만 우리는 책의 메타데이터 (metadata)가 손상된 경우 read_epub이 아무런 오류 메시지 없이 실패할 수 있다는 것을 빠르게 배웠습니다. 우리는 유효한 OPF와 최소 하나 이상의 스파인 (spine) 아이템이 있는지 확인하는 커스텀 검증 단계를 추가했습니다.
def validate_epub(book: epub.EpubBook):
if not book.opf:
raise ValueError("Missing OPF metadata")
...
2단계: XHTML 문서에서 읽기 가능한 텍스트 추출하기
EPUB의 콘텐츠는 epub.EpubHtml 객체에 저장됩니다. 우리는 읽기 순서 (spine)에 따라 모든 아이템을 반복하며, BeautifulSoup (lxml 파서)을 사용하여 본문 콘텐츠를 파싱 (parse)합니다. ebooklib 자체의 get_body_content()는 가공되지 않은 바이트 (raw bytes)를 반환하며, 우리는 HTML 구조를 유지하면서 문단 단위로 텍스트를 추출해야 하기 때문입니다.
from bs4 import BeautifulSoup
import html
...
나중에 텍스트를 교체할 수 있도록 원본 BeautifulSoup tag 객체에 대한 참조를 유지합니다. 이는 대용량 도서의 경우 메모리 소모가 크지만, 10MB 미만의 도서(우리의 VPS 제한)에는 효과적입니다.
3단계: LLM을 이용한 번역 (및 길이 제어)
각 문단에 대해 번역 API (Claude 또는 DeepSeek)를 호출합니다. 까다로운 부분은 일부 문단이 매우 짧거나 (헤더) 엔티티 참조 (entity references)를 포함하고 있다는 점입니다. 우리는 전송하기 전에 HTML 엔티티를 이스케이프 (escape)하고, 전송 후에 이를 디코딩 (decode)합니다.
import requests
def translate_text(text: str, source_lang: str, target_lang: str) -> str:
...
우리는 LLM이 때때로 추가적인 공백이나 문장 부호를 삽입할 수 있다는 것을 발견했습니다. 따라서 가벼운 후처리 (post-processing)를 적용합니다: 트리밍 (trim), 공백 정규화 (normalize spaces), 그리고 번역된 텍스트가 포함된 태그 (tag)의 구조를 깨뜨리지 않도록 보장하는 작업입니다.
Step 4: 번역된 텍스트로 XHTML 재구축하기
extract_paragraphs 출력 결과로 돌아가서, 우리는 tag.string을 번역된 텍스트로 교체합니다. tag.string은 자식 요소(child elements)를 포함하는 NavigableString일 수 있으므로 주의해야 합니다. 만약 태그가 문자열만 포함하고 있다면 이를 교체합니다. 만약 혼합된 콘텐츠(mixed content)를 포함하고 있다면 첫 번째 텍스트 노드만 교체하는데, 이는 대부분의 도서에서 작동하는 단순화된 방식입니다.
def replace_text(tag, new_text: str):
if tag.string is not None and not tag.find_all(text=False):
# 단순한 경우: 태그가 단일 텍스트 노드만 가짐
...
모든 교체가 완료된 후, 해당 아이템의 본문 콘텐츠(body content)를 수정된 HTML로 다시 설정합니다.
def update_item(item: epub.EpubHtml, paragraphs: list[dict]):
for p in paragraphs:
if p["translated"]:
...
여기서 발생하는 문제: set_body_content는 바이트(bytes)를 기대하며, 우리는 인코딩이 UTF-8임을 보장해야 합니다. 또한, 원본 파일에 XML 선언(XML declaration)이나 네임스페이스(namespaces)가 있었다면 이를 잃어버릴 수도 있습니다. 우리는 item.media_type 및 기타 메타데이터를 보존함으로써 이를 처리합니다.
Step 5: 번역된 EPUB 쓰기
모든 아이템이 업데이트되면, 책을 새 파일로 저장합니다. 또한 수정 날짜(modified-date)를 추가하고 언어 메타데이터를 업데이트합니다.
def save_book(book: epub.EpubBook, output_path: str):
book.set_identifier("urn:uuid:" + str(uuid.uuid4()))
book.add_metadata("DC", "language", "fr") # 대상 언어
...
우리는 epub.write_epub이 매니페스트(manifest)에 제대로 등록되지 않은 리소스(이미지, 폰트)를 참조하는 아이템이 있을 경우 실패할 수 있다는 것을 고생 끝에 배웠습니다. 우리는 의존성 누락 오류를 피하기 위해 원본 도서의 모든 아이템을 반복(iterate)하여 매니페스트에 조기에 추가합니다.
실제 사례에서의 함정 및 해결 방법
-
목차(Table of Contents) 오류: 번역 후, NCX/NAV 파일이 우리가 아이템 이름을 변경했기 때문에 더 이상 존재하지 않는 이전 파일 이름이나 앵커(anchor)를 가리키는 문제가 발생했습니다. 이제 우리는 아이템의 이름을 절대 변경하지 않으며, 오직 콘텐츠만 제자리에서(in-place) 수정합니다. 만약 새로운 아이템을 추가해야 하는 경우(예: 각주),
ebooklib.epub.Link객체를 사용하여 목차를 수동으로 업데이트합니다. -
인라인 CSS 덮어쓰기 (Inline CSS Overwrites): 일부 도서는
font-size: 12pt와 같은 인라인 스타일을 사용합니다. 번역된 단락이 길어지면 고정된 높이의 컨테이너를 초과할 수 있습니다. 우리는 CSS를 직접 수정하지는 않지만, 경직된 스타일링을 가진 도서에 대해 경고를 추가하고, 고정 높이가 없는 "클린(clean)" 버전을 제공합니다. -
성능 (Performance): 500페이지 분량의 소설의 경우, 전체 파이프라인(파싱, 번역, 재구축)은 우리의 VPS(4 vCPU, 8 GB RAM)에서 약 90초가 소요됩니다. LLM 호출이 대부분의 시간을 차지합니다. 우리는 API 오버헤드를 줄이기 위해 최대 5개의 단락을 하나로 묶어 배치(batch) 처리하며, 이 과정에서 번역 품질이 약간 저하되는 것을 감수합니다.
-
메모리 (Memory): 전체 EPUB을 로드하고 BeautifulSoup 트리를 메모리에 유지하면 대용량 도서의 경우 메모리 사용량이 300 MB까지 급증할 수 있습니다. 우리는 한 번에 한 권의 도서만 처리하며, 동시성 문제를 피하기 위해 큐(queue)를 사용합니다.
학습된 교훈 (Lessons Learned)
ebooklib은 훌륭하지만 취약합니다 – 항상 EPUB 구조를 직접 검증하세요. 모든 필드가 존재한다고 가정해서는 안 됩니다.- 원본 아이템의 순서와 이름을 보존하세요 – 이름을 변경하면 내부 링크가 깨집니다.
- HTML과 일반 텍스트 간에 텍스트를 이동할 때 HTML 엔티티를 이스케이프(escape) 또는 언이스케이프(unescape) 하세요.
- 번역 품질은 문맥(context)에 달려 있습니다 – 우리는 개별 단락 대신 챕터 전체를 보내는 실험을 하고 있지만, 이는 지연 시간(latency)을 증가시킵니다.
다음 단계는? (What’s Next?)
우리는 조작하기 더 쉬운 단순한 중간 형식으로 사전 변환하기 위해 pandoc을 탐색하고 있습니다. 하지만 이 경우 재구축 단계가 더 복잡해집니다. 현재로서는 ebooklib + BeautifulSoup 조합이 우리의 요구 사항을 충족합니다.
만약 여러분이 Python으로 EPUB 처리 도구를 구축하고 있다면, 이러한 실제 사례를 통한 통찰이 우리가 소비했던 디버깅 시간을 줄여줄 수 있기를 바랍니다. 더 나은 접근 방식이 있나요? 댓글로 알려주시면 감사하겠습니다!
즐거운 코딩 되세요!
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기