나만의 만화 서버 4 — NAS 실측으로 비교한 성능과 기술 선택
11,395개 응답과 반복 실험으로 Python 캐시와 Rust 혼합 구조를 비교했습니다. 첫 읽기·처리량·p95·CPU·RSS 그래프, 원자료, 한계를 공개하고 적재적소의 기술 선택을 정리합니다.
Shelf 개발기 4/4 · 동일 NAS에서 캐시가 있는 Python과 Rust ZIP 코어를 붙인 혼합 구조를 비교했다. 2026-09-18 측정. 합성 성능 수치나 추정치를 실측처럼 채우지 않았다.
결과부터 말하면, 이번 워크로드에서 혼합 구조는 큰 ZIP의 첫 읽기 시간을 575.5 ms에서 211.3 ms로 줄였다. 동시 요청 4개에서는 처리량 중앙값이 203.0에서 239.7 req/s로 늘었고, 관측한 서버 RSS 중앙값은 81.3에서 74.8 MiB로 줄었다.
하지만 첫 읽기의 63.3% 시간 감소가 휴대폰의 모든 페이지 넘김에 적용되지는 않는다. 목차가 이미 준비된 반복 요청의 개선 폭은 작았고, 높은 동시성에서는 라운드별 변동도 있었다. 이 차이를 설명하는 것이 단순한 승패보다 중요하다.
어떤 두 구조를 비교했는가
| 항목 | Python 기준선 | Rust 혼합 구조 |
|---|---|---|
| HTTP·인증 | Flask + Waitress | 동일 |
| 색인·읽기 기록 | Python + SQLite | 동일 |
| 썸네일·포맷 변환 | Pillow | 동일 |
| ZIP 목차·멤버 읽기 | Python zipfile 캐시 | Rust zip 8.6.0, ctypes C ABI |
| 캐시 한도 | 3 ZIP / 100,000항목 / 120초 | 동일 |
| 이미지 결과 전달 | Python 바이트 | Rust 버퍼 → Python 바이트 복사 |
| 배포 복잡도 | Python 의존성 | Python 의존성 + 대상별 네이티브 바이너리 |
Python Archify와 혼합 구조 Archify에서 구성 차이를 볼 수 있다. 캐시를 도입하기 전 Python과 비교하지 않았다. 그렇지 않으면 “목차를 재사용하는 효과”와 “네이티브 구현의 효과”가 섞인다.
실험 조건과 독서 중인 서버 보호
실험 장비는 Synology DS720+, Intel Celeron J4125 4코어, 약 6 GB RAM이다. 같은 NAS의 코어 2·3에서 같은 CPython 3.12.14와 같은 Flask·Waitress·Pillow 버전을 사용했다. 실제 Blue의 Python 컨테이너와 Green 프로세스 사이를 직접 A/B 부하 대상으로 삼지 않았다. 별도 임시 서버를 같은 런타임으로 번갈아 실행해 백엔드 외 차이를 줄였다.
| 조건 | 실제 수행 내용 |
|---|---|
| 자료 | 약 17.45 GB ZIP, 40,233개 항목; HTTP는 같은 책의 9페이지 순환 |
| HTTP 서버 | Waitress 4스레드, 백그라운드 색인 없음 |
| 네트워크 | NAS 내부 loopback HTTP, 인증된 세션 |
| 부하 모델 | 응답을 기다렸다 다음 요청을 보내는 closed-loop, think time 없음 |
| 동시 클라이언트 | 1, 4, 8 |
| 반복 | 조건별 3초 × 3라운드; Python/Rust 순서를 번갈아 배치 |
| 첫 읽기 | 백엔드별 새 프로세스 3회; 목차가 없는 상태 |
| 메모리 회수 | 열기·8페이지 읽기·캐시 비우기·GC를 8회, 3라운드 |
| 수집 | 요청별 지연, 오류, 서버 CPU 시간, 50 ms 간격 RSS |
| 기존 Blue | 부하 요청 0건, 재시작 0회, 읽기 기록 수정 0건 |
첫 읽기의 “cold”는 애플리케이션 목차 캐시가 비어 있다는 뜻이다. 운영 중인 NAS의 OS 페이지 캐시를 강제로 비우지 않았으므로 물리 디스크 cold 성능은 아니다. 클라이언트도 같은 NAS에서 실행되어 자원을 공유한다. 이 통제 실험은 무선망·QuickConnect·TLS·휴대폰 이미지 디코딩을 포함한 체감 속도 측정이 아니다.
총 11,395개 HTTP 응답을 측정했고 오류는 0건이었다. 매 응답의 상태와 콘텐츠 해시를 확인했다. 두 백엔드의 수용 검증과 CRC·압축 방식 등의 테스트도 함께 수행했지만, 측정 페이지가 전체 이미지 형식을 대표하지는 않는다.
전체 결과: 시작 비용에서 차이가 컸다
막대는 3라운드 중앙값이며 오차 막대는 최솟값–최댓값이다. 신뢰구간이 아니다. RSS는 각 짧은 HTTP 구간에서 50 ms 간격으로 샘플링한 최대값의 중앙값이다. 매우 짧은 순간의 최대 메모리는 놓칠 수 있다.
| 지표 | Python | 혼합 구조 | 변화 |
|---|---|---|---|
| 첫 ZIP 멤버 읽기, 벽시계 | 575.5 ms | 211.3 ms | 63.3% 감소 |
| 첫 ZIP 멤버 읽기, CPU | 575.4 ms | 210.7 ms | 63.4% 감소 |
| 반복 HTTP 처리량, C=4 | 203.0 req/s | 239.7 req/s | 18.1% 증가 |
| 반복 HTTP p95, C=4 | 34.48 ms | 31.64 ms | 8.2% 감소 |
| 서버 CPU/요청, C=4 | 5.99 ms | 5.49 ms | 8.4% 감소 |
| 관측 서버 RSS, C=4 | 81.33 MiB | 74.78 MiB | 8.1% 감소 |
첫 읽기 CPU 시간은 벽시계 시간과 가까웠다. 이 조건에서는 네트워크 전송보다 목차를 구성하고 멤버를 준비하는 CPU 작업이 큰 비용이었다고 해석할 수 있다. 다만 코드가 서로 다른 ZIP 라이브러리와 자료구조를 쓰므로 이 차이를 “Rust 언어 자체의 순수 효과”로 분해한 실험은 아니다.
동시성을 늘리면 어디까지 좋아졌나
| 동시 요청 | Python req/s | 혼합 req/s | Python p95 | 혼합 p95 |
|---|---|---|---|---|
| 1 | 195.8 | 208.3 | 5.04 ms | 4.73 ms |
| 4 | 203.0 | 239.7 | 34.48 ms | 31.64 ms |
| 8 | 196.1 | 220.4 | 88.95 ms | 65.68 ms |
동시성을 8까지 늘렸지만 처리량이 계속 비례해서 늘지 않았다. 작업 스레드는 4개이고, Rust 구현의 같은 ZIP 읽기는 ZIP별 잠금으로 직렬화된다. 공통 Python API의 SQLite 조회·인증 처리와 응답 구성도 남아 있다. 이들은 코드상 확인한 제약이며, 각 제약의 기여율까지 프로파일링으로 분리한 결과는 아니다.
서버 CPU/요청은 서버 프로세스의 CPU 시간 증가분을 완료 요청 수로 나눈 값이다. 사용자 대기시간이나 클라이언트 CPU 사용량과 다르다. CPU 비용이 줄어도 큐에서 기다리는 시간이 늘면 p95가 나빠질 수 있다.
평균만 보면 숨는 지연 분포
왼쪽 누적 분포는 C=4의 세 라운드 요청을 합친 것이다. 95% 높이에서 읽는 값은 이 합친 표본의 p95이며, 오른쪽의 “라운드별 p95의 중앙값”과 계산 방식이 다르다. 요청이 많은 라운드가 왼쪽 분포에 더 큰 비중을 갖는다.
혼합 구조의 중앙값은 개선됐지만 모든 라운드의 꼬리 지연이 더 짧지는 않았다. C=8 첫 라운드의 p95는 Python 약 64.9 ms, 혼합 구조 약 82.5 ms였다. 최종 p99 중앙값도 Python 약 109.6 ms, 혼합 구조 약 108.0 ms로 차이가 작았다. “항상 빠르다”거나 특정 p99를 보장한다고 말할 근거는 없다.
closed-loop 방식은 클라이언트가 느린 응답을 기다리면 새 요청 발생도 줄어든다. 외부에서 일정한 도착률로 계속 유입되는 과부하를 재현한 것이 아니다. 이번 결과는 짧은 동시성 부하 탐색이며 서비스의 최대 수용량이나 장기 SLO 검증은 아니다.
캐시를 비운 뒤 메모리는 어땠나
별도 마이크로 실험의 8번째 회수 후 RSS 중앙값은 Python 99.31 MiB, 혼합 구조 65.64 MiB였다. 이 값은 앞의 HTTP 서버 RSS와 서로 다른 프로세스·작업 단계에서 측정했으므로 하나의 연속 그래프로 섞지 않았다.
Python에서 캐시 참조가 없어져도 메모리 할당기가 확보한 공간을 보유할 수 있다. 이 결과만으로 Python에 누수가 있다고 단정하지 않는다. 반대로 Rust의 짧은 평탄한 선도 무누수의 증명이 아니다. 2편에서 설명했듯 객체 수명·재사용 가능한 힙·OS RSS는 구분해야 한다.
결과를 다시 만들 수 있게 남긴 것
측정 원자료 JSON에는 조건, 라운드별 결과와 요청별 지연을 보존했다. 비교표 CSV는 18개 HTTP 구간의 집계다. 공개 파일에는 NAS 경로·작품명·인증 정보·원본 이미지가 포함되지 않는다. 그래프는 이 기록을 Matplotlib으로 그린 SVG와 PNG이며, 이미지 생성 모델이 만든 그래프가 아니다.
실험 코드는 별도 서버 프로젝트의 scripts/benchmark_hybrid.py, 그래프 코드는 scripts/plot_hybrid_results.py에 있다. 실행 전 비공개 자료 경로·샘플 페이지·인증 정보를 지정해야 하므로 공개 JSON만으로 원본 이미지 요청을 재실행할 수는 없다. 다음은 서버 프로젝트 안에서 사용하는 형태다.
# 비공개 입력 파일은 별도 보관. 기존 Blue 주소를 부하 대상으로 쓰지 않는다.
PYTHONPATH=. runtime/python/bin/python3 scripts/benchmark_hybrid.py \
--config data/benchmark-input.private.json --output benchmark-results
python scripts/plot_hybrid_results.py benchmark-results/results.json ./figures
실행 대상은 NAS의 동일 런타임이어야 한다. 다른 컴퓨터에서 그린 그래프 자체와 NAS에서 수집한 측정값도 구별했다. 반복 횟수는 3회이므로 유의성 검정이나 정밀한 오차 추정까지 주장하지 않는다. 장시간 soak, 더 다양한 ZIP·이미지 크기, 실제 휴대폰의 end-to-end 지연, 개방형 도착률 부하, 전력 사용량은 이번에 측정하지 않았다.
기술 선택에 남은 판단
| 선택 | 이점 | 지불하는 비용 | 이 서버에서의 판단 |
|---|---|---|---|
| Python + 제한 캐시 | 구현·배포가 단순하고 기존 기능을 빠르게 수정 | 큰 목차의 객체 생성 비용 | 이미 충분히 유용한 기준선 |
| Python + Rust ZIP 코어 | 큰 목차 첫 읽기·일부 CPU·메모리 개선 | FFI 복사, ABI 수명 계약, 대상별 빌드 | 측정된 핫패스에 한정해 도입 |
| 전면 Rust 재작성 | 웹 경로까지 설계 변경할 자유 | 인증·색인·변환·화면 회귀 범위 확대 | 이번 측정만으로 정당화하지 않음 |
이번 경험에서 먼저 효과가 컸던 선택은 불필요한 목차 재생성을 없애는 것이었다. 그 다음 같은 캐시 정책 안에서 Rust 구현이 추가 이득을 보였다. Python의 이미지 처리와 제품 규칙을 남기고 Rust의 ZIP 처리에 집중한 것은 이 두 단계를 분리해 생각한 결과다.
언어 하나를 전부에 적용하는 일보다, 병목을 측정하고 경계를 작게 정한 뒤 그 경계의 비용까지 함께 평가하는 일이 도움이 됐다. 혼합 구조는 이 워크로드에서 유리했지만, 더 작은 서재나 수정 빈도가 높은 프로젝트에서는 단순한 Python 구조가 더 나은 선택일 수 있다. 이것이 네 편을 통해 남기고 싶었던 기술 선택의 기준이다.