Celery로 파이썬 비동기 작업 처리: 큐, 워커, 운영 체크리스트
이메일 발송, 리포트 생성, 썸네일 변환, 외부 API 대량 호출. 이런 작업을 웹 요청 안에서 처리하면 응답은 느려지고, 타임아웃이 나고, 실패하면 재시도할 방법도 없습니다. 답은 오래전부터 같습니다. 요청에서는 작업을 큐에 등록만 하고, 실제 실행은 별도 프로세스에 맡깁니다. 파이썬에서 이 패턴의 사실상 표준이 Celery입니다. 이 글은 Celery의 구조와 기본 사용법, 그리고 운영에서 반드시 걸리는 지점들을 정리합니다.
구조: 브로커, 워커, 결과 백엔드 #
Celery는 세 가지 구성 요소로 동작합니다.
| 부품 | 역할 | 대표 선택지 |
|---|---|---|
| 브로커(broker) | 작업 메시지를 담는 큐 | Redis, RabbitMQ |
| 워커(worker) | 큐에서 꺼내 실행하는 프로세스 | celery worker 명령으로 실행 |
| 결과 백엔드(result backend) | 실행 결과·상태 저장 (선택) | Redis, DB |
웹 앱이 task.delay()를 호출하면 브로커에 메시지가 들어가고, 워커가 꺼내 실행합니다. 웹 프로세스와 워커 프로세스는 완전히 분리되어 있어서 각각 독립적으로 스케일할 수 있습니다. 브로커 선택은 단순하게 정리됩니다. 이미 Redis를 쓰고 있으면 Redis로 시작하고, 메시지 유실에 극도로 민감하거나 복잡한 라우팅이 필요하면 RabbitMQ를 검토합니다.
최소 구성 #
uv add "celery[redis]"# tasks.py
from celery import Celery
app = Celery(
"myapp",
broker="redis://localhost:6379/0",
backend="redis://localhost:6379/1",
)
@app.task
def send_welcome_email(user_id: int) -> str:
# 실제로는 여기서 메일 발송
return f"user {user_id}에게 발송 완료"# 워커 실행
celery -A tasks worker --loglevel=info# 웹 코드 쪽: 등록만 하고 바로 반환
result = send_welcome_email.delay(42)
print(result.id) # 태스크 ID
print(result.get(timeout=10)) # 결과가 필요하면 대기 (웹 요청 안에서는 비권장)delay()는 즉시 반환됩니다. 웹 요청 안에서 result.get()으로 기다리면 작업 큐를 쓰는 의미가 없어지므로, 결과가 필요한 경우에는 태스크 ID를 반환하고 클라이언트가 상태 조회 API를 폴링하는 구성이 일반적입니다.
재시도: 실패는 기본 전제입니다 #
백그라운드 작업은 대부분 외부 시스템(메일 서버, 외부 API)과 통신하므로 일시적 실패가 일상입니다. 재시도는 데코레이터 옵션으로 선언합니다.
@app.task(
autoretry_for=(ConnectionError, TimeoutError), # 이 예외면 자동 재시도
max_retries=5,
retry_backoff=True, # 지수 백오프: 1초, 2초, 4초...
retry_backoff_max=600, # 백오프 상한 10분
retry_jitter=True, # 무작위 지연을 섞어 동시 재시도 폭주 방지
)
def call_external_api(payload: dict) -> dict:
...- 재시도 대상 예외를 명시하는 것이 중요합니다. 모든 예외를 재시도하면 코드 버그(KeyError 등)까지 5번 반복 실행됩니다.
- 지수 백오프와 지터는 상대 서비스가 죽었을 때 재시도가 몰려서 이중으로 타격을 주는 것을 막는 기본 장치입니다.
멱등성: 최소 한 번 실행을 전제로 짭니다 #
Celery의 전달 보장은 기본적으로 최소 한 번(at-least-once)입니다. 워커가 실행 도중 죽으면 같은 메시지가 다른 워커에서 다시 실행될 수 있고, 재시도까지 겹치면 같은 태스크가 두 번 돌아가는 상황은 언젠가 반드시 옵니다. 그래서 태스크는 두 번 실행돼도 결과가 같도록(멱등하게) 짜는 것이 원칙입니다.
- “포인트 1000점 추가” 대신 “주문 X에 대한 포인트 적립을 기록(이미 있으면 무시)“으로 설계합니다. 유니크 제약이나 처리 이력 테이블이 도구가 됩니다.
- 결제, 발송처럼 멱등하게 만들기 어려운 작업은 외부 서비스의 멱등성 키(idempotency key)를 활용합니다.
운영 설정: 사고가 나기 전에 켜 둘 것들 #
app.conf.update(
task_acks_late=True, # 실행 완료 후에 큐에서 제거 (워커 사망 시 재전달)
worker_prefetch_multiplier=1, # 긴 작업이 섞이면 선점 가져가기를 줄임
task_time_limit=600, # 10분 넘으면 강제 종료
task_soft_time_limit=540, # 9분에 예외를 발생시켜 정리 기회 제공
)- acks_late: 기본값은 “받자마자 확인 처리"라서 워커가 실행 중에 죽으면 그 작업이 사라집니다. 켜면 완료 후 확인 처리로 바뀌는 대신, 위에서 말한 중복 실행 가능성이 생깁니다. 멱등성과 세트인 설정입니다.
- 시간 제한: 제한 없는 태스크 하나가 행에 걸리면 워커 슬롯 하나가 영구히 사라집니다. soft limit에서 정리하고 hard limit이 최후 방어선입니다.
- 모니터링: Flower를 띄우면 큐 길이, 태스크 성공·실패, 워커 상태를 웹 UI로 볼 수 있습니다. 최소한 큐 길이에는 알림을 걸어야 합니다. 큐가 계속 자라는 것은 워커가 처리량을 못 따라간다는 가장 이른 신호입니다.
Celery까지 필요 없는 경우 #
Celery는 강력하지만 브로커 운영, 워커 배포, 모니터링이라는 인프라 비용이 따라옵니다. 더 가벼운 선택지가 맞는 경우도 많습니다.
- FastAPI BackgroundTasks: 응답 후 같은 프로세스에서 실행되는 가장 가벼운 방식입니다. 재시도도 영속성도 없으므로, 실패해도 괜찮은 부수 작업(로그 적재, 캐시 갱신)까지만 씁니다. 모던 파이썬 실전 #5에서 다뤘습니다.
- RQ, arq: Redis 전용의 단순한 작업 큐입니다. RQ는 동기, arq는 asyncio 기반입니다. 설정이 Celery보다 훨씬 적어서, 태스크 종류가 몇 개 안 되는 프로젝트라면 충분합니다.
- Celery가 맞는 경우: 태스크 종류가 많고, 재시도·스케줄링(celery beat)·라우팅 같은 기능이 실제로 필요하고, 운영을 감당할 팀이 있을 때입니다.
판단 기준을 하나로 줄이면 이렇습니다. “이 작업이 유실되면 사고인가?” 사고라면 브로커의 내구성과 재시도를 갖춘 Celery(또는 RabbitMQ 조합)로 가고, 아니라면 더 가벼운 도구로 시작하는 것이 낫습니다.
정리 #
- 작업 큐의 구조는 브로커(큐), 워커(실행), 결과 백엔드(상태)의 세 가지 구성 요소이고, 웹과 워커는 독립적으로 스케일합니다.
delay()는 등록만 하고 즉시 반환됩니다. 웹 요청 안에서 결과를 기다리는 구성은 피합니다.- 재시도는 대상 예외를 명시하고 지수 백오프 + 지터를 켭니다. 전달 보장은 최소 한 번이므로 태스크는 멱등하게 설계합니다.
- acks_late, 시간 제한, 큐 길이 알림은 사고 전에 켜 두는 기본 설정입니다.
- 실패해도 되는 작업은 BackgroundTasks, 단순한 큐는 RQ·arq, 기능과 규모가 필요할 때 Celery입니다.