들어가며
CellCraft는 유전자 조절 네트워크 추론을 수행할 수 있는 웹 플랫폼으로서, 분석 작업이 알고리즘 종류, 그리고 데이터셋 규모에 따라 수분에서 최대 수주까지 소요됩니다. 이러한 장기 실행 작업을 비동기로 처리하기 위해 Celery + RabbitMQ 기반의 비동기 시스템을 구현했습니다.
해당 글에서는 개발 과정 중 발생한 RabbitMQ Timeout 오류의 원인을 추적하고 해결한 과정을 정리하고자 합니다. 약 1년 전에 작성했었던 Celery를 활용한 비동기 작업 처리 에서도 언급하고 있었던 RabbitMQ Timeout 오류 방지 설정 부족 문제와 이어지는 내용으로 현재 시점에서의 결과로 문제 해결 과정을 정리해봤습니다.
문제 상황
CellCraft 개발 과정 중, 수시간 소요되는 분석 작업은 정상적으로 완료되었지만 수일 이상 소요되는 장기 분석 작업에서는 Timeout 관련 오류가 반복적으로 발생하는 문제를 겪어왔습니다. 아래는 그 중 하나의 오류 로그입니다.
'PRECONDITION_FAILED - delivery acknowledgement on channel 1 timed out.
Timeout value used: 1800000 ms.
이 외에도 장기 작업 실행 중 Worker가 크래시되거나, RabbitMQ와의 연결이 끊어지는 문제가 발생했지만, 솔직히 당시에는 모든 오류를 체계적으로 기록하지 못했고.. 정확히 어떤 설정이 어떤 오류를 발생시키는지 특정하기 어려운 상황이었습니다.
Timeout 관련 설정들을 조정하며 테스트를 진행하며 해결하고자 했지만, Celery, RabbitMQ 설정들에 따른 동작 방식들을 이해하지 못한 상태에서는 근본적인 해결에 도달하기 어려웠습니다. 문제가 해결된 현재 시점에서 Celery와 RabbitMQ 간의 메시지 통신 구조를 다시 정리해보고, 이를 기반으로 Timeout에 영향을 주는 설정들을 하나씩 해결했던 경험을 공유해보겠습니다.
Celery-RabbitMQ 메시지 통신 구조
메시지의 생명주기
FastAPI에서 apply_async()를 호출하면 태스크 정보가 JSON으로 직렬화되어 AMQP(Advanced Message Queuing Protocol) 프로토콜을 통해 RabbitMQ에 메시지로 발행(publish)됩니다. RabbitMQ는 메시지를 큐에 저장하고, 해당 큐를 구독 중인 Celery Worker가 메시지를 가져가서(consume) Celery Task 함수를 실행합니다.
FastAPI (apply_async) → RabbitMQ 큐 → Celery Worker (태스크 실행)
publish 저장 consume
해당 과정에서의 핵심은 ACK(Acknowledgement)입니다. Celery Worker가 메시지를 가져간 후 ACK를 보내야 RabbitMQ가 해당 메시지를 큐에서 삭제합니다. ACK를 보내지 않으면 RabbitMQ는 메시지가 아직 처리되지 않은 것으로 간주합니다. 즉, 수신확인 후에 메세지를 안전하게 삭제하겠다는 의미입니다.
task_acks_late (ACK 타이밍 설정)
Celery의 task_acks_late 설정은 ACK를 언제 보낼지를 결정합니다.
task_acks_late=False — 태스크 실행을 시작할 때 즉시 ACK
Worker가 메시지를 가져옴
→ 실행 시작 → 즉시 ACK 전송 → RabbitMQ 큐에서 메시지 삭제
→ Task 실행 중... (수분~수주)
→ 완료 또는 실패
└─ 실패 시: 메시지는 이미 삭제됨 → 자동 재실행 불가
task_acks_late=True — 태스크 실행이 완료된 후 ACK
Worker가 메시지를 가져옴
→ 실행 시작 (ACK 미전송, 메시지는 큐에 unacked 상태로 유지)
→ Task 실행 중... (수분~수주)
→ 완료: ACK 전송 → 큐에서 삭제
└─ 크래시 시: ACK 없음 → RabbitMQ가 다른 Worker에게 재전달 (자동 재실행)
Prefetch 설정
Celery Worker는 현재 메세지에 의한 Task를 실행하는 동안 다음 메시지를 Prefetch를 통해 미리 대기시킵니다. worker_prefetch_multiplier 설정으로 미리 대기시킬 메시지의 수를 제한할 수 있으며, 각 Worker 프로세스가 한 번에 가져오는 메시지 수를 결정합니다. 설정값을 지정하지 않으면 기본값인 4로 정해집니다.
Worker 프로세스 (prefetch_multiplier=4):
[RabbitMQ 큐] → [Prefetch 버퍼] → [실행 중]
B (대기) A (실행 중)
C (대기)
D (대기)
Prefetch 설정을 통해 Task 완료 후 다음 Task를 가져올 때 발생하는 네트워크 왕복 시간을 줄여 처리량을 높여서 최적화 가능합니다. 하지만, Prefetch 버퍼에서 대기 중인 메시지는 아직 실행이 시작되지 않았으므로 ACK가 전송되지 않은 상태로 남아있게 됩니다.
Timeout 관련 설정
Timeout과 관련된 주요 설정은 관리 주체(Celery, RabbitMQ)에 따라 두 가지로 나뉩니다.
RabbitMQ가 관리하는 Timeout — 미ACK 메시지에만 영향
| 설정 | 역할 | 트리거 조건 |
|---|---|---|
consumer_timeout |
미ACK 메시지가 있는 Consumer 연결을 강제 종료 | 미ACK 상태 지속 시간 초과 |
visibility_timeout |
미ACK 메시지를 다른 Worker에게 재할당 | 미ACK 상태 지속 시간 초과 |
해당 설정들은 ACK를 전송하지 않은 미ACK 메시지에만 영향을 줍니다. ACK가 전송된 메시지에는 적용되지 않으므로, task_acks_late=False로 인해 메시지가 전달되는 즉시 ACK가 전송되고 Prefetch 대기 메시지도 없다면, 해당 설정에 의한 Timeout들이 어떤 값이든 실행 중인 작업에 영향을 주지 않습니다.
Celery가 관리하는 Timeout — ACK와 무관하게 항상 영향
| 설정 | 역할 | 트리거 조건 |
|---|---|---|
task_time_limit |
초과 시 Worker 프로세스를 SIGKILL로 강제 종료 | 태스크 실행 시간 초과 |
task_time_limit은 RabbitMQ의 메시지 상태와 관계없이, Celery가 자체적으로 Worker 프로세스의 실행 시간을 감시하여 초과 시 강제 종료합니다. ACK 전송이 완료되었더라도, task_acks_late가 어떤 값이든 관계없이 동작합니다.
해결 과정
위에서 설명드린 배경 지식을 기반으로 Timeout 오류에 영향을 줄 수 있는 설정들을 하나씩 바꿔가며 해결한 과정을 정리해봤습니다.
task_acks_late: ACK 타이밍 결정
task_acks_late=True에서는 실행 중인 Task에 해당하는 메시지가 아직 미ACK 상태이므로, CellCraft에서의 장시간 분석 작업이 수일간 실행되는 동안 consumer_timeout에 걸릴 수 있습니다. 반면 task_acks_late=False에서는 실행 시작 시 즉시 ACK가 전송되므로, 실행 중인 Task는 consumer_timeout의 영향을 받지 않습니다.
acks_late=True + 작업 3일 소요 → 3일간 미ACK → consumer_timeout 영향
acks_late=False + 작업 3일 소요 → 즉시 ACK 완료 → consumer_timeout 무관
전달 보장 측면에서 acks_late=True는 Worker 크래시 시 자동 재실행이 가능하다는 장점이 있지만, CellCraft의 작업 특성과 맞지 않았습니다.
- 멱등성 미보장: CellCraft의 분석 작업은 Docker 컨테이너 생성, 파일시스템 쓰기, DB 상태 기록 등 다양한 부작용을 가집니다. 그러므로 자동 재실행 시 중복 실행으로 인한 상태 불일치가 발생할 수 있습니다.
- 실패 원인 파악 우선: 수일간 실행된 작업이 크래시되었을 때, 원인 파악 없이 자동 재실행하면 같은 원인으로 다시 실패할 가능성이 높습니다. 실패를 기록하고 사용자에게 로그를 보여주어 수동으로 재실행할 수 있도록 하는 것이 더 적합했습니다.
celery_app.conf.update(task_acks_late=False)
그래서 task_acks_late=False 설정을 통해서 실행 중인 Task에 대한 RabbitMQ 측 Timeout(consumer_timeout, visibility_timeout) 문제를 해소했습니다.
consumer_timeout과 visibility_timeout은 task_acks_late=False에서 실행 중인 태스크에 실질적 영향을 주지 않게 되었으므로, 어떤 값으로 설정하든 무관합니다. RabbitMQ 4.x에서는 consumer_timeout=0(비활성화)을 지원하지 않기 때문에, 최대값인 30일로 설정하여 향후 설정 변경에 대비해서 방어적으로 구성하였습니다.
# rabbitmq.conf
# RabbitMQ 4.x에서 0(비활성화) 미지원이므로 최대값으로 설정
consumer_timeout = 2592000000 # 30일
task_time_limit: 실행-모니터링 Timeout 분리
task_time_limit은 위에서 정리한 것처럼 ACK 타이밍과 무관하게 Celery가 자체적으로 태스크 실행 시간을 감시하여 초과 시 SIGKILL로 강제 종료하는 설정입니다. 따라서 task_acks_late=False로 RabbitMQ 측 Timeout 설정의 영향을 받지 않더라도, task_time_limit이 설정되어 있으면 장기 작업이 강제 종료될 수 있습니다.
처음에는 단순히 몇가지 테스트 과정의 시간을 기준으로 task_time_limit을 24시간 → 72시간으로 늘려보았지만, 이를 초과하는 알고리즘과 입력 데이터셋이 존재했고, 실행 시간을 사전에 예측할 수 없는 작업에서 고정 Timeout 값을 설정하는 것 자체가 근본적인 한계라는 문제를 인지할 수 있었습니다.
이에 실행 계층과 모니터링 계층의 Timeout 전략을 분리했습니다.
이전: 모든 계층이 "작업 최대 실행 시간"에 종속
실행 계층 (Celery): task_time_limit = 24시간 ─┐
모니터링 계층 (SSE): timeout = ? ─┘── 하나의 기준
현재: 각 계층이 자기 책임에 맞는 독립적인 전략
실행 계층 (Celery): task_time_limit = None → "작업은 끝날 때까지 실행"
모니터링 계층 (SSE): timeout = 1시간 주기 재연결 → "연결을 건강하게 유지"
실행 계층에서는 Timeout으로 인한 강제 종료 경로를 제거하고, 작업 중단이 필요한 경우 사용자가 UI에서 revoke 기능을 통해 graceful termination을 수행하도록 구성했습니다.
celery_app.conf.update(task_time_limit=None) # 강제 종료(SIGKILL) 제거
celery_app.conf.update(task_soft_time_limit=None) # 사전 경고(SoftTimeLimitExceeded 예외) 제거
모니터링 계층에서는 SSE 연결을 1시간 주기로 갱신합니다. 이 1시간은 작업이 1시간 안에 끝나야 한다는 의미가 아니라, SSE 연결의 메모리 누수 방지와 서버간 연결 상태에 따른 유연한 재갱신이 목적입니다. 즉, 실제 작업 실행 시간과는 무관한 독립적인 값입니다.
재연결 주기가 너무 짧으면 재연결 빈도가 높아져 불필요한 오버헤드가 발생하고, 너무 길면 장시간 유지되는 SSE 연결에서의 서버 측 메모리 축적과 네트워크 불안정성 리스크가 증가합니다. 1시간은 재연결 빈도가 합리적이면서(예: 2주 작업 기준 약 336회) 메모리와 네트워크 안정성을 확보할 수 있는 실용적인 값으로 판단하여 설정했습니다.
async def event_generator():
timeout = 3600 # 1시간
start_time = time.time()
try:
while True:
if time.time() - start_time > timeout:
yield f"TIMEOUT" # 프론트엔드에서 감지 → 즉시 새 SSE 연결 생성
break
...
await asyncio.sleep(5)
finally:
print(f"SSE generator cleanup for task {task_id}")
해당 개선을 통해 각 계층이 독립적인 설정값을 가질 수 있게 되었고, 한 계층의 변경이 다른 계층에 영향을 주지 않는 구조로 분리할 수 있었습니다.
worker_prefetch_multiplier: Prefetch와 미ACK의 관계
task_acks_late=False로 실행 중인 Task의 RabbitMQ 측 Timeout 문제를 해소하고, task_time_limit=None으로 Celery 측 강제 종료를 제거한 이후에도 Timeout 관련 오류가 간헐적으로 발생했습니다. Timeout 관련 설정들을 전부 개선했다고 생각했지만, 왜 문제가 발생하는지 의문이었습니다.
원인은 배경 지식에서 정리한 Prefetch 메커니즘 때문이었습니다. 당시 worker_prefetch_multiplier를 별도로 설정하지 않아 기본값 4가 적용되고 있었습니다.
Worker 프로세스 (prefetch_multiplier=4):
[RabbitMQ 큐] → [Prefetch 버퍼] → [실행 중]
B (미ACK ✗) A (ACK 완료 ✓)
C (미ACK ✗)
D (미ACK ✗)
task_acks_late=False는 실행을 시작한 Task에 대한 메시지만 즉시 ACK를 전송합니다. Prefetch 버퍼에서 대기 중인 B, C, D는 아직 실행이 시작되지 않았으므로 ACK가 전송되지 않은 상태입니다. 메시지 A의 실행이 장시간 소요되는 동안, Prefetch된 B, C, D는 미ACK 상태로 대기하다가 consumer_timeout에 도달하여 연결이 강제 종료되었던 것입니다.
celery_app.conf.update(worker_prefetch_multiplier=1)
따라서, worker_prefetch_multiplier=1로 설정하여 각 Worker 프로세스가 1개의 메시지만 가져오도록 변경했습니다. 메시지에 대한 Task가 실행되고 즉시 ACK가 전송되므로, Prefetch로 인한 대기 메시지가 존재하지 않게 됩니다.
하지만 Prefetch를 1로 제한하면 일반적으로 처리량이 감소할 수 있긴합니다. Task 완료 후 다음 메시지를 가져올 때까지의 네트워크 대기 시간이 추가되기 때문입니다.
하지만 CellCraft의 경우에는 예상 작업 처리량이 많지 않고 장기 실행 작업을 처리하는 환경이기 때문에 Prefetch 공백(1~5ms)이 작업 시간(수시간~수주) 대비 무시할 수 있는 수준이라고 판단했습니다.
최종 결과
# 최종 Celery 설정
celery_app.conf.update(task_acks_late=False) # 즉시 ACK
celery_app.conf.update(task_time_limit=None) # 실행 계층 무제한
celery_app.conf.update(task_soft_time_limit=None) # 실행 계층 무제한
celery_app.conf.update(worker_prefetch_multiplier=1) # 미ACK 대기 메시지 제거
# rabbitmq.conf
consumer_timeout = 2592000000 # 미ACK 영향 없으므로 최대값 (방어적)
Timeout 오류에 영향을 줄 수 있는 설정들을 탐구하며, 각 설정의 역할과 CellCraft의 작업 특성에 맞는 값을 결정했습니다.
| 설정 | 관리 주체 | 결정 | 근거 |
|---|---|---|---|
task_acks_late |
Celery → RabbitMQ | False (즉시 ACK 전송) |
비멱등 작업, 실패 원인 파악 우선. 실행 중 태스크의 RabbitMQ Timeout 해소 |
task_time_limit |
Celery (ACK 무관) | None (무제한) |
실행 시간 예측 불가. 실행-모니터링 계층 분리 |
prefetch_multiplier |
Celery → RabbitMQ | 1 |
미ACK 대기 메시지 제거. 장기 작업 환경에서 처리량 손실 무시 가능 |
consumer_timeout |
RabbitMQ (미ACK만) | 30일 (최대값) | acks_late=False + prefetch=1에서 실질적 영향 없음. 방어적 설정 |
마치며
해당 경험을 통해 여러 구성 요소간의 상호작용이 얽혀 있는 시스템에서는 하나의 해결책이 존재하기보다 여러 옵션들을 선택하며 조정해 시스템을 의도대로 통제할 수 있어야 함을 배웠습니다. timeout이라는 오류 로그를 보고 단순 Timeout 설정값을 키우는 것은 1차원적인 해결책이었고, Celery의 ACK 메커니즘과 Prefetch 동작 방식을 이해한 후에 각 설정의 역할과 상호 관계를 기반으로 해결책을 결정해야했죠.
또한 task_acks_late, worker_prefetch_multiplier, task_time_limit, consumer_timeout 등 여러 설정이 서로 연관되어 있어, 하나를 변경하면 다른 설정의 영향 범위가 달라지는 복합적인 문제였습니다. 개인적으로는 관련 예시를 잘 찾지 못해서 개별 설정을 시행착오로 조정했었지만, 시스템의 통신 구조를 먼저 이해하고 각 설정의 역할을 이해한 상태에서 효율적으로 접근해야겠다는 생각을 할 수 있었던 것 같습니다.
'백엔드' 카테고리의 다른 글
| Docker 기반 플러그인 아키텍처 설계 (0) | 2026.03.29 |
|---|---|
| Celery 비동기 시스템 개선기: 리소스 기반 동시성 제어 (0) | 2026.03.28 |
| Alembic을 이용한 DB 테이블 생성 레거시 개선하기 (0) | 2026.03.25 |
| Docker로 GPU 환경 구성하기 (0) | 2025.03.30 |
| Celery를 활용한 비동기 작업 처리 (0) | 2025.03.02 |