나만의 만화 서버 1 — NAS 폴더에서 모바일 책장까지
폴더와 ZIP을 책으로 묶고, 작품명·가로 맞춤·읽기 위치·연속 읽기를 개선한 기록. 17 GB ZIP이 검색에서 빠진 원인과 Python 아키텍처를 살펴봅니다.
Shelf 개발기 1/4 · 개인 NAS의 이미지 폴더를 책장으로 바꾸는 과정. 이 시리즈의 수치는 실제 구현과 NAS 측정에서 가져왔고, 작품명·이미지·접속 정보는 공개하지 않는다.
처음 요구는 단순했다. NAS의 특정 경로에 이미지나 ZIP을 넣으면 하위 폴더를 책처럼 묶고, 첫 이미지가 표지가 되고, 휴대폰에서 탭으로 넘기며 읽고 싶었다. 블로그에 만화를 붙이는 기능이 아니라, 나만 쓰는 독립적인 이미지 서버가 필요했다.
사용하며 ZIP 내부 책 구분, 작품명, 긴 이미지 맞춤, 목록 위치 복원, 다음 권 연결이 추가됐다.
먼저 정한 경계: 원본과 읽기 상태
상위 경로 하나를 서재(library) 로 보고, 그 안의 책을 탐색한다. 서로 다른 디스크나 공유 폴더를 추가할 수 있도록 서재는 배열로 설정한다. 현재 실제로 연결한 것은 첫 번째 서재이며, 두 번째 경로까지 이미 연결됐다는 뜻은 아니다.
원본과 사용자 상태는 분리했다. 원본 이미지와 ZIP은 읽기 대상으로 두고, SQLite 색인·즐겨찾기·읽은 페이지와 썸네일은 별도 데이터 디렉터리에 저장한다. 운영 중인 Python 컨테이너에서는 원본 경로를 읽기 전용으로 마운트했다. 다시 색인하더라도 같은 서재 ID와 상대 경로에서 같은 책 ID가 만들어져 읽기 기록을 연결할 수 있다. 다만 파일을 다른 경로로 옮기거나 이름을 바꾸면 ID가 달라질 수 있다.
Python 서버의 전체 구성
Archify 인터랙티브 아키텍처 열기 · Archify 입력 JSON
주요 구성은 Flask API, Waitress HTTP 서버, SQLite, Python zipfile, Pillow다. Archify 그림은 이 글의 최종 Python 구조를 보여준다. 따라서 뒤에서 도입 과정을 설명할 ZIP 목차 캐시도 포함한다. 파일 색인은 Python이 맡고, 페이지 요청에서는 색인에 저장된 책 ID를 통해 허용된 원본을 찾는다. 일반 이미지 파일은 파일 경로로 전송하고, ZIP 이미지라면 해당 멤버를 읽어 응답한다.
도표는 본문 언어로 전환되며 Archify 메뉴는 영어다.
“하위의 모든 이미지”를 어떻게 책으로 나눴나
규칙은 이미지가 직접 들어 있는 폴더 하나가 책 하나다. 하위 폴더를 모두 합쳐 하나의 거대한 책으로 만들면 권 구분이 사라진다. 반대로 ZIP을 무조건 한 권으로 보면 여러 작품과 수십 권이 든 압축 파일을 읽기 어렵다.
서재/
작품 A/
01권/001.jpg, 002.jpg → 책: 01권
02권/001.png, 002.png → 책: 02권
새 폴더 (4).zip/
작품 B/01권/001.jpg → 책: 작품 B · 01권
작품 B/02권/001.jpg → 책: 작품 B · 02권
ZIP 내부 경로는 가상 폴더처럼 색인한다. 여기서 “ZIP 하위 지원”은 ZIP 안의 이미지와 디렉터리를 뜻한다. ZIP 안에 다시 ZIP이 들어 있는 중첩 압축을 재귀적으로 푸는 기능까지 구현한 것은 아니다. 지원 압축 컨테이너는 ZIP/CBZ이며 RAR/7z는 포함하지 않는다.
이미지 확장자는 JPG/JPEG/JFIF, PNG, WebP, GIF, BMP, AVIF, TIFF, ICO, TGA, PPM/PGM/PBM/PNM, PSD를 인식한다. 브라우저가 바로 표시하기 어려운 일부 형식은 Pillow로 JPEG 변환한다. “모든 이미지 포맷”을 무제한 지원한다고 말할 수는 없다. 설치된 디코더와 브라우저 지원이 필요하고, PSD 레이어 편집이나 다중 페이지 TIFF 전체 탐색까지 제공하지는 않는다.
정렬은 숫자를 구분하는 자연 정렬을 사용했다. 1, 2, 10 순서가 되어야 독서 흐름이 맞는다. 각 책의 첫 이미지를 표지로 만들고, 이미지가 없는 상위 폴더는 하위 항목을 찾아 탐색할 수 있게 했다.
제목 수정은 경로를 지우는 작업이 아니었다
처음에는 ZIP을 감싸는 이름이 제목 앞에 붙어 새 폴더 (4)가 반복됐다. 내부 폴더명만 남기는 것으로 끝내면 또 다른 문제가 생긴다. 작품 A.zip/01권에서 01권만 보여 작품명을 잃기 때문이다.
최종 규칙은 자동 생성된 포장 이름을 제거하되 의미 있는 작품명과 권 정보를 보존하는 것이다. 새 폴더, New Folder 계열의 이름과 내부의 images, pages, scans 같은 보조 디렉터리를 표시 제목에서 걸러낸다. 연속으로 중복된 이름도 정리한다. 실제 파일 경로와 책 ID는 이 표시 규칙 때문에 바꾸지 않는다.
| 입력 예시 | 표시 제목 | 남겨야 하는 정보 |
|---|---|---|
| 새 폴더 (4).zip/작품 A/01권 | 작품 A · 01권 | 작품과 권 |
| 작품 A.zip/01권 | 작품 A · 01권 | ZIP 자체의 작품명 |
| 작품 A.zip/작품 A/images | 작품 A | 중복·보조 경로 제거 |
모든 임의의 폴더 이름에서 작품명을 추론하는 모델은 아니다. 명시적인 이름 규칙이므로 실제 작품명이 우연히 포장 이름과 같다면 예외 처리가 필요하다.
읽는 도중 나온 요구사항이 UI를 바꿨다
| 사용자 경험에서 드러난 문제 | 반영한 동작 | 상태가 사는 곳 |
|---|---|---|
| 세로로 긴 웹툰이 너무 작게 보임 | 읽기 중 가로 맞춤·세로 맞춤 전환 | 브라우저 설정 |
| 목록 아래에서 책을 읽고 돌아오면 맨 위로 이동 | 목록의 탐색 상태와 스크롤 위치 복원 | 브라우저 상태 |
| 책을 다시 열면 읽던 곳을 잃음 | 마지막 페이지·완료·즐겨찾기 저장 | SQLite |
| 매 권마다 뒤로 가서 다음 책을 열어야 함 | 마지막 페이지 다음 조작에서 다음 책 연결 | 정렬된 책 순서 |
| 폴더와 압축 파일이 뒤섞여 읽기 어려움 | 폴더 탐색과 책 보기 구분 | 색인·API |
가로 맞춤은 화면 폭에 맞추므로 긴 이미지를 아래로 스크롤하며 읽기 좋다. 세로 맞춤은 한 장의 전체 구성을 보고 싶을 때 유용하다. 다음 권 연결은 파일을 합치는 대신 앞뒤 책의 ID를 계산해 이동하는 방식이다. 휴대폰에서는 좌우 탭 영역과 스크롤을 사용한다. 손가락 동작은 브라우저와 기기에 따라 차이가 있으므로 서버 처리량만으로 실제 모바일 체감을 보장할 수는 없다.
위치 복원도 공짜는 아니다. 목록 내용이나 정렬이 바뀌면 예전 픽셀 위치가 다른 책을 가리킬 수 있다. 브라우저 저장소를 지우거나 다른 기기로 접속하면 목록의 로컬 상태는 공유되지 않는다. 서버의 읽기 페이지와 브라우저의 목록 위치를 구별해야 이 동작을 이해할 수 있다.
검색되지 않던 17 GB ZIP의 원인
제목에 8000이 들어 있는 책이 검색되지 않았다. 원본 ZIP은 약 17.45 GB였고, 내부 항목은 40,233개, 이미지 39,605장, 이미지가 들어 있는 책 폴더는 628개였다. 당시 색인 제한은 30,000개였다. 파일이 단순히 “17 GB라서” 실패한 것이 아니라 항목 개수 제한에 걸려 압축 파일의 색인이 빠진 것이었다.
제한을 100,000개로 조정하고, 읽을 수 없는 압축 파일이 조용히 사라지는 대신 오류를 표시하도록 했다. 그 뒤 전체 서재는 1,652권, 97,068페이지로 확인됐다. 새 한도도 무한대가 아니다. ZIP 목차를 읽은 뒤 항목 수를 검사하므로 이 값만으로 파서의 순간 최대 메모리까지 엄격하게 제한하지는 못한다.
대역폭이 높아도 페이지는 느릴 수 있다
문제가 해결되자 이번에는 페이지 넘김 지연이 보였다. Wi-Fi가 500 Mbps라면 충분할 것 같지만, 요청마다 큰 ZIP의 중앙 디렉터리를 다시 읽어 수만 개 메타데이터 객체를 만들면 이미지 전송 전에 시간을 소모한다. 네트워크 처리량과 요청의 시작 지연은 다른 축이다.
수정 방향은 ZIP 전체를 메모리에 올리는 것이 아니라 열린 ZIP과 목차를 제한적으로 재사용하는 것이었다. 이후 같은 ZIP의 다음 장은 목차 파싱을 반복하지 않는다. 이 변화는 Rust 도입 이전에 Python만으로 적용했다. 언어 비교에서 캐시 없는 Python을 기준으로 삼으면 캐시 알고리즘의 이득을 Rust의 이득으로 잘못 설명하게 된다.
2편에서 메모리 관리를 살펴본다.
참고와 다음 글
ZIP 목차와 멤버 읽기의 동작은 Python zipfile, 이미지 처리 범위는 Pillow formats를 참고했다. 그림의 구성과 수치는 이 서버의 실제 소스와 색인 결과를 기준으로 작성했다.