나만의 만화 서버 3 — Rust + Python 혼합 구조와 Blue/Green 배포
ZIP 읽기 경로를 Rust로 옮기고 Flask·SQLite·Pillow는 유지했습니다. Archify As-Is/To-Be, FFI 버퍼 소유권, 실제 검증과 독서 중인 Blue를 보존한 배포를 기록합니다.
Shelf 개발기 3/4 · 실제 ZIP 읽기 경로를 Rust로 옮기고 Python의 웹·색인·이미지 처리 기능을 유지했다. 기존 서버를 읽는 사용자가 있어 Blue/Green 병행 환경으로 배포했다.
1편에서 필요한 기능을 만들었고, 2편에서는 목차 캐시의 수명을 정리했다. 다음 실험은 서버 전체를 다시 작성하는 일이 아니라, 큰 ZIP을 여는 경로와 재사용하는 목차를 네이티브 코드로 옮기는 일이었다.
Flask의 인증·라우팅, SQLite 색인과 읽기 기록, Pillow의 다양한 이미지 처리는 이미 요구사항을 충족하고 있었다. 이 부분까지 동시에 교체하면 성능 차이의 원인을 분리하기 어렵고 기능 회귀의 범위도 넓어진다. Rust의 역할을 ZIP 코어로 한정한 이유다.
As-Is: 캐시가 있는 Python 서버
페이지 API가 SQLite에서 책을 찾고, ArchiveCache.open_member()로 ZIP의 멤버를 열어 바이트를 읽는다. 캐시는 최대 3개 ZIP과 합계 100,000개 항목을 유지한다. 이미 적용한 목차 캐시는 그대로 비교 기준에 포함한다. 즉, 이 글의 As-Is는 페이지마다 목차를 다시 만드는 최초 구현이 아니다.
To-Be: Python API 안에 Rust ZIP 코어
Archify 메뉴는 영어다.
Rust 코어는 별도 HTTP 서버가 아니라 같은 프로세스에 로드하는 공유 라이브러리다. Linux NAS에서는 .so, 개발용 macOS에서는 .dylib로 빌드했다. Python은 ctypes.CDLL을 통해 작은 C ABI를 호출한다. 배포 환경에는 Rust 컴파일러가 없어도 빌드된 라이브러리를 로드할 수 있다.
| 책임 | 실제 구현 위치 | 선택 이유 |
|---|---|---|
| 로그인·요청 검증·세션·API | Python/Flask | 기존 동작과 테스트 유지 |
| 디렉터리 순회·책 묶기·제목·읽기 기록 | Python/SQLite | 변경이 잦은 제품 규칙 유지 |
| 목차 생성·LRU·ZIP 멤버 읽기 | Rust zip 8.6.0 | 반복 비용과 자원 수명을 작은 범위에서 관리 |
| PSD/TIFF 등 변환·표지 생성 | Python/Pillow | 이미지 형식 지원 유지 |
| 탭·맞춤·스크롤·연속 읽기 | 브라우저 | 네이티브 코어 교체와 독립 |
환경 변수 SHELF_ARCHIVE_BACKEND=python 또는 rust로 백엔드를 선택한다. 기본값은 Python이다. 두 백엔드가 같은 open_member() 인터페이스를 제공하므로 API와 화면은 그대로 사용할 수 있다. Rust 선택 시 라이브러리 로드 실패를 조용히 Python으로 숨기지는 않는다. 실패가 드러나야 실제로 어느 백엔드를 측정하고 운영하는지 알 수 있다.
FFI에서 가장 중요한 것은 버퍼 소유권
Rust는 요청한 멤버를 읽어 소유한 바이트 버퍼를 만든다. C ABI 결과는 포인터와 길이로 전달한다. Python은 이를 bytes로 복사하고 finally에서 Rust의 해제 함수를 호출한다. 읽기에 실패했을 때 반환하는 오류 메시지 버퍼에도 같은 해제 규칙을 적용한다.
Rust에서 할당 → 포인터·길이 반환 → Python bytes로 복사
↓ 성공·오류 모두
Rust에서 원래 버퍼 해제
이 설계는 zero-copy가 아니다. 경계에서 복사 비용을 지불하는 대신 HTTP 응답이 원래 Rust 버퍼보다 오래 살아도 안전하게 사용할 수 있게 했다. 한순간에는 Rust 버퍼와 Python 복사본이 함께 존재할 수 있다. 그 후의 BytesIO, 이미지 변환, 응답 처리까지 생각하면 “이미지 하나당 버퍼가 항상 한 개”라는 설명도 부정확하다.
캐시 핸들은 Python 래퍼가 소유하며 종료 시 finalizer가 네이티브 핸들을 해제한다. close()는 재사용 가능한 캐시를 비우는 동작이다. ABI의 unsafe 함수에는 포인터 유효성, 중복 해제 금지, 실행 중 핸들 파괴 금지 같은 계약을 명시했다. Rust 내부가 안전한 타입을 쓰더라도 C ABI 호출자가 잘못된 포인터를 주면 컴파일러가 대신 보호해 주지 않는다.
ctypes.CDLL 호출 중에는 Python GIL이 해제된다. 다만 이 사실만으로 같은 ZIP의 여러 페이지가 동시에 압축 해제되는 것은 아니다. 이 구현은 ZIP별 Mutex로 seek/read를 직렬화한다. ctypes와 실제 잠금 구조를 함께 봐야 동시성의 범위를 이해할 수 있다.
캐시의 생명주기와 실패 경로
Rust 캐시는 전체 상태를 잠금으로 보호하고 각 ZIP은 Arc<Mutex<ZipArchive<File>>>로 보유한다. LRU에서 항목을 제거해도 이미 읽는 요청이 Arc를 가지고 있으면 해당 작업이 끝날 때까지 파일이 살아 있다. 마지막 소유자가 사라지면 자원이 해제된다.
파일 교체는 경로 문자열만으로 판단하지 않는다. 디바이스·inode·크기·수정 시각·변경 시각을 확인해 예전 목차를 폐기한다. 같은 크기와 수정 시각을 가진 파일로 원자적 교체하는 경우도 테스트했다. 압축 파일의 실시간 덮어쓰기를 트랜잭션처럼 보장하는 것은 아니므로, 업로드가 끝난 파일을 이름 변경으로 배치하는 편이 이 모델에 잘 맞는다.
한 이미지의 해제 결과는 64 MiB, 압축비는 1,000배로 제한하고 암호화된 멤버를 거부한다. 캐시의 ZIP 개수·항목 수·유휴 시간은 Python과 동일하게 유지했다. 이 제한은 전체 프로세스 메모리의 엄격한 상한이 아니다. 목차를 처음 파싱하는 순간과 이미지 변환 중 메모리는 별도로 남는다.
검증에서는 Stored/Deflate/Bzip2/LZMA의 바이트 일치, 80개 동시 읽기에서 목차 재사용, 교체·삭제·크기 초과, 반복 퇴출, CRC 오류와 없는 멤버를 확인했다. 기존 책 분류·인증·이미지 API 테스트를 합쳐 로컬의 두 백엔드에서 각각 27개가 통과했고, 실제 NAS의 Rust 백엔드에서도 27개가 통과했다. Rust 코드는 cargo clippy -- -D warnings도 통과했다.
Blue/Green: 독서 중인 Blue를 유지했다
이번 배포는 기존 Python 서버를 Blue로 두고, 다른 포트에 혼합 구조 Green을 띄워 검증하는 단계까지 진행했다. 운영 중인 Blue의 컨테이너와 소스는 교체하거나 재시작하지 않았다. 기존 독자의 요청도 Green으로 무작위 분산하지 않았다.
Green은 SQLite backup API로 만든 별도 DB와 별도 썸네일 디렉터리를 사용한다. 같은 원본을 읽지만 즐겨찾기·진도 저장은 각 환경에 기록된다. 단순히 실행 중인 SQLite 파일만 복사하면 WAL의 변경을 놓칠 수 있어 backup API를 사용했다. 세션 키와 책 ID 체계는 유지했다.
현재 Green은 NAS 계정으로 실행하는 별도 CPython 3.12.14 프로세스다. CPU 코어 2·3에 배치하고 nice 값을 조정했으며, 자동 전체 색인은 꺼 두었다. 새 파일 반영은 수동 새로고침으로 가능하다. 유휴 캐시 정리는 30초 주기로 수행한다. CPU를 나눠도 디스크·메모리·OS는 공유하므로 완전한 성능 격리는 아니다.
운영 조건도 정확히 구분해야 한다. Blue는 컨테이너의 읽기 전용 원본 마운트와 768 MiB 메모리 제한이 있지만, 현재 Green 프로세스에는 동일한 컨테이너 경계나 자동 재부팅 복구가 없다. Green 코드는 원본을 읽기만 하도록 구성했지만 OS 권한 수준의 읽기 전용 마운트와 같다고 표현하지 않는다. 별도의 컨테이너 배포 파일도 준비했으나 현재 NAS에서 실행한 형태와 구별한다.
따라서 이 상태는 Blue/Green 병행 배포와 Green 검증 완료, 공용 주소 전환 전이다. 단일 프록시 주소의 원자적 라우팅 전환까지 끝났다고 주장하지 않는다. 전환 시에는 새 요청과 상태 쓰기의 주체를 하나로 정하고 최신 진도를 동기화해야 한다. 두 DB가 따로 변한 뒤 이전 Blue로 돌아가는 일은 URL만 되돌린다고 해결되지 않는다. Green에서 새로 쌓인 진도를 보존할 계획이 먼저 필요하다. 브라우저의 로컬 저장 상태 또한 포트가 다른 origin 사이에서는 자동 공유되지 않는다.
Green의 수용 검증에서는 1,652권·97,068페이지를 확인했고, 문제였던 검색을 다시 확인했다. 실제 ZIP의 9개 페이지는 원본과 SHA-256이 같았고 표지도 정상적으로 생성됐다. 이 검증은 읽기 기록을 변경하지 않았다.
다음 질문은 언어가 아니라 효과의 크기다
이번 변경은 목차와 멤버 읽기의 구현만 바꿨다. HTTP·SQLite·Pillow 비용, 같은 ZIP의 잠금, 네트워크와 브라우저 디코딩은 그대로다. 첫 읽기와 반복 읽기에서 같은 비율로 빨라질 이유가 없다. 4편에서는 동일한 NAS와 런타임에서 이 차이를 측정한다.
자료구조와 형식 지원은 zip-rs, 소유권은 Rust ownership, Python 네이티브 호출은 ctypes를 참고했다. 이번 구현의 성능 결과는 해당 라이브러리 전체에 대한 일반적인 보증이 아니다.