목차
- 왜 Celery + Redis 조합인가
- 설치와 최소 구성
- 프론트 출신이 헷갈리기 쉬운 부분
- 재시도 설정의 함정
- Flower로 큐 들여다보기
- 운영 들어가기 전 체크리스트
- 자주 마주치는 에러와 원인
- 다음 단계
celery redis 비동기 작업 큐를 도입하기 전엔 PDF 리포트 하나 만드는 데 평균 7초가 걸려서 사용자가 새로고침을 눌러버리는 일이 잦았다. 도입 이후 API 응답은 80ms로 떨어졌고, 무거운 작업은 워커가 백그라운드에서 받아갔다.
실제로, 이 글은 프론트엔드에서 백엔드로 넘어온 지 얼마 안 된 사람, 또는 동기 처리만 해보고 큐를 처음 만져보는 사람을 위한 실전 가이드다. Node.js의 Bull Queue를 써본 적이 있다면 비교하면서 보면 훨씬 빠르게 감을 잡는다.
왜 Celery + Redis 조합인가
비동기 작업 큐는 한 마디로 "지금 당장 답을 줄 수 없는 작업을 다른 프로세스에 넘기는 장치"다. 이메일 발송, 외부 API 호출, 이미지 리사이즈, 리포트 생성, AI 요약처럼 응답이 몇 초 이상 걸리는 작업이 전형적인 대상이다.
Python 진영에서 큐는 사실상 Celery가 표준에 가깝다. RQ, Dramatiq 같은 가벼운 대안도 있지만 운영 사례, 한국어/영어 자료, Django·FastAPI 통합 라이브러리가 비교할 수 없을 정도로 많다. 새 프로젝트라고 굳이 RQ로 가야 할 강한 이유가 없다면 처음엔 Celery로 시작하는 게 무난해 보인다.
게다가, 브로커는 Redis와 RabbitMQ가 양대 산맥이다. 작업 메시지를 받아 워커에 분배하는 역할인데, 둘 사이엔 분명한 트레이드오프가 있다.
| 항목 | Redis | RabbitMQ |
|---|---|---|
| 설치/운영 난이도 | 낮다 | 중간 |
| 메시지 신뢰성 | persistence 설정에 의존 | 기본적으로 강함 |
| 처리량 | 매우 높다 | 높지만 Redis보단 낮다 |
| 라우팅 기능 | 단순 | 풍부 (exchange/binding) |
| 운영 도구 | 익숙한 redis-cli | 별도 학습 필요 |
결국, 이메일·알림·캐시 무효화 같은 일반 워크로드면 Redis가 충분하다. 금융 트랜잭션처럼 메시지 한 건도 잃으면 안 되는 환경이라면 RabbitMQ를 검토하는 게 맞다. 개인적으로 일반 SaaS 백엔드에선 Redis로 시작해도 후회한 적이 별로 없다.
반면, Redis를 브로커로 쓸 거면 appendonly yes(AOF)로 설정하고, OS 단에서 정전·강제 종료 대비 디스크 동기화 정책을 점검하는 게 첫걸음이다. 인메모리만 믿고 가면 컨테이너 재시작 한 번에 큐가 비어버린다.
설치와 최소 구성
게다가, 신입이 입사해서 "Celery 어떻게 깔아요?" 하고 물으면 아래 순서대로 따라가게 한다. 환경은 Python 3.12, Celery 5.4(2024년 11월 릴리즈), Redis 7.2 기준이다.
# 의존성 설치
pip install "celery[redis]==5.4.0" flower==2.0.1
# Redis는 도커가 가장 편하다
docker run -d --name redis-broker \
-p 6379:6379 \
redis:7.2-alpine redis-server --appendonly yes
프로젝트 루트에 celery_app.py를 만든다. Django면 proj/celery.py로 두는 게 관례다.
# celery_app.py
from celery import Celery
app = Celery(
"myproject",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1", # 결과 저장용 DB 분리
include=["myproject.tasks"],
)
app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="Asia/Seoul",
enable_utc=False,
task_track_started=True,
task_time_limit=300, # 하드 리밋 5분
task_soft_time_limit=270, # 소프트 리밋 4분 30초
result_expires=3600, # 결과는 1시간만 보관
)
broker와 backend의 DB 번호를 분리한 게 핵심이다. 같이 0번에 몰아넣으면 디버깅할 때 키가 섞여서 헷갈린다. 처음 세팅할 땐 이걸 모르고 한참 헤맨 적이 있다.
첫 태스크 정의와 실행
# myproject/tasks.py
from celery_app import app
import time
@app.task(name="tasks.send_welcome_email")
def send_welcome_email(user_id: int) -> str:
# 실제로는 SES, SendGrid 같은 메일 서비스 호출
time.sleep(2)
return f"sent to user={user_id}"
워커는 별도 프로세스로 띄운다.
celery -A celery_app worker --loglevel=info --concurrency=4
--concurrency=4는 워커당 4개의 자식 프로세스를 의미한다. 기본은 CPU 코어 수다. I/O 바운드 작업이 대부분이면 --pool=gevent --concurrency=100처럼 그린쓰레드 풀로 바꾸면 처리량이 훨씬 잘 나온다.
결국, 호출 측은 이렇게 쓴다.
from myproject.tasks import send_welcome_email
result = send_welcome_email.delay(user_id=42)
print(result.id) # task UUID
# result.get()은 운영 코드에서 거의 안 쓴다. 동기 대기로 바뀌어버린다.
프론트 출신이 헷갈리기 쉬운 부분
또한, Node.js의 Bull Queue를 쓰던 사람이 Celery로 넘어오면 세 군데에서 멘붕이 온다.
그런데, 첫째, 태스크는 워커 시작 시점에 임포트되어야 한다. Bull은 큐 객체에 핸들러를 등록하는 방식이라 위치가 비교적 자유롭지만, Celery는 워커 프로세스가 시작될 때 include 또는 autodiscover_tasks()로 태스크 모듈을 발견해야 한다. 안 그러면 Received unregistered task of type 'tasks.xxx' 에러가 뜬다. Django면 app.autodiscover_tasks()를 호출해 각 앱의 tasks.py를 자동으로 긁어오는 게 정석이다.
둘째, 결과(result)는 옵션이다. Bull은 작업 결과를 큐가 자동으로 보관해주지만, Celery는 backend 설정을 안 하면 result.get() 호출 시 DisabledBackend 예외가 난다. 결과를 안 쓰는 fire-and-forget 작업이면 @app.task(ignore_result=True)로 명시하는 게 메모리·속도 모두 이득이다.
이처럼, 셋째, 직렬화는 JSON이 디폴트, pickle은 보안 위험이다. 오래된 글에서 pickle을 권하는 경우가 있는데, 신뢰할 수 없는 워커에서 역직렬화하면 임의 코드 실행이 가능하다. JSON으로 표현 불가능한 객체(예: Django ORM 인스턴스)는 ID만 넘기고 워커 안에서 다시 조회하는 게 안전한 패턴이다.
재시도 설정의 함정
태스크가 외부 API를 호출한다면 재시도는 거의 필수다. 그런데 기본 retry()를 그냥 쓰면 의외의 곳에서 다친다.
import requests
@app.task(
bind=True,
autoretry_for=(requests.RequestException,),
retry_backoff=2, # 2초, 4초, 8초, 16초...
retry_backoff_max=600, # 최대 10분
retry_jitter=True, # 동시 재시도 분산
max_retries=5,
acks_late=True, # 워커 크래시 시 메시지 보존
)
def call_external_api(self, payload: dict) -> dict:
resp = requests.post(
"https://api.example.com/v1/events",
json=payload,
timeout=5,
)
resp.raise_for_status()
return resp.json()
한편, 중요한 옵션 세 가지를 따로 짚어둔다.
- retry_backoff_max: 백오프가 무한정 늘어나지 않게 상한을 둔다. 안 두면 다섯 번째 재시도가 며칠 뒤에 일어나는 어이없는 일이 벌어진다.
- retry_jitter: 외부 API가 잠시 죽었다 살아나면 모든 워커가 같은 타이밍에 재시도해서 또 죽이는 패턴이 흔하다. jitter가 이걸 완화한다.
- acks_late: 기본값은 메시지를 받자마자 ack한다. 작업 중 워커가 죽으면 메시지가 사라진다.
acks_late=True로 두면 작업 완료 후 ack하므로 안전하지만, 멱등성을 보장하는 로직이 같이 필요하다.
멱등성은 옵션이 아니다
같은 작업이 두 번 실행돼도 결과가 같아야 안전하다. 결제 처리 같은 작업이라면 외부 트랜잭션 ID를 키로 Redis에 SETNX 락을 거는 게 흔한 패턴이다.
from redis import Redis
r = Redis(host="localhost", port=6379, db=2)
@app.task(bind=True, acks_late=True, max_retries=3)
def charge_payment(self, txn_id: str, amount: int):
# SETNX: 키가 없을 때만 set, 있으면 False
if not r.set(f"lock:txn:{txn_id}", "1", nx=True, ex=3600):
return {"status": "skipped", "reason": "already processed"}
# 이하 실제 결제 처리
...
이걸 안 짜두고 acks_late=True만 켜두면 결제가 두 번 일어나는 사고가 가능하다. Bull에 익숙하던 사람일수록 큐가 자동으로 중복을 막아준다고 착각하기 쉽다.
큐를 분리해라
예를 들어, 태스크가 늘어나면 한 큐에 다 몰아넣는 순간 우선순위가 뒤죽박죽이 된다. 짧은 이메일 발송이 5분짜리 리포트 작업 뒤에 줄을 서는 식이다. 큐 이름을 기능별로 나누고 워커도 분리해서 띄우는 게 정석이다.
app.conf.task_routes = {
"tasks.send_welcome_email": {"queue": "email"},
"tasks.generate_report": {"queue": "report"},
"tasks.call_external_api": {"queue": "default"},
}
# 빠른 작업 전용 워커
celery -A celery_app worker -Q email,default --concurrency=8
# 무거운 작업 전용 워커 (동시성 낮춤)
celery -A celery_app worker -Q report --concurrency=2
Flower로 큐 들여다보기
이처럼, 워커가 잘 도는지, 큐에 작업이 쌓이는지, 어떤 태스크가 실패하는지 눈으로 보려면 Flower가 사실상 표준이다.
celery -A celery_app flower --port=5555 \
--basic_auth=admin:strongpassword \
--persistent=True --db=/var/lib/flower/db
또한, 브라우저로 http://localhost:5555에 접속하면 워커 목록, 태스크 히스토리, 실패한 작업의 traceback, 큐별 메시지 수, 처리 속도까지 한눈에 보인다. --persistent=True를 켜두지 않으면 Flower를 재시작할 때마다 히스토리가 날아가니 운영 환경에선 반드시 켠다.
사내에서 운영할 땐 --basic_auth 또는 OAuth 프록시(예: oauth2-proxy)로 가려야 한다. 외부 노출되면 작업 트리거 권한까지 같이 열린다. 지표만 필요한 환경이면 celery-exporter를 Prometheus와 연결해 Grafana 대시보드를 만드는 방법도 있다. Flower는 운영자 인터페이스에, Prometheus는 알람·SLO 모니터링에 더 어울리는 편이다.
자주 보는 지표 세 개
- Active tasks: 지금 워커가 실행 중인 작업 수. CPU 코어 수 × 워커 수를 넘기면 큐가 밀린다는 신호다.
- Failed tasks: 단순 카운트가 아니라 traceback까지 본다. 같은 에러가 반복되면 retry 폭주의 원인이 된다.
- Queue length: 늘어나는 추세면 워커를 추가하거나 태스크를 쪼개야 한다.
운영 들어가기 전 체크리스트
처음 프로덕션에 띄우기 전에 아래만 점검해도 사고 절반은 막는다.
- [ ]
task_time_limit과task_soft_time_limit둘 다 설정했는가 - [ ]
acks_late=True인 태스크는 멱등성이 보장되는가 - [ ]
result_expires를 설정해 Redis가 무한정 부풀지 않게 막았는가 (기본 1일, 보통 1~6시간으로 줄임) - [ ] 큐 이름을 우선순위별로 분리했는가 (
default,email,report등) - [ ] 워커는
supervisord또는systemd로 자동 재시작 설정했는가 - [ ] Redis에
maxmemory-policy noeviction을 설정해서 메시지가 LRU로 사라지지 않게 했는가 - [ ] Flower 또는 Prometheus 모니터링이 붙어 있는가
- [ ] Sentry 같은 에러 추적 도구가 워커 프로세스에도 연결돼 있는가
특히 마지막 항목, 워커 Sentry는 처음에 빠뜨리기 쉽다. 웹 프로세스에만 Sentry를 붙이고 워커엔 안 붙여두면 백그라운드 작업이 조용히 실패하는 걸 며칠 동안 모를 수도 있다. 5.x 사용자라면 sentry_sdk.init()을 워커 부트 시점에 별도로 호출해줘야 한다.
자주 마주치는 에러와 원인
Received unregistered task of type 'xxx'→include또는autodiscover_tasks()누락DisabledBackend: No result backend configured→ backend 설정 빠짐kombu.exceptions.OperationalError: Connection refused→ Redis 컨테이너가 안 떠 있거나 포트가 다름WorkerLostError: Worker exited prematurely: signal 9 (SIGKILL)→ OOM. 보통 메모리 누수 또는 너무 큰 페이로드SoftTimeLimitExceeded→ 정상 시그널. try/except로 받아서 정리 작업 후 종료하면 된다
OOM 케이스는 특히 골치 아프다. 워커가 죽고 살아나기를 반복하면 작업이 두 번씩 들어가는 패턴이 생긴다. 멱등성을 안 짜뒀다면 데이터가 망가지는 사고가 시작된다.
다음 단계
여기까지 따라했으면 큐가 도는 상태가 된다. 다음엔 Celery Beat로 크론 작업을 큐에 통합하는 패턴과, Kubernetes 환경에서 KEDA로 큐 길이에 따라 워커를 자동 스케일링하는 구성을 정리해볼 생각이다.
관련 글
- 쿠버네티스 리소스 설정 — OOMKilled와 CPU Throttling 잡는 최적값 – 쿠버네티스 리소스 설정에서 requests와 limits의 역할은 다르다. 같은 값으로 두면 두 메커니즘이 충돌한다. FastAPI 서비스…
- 캘리포니아 게임 EOL 패치 의무화 — SaaS 운영자가 지금 점검할 실무 영향 – 캘리포니아 게임 종료 패치 의무화는 SaaS 전반에 영향을 준다. 환불 처리와 EOL 패치 설계를 실무 관점에서 본다.
- Python Redis TTL 캐시 설정에서 Jitter로 스탬피드 막기 – TTL을 고정값으로 두면 만료가 한 시점에 몰린다. Redis 캐시에 Jitter를 더해 부하를 분산하는 패턴과 실제 측정값을 정리했다.