결제 구독 계단식 409
이 회고는 2026년 5월 27~28일의 청구 구독 사건을 다룹니다. 실패는 데모 차단으로 시작되었지만 세 가지 지속적인 백엔드 계약 문제를 드러냈습니다. 구독 경계가 프런트엔드에 있었고, 데이터베이스 무결성 실패가 모두 동일한 409처럼 보였으며, 동일한 트랜잭션 청구 키 upsert가 여전히 활성 키 고유 인덱스를 위반했습니다.
컨텍스트
수정 전에는 Toss 청구 콜백 후 프런트엔드 성공 페이지에서 유료 요금제 구독 흐름이 조정되었습니다.
- 빌링키를 등록하고,
- 테넌트 구독 변경
- 첫 번째 청구서를 청구합니다.
해당 시퀀스는 항상 참이 아닌 두 가지 불변성을 가정했습니다.
- 모든 테넌트에는 이미 무료 구독 행이 있습니다.
- 청구가 절반 완료된 상태에서 테넌트를 방해하지 않고 각 단계를 독립적으로 재시도할 수 있습니다.
현재 저장소에는 여전히 해당 디자인을 대체한 최종 경계가 표시됩니다.
lumie-backend/modules/billing/src/main/java/com/lumie/billing/application/service/BillingSubscribeService.javalumie-frontend/src/widgets/billing-home/ui/BillingSetupSuccess.tsx
무엇이 깨졌거나 변경되었는지
눈에 보이 는 증상은 거의 동일한 일련의 409 Conflict 반응이었지만 원인은 달랐습니다.
- 구독 행이 없는 레거시 테넌트는
V35__backfill_free_subscriptions.sql가 불변성을 복원할 때까지404를 계속 반환했습니다. V36__billing_keys_nullable_card_metadata.sql가 스키마를 확장할 때까지 청구 키 지속성이 nullable 카드 메타데이터 드리프트에 도달했습니다.- 부분적인 오류가 테넌트의 기존 활성 키와 충돌한 후 청구 키 단계를 다시 실행합니다.
- 프런트엔드는 세 개의 백엔드 호출에 걸쳐 분산 트랜잭션을 조정했습니다.
- 실제 원인이 서버 측
NOT NULL또는 외래 키 버그인 경우에도DataIntegrityViolationException는 동일한 일반409에 매핑되었습니다.
유선 응답에서는 사용자 충돌과 서버 결함 간의 차이를 숨겼기 때문에 각 수정 사항은 다음 오류 클래스만 드러냈습니다.
근본 원인
3개의 확인된 결함이 사고에서 살아남아 최종 설계를 설명합니다.
1. 비즈니스 운영 경계가 잘못된 레이어에 있었습니다.
내구성 있는 수정 사항은 "새 카드로 유료 요금제 구독"을 하나의 백엔드 조정 단계로 이동하는 것이었습니다. 현재 정보 출처는 BillingSubscribeService로, 청구 키 발급, 활성 키 교체, 구독 연결 및 POST /v1/billing/subscribe에 따른 첫 번째 청구를 소유하고 있습니다.
이제 프런트엔드 성공 페이지에 해당 분할이 반영됩니다.
planId === CHANGE_METHOD_SENTINEL는useRegisterBillingKey()의 카드 교환 경로를 유지합니다.- 모든 유료 구독 경로는
useSubscribe()를 한 번 호출하고 백엔드가 작업을 완료할 때까지 기다립니다.
2. 전역 예외 매퍼는 실제 실패 클래스를 숨겼습니다.
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/GlobalExceptionHandler.java는 이제 모든 DataIntegrityViolationException를 409 RESOURCE_CONFLICT로 평면화하는 대신 JDBC SQLSTATE에서 분기합니다.
이러한 변화로 인해 디버깅 계약이 정직하게 바뀌었습니다.
23505는 실제 갈등을 유지합니다.23514는422 BUSINESS_RULE_VIOLATION가 됩니다.23502,23503및 알 수 없는 무결성 오류는500 INTERNAL_ERROR로 나타납니다.
3. 청구 키 교체에 명시적인 플러시 순서가 필요함
이제 lumie-backend/modules/billing/src/main/java/com/lumie/billing/application/service/BillingKeyPersistenceHelper.java는 새 키를 삽입하기 전에 saveAndFlush(existing)를 호출합니다. 부분 고유 인덱스는 테넌트당 하나의 ACTIVE 청구 키만 허용하고 JPA는 삽입을 즉시 플러시하는 동안 커밋할 때까지 취소 업데이트를 연기할 수 있기 때문에 순서가 중요합니다.
해결
지속적인 저장소 상태는 다음과 같습니다.
V35__backfill_free_subscriptions.sql는 레거시 테넌트에 대해 "모든 테넌트에 구독 행이 있음" 불변성을 복원합니다.V36__billing_keys_nullable_card_metadata.sql는 Toss 샌드박스 응답이 생략할 수 있는 nullable 카드 메타데이터를 허용합니다.BillingSubscribeService.java는 단일 백엔드 구독 경계입니다.GlobalExceptionHandler.java는 HTTP 계층에서 SQLSTATE 수준 원인 가시성을 유지합니다.BillingKeyPersistenceHelper.java는 삽입 전 취소 순서를 명시적으로 플러시합니다.BillingSetupSuccess.tsx는 유료 구독 설정을 세 개를 조정하는 대신 하나의 백엔드 변형에 위임합니다.
회귀 적용 범위는 현재 소스에서도 유지되었습니다.
lumie-backend/libs/common/src/test/java/com/lumie/common/exception/GlobalExceptionHandlerTest.javalumie-backend/modules/billing/src/test/java/com/lumie/billing/application/service/BillingKeyPersistenceHelperTest.javalumie-backend/modules/billing/src/test/java/com/lumie/billing/application/service/BillingSubscribeServiceTest.java
예방
- 사용자 작업이 청구 키 등록, 구독 상태 및 청구에 걸쳐 있을 때 비즈니스 운영을 백엔드에 배치합니다.
- API 개선뿐만 아니라 예외-HTTP 매핑을 관찰성 인프라로 처리합니다.
- 동일한 트랜잭션 업데이트 및 삽입이 부분 고유 인덱스를 공유하는 경우 쓰기 순서를 명시적으로 강제 적용합니다.
- 프런트엔드 재시도 또는 임시 운영자 단계가 아닌 마이그레이션 시 레거시 불변 복구를 유지합니다.