본문 바로가기

EPUB 변환

txt, 워드, 한글파일을 EPUB으로 간단히 변환하는 사이트

배포: https://jhste102lab.github.io/epub-forge/
GitHub: https://github.com/jhste102lab/epub-forge

 

epub-forge — Convert documents to EPUB in your browser

Convert txt, docx, hwpx and hwp files to EPUB books entirely in your browser. Nothing is uploaded.

jhste102lab.github.io

 

 

GitHub - jhste102lab/epub-forge: Client-side document-to-EPUB converter.

Client-side document-to-EPUB converter. Contribute to jhste102lab/epub-forge development by creating an account on GitHub.

github.com

 

문서를 EPUB 전자책으로 바꾸는 도구를 만들었다.
흔한 변환기다. 다만 시작할 때 제약을 하나 걸었다
모든 처리는 사용자 브라우저 안에서, 어떤 파일도 서버로 보내지 않는다.

코드를 어떻게 짰는지가 아니라, 무엇을 기준으로 결정했는지에 대한 기록이다.
구현은 GPT의 디자인을 신뢰할 수 없어 클로드에게 시켰고, 나는 명세를 다듬고, 아키텍처를 정하고, 막힌 곳의 디버깅 방향을 잡앗다.

목차


만든 이유

Sigil에서 매번 손보는 게 지겨웠다

기존에 쓰던 변환기에는 사소하지만 매번 거슬리는 불편이 있었다.
파일명이 그대로 책 제목과 목차에 박힌다.
그래서 변환할 때마다 Sigil(전자책 편집기)을 열어서 제목·목차·표지를 손으로 고쳤다.

내가 원한 건 단순했다. 변환하고 나서 손대는 일을 0으로.

제목·표지·목차를 변환 전에 그 자리에서 정하고, 끝.

원고를 남의 서버에 올리기 싫었다

그리고 원고를 누구 서버에도 올리고 싶지 않았다. 출간 전 소설 원고를 모르는 변환 사이트에 업로드하는 건 찜찜한 일이다.

여기서 "서버에 안 올린다"는 마케팅 문구가 아니라 설계 제약이 된다.

파싱도, 표지 처리도, 폰트 가공도, EPUB 생성도 전부 브라우저 안에서 끝나야 한다.

무엇이 나왔나

드롭하면 끝

.txt, .docx, .hwpx, 그리고 베스트에포트로 구형 한글 .hwp까지 받는다.
이걸 담은 .zip을 던지면 알아서 풀어서 각각을 책으로 만든다.
드래그앤드롭하거나 첨부 카드를 눌러서 여러 개를 한 번에 올린다. 올리는 즉시 아래에 편집 가능한 목록이 뜬다.

책마다 제목과 표지를 그 자리에서

이게 이 도구를 만든 이유 그 자체다.
목록에서 제목·저자를 바로 고치고, 표지를 지정한다.
표지를 안 넣으면 제목·저자로 자동 생성한다. Sigil이 필요 없다.

글꼴을 깎아서 넣는다

읽기 좋은 무료 글꼴 네 개(본명조·본고딕·Pretendard·리디바탕) 중에 고른다.
고른 글꼴은 그 책에 실제로 쓰인 글자만 남겨서 EPUB 안에 넣는다.
그래서 리더기에서도 고른 글꼴 그대로 보이는데, 용량은 작다. 한글 소설 한 권이면 임베드 폰트가 1MB 미만이다.

이 "글자만 깎아서 넣기"가 이 프로젝트에서 제일 어려웠던 부분인데, 뒤에서 따로 다룬다.

여기에 글자 크기·여백·줄 간격·들여쓰기, 줄바꿈으로 끊긴 문장을 한 문단으로 다시 합치는 정리 기능, 그리고 그게 어떻게 합쳐질지 보여주는 라이브 미리보기가 붙는다.

UI는 영어·한국어·일본어·중국어 4개 언어에 라이트/다크 테마다.

처음 켜서 파일 몇 개 던져보면, 솔직히 별 감흥 없이 잘 굴러간다. 변환기가 화려할 일은 없으니까
재밌는 건 이걸 전부 서버 없이 만들면서 생긴 문제들이다.

구현 구조

아키텍처 서버 없이 무거운 건 워커로

전체 그림은 단순하다. 순수 로직과 UI를 떼어놓고, 무거운 일은 워커로 내린다.

