Solapi Dispatch Webhook State
SMS send가 provider boundary를 넘었고, 실패 지점이 dispatch claim, provider result persistence, webhook binding, tenant-scoped history update일 때 이 가이드를 사용합니다.
더 넓은 rollout 서사는 Solapi SMS 회고에 있습니다. 이 문서는 운영 runbook입니다.
증상
- SMS는 Solapi를 통해 발송된 것처럼 보이지만 Lumie history가
PENDING또는DISPATCHED에 머뭅니다. - webhook request는 200을 반환하지만 recipient delivery state가 갱신되지 않습니다.
- webhook event가 ignored로 기록됩니다.
- retry attempt가 같은 message를 계속 claim하지만 durable terminal state가 없습니다.
- mock provider에서는 되던 live-provider send가 전환 후 실패합니다.
알려진 실패 이력
| Commit | 실패 모드 | 지속되는 수정 |
|---|---|---|
afab0183 | live provider dispatch에 provider id, recipient binding, webhook hardening이 필요했습니다. | provider group/message tracking, binding validation, test를 추가했습니다. |
4de6a6c7 | REQUIRES_NEW dispatch transaction이 RLS tenant binding을 잃었습니다. | dispatch transaction 내부에서 RlsTenantTransactionBinder를 호출합니다. |
01ad30b2 | Solapi webhook report가 provider delivery state를 완전히 반영하지 못했습니다. | webhook application이 dispatch state와 recipient status를 갱신합니다. |
e73277f2 | 이미 적용된 SMS migration이 실수로 편집됐습니다. | historical migration을 복원하고 forward-only로 이동했습니다. |
04aae48c | 후속 SMS index migration이 이미 배포된 환경에 안전해야 했습니다. | V77은 transactional no-op이 되었고 V76이 provider column과 index를 소유합니다. |
현재 런타임 계약
| 표면 | 소스 경로 | 필요한 동작 |
|---|---|---|
| Dispatch claim | modules/notification/src/main/java/com/lumie/notification/application/service/SmsDispatchService.java | 짧은 tenant-bound transaction에서 pending message를 claim합니다. |
| Provider send | SolapiSmsProviderAdapter | database transaction 밖에서 send하고 provider group/message id를 반환합니다. |
| Webhook entry | SolapiSmsWebhookController | X-Solapi-Secret을 검증하고 Lumie custom field를 parse하며, 알려진 invalid event만 ignore합니다. |
| Webhook application | SmsWebhookService, SmsDispatchService.applyWebhookReport(...) | tenant context를 복원하고 message, provider group, recipient index, provider message id를 검증합니다. |
| Migration state | V76__sms_provider_dispatch_tracking.sql, V77__sms_provider_dispatch_tracking_indexes.sql | provider tracking은 V76에 있고 V77은 의도적인 no-op입니다. |
진단
- 실패가 provider send 전인지, provider result 뒤인지, webhook application 중인지 식별합니다.
SmsMessageaggregate state를 확인합니다. provider, provider group id, dispatch attempts, recipients, per-recipient provider message id를 봅니다.- log에서
SMS after-commit dispatch failed, provider send error,Ignored SOLAPI webhook event를 확인합니다. - webhook에
lumieTenant,lumieSmsId,lumieRecipientcustom field가 있는지 확인합니다. - webhook
messageId가 recipient의 provider message id와 일치하고, group id가 있으면 message provider group id와 일치하는지 확인합니다. - dispatch transaction이
RlsTenantTransactionBinder.bindRequiredTenantToCurrentTransaction()을 호출하는지 확인합니다. - schema drift가 의심되면
V76과V77이 현재 forward-only 계약과 일치하는지 확인합니다.
살펴볼 database field에는 provider_group_id, dispatch_attempt_count, last_provider_status, last_provider_error, dispatched_at, confirmed_at, recipients payload 안의 recipient-level provider id가 있습니다.
유용한 소스 확인:
cd lumie-backend
rg -n "applyWebhookReport|bindRequiredTenantToCurrentTransaction|lumieTenant|providerGroupId|claimDispatchAttempt" \
modules/notification app/src/main/resources/db/migration/public
수정
- dispatch가 commit 뒤에 실행된다면
dispatchMessage(...)주위의TenantContextHolder.withinContext(...)를 유지합니다. TransactionTemplate을 쓴다면 repository read/write 전에 transaction 내부에서 required tenant를 bind합니다.- webhook event가 ignored라면 custom field 또는 provider id binding을 고칩니다. missing provider group id는 Lumie message id로 fallback할 수 있지만 conflicting id는 계속 거부해야 합니다.
- provider send가 partial recipient result를 반환하면 recipient index와 provider message id 기준으로 merge합니다.
- migration drift가 보이면 historical migration을 정확히 복원하고 새 forward migration을 추가합니다.
검증 증거
- pending message가 한 번 claim되고 provider response 뒤 retry state를 벗어납니다.
- provider group id와 per-recipient provider message id가 저장됩니다.
- valid Solapi webhook이 의도한 recipient만 한 번 갱신합니다.
- tenant, group id, message id, recipient index가 맞지 않는 webhook은 history를 변경하지 않고 ignored됩니다.
- dispatch와 webhook write가 올바른 tenant의 RLS 아래에서 통과합니다.
- SMS history가 operator가 기대한 sender number와 final delivery status를 노출합니다.
예방
- provider callback은 Lumie id custom field와 provider id로 묶습니다. provider status만 신뢰하지 않습니다.
- live-provider write는 명시적인 tenant-bound transaction 안에 둡니다.
- webhook 200을 state mutation의 증거로 보지 말고 aggregate를 확인합니다.
- provider tracking column과 index는 특히 migration fix를 forward-only로 유지합니다.
- mock-provider behavior와 live Solapi binding rule을 분리해 유지합니다.