본문으로 건너뛰기

SOLAPI SMS 출시

컨텍스트

알림 모듈은 대기 중인 테넌트 범위 SMS 기록 및 예약으로 시작된 후 2026년 6월 28일 실제 공급자 출시로 확장되었습니다. 주요 단계는 다음과 같습니다.

  • 3289196c: 공급자 디스패치, 스케줄러 흐름, 웹훅 수신 및 sms_messages 공급자 추적 열을 추가합니다.
  • afab0183, e73277f204aae48c: 인덱스 및 마이그레이션 순서 문제 후 스키마 롤아웃을 강화합니다.
  • 4de6a6c7: 커밋 후 디스패치 및 웹훅 쓰기가 여전히 RLS에서 app.tenant_id를 바인딩하도록 트랜잭션 경계를 복구합니다.
  • 03981d38, 01ad30b213852f8d: SOLAPI 관련 LMS 제목 불일치를 제거하고, groupId를 생략하는 웹후크 페이로드를 허용하고, 글로벌 구성에서 테넌트 소유 발신자 번호로 라이브 전송을 전환합니다.

이제 정상 상태 계약이 알림 서비스 참조 페이지에 있습니다.

무엇이 깨졌거나 변경되었는지

롤아웃은 단순히 "SOLAPI를 켜는 것"이 아닙니다. 한 번에 세 가지 계약이 변경되었습니다.

  • 아웃바운드 보내기 실행이 단일 트랜잭션 내 큐 쓰기에서 SmsDispatchService의 커밋 후 공급자 디스패치로 이동되었습니다.
  • 공급자 상태는 V76__sms_provider_dispatch_tracking.sql를 통해 지속된 메시지 계약의 일부가 되었습니다.
  • 실시간 발신자 ID가 앱 구성에서 V78__sms_sender_settings.sql의 테넌트 범위 sms_sender_settings로 이동되었습니다.

대기 중인 MVP에는 없는 실패 모드가 노출되었습니다.

  • 클레임 상태가 먼저 지속되지 않는 한 두 디스패치 워커가 동일한 PENDING 행에서 경합할 수 있습니다.
  • REQUIRES_NEW 트랜잭션은 원래 테넌트 바인딩을 잃고 빈 RLS 보기를 읽을 수 있습니다.
  • SOLAPI 웹훅 페이로드는 특히 groupId 및 LMS 전용 필드 주변에서 완벽하게 안정적이지 않았습니다.
  • 하나의 백엔드가 많은 테넌트에 서비스를 제공하면 글로벌 SOLAPI_FROM_NUMBER로는 충분하지 않습니다.

진단 및 결정 사다리

  1. 첫 번째 실제 공급자 패스에는 공급자 열과 스케줄러가 추가되었지만 여전히 동일한 트랜잭션과 테넌트 컨텍스트가 커밋 후 작업에서 자연스럽게 유지될 것이라고 가정했습니다.
  2. 디스패치를 "행 로드 후 변경"에서 "먼저 요청한 후 로드"로 이동하여 재시도 및 동시 스케줄러가 동일한 메시지를 이중으로 보낼 수 없도록 강화했습니다.
  3. 마이그레이션 계획에서는 공급자 인덱스를 CREATE INDEX CONCURRENTLY를 사용하여 V77로 잠시 분할한 다음 롤아웃 문제가 발생한 후 철회했습니다. 현재 소스는 V76를 실제 인덱스 보유 마이그레이션으로 유지하고 V77를 트랜잭션 무작동으로 남겨 둡니다.
  4. 결정적인 수정은 프로그래밍 방식 트랜잭션에 대해 선언적 RLS 측면에 의존하지 않는 것이었습니다. SmsDispatchService는 이제 전용 @RequiresNewTransaction 템플릿을 통해 클레임, 결과, 실패 및 웹후크 쓰기를 실행하고 리포지토리에 액세스하기 전에 테넌트를 명시적으로 바인딩합니다.
  5. 테넌트 소유 발신자 번호가 도착하면 라이브 SOLAPI 디스패치가 단일 앱 수준 발신자 번호 읽기를 중지하고 대신 저장된 메시지 발신자 또는 테넌트가 승인한 대체를 확인했습니다.

현재 디스패치 쓰기는 다음 명시적 바인더 경로에 따라 달라집니다.

// lumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/SmsDispatchService.java
private <T> T executeDispatchTransaction(TransactionCallback<T> callback) {
return dispatchTransactionTemplate.execute(status -> {
rlsTenantTransactionBinder.bindRequiredTenantToCurrentTransaction();
return callback.doInTransaction(status);
});
}

해결

이제 코드가 문제를 명확하게 구분하므로 현재 출시 형태는 안정적입니다.

  • SmsDispatchService 클레임 작업은 트랜잭션 외부에서 공급자 I/O를 수행한 다음 새로운 테넌트 바인딩 트랜잭션에서 결과를 유지합니다.
  • RlsTenantTransactionBinderapp.tenant_id를 해당 트랜잭션에서 사용하는 JDBC 연결에 직접 바인딩합니다.
  • SolapiSmsWebhookControllerSmsWebhookService는 콜백 customFields에서 테넌트 컨텍스트를 다시 수화한 다음 SmsDispatchService는 기록을 변경하기 전에 메시지 ID, 공급자 메시지 ID 및 공급자 그룹 ID의 유효성을 검사합니다.
  • SolapiSmsProviderAdapter는 더 이상 LMS subject 필드를 보내지 않으며 확인된 발신자 번호가 없는 실제 발송을 거부합니다.
  • V78__sms_sender_settings.sql는 승인된 발신자 번호를 소유한 테넌트 RLS 테이블을 추가하는 반면, SmsMessageResponse는 저장된 senderNumber를 노출하므로 기록에 실제 디스패치 ID가 반영됩니다.

현재 기준 파일은 다음과 같습니다.

  • lumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/SmsDispatchService.java
  • lumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/SmsWebhookService.java
  • lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/external/SolapiSmsProviderAdapter.java
  • lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java
  • lumie-backend/app/src/main/resources/db/migration/public/V76__sms_provider_dispatch_tracking.sql
  • lumie-backend/app/src/main/resources/db/migration/public/V77__sms_provider_dispatch_tracking_indexes.sql
  • lumie-backend/app/src/main/resources/db/migration/public/V78__sms_sender_settings.sql

예방

  • 공급자 롤아웃 작업을 어댑터 전용 기능이 아닌 대기열, 테넌시, 스키마 및 콜백 계약 전반에 걸친 교차 변경으로 처리합니다.
  • 명시적인 트랜잭션 인프라에 대한 실시간 공급자 쓰기를 유지합니다. 프로그래밍 방식의 TransactionTemplate 흐름은 RLS 측면을 무료로 얻지 못합니다.
  • 비동기 콜백을 신뢰하기 전에 데이터베이스에 메시지 수준 공급자 상태를 유지합니다.
  • 전역 SMS 발신자 구성보다 테넌트 소유 발신자 설정을 선호합니다. 현재 application.yamllumie.sms.solapi.{api-key,api-secret,base-url,webhook-token}만 노출합니다. SOLAPI_FROM_NUMBER는 더 이상 런타임 계약의 일부가 아닙니다.
  • 웹훅 공급자가 일관되지 않은 페이로드를 보내는 경우 방어적으로 바인딩의 유효성을 검사하고 저장된 행이 증명할 수 있는 최소한의 이전 버전과 호환되는 폴백을 허용합니다. 현재 코드는 메시지 ID가 여전히 일치하는 경우에만 누락된 providerGroupId를 허용합니다.