
새벽에 트래픽이 몰려서 워커 프로세스를 4개로 늘렸는데, 정작 대시보드의 캐시 적중률이 뚝 떨어지는 그래프를 보고 당황한 적이 있으신가요. 원인은 메모리 부족이나 캐시 로직 버그가 아니라, 워커마다 캐시가 따로 만들어지는 구조 때문인 경우가 많습니다. 이 글에서는 프로세스별 메모리가 어떻게 캐시 적중률을 갉아먹는지, 그리고 어떤 조건에서 공유 캐시로 옮기는 게 맞는지를 구체적으로 짚어보겠습니다.
워커 수를 늘렸는데 캐시 적중률이 떨어지는 이유
Gunicorn이나 PM2, uWSGI처럼 멀티 워커로 돌리는 서버는 하나의 애플리케이션 코드를 여러 개의 독립된 프로세스로 띄워서 실행합니다. 이때 각 프로세스는 완전히 분리된 메모리 공간을 가집니다. 워커가 1개였을 때는 모든 요청이 같은 프로세스로 들어오니 캐시도 하나만 있으면 충분했지만, 워커를 4개로 늘리면 캐시도 4벌이 따로 생기는 셈입니다.
문제는 이 4벌의 캐시가 서로의 내용을 전혀 모른다는 점입니다. 워커 A가 어떤 사용자의 데이터를 이미 캐시해 뒀어도, 다음 요청이 워커 B로 가면 워커 B 입장에서는 처음 보는 요청이라 다시 DB를 조회합니다. 워커 수가 늘어날수록 같은 키라도 “처음 보는 워커”에 걸릴 확률이 높아지기 때문에, 전체 적중률은 구조적으로 떨어지게 됩니다.
워커마다 캐시가 따로 복사되는 구조를 보면 알 수 있습니다
Gunicorn의 --preload 옵션을 쓰면 애플리케이션 코드를 부모 프로세스에서 미리 불러온 뒤 fork로 워커를 만듭니다. 이렇게 하면 시작 시점의 메모리는 OS의 copy-on-write 덕분에 워커들끼리 물리적으로 공유됩니다. 다만 이건 “처음 떠있던 순간”의 상태만 공유되는 것이고, 요청을 처리하면서 캐시에 새 항목이 쓰이는 순간부터는 해당 페이지만 워커별로 복제되어 완전히 독립된 값을 가집니다.

로드밸런서가 기본값인 라운드로빈 방식으로 요청을 돌리면 같은 키라도 매번 다른 워커로 가게 됩니다. 캐시 적중률을 결정하는 건 “같은 요청이 같은 캐시를 다시 만나는가”인데, 라운드로빈은 이 조건을 구조적으로 깨뜨립니다. Node.js의 cluster 모듈도 마찬가지로, 마스터 프로세스가 들어온 연결을 자식 워커들에 나눠주는 구조라서 각 워커의 V8 힙에 있는 Map이나 객체 기반 캐시는 서로 완전히 분리되어 있습니다.
코드로 재현해보는 적중률 저하
간단한 예시로 확인해 보겠습니다. 파이썬의 lru_cache는 프로세스 안에서만 결과를 저장하기 때문에, fork로 만들어진 다른 워커의 캐시 내용을 알 수 없습니다.
from functools import lru_cache
import os
@lru_cache(maxsize=128)
def get_user_profile(user_id):
# 실제로는 DB 조회라고 가정
return {"id": user_id, "pid": os.getpid()}
def handle_request(user_id):
result = get_user_profile(user_id)
info = get_user_profile.cache_info()
print(f"PID={os.getpid()} hits={info.hits} misses={info.misses}")
return result
워커가 1개일 때는 user_id=42 요청이 10번 들어오면 처음 1번만 미스가 나고 나머지 9번은 히트로 잡힙니다. cache_info()를 찍어보면 CacheInfo(hits=9, misses=1, maxsize=128, currsize=1) 형태가 나오는 걸 확인할 수 있습니다.
같은 요청을 워커 4개 환경(gunicorn -w 4)에서 돌리면 라운드로빈 때문에 10번의 요청이 PID별로 2~3번씩 흩어집니다. 각 워커는 자기가 받은 요청만 기준으로 미스/히트를 계산하므로, 워커별로는 misses=1, hits=1 또는 misses=1, hits=2 식으로 나타나고, 4개 워커를 합산한 전체 미스 횟수는 1번이 아니라 최대 4번까지 늘어납니다. 캐시 로직은 그대로인데 전체 적중률만 떨어지는 전형적인 패턴입니다.

그럼 캐시를 중앙에 모으면 바로 해결될까요?
꼭 그렇지는 않습니다. 선택지별로 얻는 것과 잃는 것이 다르기 때문에, 상황에 맞는 쪼를 골라야 합니다.
| 방법 | 적중률 개선 | 트레이드오프 |
|---|---|---|
| 프로세스별 캐시 유지 | 워커가 늘수록 낮아짐 | 구현은 가장 단순하지만 메모리를 워커 수만큼 중복 사용 |
| 외부 공유 캐시(Redis 등) | 워커 수와 무관하게 유지 | 네트워크 왕복 비용 추가, 캐시 서버 자체가 장애점이 됨 |
| 요청 고정 라우팅(consistent hashing / sticky) | 중간~높은 수준 유지 | 로드밸런서 설정이 복잡해지고 워커 장애 시 재분배 비용 발생 |
| 워커 수를 최소화하고 수직 확장 | 캐시 관점에서는 가장 유리 | CPU 코어가 부족하면 처리량 자체가 병목에 걸림 |
외부 공유 캐시로 옮기면 로컬 딕셔너리 조회가 마이크로초 단위였던 것이 네트워크 홉이 끼면서 밀리초 단위로 늘어납니다. 캐시 적중률 숫자는 좋아져도 건당 응답 시간이 오히려 늘 수 있다는 뜻입니다. 반대로 캐시 대상 데이터가 몇 개 안 되는 설정값이나 작은 참조 테이블이라면, 워커가 10개여도 각자 다 담을 수 있어서 이 문제가 처음부터 발생하지 않습니다. 즉 “캐시해야 할 키의 종류 수”가 “워커당 캐시 용량”보다 작으면 프로세스별 캐시를 유지해도 큰 손해가 없습니다.
워커를 늘릴 계획이라면 이 순서로 점검해 보세요
먼저 캐시 적중률을 워커 전체 합산이 아니라 워커별로 나눠서 로깅해 보시는 걸 추천합니다. PID나 워커 ID를 로그에 같이 찍으면, 적중률 저하가 실제로 프로세스 분산 때문인지 다른 원인인지 바로 구분됩니다.
그다음 캐시 대상 데이터의 키 공간 크기를 확인해 보세요. 키 종류가 수백 개 수준으로 적다면 워커별 캐시를 그대로 둬도 됩니다. 반면 사용자별 데이터처럼 키가 수십만 개 이상으로 퍼져 있다면, Redis 같은 외부 캐시나 로드밸런서의 sticky 라우팅 쪽으로 옮기는 게 적중률 측면에서 더 안정적입니다. 둘 다 적용하기 전에는 트래픽 패턴 상 어느 쪽이 더 맞는지 먼저 짧게 검증해 보시는 편이 안전합니다.
