본문으로 건너뛰기
목록으로 돌아가기
기술
10 분

나만의 만화 서버 2 — 목차 캐시, GC, 메모리 회수의 경계

캐시가 붙잡은 객체는 왜 GC로 사라지지 않을까? LRU·TTL·참조 수명·RSS를 구분하고, Rust의 소유권이 바꾸는 것과 바꾸지 않는 것을 실측으로 살펴봅니다.

박효열 (Hyoyoul Park)
#Shelf#Cache#GC#Memory#Rust
나만의 만화 서버 2 — 목차 캐시, GC, 메모리 회수의 경계

Shelf 개발기 2/4 · “목차를 캐시하면 나중에 GC가 안 되는 것 아닐까?”라는 질문에서 시작한 메모리 관리 기록.

목차 재사용으로 페이지는 빨라졌다. 이제 캐시의 수명과 메모리 비용을 살펴보자.

살아 있는 객체, 캐시 정책, 할당기, RSS를 나눠 보자. 이 글의 Python 설명은 서버에서 사용한 일반적인 GIL 빌드의 CPython 3.12를 기준으로 한다. 다른 Python 구현이나 이후 버전의 세대별 GC 정책까지 동일하다고 가정하지 않는다.

무엇을 캐시했는가

ZIP은 각 파일의 이름·크기·압축 방식·위치 등을 중앙 디렉터리에 기록한다. Python의 ZipFile을 열면 이를 읽고 ZipInfo 같은 객체를 구성한다. 수만 개 항목이 든 ZIP을 페이지마다 새로 열면 같은 준비 작업을 반복한다.

이번 목차 캐시는 ZipFile과 메타데이터를 유지한다. 17.45 GB 압축 파일 전체나 39,605장의 이미지 픽셀을 모두 RAM에 올리는 구조가 아니다. 요청한 이미지의 압축 해제 결과는 요청 처리 중 별도 버퍼로 존재한다.

저장 대상위치제한과 수명
ZIP 핸들·중앙 디렉터리프로세스 메모리최대 3개 ZIP, 합계 100,000개 항목
사용하지 않은 ZIP 목차프로세스 메모리마지막 사용 후 120초부터 회수 대상
요청 중 이미지 바이트요청 처리 메모리한 멤버 최대 64 MiB, 동시 요청 수에 영향 받음
첫 이미지의 작은 표지디스크새 표지를 만들 때 총 256 MiB 한도를 적용
책 목록·읽기 기록SQLite 파일영속 상태, GC의 대상이 아님
목록 스크롤·보기 설정브라우저서버 캐시와 별도의 수명

같은 “캐시”라는 단어로 묶어도 정리 주체가 다르다. 디스크 썸네일을 지운다고 Python RSS가 바로 내려가지는 않으며, 브라우저 위치 복원은 NAS의 ZIP 캐시와 무관하다.

읽기 한도를 늘릴 때 메모리는 얼마나 늘까

항목 제한을 30,000에서 100,000으로 올렸다고 시작하자마자 메모리가 3.33배 할당되지는 않는다. 실제로 읽는 항목 수만큼 객체가 생긴다. 반대로 객체마다 이름 문자열·컨테이너·인덱스 오버헤드가 있으므로 “항목 하나는 정확히 몇 바이트”라는 상수로도 설명하기 어렵다.

구성은 다음과 같다.

프로세스 메모리 ≈ 런타임·라이브러리
               + 보관 중 ZIP 메타데이터
               + 처리 중 이미지 버퍼와 변환 픽셀
               + SQLite·HTTP 작업 메모리
               + 할당기가 보유한 여유 공간

100,000개는 보관할 목차의 항목 수 제한이지, 프로세스 전체의 바이트 제한이 아니다. 긴 파일명은 더 많은 메모리를 쓰고, Pillow가 압축 이미지를 픽셀로 펼치면 파일 크기보다 훨씬 커질 수 있다. 4개 요청이 각각 64 MiB에 가까운 이미지를 처리하면 결과 버퍼만으로도 부담이 커진다. 혼합 구조에서는 FFI 경계의 복사까지 고려해야 한다.

게다가 현재 두 구현은 ZIP을 파싱한 뒤에 보관할지 결정한다. 제한을 넘는 목차의 순간 할당 자체를 차단하는 선행 파서는 아니다. 엄격한 총량 제한은 컨테이너 메모리 한도 같은 운영체제 경계와 함께 설계해야 한다. 기존 Blue 컨테이너의 한도는 768 MiB다. Docker memory limits가 설명하듯 한도는 가용 RAM을 무한히 늘려 주는 장치가 아니며, 초과하면 프로세스가 종료될 수 있다.

캐시가 참조하면 GC는 수거하지 않는다

캐시는 의도적으로 객체를 붙잡는다. 참조가 살아 있으므로 GC가 이를 “안 쓰는 쓰레기”라고 판단해서 지우면 오히려 잘못이다. 필요한 것은 GC 호출이 아니라 어떤 객체를 언제 캐시에서 제거할지 정하는 정책이다.

이번 Python 구현은 최근 사용 순서를 유지하는 LRU를 사용한다. 새 ZIP을 넣을 때 ZIP 개수나 총 항목 수를 넘으면 오래 사용하지 않은 항목을 제거하고 close()를 호출한다. 파일의 크기·시각 등 식별 정보가 바뀌면 예전 목차를 재사용하지 않는다. 사용 중인 멤버 스트림의 수명은 캐시에서 제거되는 시점과 구분해야 한다.

