Django 알림 허브 운영 환경 구축하기

강정우·2026년 6월 19일

Dev_Ops

목록 보기
34/34
post-thumbnail

Django 알림 허브 운영 환경 구축기 — Redis + Celery로 비동기 이메일 발송 띄우기

2026-06-19 · Smart Silver Center(ssc-api) · Django 5.2 / DRF / Celery / Redis

경로당 관리 시스템에 "알림 허브"를 붙이는 작업의 마지막 퍼즐 — 실제로 이메일이 나가는 운영 파이프라인을 세웠다. 코드는 이미 있었지만, 비동기 발송이 동작하려면 인프라(Redis 브로커, Celery 워커, SMTP)가 받쳐줘야 한다. 그 과정을 섹션별로 남긴다.


1. 배경 — notify() 한 함수로 모든 알림이 통과한다

알림 허브의 핵심은 단일 코어 함수 notify()다. 비즈니스 이벤트(연계 공지, 긴급방송, 가족 가입)가 발생하면 이 함수를 호출하고, 함수는 이렇게 동작한다.

def notify(*, event_code, idempotency_prefix, object_id, context=None, source="ssc-api"):
    # 1. EventType 활성 확인 (없으면 조용히 return)
    # 2. is_default 템플릿 확인
    # 3. 구독자 조회 (event_type별)
    # 4. 수신자별 NotificationLog(status="queued") 생성 — idempotency_key unique로 중복 차단
    # 5. transaction.on_commit으로 Celery enqueue (호출부 트랜잭션 롤백 시 발송 안 함)
    # 반환: {"queued": N}

설계 원칙 두 가지가 인상적이었다.

  • 절대 예외를 호출부로 던지지 않는다. 전체를 try/except로 감싼다. 알림 발송이 실패해도 "공지 생성" 같은 비즈니스 로직이 롤백되면 안 되기 때문.
  • 동기 코어 + 비동기 발송 분리. notify()는 조회·검증·로그·enqueue까지만 동기로 하고 즉시 반환. 실제 SMTP 발송은 Celery 워커에 넘겨 뷰 응답을 막지 않는다.

진입점은 두 갈래다.

  • 경로 A (코드 직접 호출): ssc-api 내부 3곳에서 함수를 그대로 호출.
  • 경로 B (HTTP): POST /api/v2/notifications/notify/ — 외부 서비스용. X-Service-Token + IP 화이트리스트로 이중 방어.

(끝까지 안 만든 경로 C — Loki/Alertmanager 로그 기반 알림 — 은 다음 작업으로 남겼다.)


2. "SMTP 설정도 안 했는데 메일이 간다고?" — 착각의 정체

처음엔 테스트가 통과하니 메일이 나가는 줄 알았다. 코드를 열어보니 아니었다.

# core/base.py
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"  # 진짜 SMTP 백엔드
EMAIL_HOST = os.getenv("EMAIL_HOST")  # ← env 없으면 None → 발송 시 예외

테스트가 "성공"한 건 두 장치 덕이었다.

@override_settings(CELERY_TASK_ALWAYS_EAGER=True, DEFAULT_FROM_EMAIL="from@ssc.com")
@patch("notifications.tasks.EmailMessage.send")  # 실제 send를 mock으로 치환
def test_success_marks_sent(self, mock_send): ...

즉 테스트의 sentmock된 가짜 성공이지 실제 메일이 아니다. 운영에서 실제로 메일이 나가려면 세 가지가 모두 필요했다.

필요 조건없으면
SMTP env (EMAIL_HOST 등)task가 failed로 떨어짐
Redis 브로커delay() 적재 실패 → 즉시 failed
Celery 워커 프로세스큐에 쌓이기만 하고 영영 안 보냄

이 세 개를 채우는 게 이번 작업의 전부였다.


3. 왜 Celery + Redis인가 — 기술 선택의 근거

"이메일 하나 비동기로 보내는 데 굳이 Celery랑 Redis까지 필요한가?"는 정당한 질문이다. 선택지를 하나씩 따져봤다.

