개발 slecs

인앱결제 전체 플로우와 구독 권한 확인까지 연결 완료

목차

단계별 IAP 전체 플로우를 한 번에 연결하는 작업이었다. 페이월 UI부터 결제, 가족공유, 소셜로그인, JWT 인증, 그리고 entitlement 확인까지 — 개념상으로는 "결제 한 번"이지만 실제로는 레이어가 꽤 많이 쌓인다.

왜 이 작업이 이렇게 묶였나

IAP 관련 피처를 잡으면 항상 고민이 생기는 게, 어디까지 한 PR에 넣느냐다. 결제 로직만 뽑아서 먼저 머지하고 싶은 욕구가 있지만, 실제로 페이월 → 결제 → entitlement 확인까지 연결이 안 된 상태에서는 QA가 불가능하다. 반대로 너무 크게 묶으면 리뷰하는 팀원 입장에서 코드 컨텍스트가 분산돼서 리뷰 품질이 떨어진다.

이번엔 "단계" IAP라는 전제가 있어서 플로우 자체가 순차적으로 강제됨. 그래서 레이어별로 나누기보다 플로우 단위로 묶는 게 맞다고 판단했다. 리뷰어한테는 커밋 메시지에 괄호로 플로우 순서를 적어줬고, PR 설명에도 시퀀스 다이어그램 간단히 그려넣었다.

변경된 파일들과 각자의 역할

파일 역할 이번 변경 포인트
app.dart 앱 루트 / 프로바이더 바인딩 IAP / entitlement 리포지토리 DI 연결
api_client.dart 서버 통신 클라이언트 JWT 첨부, entitlement 검증 API 연동
auth_storage.dart 로컬 인증 정보 저장 소셜로그인 토큰 + JWT 영속화
entitlement.dart entitlement 모델 구독 상태 / 만료일 / 가족공유 여부 필드
entitlement_repository.dart entitlement 상태 관리 서버 동기화 + 로컬 캐싱 레이어
iap_repository.dart 스토어 결제 처리 구매 완료 → 영수증 검증 → entitlement 갱신

파일 수가 많아 보이지만 사실 각각이 맡은 레이어가 명확히 분리돼 있다. iap_repository가 스토어 결제를 처리하고 나면 영수증을 서버로 보내고, 서버가 검증 후 entitlement를 내려주는 흐름. entitlement_repository는 그 결과를 받아서 UI에 뿌려줄 수 있게 상태를 들고 있는다.

// entitlement_repository.dart 패턴 스케치
Future<void> syncEntitlement() async {
  final result = await _apiClient.fetchEntitlement(); // JWT 첨부된 요청
  _entitlement = result;
  notifyListeners(); // 또는 emit, stream sink
}

// iap_repository.dart 결제 완료 후 흐름
Future<void> onPurchaseCompleted(PurchaseDetails details) async {
  await _validateReceipt(details.verificationData);
  await _entitlementRepository.syncEntitlement(); // 결제 후 즉시 동기화
}

가족공유와 소셜로그인이 얽히는 지점

가족공유는 생각보다 엣지케이스가 많다. 가족 구성원이 직접 결제한 게 아니기 때문에 iap_repository 레벨에서 구매 이벤트가 오지 않는 경우도 있고, 앱 재시작 시 스토어에서 restore 트리거를 해야 entitlement가 살아나는 케이스가 있다. 이번에는 앱 시작 시점에 entitlement_repository가 서버 동기화를 한 번 수행하도록 했고, 가족공유 여부는 서버 사이드에서 판단해서 entitlement 모델에 필드로 내려오게 설계했다.

소셜로그인이 IAP에 영향을 주는 이유는 JWT 때문이다. 소셜로그인 완료 → JWT 발급 → 이후 모든 API 요청(entitlement 포함)에 JWT 첨부. auth_storage가 이 JWT를 들고 있고, api_client가 요청마다 꺼내서 헤더에 넣는다. 로그인 상태가 만료되거나 토큰이 없는 상태에서 페이월 진입이 가능한 시나리오를 방어하는 게 은근히 신경 쓰였다.

// api_client.dart 인터셉터 패턴
Future<Options> _buildOptions() async {
  final token = await _authStorage.readJwt();
  return Options(headers: {
    if (token != null) 'Authorization': 'Bearer $token',
  });
}

회고

이런 결제 플로우 작업에서 팀장 입장으로 가장 신경 쓰는 건 "실패 케이스의 UX"다. 결제는 됐는데 entitlement 동기화가 실패한 경우, 가족공유인데 로그인이 안 된 경우, JWT 만료 상태에서 결제 버튼을 누른 경우 — 이런 엣지가 실제 사용자 리포트로 올라오면 재현 자체가 어렵다. 그래서 각 레이어에서 실패 시 어떤 상태로 떨어지는지, 그 상태를 UI가 어떻게 표현하는지를 리뷰할 때 제일 먼저 봤다.

결제 관련 코드는 보안 감도도 높고 스토어 정책 변경에도 민감하다. 파일별 역할을 명확히 나눠두는 게 나중에 정책 변경이나 스토어 SDK 업데이트가 왔을 때 영향 범위를 좁히는 데 진짜 도움이 된다. 이번 구조가 그 기반이 됐으면 한다.

다음 스텝은 구독 갱신 실패 시 재시도 플로우와 entitlement 만료 알림 처리.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.