15. 서비스 알림(아웃고잉 웹훅) 기능
구독·결제·카드·요금제의 상태가 바뀌면, 본 시스템은 서비스가 등록한 notification_url로 JSON을 POST한다. 이를 "서비스 알림(아웃고잉 웹훅)"이라 부른다. 이 문서는 이벤트 종류, payload·서명 구조, best-effort 발송, 어드민 테스트 버튼, 샘플 수신 흐름을 코드와 함께 추적한다.
🍎쉽게 말하면 "우리 쪽에서 어떤 일이 생겼을 때, 서비스가 알려준 주소로 서명된 JSON을 한 번 쏴 주는" 것이다. 받아도 그만 안 받아도 본 처리는 안 깨진다.
함께 보기: 서비스 API, 일반결제·취소·정산
15.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 |
15.2 이벤트 16종(+테스트 1종)
app/notifications/service_notify.py:29~46에 이벤트 식별자 상수가 정의돼 있다. 이 값은 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_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 |
💡참고: 표의 데이터 이벤트는 16종이고, 여기에 어드민 "테스트 알림 전송" 버튼이 쓰는
notification.test가 더해진다.
15.3 payload 구조(EVENT 포함)
app/notifications/service_notify.py:49의 build_payload가 고정 구조의 dict를 만든다. 없는 값은 빈 문자열로 채운다(키는 항상 존재).
# app/notifications/service_notify.py:53
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 |
사람이 읽는 설명 |
15.4 발송 흐름(HttpServiceNotifier.send) — best-effort
app/notifications/service_notify.py:86의 send가 발송을 구성한다. 전체가 try/except로 감싸져 payload 구성·서명·스케줄 어떤 예외도 본 처리를 막지 않는다.
- URL 확인 —
notification_url이 없으면 즉시 반환(발송 안 함) (service_notify.py:91~93). - payload 구성 —
build_payload(...)후json.dumps(..., ensure_ascii=False)로 바이트 본문 생성 (service_notify.py:94~97). - 서명 — 서비스의 HMAC 시크릿을 복호화하고, 타임스탬프·nonce를 만들어
sign_request로 서명 (service_notify.py:99~105).
# app/notifications/service_notify.py:99
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:107~109). - 예외 흡수 — 구성 단계 실패는
logger.warning만 남긴다(service_notify.py:110). 백그라운드_post는 HTTP 4xx/5xx·네트워크 오류 모두 로그만 남기고 흡수한다(service_notify.py:113~121).
서명 규칙(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:194
await notifier.send(service, event=EVENT_PAYMENT_ONE_OFF, order_id=payment.order_id,
status=payment.status, email=external_user_id,
desc=f"일반결제 {amount:,}원({order_name})")
15.5 알림 URL 등록 + 어드민 테스트 버튼
URL 저장
app/admin/routes/services.py:416의 services_notification_url. CSRF 검증 후 폼 notification_url을 받는다.
- 값이 있으면
http:///https://로 시작해야 한다. 아니면?error=로 안내 후 리다이렉트 (services.py:428~431). - 빈 값이면
service.notification_url = None(알림 끔)으로 저장 (services.py:436). - 감사 로그
service.notification_url_updated(old/new URL 기록) (services.py:437).
테스트 발송(동기)
app/admin/routes/services.py:445의 services_notification_test. 일반 send와 달리 동기로 보내 수신 측 응답을 운영자에게 즉시 토스트로 보여준다.
# app/admin/routes/services.py:459
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:123)는 EVENT=notification.test, STATUS=TEST인 payload를 만들어 동일하게 서명한 뒤 즉시 POST하고 (성공여부, 상세문자열)을 반환한다.
| 결과 | 반환 |
|---|---|
| URL 미등록 | (False, "알림 URL이 등록되어 있지 않습니다") |
| 수신 응답 < 400 | (True, "수신 측 응답 HTTP {코드}") |
| 수신 응답 ≥ 400 | (False, "수신 측 응답 오류 HTTP {코드}") |
| 네트워크 예외 | (False, "전송 실패: ...") |
15.6 샘플 수신(sample_service)
샘플 서비스(sample_service/shop/views.py)가 수신·검증 예시를 보여준다.
- 서명 검증 —
_verify_notify_signature(sample_service/shop/views.py:642)가X-Signature를 읽고,service_name으로 찾은(또는 전체) HMAC 시크릿으로HMAC-SHA256(message)를 계산해hmac.compare_digest로 비교한다. - 수신 처리 — 본문 JSON +
X-Signature/X-Timestamp/X-Nonce를 검증한 뒤EVENT/STATUS등을 꺼내 저장한다(views.py:672~683). - 표시 —
notifications_view(views.py:690)가 수신 내역을 화면에 보여준다.
💡참고: 검증 message는 발신 측
sign_request와 동일하게METHOD\nPATH\nTS\nNONCE\nSHA256(body)를 만들어야 일치한다. 본 시스템은path로 URL 경로 부분(urlsplit(url).path)을 쓴다.
15.7 수신 측 구현 규약 (중요)
알림을 받는 서비스 서버가 지켜야 할 계약이다. 발송은 단발(at-most-once)이라 전달이 보장되지 않는다는 점을 전제로 설계해야 한다.
전달 보장 — 없음(재시도 없음)
- 알림은 한 번만 POST하며 재시도·재전송이 없다(15.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:81). 5초 내 응답하지 못하면 실패로 간주되고 재시도는 없다 → 무거운 처리는 수신 직후 비동기로 넘기고 즉시 2xx를 반환하라. - 4xx/5xx를 반환해도 발송 측은 로그만 남기고 흡수한다(별도 통지·재시도 없음). 단, 어드민 '테스트 알림'은 동기 발송이라 이 응답 코드가 운영자 토스트로 보인다(15.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로 판단한다.)
15.8 제약·에러 처리
| 상황 | 동작 |
|---|---|
notification_url 미등록 |
발송 안 함(send는 조용히 반환, send_test는 실패 보고) |
| payload 구성/서명 실패 | logger.warning만 남기고 본 처리 계속(service_notify.py:110) |
| 백그라운드 POST 실패(4xx/5xx·네트워크) | 로그만 남기고 흡수 — 결제·구독은 영향 없음(service_notify.py:113) |
| URL 형식 오류(어드민 저장) | ?error=로 안내, 저장 안 함(services.py:428) |
| 테스트 발송 | 동기 — 수신 응답/네트워크 오류를 운영자에게 즉시 표시 |
⚠️중요: 알림은 전 구간 best-effort다. "알림이 안 왔다"고 해서 결제/구독이 실패한 것이 아니다. 누락 추적은 수신 측 로그와 본 시스템
service_notify로거(logger.warning)를 함께 봐야 한다.
15.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:153). URL 미등록 서비스는 기록하지 않으므로 테스트에서 URL을 먼저 세팅한다. - best-effort를 깨지 말 것 — 발송 경로에서 예외를 다시 던지면 결제/구독이 함께 실패한다.
send의 광범위한except(# noqa: BLE001)는 의도된 것이다. - HMAC 시크릿 재사용 — 알림 서명은 별도 시크릿이 아니라 서비스 API와 같은
hmac_secret_encrypted를 쓴다. 시크릿 로테이션 시 알림 검증도 함께 영향받는 점을 기억한다.