먼저, 왜 동기 발송은 안 되나

가장 단순한 길은 뷰 안에서 send_mail()을 그냥 호출하는 것이다. 하지만 SMTP 발송은 외부 서버(Gmail)와의 네트워크 왕복이라 수 초가 걸리고, 언제든 실패·타임아웃할 수 있다. 이걸 요청 스레드에서 동기로 하면:

  • 공지 생성 API 응답이 메일 발송이 끝날 때까지 블로킹된다. 구독자가 10명이면 10번의 SMTP 왕복을 사용자가 기다린다.
  • Gmail이 일시적으로 느리거나 막히면 그 API 전체가 같이 느려지거나 죽는다. 알림이 비즈니스 기능을 인질로 잡는 셈.
  • gunicorn 워커 스레드가 메일 I/O에 묶여 처리량이 떨어진다.

그래서 "받아서 큐에 넣고 즉시 응답, 발송은 백그라운드에서"라는 비동기 작업 큐가 필요했다. 이 지점부터 선택지가 갈린다.

후보 비교

방식장점단점이 프로젝트에서
동기 send_mail()인프라 0, 가장 단순응답 블로킹, 실패가 비즈니스 로직에 전파, 재시도 없음✗ 위 이유로 탈락
threading/ThreadPoolExecutor의존성 없음, 응답은 안 막음프로세스 죽으면 작업 유실, 재시도·모니터링 없음, gunicorn --preload/멀티워커와 충돌 위험✗ 신뢰성 부족
DB 기반 큐 (django-q 등)브로커 불필요(기존 MySQL 재사용)DB 폴링 부하, 처리량 한계, 생태계 작음△ 가능하나 확장성 아쉬움
Celery + Redis표준·성숙, 재시도/백오프 내장, 워커 수평 확장, 모니터링 도구 풍부브로커(Redis) + 워커 프로세스 운영 부담채택
Celery + RabbitMQ메시지 보장 가장 강력(AMQP)무겁고 운영 복잡, 이 규모엔 과함✗ 오버스펙

왜 Celery였나

  • 재시도·지수 백오프가 선언형으로 내장. SMTP는 일시 실패가 흔한데, @shared_task(bind=True, max_retries=3, retry_backoff=True) 한 줄로 "실패 시 점점 간격을 늘려 3번 재시도"가 끝난다. 직접 구현하면 의외로 손이 많이 가는 부분.
  • Django 생태계의 사실상 표준. autodiscover_tasks()로 각 앱의 tasks.py를 자동 수집하고, settings에 CELERY_* 네임스페이스로 통합된다. 자료·troubleshooting이 압도적으로 많다.
  • 워커를 수평 확장할 수 있다. 알림량이 늘면 --concurrency를 올리거나 워커 프로세스를 추가하면 된다. 동기/스레드 방식은 여기서 막힌다.
  • 로컬/테스트에서 인프라 없이 돌릴 수 있다. CELERY_TASK_ALWAYS_EAGER=True면 브로커·워커 없이 task가 그 자리에서 동기 실행된다(테스트에서 이걸 썼다). 운영과 개발의 코드가 동일한 게 큰 장점.

왜 RabbitMQ가 아니라 Redis였나

Celery의 브로커로는 RabbitMQ가 "정석"으로 꼽히지만, 이 프로젝트엔 Redis가 더 맞았다.

  • 규모가 작다. 알림 메일은 초당 수천 건이 아니라 이벤트당 수~수십 건이다. RabbitMQ의 강력한 메시지 라우팅·보장은 여기선 안 쓰는 기능에 운영 복잡도만 더한다.
  • 운영이 가볍다. apt install redis-server 한 번에 끝나고, localhost 바인딩만 하면 보안도 단순하다. RabbitMQ는 Erlang 런타임·vhost·exchange 설정 등 학습/운영 곡선이 가파르다.
  • 브로커 + 결과 백엔드를 하나로. Celery는 작업 큐(브로커)와 결과 저장(result backend)이 둘 다 필요한데, Redis는 DB 인덱스만 나눠(/0 브로커, /1 결과) 한 서버로 둘 다 처리한다. RabbitMQ는 결과 백엔드를 따로 둬야 한다.
  • 이미 친숙한 스택. 캐시·세션 용도로도 재활용 가능해 한 번 띄워두면 쓰임이 넓다.

