2026-06-19 · Smart Silver Center(ssc-api) · Django 5.2 / DRF / Celery / Redis
경로당 관리 시스템에 "알림 허브"를 붙이는 작업의 마지막 퍼즐 — 실제로 이메일이 나가는 운영 파이프라인을 세웠다. 코드는 이미 있었지만, 비동기 발송이 동작하려면 인프라(Redis 브로커, Celery 워커, SMTP)가 받쳐줘야 한다. 그 과정을 섹션별로 남긴다.
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 워커에 넘겨 뷰 응답을 막지 않는다.진입점은 두 갈래다.
POST /api/v2/notifications/notify/ — 외부 서비스용. X-Service-Token + IP 화이트리스트로 이중 방어.(끝까지 안 만든 경로 C — Loki/Alertmanager 로그 기반 알림 — 은 다음 작업으로 남겼다.)
처음엔 테스트가 통과하니 메일이 나가는 줄 알았다. 코드를 열어보니 아니었다.
# 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): ...
즉 테스트의 sent는 mock된 가짜 성공이지 실제 메일이 아니다. 운영에서 실제로 메일이 나가려면 세 가지가 모두 필요했다.
| 필요 조건 | 없으면 |
|---|---|
| SMTP env (EMAIL_HOST 등) | task가 failed로 떨어짐 |
| Redis 브로커 | delay() 적재 실패 → 즉시 failed |
| Celery 워커 프로세스 | 큐에 쌓이기만 하고 영영 안 보냄 |
이 세 개를 채우는 게 이번 작업의 전부였다.
"이메일 하나 비동기로 보내는 데 굳이 Celery랑 Redis까지 필요한가?"는 정당한 질문이다. 선택지를 하나씩 따져봤다.
가장 단순한 길은 뷰 안에서 send_mail()을 그냥 호출하는 것이다. 하지만 SMTP 발송은 외부 서버(Gmail)와의 네트워크 왕복이라 수 초가 걸리고, 언제든 실패·타임아웃할 수 있다. 이걸 요청 스레드에서 동기로 하면:
그래서 "받아서 큐에 넣고 즉시 응답, 발송은 백그라운드에서"라는 비동기 작업 큐가 필요했다. 이 지점부터 선택지가 갈린다.
| 방식 | 장점 | 단점 | 이 프로젝트에서 |
|---|---|---|---|
동기 send_mail() | 인프라 0, 가장 단순 | 응답 블로킹, 실패가 비즈니스 로직에 전파, 재시도 없음 | ✗ 위 이유로 탈락 |
threading/ThreadPoolExecutor | 의존성 없음, 응답은 안 막음 | 프로세스 죽으면 작업 유실, 재시도·모니터링 없음, gunicorn --preload/멀티워커와 충돌 위험 | ✗ 신뢰성 부족 |
| DB 기반 큐 (django-q 등) | 브로커 불필요(기존 MySQL 재사용) | DB 폴링 부하, 처리량 한계, 생태계 작음 | △ 가능하나 확장성 아쉬움 |
| Celery + Redis | 표준·성숙, 재시도/백오프 내장, 워커 수평 확장, 모니터링 도구 풍부 | 브로커(Redis) + 워커 프로세스 운영 부담 | ✓ 채택 |
| Celery + RabbitMQ | 메시지 보장 가장 강력(AMQP) | 무겁고 운영 복잡, 이 규모엔 과함 | ✗ 오버스펙 |
@shared_task(bind=True, max_retries=3, retry_backoff=True) 한 줄로 "실패 시 점점 간격을 늘려 3번 재시도"가 끝난다. 직접 구현하면 의외로 손이 많이 가는 부분.autodiscover_tasks()로 각 앱의 tasks.py를 자동 수집하고, settings에 CELERY_* 네임스페이스로 통합된다. 자료·troubleshooting이 압도적으로 많다.--concurrency를 올리거나 워커 프로세스를 추가하면 된다. 동기/스레드 방식은 여기서 막힌다.CELERY_TASK_ALWAYS_EAGER=True면 브로커·워커 없이 task가 그 자리에서 동기 실행된다(테스트에서 이걸 썼다). 운영과 개발의 코드가 동일한 게 큰 장점.Celery의 브로커로는 RabbitMQ가 "정석"으로 꼽히지만, 이 프로젝트엔 Redis가 더 맞았다.
apt install redis-server 한 번에 끝나고, localhost 바인딩만 하면 보안도 단순하다. RabbitMQ는 Erlang 런타임·vhost·exchange 설정 등 학습/운영 곡선이 가파르다./0 브로커, /1 결과) 한 서버로 둘 다 처리한다. RabbitMQ는 결과 백엔드를 따로 둬야 한다.정리하면 — "비동기 + 재시도 + 확장 가능"이 필요해서 Celery, "이 규모엔 가볍고 충분"해서 Redis. 트래픽이 폭증하거나 메시지 유실이 절대 불가한 요구가 생기면 그때 RabbitMQ로 브로커만 교체하면 된다(Celery 코드는 그대로).
운영 서버(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
기존 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 core는 core/celery.py의 app을 가리킨다 (gunicorn이 core.incheon.wsgi를 쓰는 것과 같은 core 패키지).
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가 별도로 필요하다.
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 → 수신 전 구간이 운영 상태로 연결됐다.
/app/...), 계정(shbae), 서비스명(gunicorn-ics)이 실제 서버(/home/ubuntu/..., ubuntu, gunicorn.service)와 전부 달랐다. 진단 명령으로 실물을 확인한 게 사고를 막았다.uv add ≠ 설치. 배포 서버에선 uv sync. 203/EXEC의 범인.[경로 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 인증.