워커를 늘렸는데 캐시 적중률이 떨어지는 이유

워커를 늘렸는데 캐시

새벽에 트래픽이 몰려서 워커 프로세스를 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 라우팅 쪽으로 옮기는 게 적중률 측면에서 더 안정적입니다. 둘 다 적용하기 전에는 트래픽 패턴 상 어느 쪽이 더 맞는지 먼저 짧게 검증해 보시는 편이 안전합니다.

도커 이미지 빌드가 매번 느려질 때, 캐시 순서부터 확인하세요

참고: 워커를 늘렸는데 캐시 — 위키백과

Leave a Comment