[ React UI (features/) ]      드롭존 · 책 목록 · 서식/폰트 · 미리보기
        │  (Comlink)
        ▼
[ Web Worker (workers/) ]     메인 스레드를 막지 않는다
        │
        ▼
[ 순수 코어 (core/) ]         프레임워크·DOM 무관, 단위 테스트 대상
   parse(레지스트리) · reflow · epub(직접 writer) · fonts(wasm 서브셋)

원칙은 세 개다.

  • 메인 스레드를 지킨다. docx/hwp 파싱, 폰트 서브셋(WASM), zip 생성은 전부 워커에서 돈다. 변환 중에도 UI는 안 멈춘다.
  • 무거운 건 그때 받는다. docx 파서, hwp 파서, harfbuzz wasm, 폰트 파일은 그 기능을 실제로 쓸 때만 내려받는다. 그래서 초기 번들은 gzip 약 57KB로 가볍다.
  • 코어는 순수하게 둔다. core/는 DOM도 React도 모른다. 덕분에 변환 로직 전체를 Node에서 그대로 단위 테스트할 수 있고, 이 "테스트 가능한 경계"가 나중에 폰트 문제를 풀 때 결정적이었다.

안 쓰기로 한 것들

기능을 늘리기 전에 무엇을 안 할지부터 정했다. 결정마다 ADR로 "왜 이렇게 정했고 뭘 버렸나"를 남겼다(총 6장). 핵심 둘만 옮긴다.

EPUB 생성 라이브러리를 안 쓴다. 역설적이다. 기존 도구의 불편(제목·목차·표지를 멋대로 정함)을 없애려면 그 부분을 내가 통제해야 하는데, 블랙박스 생성기를 쓰면 바로 그 통제권을 잃는다. 그래서 EPUB3 컨테이너(XHTML·OPF·nav/NCX·표지)를 fflate(경량 zip) 위에서 손으로 조립했다. EPUB은 사실 "정해진 구조의 zip"이라 다룰 만했고, 대신 제목·단일 목차·표지를 한 치의 모호함 없이 내가 정한다.

.doc는 아예 버린다. 구형 한글 .hwp는 베스트에포트로(실패하면 명확히 알리고 그 책만 건너뜀), 레거시 바이너리 .doc는 지원하지 않기로 했다. 브라우저에서 멀쩡히 파싱할 길이 없는 포맷을 위해 서버를 들이면 첫 번째 제약이 무너지니까. "안 하기로 한 결정"도 결정이다. 대신 파서는 레지스트리로 두어서, 새 포맷 = 파서 하나 추가로 끝나게 했다.

사람이 한 일과 AI가 한 일

정직하게 적는다. 이 프로젝트의 코드 타이핑은 AI 에이전트가 했다. 그럼 나는 뭘 했고, 그게 왜 의미가 있나. 사람이 한 일과 AI가 한 일을 갈라두면 이렇다.