정리하면 — "비동기 + 재시도 + 확장 가능"이 필요해서 Celery, "이 규모엔 가볍고 충분"해서 Redis. 트래픽이 폭증하거나 메시지 유실이 절대 불가한 요구가 생기면 그때 RabbitMQ로 브로커만 교체하면 된다(Celery 코드는 그대로).


4. Redis 설치 — "상황 파악 먼저"

운영 서버(Ubuntu 22.04)에 Redis를 올리기 전에, 충돌·중복을 막으려고 현재 상태부터 진단했다.

which redis-server redis-cli || echo "redis 미설치"
sudo ss -tlnp | grep 6379 || echo "6379 사용 안 함(깨끗)"
. /etc/os-release && echo "$NAME $VERSION"
ps aux | grep "[c]elery" || echo "celery 워커 없음"

결과는 "엄청 깨끗" — 미설치, 포트 비어있음, 워커 없음. 그대로 진행했다.

sudo apt update && sudo apt install -y redis-server

# 보안: localhost 전용 바인딩 확인 (Redis는 기본 인증이 없어 외부 노출이 위험)
grep -E "^bind|^protected-mode" /etc/redis/redis.conf
# → bind 127.0.0.1 -::1 / protected-mode yes  (Ubuntu 기본값이 이미 안전)

sudo systemctl enable --now redis-server

