유대선
프로젝트로
·기술 회고·4

PAY-2 — RevenueCat 연동에서 적대 리뷰가 진짜 경쟁 조건과 보안 오탐지를 잡다

216개 테스트가 green인 상태에서 3-렌즈 적대 리뷰가 신규 entitlement 행의 실제 경쟁 조건과 sandbox 오탐지 버그(유료 고객이 프로덕션에서 안 보이게 됨)를 찾아 원자적 SQL upsert로 재설계했다.

무엇을 했나

PAY-1의 capability 판정 위에 실제 RevenueCat 연동을 얹었다: provider-neutral 인터페이스, RevenueCat REST 어댑터, webhook(auth header + 선택적 HMAC-SHA256), 인증된 /api/billing/sync, 구 payment-agnostic webhook 완전 은퇴(terraform 시크릿 프로비저닝까지).

RevenueCat REST API 필드명은 지어내지 않고 이번 세션에 공식 문서를 fetch해서 확인했다 — entitlements.{id}.expires_date, subscriptions.{id}.is_sandbox, webhook body가 {api_version, event: {...}}로 감싸져 있다는 것, HMAC 헤더 형식(t=..,v1=..)까지.

green인데도 적대 리뷰가 찾은 것

216개 테스트가 전부 통과한 상태에서 3-렌즈 리뷰(보안/동기화 정확성/수용기준)를 돌렸고, 실제 결함 6건이 나왔다. 그중 둘은 프로덕션에서 돈이 걸린 문제였다:

  1. 신규 entitlement 행의 경쟁 조건. EntitlementSyncService가 find-then-save (JPA로 조회 → 없으면 저장) 방식이었다. RevenueCat이 같은 이벤트를 동시에 두 번 배달하거나, webhook과 방금 결제 후 클라이언트가 부른 /sync가 경합하면, 둘 다 "행 없음"을 보고 둘 다 INSERT를 시도한다 — 어느 쪽이 이길지, 그게 더 최신 데이터인지 보장이 없었다. 클래스 Javadoc은 "단조성 보장"을 주장했지만 신규 행 케이스에서는 거짓이었다. INSERT ... ON CONFLICT ... DO UPDATE ... WHERE last_provider_event_at < EXCLUDED... 단일 원자 문장으로 재작성해 DB 레벨에서 완전히 막았다.
  2. sandbox 오탐지. 환경 판정이 구독자의 모든 구독 목록을 스캔해서 하나라도 sandbox면 전체 스냅샷을 SANDBOX로 태그했다. TestFlight로 한 번이라도 테스트 구매를 해본 적 있는 진짜 유료 고객(정확히 테스터/개발자 계층)이 프로덕션 접근 판정에서 안 보이게 되는 버그였다. entitlement마다 자기 product_identifier에 해당하는 구독만 조회하도록 고쳤다.

나머지는 공유 provider-call budget 부재(§8.2/§8.3 요구사항), 재시도 테스트가 실제로는 재시도 경로를 트리거 안 하는 과장 주장, malformed webhook 미로깅, terraform에 남은 죽은 시크릿 프로비저닝.

반복된 함정

PAY-1에서 이미 겪었던 "@Transactional 테스트에서 JPA 쓰기 직후 raw JDBC로 읽으면 flush 타이밍에 속는다"를 새 테스트(EntitlementSyncServiceTest.createUser)에서 또 밟았다. 같은 수정(UserRepository.findByEmail로 auto-flush 유도)을 다시 적용했다 — 패턴을 문서화해도 새 코드에서 잊어버리기 쉽다는 뜻이다.

교훈

find-then-save는 동시성이 걸린 자리에서는 기본값이 아니라 예외적으로 정당화해야 하는 선택이다. 멱등성·단조성이 실제 요구사항이면 처음부터 DB 원자 연산으로 설계하는 게 나중에 "경쟁 조건 찾아서 재작성"보다 싸다.

남은 것

PAY-2는 코드 완결이고 실제 RevenueCat 연동은 라이브가 아니다 — 오너의 RevenueCat 프로젝트/ API 키 입력이 PAY-2 라이브 통합과 PAY-3(모바일 SDK)의 선행 조건이다.