17. 서비스 알림(아웃고잉 웹훅) 기능
구독·결제·카드·요금제의 상태가 바뀌면, 본 시스템은 서비스가 등록한 notification_url로 JSON을 POST한다. 이를 "서비스 알림(아웃고잉 웹훅)"이라 부른다. 이 문서는 이벤트 종류, payload·서명 구조, best-effort 발송, 어드민 테스트 버튼, 샘플 수신 흐름을 코드와 함께 추적한다.
🍎쉽게 말하면 "우리 쪽에서 어떤 일이 생겼을 때, 서비스가 알려준 주소로 서명된 JSON을 한 번 쏴 주는" 것이다. 받아도 그만 안 받아도 본 처리는 안 깨진다.
함께 보기: 서비스 API, 일반결제·취소·정산
17.1 기능 개요·관련 파일
핵심 성질
- best-effort(fire-and-forget) — 실제 POST는 백그라운드 태스크로 보내고, 실패해도 본 처리(결제·구독)에는 영향을 주지 않는다(로그만 남김).
- HMAC 서명(둘만 아는 비밀키로 만든 위조 방지 서명) — 서비스의 기존 HMAC 시크릿(
hmac_secret_encrypted)을 재사용해X-Signature/X-Timestamp/X-Nonce헤더로 보낸다(수신 측이 진짜 발신인지 검증 가능). - URL 미등록이면 발송 안 함 —
notification_url이 비어 있으면 아무것도 보내지 않는다.
관련 파일
| 역할 | 파일 |
|---|---|
| 이벤트 상수·payload 구성·발송기(Notifier) | app/notifications/service_notify.py |
| 알림 URL 저장·테스트 버튼 라우트 | app/admin/routes/services.py |
| HMAC 서명 함수 | app/core/security.py(sign_request) |
| 이벤트 발생 지점(구독/결제/카드/요금제) | app/services/subscriptions.py, renewals.py, payments.py, cards.py, plans.py |
| 샘플 수신기(검증·저장·표시) | sample_service/shop/views.py |
17.2 이벤트 18종(+테스트 1종)
app/notifications/service_notify.py:29~47에 이벤트 식별자 상수가 정의돼 있다. 이 값은 payload의 EVENT 필드와 X-Event 헤더에 들어간다.
| 분류 | 상수 | EVENT 값 |
|---|---|---|
| 구독 | EVENT_SUBSCRIPTION_CREATED |
subscription.created |
| 구독 | EVENT_SUBSCRIPTION_STATUS |
subscription.status_changed |
| 구독 | EVENT_SUBSCRIPTION_RENEWED |
subscription.renewed |
| 구독 | EVENT_SUBSCRIPTION_FORCE_CANCELED |
subscription.force_canceled |
| 구독 | EVENT_SUBSCRIPTION_EXTENDED |
subscription.extended |
| 구독 | EVENT_SUBSCRIPTION_PLAN_CHANGED |
subscription.plan_changed |
| 카드 | EVENT_CARD_REGISTERED |
card.registered |
| 카드 | EVENT_CARD_REPLACED |
card.replaced |
| 카드 | EVENT_CARD_DELETED |
card.deleted |
| 카드 | EVENT_CARD_ACTIVATED |
card.activated |
| 카드 | EVENT_CARD_DEACTIVATED |
card.deactivated |
| 결제 | EVENT_PAYMENT_ONE_OFF |
payment.one_off |
| 결제 | EVENT_PAYMENT_ONE_OFF_CANCELED |
payment.one_off_canceled |
| 결제 | EVENT_PAYMENT_ONE_OFF_ADMIN_CANCELED |
payment.one_off_admin_canceled |
| 요금제 | EVENT_PLAN_ACTIVATED |
plan.activated |
| 요금제 | EVENT_PLAN_ARCHIVED |
plan.archived |
| 요금제 | EVENT_PLAN_DELETED |
plan.deleted |
| 요금제 | EVENT_PLAN_BONUS_DAYS |
plan.bonus_days |
| 테스트 | EVENT_TEST |
notification.test |
💡참고: 표의 데이터 이벤트는 18종(구독 6·카드 5·결제 3·요금제 4)이고, 여기에 어드민 "테스트 알림 전송" 버튼이 쓰는
notification.test가 더해진다.
17.3 payload 구조(EVENT 포함)
app/notifications/service_notify.py:50의 build_payload가 고정 구조의 dict를 만든다. 없는 값은 빈 문자열로 채운다(키는 항상 존재).
# app/notifications/service_notify.py:54
return {
"EVENT": event, # 이벤트 식별자
"subscribe_id": subscribe_id or "",
"order_id": order_id or "",
"PRE_STATUS": pre_status or "",
"STATUS": status or "",
"service_name": service.name,
"email": email or "",
"date": kst_format(utcnow(), "%Y-%m-%d %H:%M:%S"), # 발생 시각(KST)
"DESC": desc or "",
}
| 키 | 의미 |
|---|---|
EVENT |
이벤트 식별자(위 표 값) |
subscribe_id / order_id |
구독 ID / 주문 ID(해당될 때만) |
PRE_STATUS / STATUS |
변경 전/후 상태 |
service_name |
서비스명 |
email |
대상 외부 사용자(이메일/식별자) |
date |
발생 시각(KST, YYYY-MM-DD HH:MM:SS) |
DESC |
사람이 읽는 설명 |
17.4 발송 흐름(HttpServiceNotifier.send) — best-effort
app/notifications/service_notify.py:87의 send가 발송을 구성한다. 전체가 try/except로 감싸져 payload 구성·서명·스케줄 어떤 예외도 본 처리를 막지 않는다.
- URL 확인 —
notification_url이 없으면 즉시 반환(발송 안 함) (service_notify.py:92~94). - payload 구성 —
build_payload(...)후json.dumps(..., ensure_ascii=False)로 바이트 본문 생성 (service_notify.py:95~98). - 서명 — 서비스의 HMAC 시크릿을 복호화하고, 타임스탬프·nonce를 만들어
sign_request로 서명 (service_notify.py:100~106).
# app/notifications/service_notify.py:100
secret = self._cipher.decrypt(service.hmac_secret_encrypted)
ts = str(int(utcnow().timestamp()))
nonce = secrets.token_hex(16)
path = urlsplit(url).path or "/"
sig = sign_request(secret, "POST", path, ts, nonce, body)
headers = {"Content-Type": "application/json", "X-Event": event,
"X-Signature": sig, "X-Timestamp": ts, "X-Nonce": nonce}
- fire-and-forget — 실제 POST는
asyncio.create_task(self._post(...))로 백그라운드에 띄운다. 태스크 참조를 set에 보관해 GC를 막는다 (service_notify.py:108~110). - 예외 흡수 — 구성 단계 실패는
logger.warning만 남긴다(service_notify.py:112). 백그라운드_post는 HTTP 4xx/5xx·네트워크 오류 모두 로그만 남기고 흡수한다(service_notify.py:114~122).
서명 규칙(sign_request)
app/core/security.py:62. canonical string은 개행으로 연결된 METHOD\nPATH\nTIMESTAMP\nNONCE\nSHA256(body)이며, HMAC-SHA256(시크릿)으로 서명한다. 각 구성요소에 개행이 있으면 거부한다(필드 간 바이트 이동 공격 차단).
# app/core/security.py:73
body_hash = hashlib.sha256(body).hexdigest()
message = "\n".join([method.upper(), path, timestamp, nonce, body_hash])
return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
⚠️중요: 서명 본문은
path(URL의 경로 부분)와body를 포함한다. 수신 측은 자신이 받은 경로·본문으로 같은 식을 계산해X-Signature와 비교해야 한다.
이벤트 발생 지점
각 이벤트는 도메인 서비스에서 notifier.send(...)로 발생시킨다(emit). 결제 3종은 app/services/payments.py에서, 구독/카드/요금제 이벤트는 각각 subscriptions.py·renewals.py·cards.py·plans.py에서 발생한다. 예: 단건결제 성공 시
# app/services/payments.py:243
await notifier.send(service, event=EVENT_PAYMENT_ONE_OFF,
order_id=payment.order_id, status=payment.status,
email=payment.external_user_id,
desc=f"일반결제 {payment.amount:,}원({payment.order_name})")
17.5 알림 URL 등록 + 어드민 테스트 버튼
URL 저장
app/admin/routes/services.py:462의 services_notification_url. CSRF 검증 후 폼 notification_url을 받는다.
- 값이 있으면
http:///https://로 시작해야 한다. 아니면?error=로 안내 후 리다이렉트 (services.py:473-476). - 빈 값이면
service.notification_url = None(알림 끔)으로 저장 (services.py:481). - 감사 로그
service.notification_url_updated(old/new URL 기록) (services.py:482-485).
테스트 발송(동기)
app/admin/routes/services.py:491의 services_notification_test. 일반 send와 달리 동기로 보내 수신 측 응답을 운영자에게 즉시 토스트로 보여준다.
# app/admin/routes/services.py:504
ok, detail = await notifier.send_test(service)
if ok:
return saved_redirect(f"/admin/services/{service_id}",
f"테스트 알림을 전송했습니다 ({detail})")
return RedirectResponse(
f"/admin/services/{service_id}?error={quote(f'테스트 알림 전송 실패: {detail}')}",
status_code=303)
send_test(app/notifications/service_notify.py:124)는 EVENT=notification.test, STATUS=TEST인 payload를 만들어 동일하게 서명한 뒤 즉시 POST하고 (성공여부, 상세문자열)을 반환한다.
| 결과 | 반환 |
|---|---|
| URL 미등록 | (False, "알림 URL이 등록되어 있지 않습니다") |
| 수신 응답 < 400 | (True, "수신 측 응답 HTTP {코드}") |
| 수신 응답 ≥ 400 | (False, "수신 측 응답 오류 HTTP {코드}") |
| 네트워크 예외 | (False, "전송 실패: ...") |
17.6 샘플 수신(sample_service)
샘플 서비스(sample_service/shop/views.py)가 수신·검증 예시를 보여준다.
- 서명 검증 —
_verify_notify_signature(sample_service/shop/views.py:957)가X-Signature를 읽고,service_name으로 찾은(또는 전체) HMAC 시크릿으로HMAC-SHA256(message)를 계산해hmac.compare_digest로 비교한다. - 수신 처리 —
notify_receive_view(sample_service/shop/views.py:984)가 본문 JSON +X-Signature/X-Timestamp/X-Nonce를 검증한 뒤EVENT/STATUS등을 꺼내NotificationRecord로 저장한다(views.py:998~1002). - 표시 —
notifications_view(views.py:1005)가 수신 내역을 화면에 보여준다.
💡참고: 검증 message는 발신 측
sign_request와 동일하게METHOD\nPATH\nTS\nNONCE\nSHA256(body)를 만들어야 일치한다. 본 시스템은path로 URL 경로 부분(urlsplit(url).path)을 쓴다.
17.7 수신 측 구현 규약 (중요)
알림을 받는 서비스 서버가 지켜야 할 계약이다. 발송은 단발(at-most-once)이라 전달이 보장되지 않는다는 점을 전제로 설계해야 한다.
전달 보장 — 없음(재시도 없음)
- 알림은 한 번만 POST하며 재시도·재전송이 없다(17.4). 수신 서버가 다운·타임아웃·5xx면 그 이벤트는 영구 유실된다.
- 따라서 중요한 상태는 알림에만 의존하지 말 것. 구독·결제의 최종 상태는 조회 API(
GET /api/v1/subscriptions/{external_user_id}·GET /api/v1/payments/{external_user_id}, 서비스 API)로 주기적으로 재확인하거나, 사용자 접근 시점에access_allowed를 다시 조회해 보정한다.
응답·타임아웃 계약
- 수신 서버는 2xx를 빠르게 반환해야 한다. 응답 본문은 무시된다.
- 발송 측 타임아웃은 5초(
HttpServiceNotifier(timeout_seconds=5.0),app/notifications/service_notify.py:82). 5초 내 응답하지 못하면 실패로 간주되고 재시도는 없다 → 무거운 처리는 수신 직후 비동기로 넘기고 즉시 2xx를 반환하라. - 4xx/5xx를 반환해도 발송 측은 로그만 남기고 흡수한다(별도 통지·재시도 없음). 단, 어드민 '테스트 알림'은 동기 발송이라 이 응답 코드가 운영자 토스트로 보인다(17.5).
멱등·중복 처리
- 재전송이 없으므로 정상 경로에선 중복 수신이 발생하지 않는다. 다만 중간 프록시 재시도나 운영자의 수동 테스트로 유사 이벤트가 또 올 수 있으니, 수신 측은 비즈니스 키로 멱등 처리(같은 알림이 두 번 와도 한 번만 반영)하는 편이 안전하다.
- 전용 전달 ID는 없다. 멱등 키로는 결제 이벤트는
order_id, 구독 이벤트는subscribe_id+EVENT+STATUS+date조합을 쓴다.X-Nonce는 발송마다 랜덤이라 중복 판별용으로 쓰지 말 것(서명 검증용일 뿐).
payload 필드 함정
subscribe_id는 구독 ID다(키 이름이subscription_id가 아님에 주의).email은 키 이름과 달리external_user_id(이메일)(외부 서비스 측 사용자 식별자)가 담긴다 — 실제 이메일이 아닐 수 있다.- 요금제 이벤트(
plan.*)는 사용자 비귀속이라subscribe_id/order_id/email이 빈 문자열이고DESC에 상세가 담긴다.
서명 검증은 운영에서 필수
수신 측은 받은 X-Signature를 반드시 검증하고, 불일치 시 거부(401/403)해야 한다. 참고로 샘플(sample_service/shop/views.py)은 데모 편의상 서명이 불일치해도 verified=False로 기록만 남기고 200을 돌려준다 — 운영 수신기는 이 동작을 복사하지 말고, 검증 실패는 거부한다. (X-Event는 본문 EVENT와 동일한 값을 담은 힌트일 뿐이므로, 이벤트 종류는 검증된 본문의 EVENT로 판단한다.)
17.8 제약·에러 처리
| 상황 | 동작 |
|---|---|
notification_url 미등록 |
발송 안 함(send는 조용히 반환, send_test는 실패 보고) |
| payload 구성/서명 실패 | logger.warning만 남기고 본 처리 계속(service_notify.py:112) |
| 백그라운드 POST 실패(4xx/5xx·네트워크) | 로그만 남기고 흡수 — 결제·구독은 영향 없음(service_notify.py:114) |
| URL 형식 오류(어드민 저장) | ?error=로 안내, 저장 안 함(services.py:473) |
| 테스트 발송 | 동기 — 수신 응답/네트워크 오류를 운영자에게 즉시 표시 |
⚠️중요: 알림은 전 구간 best-effort다. "알림이 안 왔다"고 해서 결제/구독이 실패한 것이 아니다. 누락 추적은 수신 측 로그와 본 시스템
service_notify로거(logger.warning)를 함께 봐야 한다.
17.9 유지보수 팁
- 새 이벤트 추가 —
service_notify.py에EVENT_*상수를 추가하고, 발생 지점(해당 도메인 서비스)에서notifier.send(event=..., ...)를 호출한다. payload 구조는build_payload가 공통이므로 키를 늘릴 때만 그 함수를 수정한다. - 서명 호환 유지 —
sign_request의 canonical string 구조를 바꾸면 모든 수신기가 깨진다. 변경 시 샘플 수신기(sample_service/shop/views.py)의_verify_notify_signature도 함께 맞춰야 한다. - 테스트는 RecordingServiceNotifier로 — 실제 네트워크 없이 발송 내역을 검사한다(
service_notify.py:154). URL 미등록 서비스는 기록하지 않으므로 테스트에서 URL을 먼저 세팅한다. - best-effort를 깨지 말 것 — 발송 경로에서 예외를 다시 던지면 결제/구독이 함께 실패한다.
send의 광범위한except(# noqa: BLE001)는 의도된 것이다. - HMAC 시크릿 재사용 — 알림 서명은 별도 시크릿이 아니라 서비스 API와 같은
hmac_secret_encrypted를 쓴다. 시크릿 로테이션 시 알림 검증도 함께 영향받는 점을 기억한다.
17.10 관리자 이벤트 알림 메일(시스템 관리자)
서비스가 등록한 웹훅(위 17.1~17.9)과 별개로, 운영 이벤트가 생기면 시스템 관리자에게 메일을 보낸다. 외부 서비스가 아니라 사내 운영자가 받는 알림이다.
대상 이벤트·수신자
| 이벤트 | 발생 지점 | 수신자 |
|---|---|---|
account.created (새 관리자 계정 생성) |
app/services/accounts.py create_account |
활성(ACTIVE) SYSTEM_ADMIN 전원 |
service.created (새 서비스 등록) |
app/services/registry.py register_service |
〃 |
plan.created (새 구독 요금제 등록) |
app/services/plans.py create_plan |
〃 |
subscription.created (새 구독 생성 — 모든 구독, 외부 API 포함) |
app/services/subscriptions.py create_subscription |
〃 |
- 수신자는 DB에서
role=SYSTEM_ADMIN AND status=ACTIVE인 계정 이메일을 조회한다(admin_notify._active_admin_emails). 활성 관리자가 없으면 발송을 생략한다. - 메일은 HTML 서식(상세 표) + 평문 대체 본문으로 보낸다(
EmailSender.send(..., html=...)). 각 이벤트별로 핵심 항목(역할·담당 서비스·취소 정책·요금제 가격/주기/할인·청구 금액·구독 기간 등)을 표로 정리한다.
발송 성질 — best-effort
- 본 처리(계정·서비스·구독 생성)가 커밋된 뒤 호출하므로, 메일 발송이 실패해도 트랜잭션은 영향받지 않는다.
- 수신자 조회는 호출자의 DB 세션으로 즉시 수행하고, 각 수신처 메일을 이메일 발송 큐(§17.11) 에 적재한다. 적재는 즉시 반환되므로 API 응답이 SMTP를 기다리지 않는다(
EmailAdminNotifier._dispatch).
관련 파일
| 파일 | 역할 |
|---|---|
app/notifications/admin_notify.py |
AdminNotifier 프로토콜 · EmailAdminNotifier(운영) · RecordingAdminNotifier(테스트) · HTML 템플릿 |
app/notifications/email.py |
EmailSender.send(to, subject, body, html=None) — html 인자로 멀티파트(text/plain+text/html) 발송 |
app/main.py · app/core/deps.py |
app.state.admin_notifier 등록 · get_admin_notifier 의존성(테스트는 Recording 주입) |
💬유지보수: 새 관리자 이벤트를 추가하려면
admin_notify.py에 메서드·템플릿을 추가하고, 해당 도메인 서비스 함수가 커밋 후admin_notifier.<event>(...)를 호출하게 한다. 테스트는RecordingAdminNotifier로 호출 내역을 검사한다(실제 메일 없음).
17.11 이메일 발송 큐(메모리·순차)·감사 로그
모든 이메일(계정 설정 메일, 비밀번호 재설정 메일, 관리자 이벤트 알림)은 메모리 큐에 먼저 적재되고 한 건씩 순서대로(FIFO) 발송된다.
동작
QueuedEmailSender.send(...)는 실제 발송 대신 메모리 큐(asyncio.Queue)에 적재만 하고 즉시 반환한다 → 요청 핸들러(계정 생성·비밀번호 재설정 등)는 SMTP 완료를 기다리지 않는다.- 단일 워커 태스크가 큐에서 한 건씩 꺼내 순차 발송한다(동시 발송 없음 → 순서 보장).
- 운영에서만 큐를 사용한다. 테스트는
RecordingEmailSender를 직접 주입해 동기 검증한다(큐 미경유).
감사 로그(필수)
- 워커는 발송할 때마다 감사 로그를 1건 남긴다 — 성공은
email.sent, 실패는email.failed. - 항목:
actor_type=SYSTEM,target_type=email,target_id=<수신 이메일>,detail={to, subject, ok}. 감사 로그 화면에서 조회한다. - 도메인 감사(
account.create,user.password_reset_issued등)와 별개로 이메일 전송 계층의 결과를 별도로 추적한다.
관련 파일
| 파일 | 역할 |
|---|---|
app/notifications/email_queue.py |
EmailQueue(FIFO·단일 순차 워커·발송 감사로그) · QueuedEmailSender(적재 어댑터) |
app/notifications/email_templates.py |
트랜잭션 메일 HTML 템플릿(render_action_email) — CTA 버튼·브랜딩, 인라인 스타일. 비밀번호 재설정 메일이 사용 |
app/main.py |
lifespan에서 EmailQueue 기동/정상 종료 · app.state.email_sender = QueuedEmailSender(...) |
💬종료(shutdown) 시 워커는 적재된 메일을 (타임아웃 내) 모두 비우고 종료한다. 발송 실패는 핵심 처리와 분리되어 로깅·감사기록만 하고 흡수한다(워커는 어떤 예외에도 멈추지 않는다).