검증은 코드가 실제로 쓸 DB 인덱스까지 확인했다. 브로커는 DB 0, 결과 백엔드는 DB 1을 쓰도록 설정돼 있었다(redis://localhost:6379/0, /1).

redis-cli -n 0 set ssc:smoke ok && redis-cli -n 0 get ssc:smoke && redis-cli -n 0 del ssc:smoke
redis-cli -n 1 ping
# → OK / "ok" / (integer) 1 / PONG

5. Celery 워커 systemd 서비스 — 가장 큰 함정

기존 gunicorn.service를 본떠 워커 서비스를 만들었는데, 여기서 이번 작업에서 제일 중요한 발견이 나왔다.

gunicorn은 실행 인자로 core.incheon.wsgi:application을 줘서 settings를 강제한다. 그런데 Celery 워커에는 그런 인자가 없다. 그리고 코드의 기본 폴백은 dev로 잡혀 있다.

# manage.py — ENV_FILE에 ics/mch가 없으면 dev로 폴백
default_settings = next((s for k, s in MAP.items() if k in env_file), "core.dev.settings")

# core/celery.py — 아무것도 안 정하면 dev
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.dev.settings")

ENV_FILE=.env만 보고 워커를 띄우면 운영 DB가 아닌 dev DB를 바라보는 사고가 난다. 그래서 워커 서비스에 settings를 명시적으로 박았다.

[Service]
Environment="ENV_FILE=.env"
Environment="DJANGO_SETTINGS_MODULE=core.incheon.settings"   # ★ 이 한 줄이 핵심
ExecStart=/.../.venv/bin/celery -A core worker \
          --loglevel=info --concurrency=2 \
          --logfile=/var/log/ssc_api/celery_worker.log

[Unit]
Requires=redis-server.service
After=network.target redis-server.service

-A corecore/celery.py의 app을 가리킨다 (gunicorn이 core.incheon.wsgi를 쓰는 것과 같은 core 패키지).


6. 삽질 — status=203/EXEC

첫 기동에서 워커가 바로 죽었다.

Active: activating (auto-restart) (Result: exit-code)
Process: ExecStart=... (code=exited, status=203/EXEC)

203/EXEC는 systemd가 실행 파일 자체에 도달하지 못했다는 뜻이다. celery 바이너리에 닿지도 못했으니 --logfile도 안 생겼다. 원인 후보는 셋: 경로 오타 / 실행권한 / 바이너리 부재.

진단해보니 마지막이었다. 이 브랜치는 아직 서버에 완전히 배포·동기화되지 않아 .venv/bin/celery가 없었다. uv add celery redis는 의존성 선언만 추가할 뿐, 서버에서 실제 설치는 uv sync를 해야 한다.

cd /app/smart-silver-center/ssc-api
uv sync
.venv/bin/celery --version   # 이제 동작

교훈: uv add(선언) ≠ 서버에 설치됨. 배포 서버에선 uv sync가 별도로 필요하다.


7. End-to-End 검증 — 실제 메일이 도착하다

dev 서버에서 먼저 검증(운영 적용 전 안전장치). Django shell로 본인 메일을 구독자로 등록하고 notify()를 직접 호출했다. 멱등성 skip을 피하려고 object_id에 timestamp를 넣은 게 포인트.

import time
oid = f"e2e-{int(time.time())}"   # 매번 고유 → 중복 차단에 안 걸림
result = notify(event_code="notice", idempotency_prefix="notice", object_id=oid)
# → {'queued': 1}

워커 로그가 전 구간을 증명했다.

[10:06:00] Connected to redis://localhost:6379/0
[10:06:01] celery@smartsilver-dev-server ready.
[10:18:02] Task ...send_notification_email[72b287e6...] received      ← Redis 큐에서 꺼냄
[10:18:05] Task ...send_notification_email[72b287e6...] succeeded in 2.41s  ← SMTP 발송 성공

그리고 실제 메일함에 도착. notify → Redis → Celery 워커 → SMTP → 수신 전 구간이 운영 상태로 연결됐다.


8. 배운 것들 (요약)

  • 문서를 믿지 말고 코드/서버를 확인하라. 인프라 문서의 경로(/app/...), 계정(shbae), 서비스명(gunicorn-ics)이 실제 서버(/home/ubuntu/..., ubuntu, gunicorn.service)와 전부 달랐다. 진단 명령으로 실물을 확인한 게 사고를 막았다.
  • 워커는 settings를 자동으로 모른다. 웹 서버가 인자로 강제하는 settings를, 백그라운드 워커는 환경변수로 명시해줘야 한다. 안 하면 dev DB로 조용히 새어 나간다.
  • uv add ≠ 설치. 배포 서버에선 uv sync. 203/EXEC의 범인.
  • mock된 테스트 성공 ≠ 실제 동작. 외부 I/O(SMTP)는 운영에서 한 번은 실제로 흘려봐야 한다.
  • dev에서 먼저, prod는 나중. 같은 절차를 dev에서 검증한 뒤 prod에 적용하는 순서가 안전하다.
  • 기술 선택은 규모에 맞춰라. 비동기·재시도·확장이 필요해서 Celery, 이 규모엔 가볍고 충분해서 Redis를 골랐다. RabbitMQ 같은 "정석"이 항상 정답은 아니다 — 요구가 커지면 그때 브로커만 교체하면 된다.

부록 — 전체 데이터 흐름

[경로 A] 코드 직접 호출 (notice/broadcasting/accounts)  ──┐
[경로 B] HTTP POST /notify/ (X-Service-Token + IP)      ──┤
[경로 C] Alertmanager webhook (미구현)                   ──┘
                          │
                          ▼
              notify() 코어 (동기, 예외 안 던짐)
                          │  NotificationLog(queued) + on_commit enqueue
                          ▼
              Redis 브로커 (DB0)  ──►  Celery 워커
                                          │  EmailMessage.send()
                                          ▼
                                    📧 SMTP → 수신

남은 작업: 경로 C(Alertmanager 어댑터) 구현 — alerts[] 배열 순회, fingerprint:status 멱등성, Bearer 인증.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글