
"어씨? 뭐야? 왜 똑같이
했는데 어떨 때는 되고, 어떨 때는 안되지?"
이런 상황은 정말 개발자에게는 최악인 것 같습니다 ㅎㅎ
확실히 실패하면 원인을 찾기라도 하는데, 50% 확률이면 모든 가설이 맞는 것 같기도 하고 틀린 것 같기도 합니다. 이번에 이러한 상황이 한번 발생하였고, 18시간이 걸리고, 8번의 시도가 있었습니다. 이에 대해 제 스스로도 공부하고, 공유하려고 작성합니다. (틀린 부분이나 발전하면 좋을 것들은 언제든지 댓글로 남겨주세요!)
시스템 구조
저희 팀에서 사내 Django 기반 백엔드를 운영 중입니다. 이 백엔드는 백그라운드 작업 처리에 Celery를 쓰고 있습니다.
핵심 구조는 다음과 같습니다.
메인 Celery (Redis 기반)
- 월간 리포트 생성, PDF 마스킹, 작업 초과 알림 등 모든 백그라운드 작업 처리
- broker: Redis
- app = Celery('safety')로 선언
- 각 태스크는 @shared_task 데코레이터 사용
SMS 전용 Celery (RabbitMQ 기반)
- 문자 발송만 담당
- broker: 사내 온프레미스 RabbitMQ 서버
- 모듈 레벨에서 별도의 Celery 인스턴스 생성
문제는 SMS용 Celery 인스턴스가 3곳에 흩어져 있었다는 점입니다.
# send_sms_1: SMS 발송 유틸
app = Celery('send_sms', broker=f'amqp://admin:***@{SKO_SMS_SENDER}:5672',
backend='rpc://')
# send_sms_2: 인증 SMS 발송
app = Celery('send_sms', broker=f'amqp://admin:***@{SKO_SMS_SENDER}:5672',
backend='rpc://')
# send_sms_3: 외부 시스템 데이터 전송
app = Celery('send_sms', broker=f'amqp://admin:***@{SKO_SMS_SENDER}:5672',
backend='rpc://')
전부 동일한 RabbitMQ 브로커를 가리키는, 사실상 같은 역할의 인스턴스가 3개 중복 선언된 상태였습니다.
프로덕션 환경은 gunicorn gthread 모델로 돌아가고 있었습니다.
web: gunicorn backend.wsgi --workers 5 --threads 2 --worker-class gthread
worker 5개, thread 2개. 총 10개의 스레드가 요청을 처리합니다.
그날 무슨 일이 있었나?
PDF 마스킹 기능을 새로 개발해서 프로덕션에 배포했습니다. 마스킹 태스크는 @shared_task로 선언했고, 별도의 masking 큐에서 처리되도록 라우팅했습니다.
# PDF 마스킹
@shared_task(bind=True, max_retries=3)
def process_masking_seq(self, seq_id):
"""각 seq를 독립적으로 처리"""
배포 직후 테스트를 돌려봤는데, 어떨 때는 잘 되고 어떨 때는 태스크가 아예 실행되지 않았습니다. worker 로그에 태스크를 받았다는 기록 자체가 없었고, .delay()를 호출했는데 아무 일도 일어나지 않는 상태였습니다.
처음에는 "배포 과정에서 뭔가 꼬였나 보다"하고 재배포를 했습니다. 하지만 증상은 같았습니다. 10번 중 랜덤으로 성공하고, 실종되었습니다.
8번의 실패, 그리고 9번째 시도
실제 디버깅 타임라인을 시간 순서대로 공유하겠습니다. 각 시도에서 뭘 의심했고, 왜 틀렸는지도 같이 적었습니다.
시도 1. 태스크 자동 발견이 안 되나?
첫 번째 가설은 "worker가 마스킹 태스크를 못 찾는 것 아닌가?"였습니다. apps.py의 ready() 메서드에서 태스크 모듈을 명시적으로 import 하도록 수정했습니다.
결과: 50% 실종 현상은 그대로였습니다.
시도 2. worker가 너무 빨리 재시작하나?
MAX_TASKS_PER_CHILD가 1로 설정되어 있었습니다. 태스크 하나 처리하고 worker 프로세스가 죽었다가 다시 살아나는 구조인데, 이 과정에서 태스크가 유실되는 건 아닌지 의심했습니다. 10으로 올리고 broker retry 설정도 추가했습니다.
결과: 아무 효과 없었습니다.
시도 3. 별도 masking_worker 프로세스 분리
"기본 celery worker와 masking worker가 같은 큐를 두고 경쟁하나?"라는 가설이었습니다. Procfile에 masking 전용 worker를 분리했습니다.
masking_worker: celery -A backend worker -l INFO -Q masking -c 2
-n masking@%h --pool=threads
결과: 구조적으로 올바른 변경이었지만, 근본 원인은 여전히 남아있었습니다.
시도 4. .delay() → .apply_async(queue='masking')
Celery는 태스크를 어떤 큐에 넣을지 celery_config.py의 라우팅 설정으로 결정합니다. 이때, 제가 설정한 celery_config.py를 보고 결정하는데 라우팅이 제 때 로딩되지 않아서 그런가 생각이 들었습니다.
그래서 .delay()를 .apply_async(queue='masking')으로 바꿨습니다. 명시적으로 큐를 지정하면 라우팅 문제를 우회할 수 있을 거라 생각했습니다.
결과: 변화 없었습니다. 이때부터 "큐 라우팅 문제가 아닐 수도 있겠다"는 생각이 들기 시작했습니다.
시도 5. prefork → threads 풀
Celery Worker는 대스크를 실행할 때 프로세스 풀 방식을 선택할 수 있기에 prefork에서 threads 풀로 변경했습니다.
- prefork: 자식 프로세스를 fork해서 태스크 실행 (기본값)
- threads: 스레드로 태스크 실행
prefork 모드에서 Redis를 브로커로 쓸 때, BRPOP이 멈추는 알려진 이슈가 있었습니다. 특히 acks_late=True (태스크 완료 후에 ACK 보내기) 와 조합하면 더 불안정했습니다.
[Before]
- worker: celery -A backend worker -Q masking
[After]
- worker: celery -A backend worker -Q masking --pool=threads
결과: 첫 번째 태스크는 잘 처리됐는데, 그 다음부터 또 멈췄습니다. → 사실 이것이 문제를 인식하기에 좋은 싸인이었습니다. 나중에 문제 처리하다보니 current_app 오염을 더 극명하게 안 시도였습니다.
시도 6. acks_late 제거
acks_late는 "태스크를 받자마자 ACK" vs "완료 후 ACK" 설정입니다.
Celery의 acks_late=True 설정이 Redis consumer의 BRPOP과 충돌하는 건 아닌지 의심했습니다.
- acks_late=True (원래 설정)
1. worker가 태스크를 가져옴
2. 태스크 실행
3. 완료 후 "잘 받았어" ACK 전송
→ 만약 실행 중 worker가 죽으면? ACK 안 했으니 Redis가 다른 worker에게 재전달
- acks_late=False (변경)
1. worker가 태스크를 가져옴
2. 즉시 "잘 받았어" ACK 전송
3. 태스크 실행
→ worker가 죽으면 태스크 유실되지만, BRPOP 충돌 가능성은 줄어듦
결과: 여전히 간헐적으로 태스크가 사라졌습니다. worker가 태스크를 받는/처리하는 방식을 아무리 바꿔봐야, 애초에 태스크가 Redis에 들어오지 않고 있었으니 의미가 없었습니다.
시도 7. Celery 5.3.4 known issue 우회
Celery GitHub에 올라온 이슈 #7276을 발견했습니다. --without-heartbeat --without-gossip --without-mingle 옵션을 추가했습니다. (https://github.com/celery/celery/discussions/7276)
결과: 좀 더 안정적이 된 것 같기도 했지만 태스크는 여전히 가끔 사라졌습니다. "worker 쪽은 점점 최적화가 되는데, 왜 아직도 안 되지?"
이 시점에서 시야를 바꿔야 한다는 생각이 들었습니다.
시도 8. Redis 큐 직접 조회
7번의 시도를 전부 worker쪽으로 진행하였는데 태스크가 Redis에 들어가는 지 확인을 해봤습니다.
웹 프로세스에서 .apply_async() 직후에 Redis LLEN 명령으로 큐 길이를 직접 확인하는 코드를 넣었습니다.
import redis as _redis
_r = _redis.Redis.from_url(settings.CELERY_BROKER_URL)
_qlen = _r.llen('masking')
logger.info(f"[마스킹] Redis masking 큐 길이: {_qlen}")
결과가 당연히도 큐 길이가 0이었습니다.
[마스킹] process_masking_seq.apply_async() 호출 완료
[마스킹] Redis masking 큐 길이: 0
.apply_async()를 호출했는데 Redis 큐 길이가 0. 태스크가 Redis에 도달하지 않은 것입니다.
문제는 worker가 아니라 Django 단에 있었습니다. 7번의 시도가 전부 worker 설정을 만지작거린 건 처음부터 잘못된 곳을 보고 있었던 것입니다.
범인을 찾다: current_app
"Redis에 안 들어간다"는 걸 알고 나니 질문이 바뀌었습니다. “.apply_async()가 도대체 어디로 보내고 있는 거지?"
Celery의 @shared_task가 어떻게 동작하는지 내부를 파봤습니다.
@shared_task는 특정 Celery 앱에 종속되지 않는 태스크를 만드는 데코레이터입니다. Celery 공식 문서에서도 권장하는 패턴입니다. 그런데 이 "특정 앱에 종속되지 않는다"는 말에 함정이 있었습니다.
@shared_task로 선언된 태스크의 .delay()나 .apply_async()가 실행될 때, Celery는 그 시점의 current_app을 사용합니다. current_app은 스레드 로컬 변수(_tls.current_app)에 저장됩니다.
계속 문제가 생겼던 이유는 Celery() 생성자는 기본적으로 set_as_current=True이기 때문입니다!
class Celery:
def __init__(self, main=None, ..., set_as_current=True, ...):
...
if set_as_current:
self.set_current() # _tls.current_app = self
SMS 모듈의 코드를 다시 보겠습니다.
# send_sms_1 (SMS 발송 유틸 - 모듈 레벨)
app = Celery('send_sms', broker='amqp://...RabbitMQ서버...:5672',
backend='rpc://')
이 파일이 import되는 순간, 해당 스레드의 current_app이 RabbitMQ를 가리키는 SMS 앱으로 바뀝니다.
그런데 이 파일은 언제 import 될까요? 다른 모듈이 sending_sms를 import할 때, 그리고 그 모듈 자체도 모듈 레벨에서 또 다른 SMS Celery 인스턴스(send_sms_2)를 만듭니다. 사용자가 로그인하거나 SMS 인증을 요청하면 이 모듈들이 import되면서 그 스레드의 current_app이 오염됩니다.
gunicorn gthread 모델에서 worker 5개, thread 2개면 총 10개의 스레드가 요청을 처리합니다.
Thread A: send_sms_2 import됨 → current_app = RabbitMQ 앱 (SMS)
Thread B: (아직 import 안 됨) → current_app = Redis 앱 (메인, 정상)
어떤 요청이 Thread A에 걸리면 마스킹 태스크가 RabbitMQ로 날아가고, Thread B에 걸리면 Redis로 정상 발행됩니다. 사용자 입장에서는 같은 동작을 했는데 되었다 안 되었다 하는 것입니다.
랜덤으로 실행되는 이유가 여기에 있었습니다. Thread가 한번 오염되면 프로세스가 재시작될 때까지 계속 오염 상태입니다. 시간이 지나면서 점점 더 많은 스레드가 오염되니까, 배포 직후에는 잘 되다가 나중에 점점 안 되는 패턴이 나타나기도 했습니다.
흐름을 정리하면 다음과 같습니다.
[정상 흐름]
웹 요청 → @shared_task.delay()
→ current_app = Celery('safety', broker=Redis)
→ Redis 큐에 발행
→ masking_worker가 수신, 처리
[오염된 흐름]
사용자 로그인 → send_sms_2 모듈 import
→ Celery('send_sms', broker=RabbitMQ) 생성
→ 이 스레드의 current_app = RabbitMQ 앱으로 변경
이후 같은 스레드에서:
웹 요청 → @shared_task.delay()
→ current_app = Celery('send_sms', broker=RabbitMQ)
→ RabbitMQ로 발행 (!!!)
→ masking_worker는 Redis만 보고 있음 → 태스크 실종
수정: set_as_current=False
# Before
app = Celery('send_sms', broker=f'amqp://...', backend='rpc://')
# After
app = Celery('send_sms', broker=f'amqp://...', backend='rpc://',
set_as_current=False)
3개 파일에 set_as_current=False를 추가했습니다. 이렇게 하면 SMS용 Celery 인스턴스가 생성되더라도 current_app을 덮어쓰지 않습니다. @shared_task는 항상 backend/celery.py에서 선언한 메인 Redis 앱을 바라보게 됩니다.
배포 후 태스크 실종 현상이 완전히 사라졌습니다.
이 경험에서 얻은 것들
"어디서" 문제가 생기는지를 먼저 특정해야 합니다
메시지 큐 시스템에서 장애가 나면 본능적으로 consumer 쪽을 먼저 보게 됩니다. "왜 안 받지?"라는 질문이 자연스럽기 때문입니다.
하지만 먼저 해야 할 질문은 "메시지가 큐에 진짜 들어갔나?"입니다. 역시 문제는 근본적인 부분을 파악해야 합니다…
Redis라면 LLEN으로 큐 길이를 확인하고, RabbitMQ라면 management UI에서 큐 상태를 보면 됩니다. 발행 측과 소비 측 중 어디가 문제인지 5분 만에 특정할 수 있습니다.
저는 이걸 7번째 시도 이후에야 깨달았습니다.
실패한 시도가 헛수고는 아니었습니다
8번의 실패 중 시도 3(worker 분리), 시도 5(threads 풀), 시도 7(heartbeat/gossip 제거)은 결국 최종 프로덕션 설정에 반영됐습니다. Procfile의 masking_worker 설정을 보면 다음과 같습니다.
masking_worker: celery -A backend worker -l INFO -Q masking -c 2
-n masking@%h --pool=threads
--without-heartbeat --without-gossip --without-mingle
원인을 찾는 과정에서 worker 설정이 점점 최적화되었고, 마지막에 "worker는 충분히 최적화됐는데 왜 안 되지?"라는 확신이 관점의 전환을 이끌어냈습니다.
@shared_task는 다중 앱 환경에서 위험합니다
Celery 공식 문서에서 @shared_task를 권장하는 이유는 앱 인스턴스에 종속되지 않아서 재사용성이 좋기 때문입니다. 하지만 이건 Celery 앱이 하나일 때의 이야기입니다.
프로젝트에 Celery 인스턴스가 여러 개 존재하면 @shared_task의 "공유" 특성이 독이 됩니다. 어떤 앱이 current가 되느냐에 따라 태스크가 전혀 다른 broker로 날아갈 수 있기 때문입니다.
대안은 두 가지입니다.
- 추가 Celery 인스턴스를 만들 때 반드시 set_as_current=False 지정
- 또는 @shared_task 대신 @app.task를 사용하여 명시적으로 앱을 지정
되었다 안되었다하는 것은 이제 스레드를 의심해야 합니다
간헐적인데 확률이 대략 반반이라면, 멀티스레드 환경에서 스레드별 상태가 다를 가능성이 높습니다. 특히 thread-local 변수가 관여하는 경우가 많습니다. 이 케이스가 정확히 그랬습니다.
gunicorn --threads 2로 인해 각 worker에 2개의 스레드가 있었고, import 타이밍에 따라 오염된 스레드와 깨끗한 스레드가 공존했던 것입니다.
복잡한 분산 시스템에서도 가장 강력한 디버깅 도구는 "중간 지점을 직접 확인하는 것"입니다. 네트워크 문제라면 tcpdump, DB 문제라면 쿼리 로그, 메시지 큐 문제라면 큐 상태 직접 조회.
추상화 계층을 믿지 말고 한 단계 아래를 직접 들여다봐야 합니다. 저는 Celery의 .apply_async()가 당연히 Redis로 보낼 거라고 생각했는데, 실제로는 RabbitMQ로 보내고 있었습니다. 그 사실을 Redis LLEN 한 줄로 밝혀낸 것입니다.
# 이 세 줄이 18시간을 끝냈습니다
import redis as _redis
_r = _redis.Redis.from_url(settings.CELERY_BROKER_URL)
print(_r.llen('masking')) # 0 ← 여기 안 들어가고 있었음
부디 저와 같은 실수를 다들 하지 않으시길 …
'CS' 카테고리의 다른 글
| 마땅히 알아야할 것들 : Python이 느리고, CPU bound 작업에서 멀티스레드가 무력화되는 이유 (0) | 2026.03.30 |
|---|