[사람] 제약과 결정
 │ 끝장 인터뷰로 명세 확정 (용어집 · ADR 6장)
 │ PRD 한 장 (이슈 #1)
 │ "라이브러리 말고 wasm 직접 가자" 같은 판단
 │ 모든 PR 리뷰 · 머지 · 충돌 해결
 ▼
[AI] 그 안에서 구현
 │ 수직 슬라이스 15개 (#2~#16) 코드 · 테스트 · 빌드
 │ 충돌 적은 슬라이스는 격리 worktree에서 병렬로
 ▼
[결과] 슬라이스마다 CI 그린, 단위테스트 76개 + 브라우저 E2E

순서는 이랬다.

먼저 막연한 아이디어를 들고 에이전트한테 나를 집요하게 인터뷰하게 시켰다.

 

"파일 1개가 책 1개인가, 여러 개가 한 권인가?", "공통 표지랑 개별 표지가 충돌하는데 뭐가 맞나?" 같은 질문을 한 번에 하나씩 받으면서 결정을 못 박았고, 그 자리에서 용어집과 ADR을 기록했다.

 

그 명세를 PRD로 굳히고, 수직 슬라이스 15개로 쪼갰다. 스캐폴드 → 문단 정리 → 배치/목록 → 폰트 → 파서 3종 → i18n → 다크모드 → 표지 → 폰트 서브셋 → zip 입력 → SEO → 배포. 각 슬라이스는 "혼자서 돌려볼 수 있는 얇은 세로 조각"이라, 끝나는 대로 독립적으로 머지된다.

 

서로 안 부딪히는 슬라이스(파서 3종, E2E)는 격리된 git worktree에서 병렬 서브에이전트로 동시에 굴리고, 화면을 크게 건드리는 건 직접 진행했다. 파서들이 같은 레지스트리 파일을 건드려서 난 충돌은 머지할 때 손으로 정리했다.

 

모든 조각은 브랜치 → 타입체크·린트·포맷·단위테스트·빌드·E2E → PR → CI 그린 → 머지를 거쳤다.

나는 코드를 거의 쓰지 않았다. 대신 제약을 세우고, 결정을 내리고, 검증 루프를 깔았다. AI 시대에 시니어가 하는 일이 "코드를 더 빨리 치는 것"에서 "좋은 제약·좋은 분해·좋은 검증을 설계하고 결정을 책임지는 것"으로 옮겨간다는 걸, 이번에 손으로 확인했다.

예상하지 못한 것들

서버 없이 다 하기로 한 대가를 치른 부분들.

폰트 라이브러리가 브라우저에서 깨졌다

제일 어려웠고 제일 많이 배운 부분이다.

 

문제는 이렇다. 고른 글꼴로 리더기에서도 똑같이 보이려면 폰트를 EPUB 안에 넣어야 한다. 그런데 4개 언어를 한 폰트로 덮는 현실적 선택지는 Noto CJK 계열뿐이고, 이건 하나에 수 MB다. 통째로 넣으면 책 한 권이 수십 MB가 된다. 그렇다고 안 넣으면 의도한 글꼴로 안 보인다.

 

정답은 서브셋팅이다.
그 책에 쓰인 글자만 남기고 폰트를 깎는 것. 한글 책은 보통 2~3천 자라, 잘 깎으면 수백 KB로 떨어진다.

Node에는 subset-font라는 검증된 라이브러리가 있다. 로컬 테스트에선 완벽했다. 2MB짜리 폰트를 짧은 글자집합으로 깎으니 7KB가 나왔으니까. 그런데 브라우저(워커)에 올리자 조용히 깨졌다. 에러는 이거였다.

Cannot destructure property 'readFile' of 'fs.promises' as it is undefined.

원인은 명료했다. subset-font는 내부 harfbuzz WebAssembly를 Node의 fs.promises.readFile 읽는다. 브라우저엔 fs가 없다. 서브셋 알고리즘 자체는 100% wasm 안에서 도는데, 딱 그 wasm 바이트를 불러오는 한 줄이 Node 전용이었던 거다.

 

여기서 라이브러리를 억지로 브라우저에서 돌리려 패치하는 대신, 한 칸 내려갔다. harfbuzz의 hb-subset.wasm직접 호출하기로 했다. 서브셋 호출 시퀀스는 공개돼 있으니, wasm을 인스턴스화해서 그 export를 직접 부르면 된다. 그리고 한 번 더 단순화했다 — 폰트 소스를 woff2가 아니라 OTF로 두고 sfnt→sfnt로 깎는 것. 이러면 harfbuzz만으로 끝나고, woff2 코덱(또 다른 wasm 의존성)이 통째로 사라진다.

 

이 결정의 진짜 보상은 따로 있었다. 환경마다 다른 게 "wasm 바이트를 어디서 얻느냐"뿐이 된다. 브라우저는 fetch, Node 테스트는 파일 읽기. 그 한 점만 가장자리에서 주입하면, 서브셋 로직 본체는 브라우저 워커와 Node 테스트가 똑같은 코드 한 벌을 쓴다. 코어를 순수하게 둔 투자가 여기서 회수됐다.

 

검증은 두 겹이다. Node 단위 테스트로 "수 MB 폰트가 10배 이상 줄어드는가"를 보고, Playwright E2E로 "브라우저에서 실제로 만든 EPUB을 풀어보니 그 안에 서브셋 폰트가 들어 있는가"까지 본다. 헤드리스 크롬에서 진짜 한글 폰트를 깎는 경로가 통과하는 걸 본 순간이 제일 후련했다.

교훈 하나. 라이브러리가 막히면, 그게 감싸고 있는 진짜 엔진으로 한 칸 내려가 보라. 종종 더 단순하고 이식성 좋은 길이 거기 있다.

IP로 열면 첨부가 조용히 죽는다

"에러가 안 나는 버그"였다.

로컬에선 멀쩡한데, 같은 네트워크의 다른 기기에서 IP 주소(http://10.x.x.x:5173)로 열면 파일을 올려도 아무 일도 안 일어났다. 책 목록 0개. 에러 토스트도 없었다.

 

원인은 브라우저의 "보안 컨텍스트"였다. localhost나 HTTPS가 아니라 평범한 IP로 접속하면 window.isSecureContextfalse가 되고, 이때 crypto.randomUUID()가 아예 없다. 책마다 id를 부여하려고 이 API를 쓰고 있었는데, 첨부 핸들러가 여기서 던진 예외가 조용히 삼켜지면서 목록이 비어버린 거다.

 

재현이 핵심이었다. Playwright로 IP 주소에 접속시켜서 isSecureContext=false와 "crypto.randomUUID is not a function"을 눈으로 확인한 뒤, 보안 컨텍스트가 아니어도 동작하는 폴백으로 바꿨다. 그리고 다시 IP로 드롭→변환→다운로드까지 통과하는 걸 확인했다.

"환경이 다르면 표준 API도 사라질 수 있다"는, 머리로만 알던 걸 손으로 배운 사례였다.

로컬에선 일본어 배포에선 영어

정적 클라이언트 앱은 첫 HTML이 비어 있어서 검색 크롤러에 불리하다. 그래서 빌드 후 언어별(/en /ko /ja /zh)로 정적 HTML을 프리렌더하고, 각 페이지에 hreflang·메타·OG를 박고 sitemap.xml·robots.txt를 만들게 했다. 각 언어 페이지엔 "이 페이지는 이 언어로 열려야 한다"는 작은 플래그를 심어서, /ja/로 들어오면 일본어로 시작하게 했다.

 

함정은 검증에 있었다. 로컬 vite preview/ja/를 열면 자꾸 영어(루트)로 떨어졌다. 알고 보니 vite preview는 SPA 폴백이 있어서 하위 경로를 루트 index.html로 되돌린다. 반면 GitHub Pages는 실제 /ja/index.html을 그대로 서빙한다. 개발 서버의 동작과 실제 배포의 동작이 달랐던 거다.

 

그래서 GitHub Pages를 흉내 내는 평범한 정적 서버로 빌드 결과를 띄워 다시 확인했더니, /ja/는 일본어로 /zh/는 중국어로 정확히 열렸다.

교훈. "로컬에서 됨"과 "배포에서 됨"은 다른 명제다. 특히 정적 호스팅의 경로 처리 차이는 개발 서버가 숨겨준다.

그래서 뭘 배웠나

통한 건 ADR을 먼저 쓴 거다. "왜 이 결정인가"가 글로 남아 있으니 리뷰도, 회고도, 이 글도 그 위에서 굴러갔다. 코어를 순수하게 둔 것도 — 폰트 처리를 라이브러리에서 wasm 직접 호출로 통째 갈아엎을 때 UI와 테스트가 안 흔들렸다.

 

남은 건 있다. 큐레이션 폰트 중 하나는 소스 확보가 늦어 뒤늦게 합류했고, 배포 워크플로는 "공개 전환 후 수동 실행"으로 안전하게 잠가뒀다. 더 욕심내면 배치 변환의 동시성(워커 풀)과 라이트하우스 회귀 감시가 다음 차례다.

 

한 줄로 정리하면 이렇다. 제품의 난이도는 기능 목록이 아니라 제약이 정한다. "서버에 안 올린다"는 한 줄이 EPUB 직접 조립과 브라우저 폰트 서브셋이라는 두 봉우리를 만들었고, 그 봉우리를 넘는 과정에서 제일 많이 배웠다.

그리고 그 봉우리를 넘은 건 AI지만, 어디로 넘을지 정한 건 사람이었다.

 

배포: https://jhste102lab.github.io/epub-forge/

GitHub: https://github.com/jhste102lab/epub-forge

 

GitHub - jhste102lab/epub-forge: Client-side document-to-EPUB converter.

Client-side document-to-EPUB converter. Contribute to jhste102lab/epub-forge development by creating an account on GitHub.

github.com

 

 

epub-forge — Convert documents to EPUB in your browser

Convert txt, docx, hwpx and hwp files to EPUB books entirely in your browser. Nothing is uploaded.

jhste102lab.github.io