기존 EPUB 리더를 사용하면서 출판사 CSS가 앱의 기본 글꼴로 대체되거나, 광고와 특정 클라우드 저장소 사용이 요구되는 점이 불편했다. 출판사의 조판을 유지하고 개인 서버에서 책을 가져올 수 있는 광고 없는 리더를 만들었으며, 이름은 Booklane으로 정했다.

Booklane EPUB Reader 구조

EPUB 파일을 SAF로 가져와 Room에 등록하고, WebView 안의 foliate-js로 렌더링한다.

foliate-js를 사용한 EPUB 렌더링

글자 흐름, 페이지 분할, 각주, 목차와 CSS 처리를 직접 구현하는 대신 foliate-js를 사용했다. 브라우저 기반 EPUB 렌더러이며 원본 문서의 CSS를 유지할 수 있다는 점이 요구사항과 맞았다.

구조는 이렇게 잡았다. UI와 라이브러리 화면은 Jetpack Compose로 네이티브, 실제 책 본문은 WebView 안에서 foliate-js가 그린다. 그 사이는 네이티브 브리지로 오간다. 폰트 키우기 같은 설정은 paginator.setStyles로 CSS를 밀어 넣고, 목차 클릭이나 진도 슬라이더는 브리지로 foliate에 명령을 보낸다.

WebView의 same-origin 제약

가장 오래 확인한 문제는 same-origin 제약이었다. foliate-js는 EPUB 파일을 fetch로 읽으므로 리더 HTML과 책 파일이 같은 origin에 있어야 한다. 그러나 리더 HTML은 앱 asset에, EPUB 파일은 내부 저장소에 있어 기본 경로로는 origin이 일치하지 않았다.

리더와 EPUB을 모두 file://로 여는 방식도 검토했지만, WebView의 보안 제약으로 fetch 요청이 차단됐다. 내부 저장소의 실제 파일 경로가 WebView에 직접 노출되는 문제도 있었다.

답은 WebViewAssetLoader였다. 리더 HTML이든 책 바이트든 모두 https://booklane.local이라는 가짜 단일 오리진 아래로 서빙했다. 리더는 /asset/web/..., 책은 /book/<이름>. foliate 입장에선 둘이 같은 오리진이라 same-origin 요건이 충족되고 실제 파일시스템 경로도 밖으로 새지 않는다.

val loader = WebViewAssetLoader.Builder()
    .setDomain("booklane.local")
    .addPathHandler("/asset/", AssetsPathHandler(this))
    .addPathHandler("/book/", stagedBookHandler)   // 내부 파일을 스트림으로
    .build()

이 방식은 단일 가상 도메인으로 same-origin 조건을 충족하면서 실제 파일 경로는 노출하지 않는다.

Storage Access Framework로 파일 가져오기

광범위한 저장소 권한을 요청하지 않도록 READ_EXTERNAL_STORAGE 대신 Storage Access Framework를 사용했다. 사용자가 선택한 EPUB은 앱 전용 디렉터리(getFilesDir())로 복사하고 해시를 저장한다. 앱은 사용자가 선택한 파일에만 접근하며 별도의 저장소 권한을 요구하지 않는다.

진도와 북마크는 Room에 저장했다. foliate가 주는 CFI(책 안 위치를 가리키는 식별자)를 저장해두고 다시 열면 그 자리로 복원한다. 처음에는 한 페이지씩 어긋나서 off-by-one 문제를 확인하는 데 시간이 걸렸다.

만들고 보니

처음에는 EPUB 열람 기능만 만들었지만 이후 만화 2단 보기, 세로소설 페이지 넘김, 개인 서버 동기화와 앱 업데이트 기능까지 추가했다. 기능이 늘어난 뒤에도 출판사 조판을 유지하고 광고 없이 개인 서버의 책을 읽는다는 원칙은 바꾸지 않았다.

현재는 원본 조판 유지, 광고 없는 열람과 개인 서버 연동이라는 초기 요구사항을 충족한 상태로 매일 사용하고 있다. 직접 사용하는 과정에서 발견되는 문제를 기준으로 기능을 추가하고 있다.


참고