파이썬(Python) Celery 워커 메모리 누수 완벽 해결: 분산 큐 아키텍처
[Troubleshooting] 파이썬(Python) Celery 워커 메모리 누수 완벽 해결: 분산 큐 아키텍처
"Django 백엔드에 Redis와 Celery를 붙여서 대규모 이메일 발송 자동화를 구축했습니다. 처음엔 잘 돌아가는데, 3일 정도 지나면 Celery 워커 프로세스의 메모리 점유율이 90%를 넘기더니 리눅스 커널에 의해 OOM(Out of Memory) 킬을 당해버립니다."
파이썬 생태계에서 비동기 분산 큐 아키텍처를 설계하는 백엔드 엔지니어들이 필연적으로 마주하는 치명적인 인프라 장애입니다. 수만 개의 백그라운드 작업(Task)을 처리하다 보면, 메모리 사용량이 우상향 곡선을 그리며 서버의 숨통을 서서히 조여옵니다.
초보자들은 파이썬 코드 내부에 del 키워드를 남발하거나 강제로 gc.collect()(가비지 컬렉터)를 호출하는 등 애플리케이션 레벨에서 삽질을 반복합니다. 본 가이드에서는 파이썬 프로세스 자체의 태생적인 메모리 반환 한계를 해체하고, Celery 프레임워크의 설정만으로 메모리 누수(Memory Leak)를 완벽하게 절단하는 엔터프라이즈 아키텍처를 단호하게 제시합니다.
📌 이 글의 핵심 포인트
- 파이썬 메모리 관리의 한계: 작업이 끝나도 OS에 메모리를 즉각 반환하지 않는 CPython의 특성
- Max Tasks Per Child: 일정 수의 작업을 처리한 워커를 스스로 자결(Kill)시키고 새 워커를 띄우는 근본적 해결책
- Redis Visibility Timeout: 워커가 죽었을 때 유실되는 Task를 방어하는 메시지 브로커 설정
1단계: 원인 분석 - 왜 파이썬은 메모리를 뱉어내지 않는가?
Celery 워커는 기본적으로 서버가 켜질 때 생성된 파이썬 프로세스(Process)를 죽을 때까지 계속 재사용합니다. 수만 개의 이미지를 리사이징하거나 대용량 엑셀을 처리하는 Task가 실행되면 파이썬은 OS로부터 거대한 메모리를 할당받습니다.
문제는 Task가 끝났을 때 발생합니다. 파이썬의 가비지 컬렉터(GC)가 내부적으로 객체를 삭제하더라도, 파이썬 인터프리터 자체는 "나중에 또 쓸지 모르니 일단 내가 가지고 있을게"라며 OS(리눅스)에게 메모리를 즉각 반환하지 않는 경우가 많습니다(Memory Fragmentation). 이 현상이 몇 날 며칠 동안 누적되면 워커 프로세스는 괴물처럼 비대해져 결국 OOM 킬을 당하게 됩니다.
2단계: 완벽한 트러블슈팅 - worker_max_tasks_per_child 설정
코드 레벨에서 메모리 누수를 100% 막아내는 것은 사실상 불가능에 가깝습니다. 글로벌 기업들의 아키텍트들이 선택하는 가장 우아하고 확실한 방법은 '워커가 일정량의 일을 하면 스스로 죽고 깨끗한 뇌로 다시 태어나게 만드는 것'입니다.
Celery 설정 파일(celery.py 또는 settings.py)에 단 한 줄의 설정을 추가하십시오.
# 워커(Worker) 프로세스 하나당 최대 100개의 작업만 처리하고 프로세스를 종료시킴
# 종료된 직후 Celery 메인 프로세스가 완전히 깨끗한 새 워커를 즉시 생성함
app.conf.worker_max_tasks_per_child = 100
# 메모리 사용량이 극도로 높은 작업이라면 숫자를 더 줄일 수도 있습니다
# app.conf.worker_max_tasks_per_child = 10
이 설정을 적용하면, 워커 프로세스가 100번째 작업을 마치는 순간 시스템은 해당 파이썬 프로세스를 완전히 Kill 합니다. 이때 파이썬이 움켜쥐고 있던 모든 메모리 찌꺼기들이 강제로 OS에 반환되며 메모리가 0으로 초기화됩니다. 서버를 1년을 켜두어도 메모리는 항상 일정한 박스권 내에서만 움직이게 됩니다.
3단계: 메시지 브로커 무결성 방어 - Visibility Timeout
워커가 메모리 오버플로우나 max_tasks_per_child 설정으로 인해 교체되는 찰나의 순간, 브로커(Redis, RabbitMQ)에서 이미 가져온 Task가 유실되는 문제가 발생할 수 있습니다.
이를 방어하기 위해 브로커의 visibility_timeout을 넉넉하게 설정해야 합니다. 워커가 작업을 가져간 뒤 지정된 시간(예: 1시간) 내에 완료 신호(ACK)를 보내지 않으면, 브로커는 워커가 죽었다고 판단하고 해당 Task를 다시 대기열에 집어넣어 다른 워커가 처리하도록 보장하는 무결점 아키텍처를 구성해야 합니다.
🙋♂️ 자주 묻는 질문 (FAQ)
Q. max_tasks_per_child 값을 1로 설정하면 안 되나요?
A. 작업 1개마다 파이썬 프로세스를 죽이고 새로 띄우는 오버헤드(CPU 및 부팅 시간)가 너무 커져 시스템 전체의 처리량(Throughput)이 끔찍하게 느려집니다. 100~500 사이의 적절한 값을 찾아 타협하는 것이 좋습니다.
💡 핵심 정리 및 마무리
분산 큐 시스템에서 파이썬의 메모리 누수는 개발자의 코드 실력이 부족해서 생기는 버그가 아니라, 언어와 OS 구조의 태생적인 한계점입니다. 억지로 메모리 청소를 하려 들지 말고, 컨테이너나 프로세스를 과감하게 폐기하고 새로 띄우는 '불변 인프라(Immutable Infrastructure)'의 철학을 애플리케이션 레벨에 적용하여 견고한 백엔드를 구축하십시오.
댓글
댓글 쓰기