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_limittask_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로 큐 길이에 따라 워커를 자동 스케일링하는 구성을 정리해볼 생각이다.

관련 글