Mermaid

120초는 “정확히 120초에 타이머가 객체를 삭제한다”는 뜻이 아니다. 기존 Python 서버에서는 다음 캐시 접근이나 색인 작업에서 정리한다. 따라서 아무 요청도 없으면 더 오래 남을 수 있다. Green에는 별도의 주기적 prune을 두어 스캔이 꺼진 대기 환경에서도 유휴 목차를 정리하도록 했다. 캐시 용량 제한과 TTL의 실행 시점을 따로 확인해야 한다.

GC, 회수, RSS가 같은 말이 아닌 이유

CPython은 참조 계수와 순환 참조를 탐지하는 GC를 함께 사용한다. 단순한 비순환 객체는 마지막 참조가 사라질 때 해제될 수 있다. 서로를 참조하는 객체 집합은 참조 계수만으로 해결되지 않을 수 있어 순환 GC가 필요하다. gc.collect()는 도달 가능한 캐시를 강제로 비우는 API가 아니다. 이 구분은 CPython gc의 설명과 맞닿아 있다.

객체가 해제된 뒤에도 할당기가 메모리를 다음 할당에 재사용하려고 보유할 수 있다. OS가 보고하는 RSS는 프로세스의 물리 메모리 상주량이므로, “살아 있는 Python 객체의 크기 합”과 일치하지 않는다. Python의 작은 객체 할당기, 네이티브 라이브러리, 단편화 등도 관여한다. CPython memory를 읽을 때도 객체 수명과 OS 반환을 분리해서 보는 것이 도움이 된다.

이 서버에서 과거 수행한 제한 캐시 실험은 120회 열기에서 살아 있는 캐시 객체가 최대 3개였고, 명시적으로 비운 뒤 강제 GC 이전에도 해당 객체의 약한 참조가 사라지는 것을 확인했다. 이것은 그 실험의 참조 수명이 의도대로 끝났다는 증거다. 모든 경로에 메모리 누수가 없거나, 장기간 RSS가 절대로 증가하지 않는다는 증명은 아니다.

Rust라면 자동으로 해결될까

Rust의 소유권은 값의 책임을 명확히 한다. 값이 소멸할 때 Drop 경로로 자원을 해제하고, 공유가 필요하면 Arc 같은 타입으로 공동 소유를 표현한다. 마지막 Arc가 사라지기 전까지 값은 살아 있다. Rust도 무제한 캐시를 만들면 무제한으로 보관하며, 참조 순환이나 의도적인 누수도 가능하다. Rust ownership과 Rust cycles 설명이 이 차이를 잘 보여준다.

질문Python 구조Rust ZIP 코어
객체를 언제 놓는가참조 제거·명시적 close, 필요 시 순환 GC소유자 또는 마지막 Arc의 소멸
캐시가 무한히 커지는가정책에 달림역시 정책에 달림
객체 표현 비용은 어떤가Python 객체·문자열·컨테이너 비용네이티브 자료구조의 레이아웃 비용
해제 뒤 RSS가 즉시 줄어드는가보장하지 않음역시 보장하지 않음
네이티브 경계의 위험은 없는가라이브러리·FFI를 확인해야 함unsafe C ABI의 계약을 지켜야 함

Rust를 선택할 이유는 “GC가 없어서 모든 것이 빨라진다”가 아니다. 이 경우에는 큰 목차를 네이티브 자료구조로 보관하고, 반복적인 ZIP 작업의 CPU 비용과 수명을 작은 모듈 안에서 관리할 가능성이 있었다. 그 대신 빌드 대상별 바이너리와 FFI 버퍼 소유권이라는 새 책임이 생긴다.

실제 회수 실험은 무엇을 보여줬나

NAS에서 같은 CPython 3.12.14로 각각 새 프로세스를 시작하고, 열기 → 8페이지 읽기 → 캐시 비우기 → gc.collect()를 8회 반복했다. 3회 실험의 마지막 RSS 중앙값은 Python 99.31 MiB, 혼합 구조 65.64 MiB였다. 선은 중앙값, 음영은 관찰한 최솟값과 최댓값이다.

두 구조 모두 캐시를 비운 뒤 측정했다. Python 그래프의 상승만 보고 “GC가 안 된다”고 결론낼 수 없다. 실제 참조가 남는지, 할당기가 재사용 공간을 보유하는지, OS 상주량이 어떻게 변하는지 추가로 구분해야 한다. Rust 결과 역시 8회 실험에서 안정적이었다는 뜻이며 장기 누수 검증을 대체하지 않는다. 운영 요청마다 강제 GC를 호출하는 변경도 하지 않았다.

다음 단계의 진단이라면 캐시 항목·열린 파일 디스크립터·Python 할당 추적·네이티브 할당·RSS를 함께 장시간 기록하겠다. 하나의 그래프로 서로 다른 원인을 구별할 수 없기 때문이다.

3편에서는 혼합 서버를 구현한다. 4편에서는 이 회수 실험과 HTTP 부하 측정의 조건을 함께 공개한다.

1편 · 2편 · 3편 · 4편