[스타트업/기술] Node.js & App Store 인앱결제 | 결제 시스템 아키텍처 (Part 2)

지난 글에서는 안드로이드 생태계에서 구글 플레이 API를 활용하여 서버 검증 아키텍처를 어떻게 구축하는지 다루었다. 클라이언트의 상태를 신뢰하지 않고 서버가 주도권을 쥐어야 한다는 원칙은 iOS 환경인 앱스토어(App Store) 결제에서도 동일하게 적용된다.

하지만 Flutter를 통해 크로스 플랫폼 앱을 개발하면서, 구글과 애플이 Proof of Purchase을 다루는 방식에 꽤 큰 아키텍처적 차이가 있음을 알게 되었다. 애플은 WWDC 2021에서 StoreKit 2를 도입하면서 암호학적 서명이 포함된 JWS(JSON Web Signature) 방식을 전면적으로 내세웠다.
(2024년을 기점으로 기존 StoreKit은 deprecated가 되었다)

따라서 이번 글에서는 애플 앱스토어 인앱결제가 어떻게 동작하는지, 그리고 JWS 기반의 증명 방식이 서버 아키텍처에 어떤 영향을 미치는지 공식 문서와 설계 과정을 바탕으로 톺아보고자 한다.




1. StoreKit 2와 JWS

구글 플레이가 Purchase Token이라는 임의의 불투명한 문자열(Opaque String)을 발급하고, 서버가 구글 API에 이를 던져서 상태를 물어봐야만 내용을 알 수 있는 방식이라면, 애플의 StoreKit 2는 결제 정보를 JWS(JSON Web Signature) 형태로 발급한다.

JWS는 Header, Payload, Signature 세 부분으로 이루어진 규격화된 데이터다. 이것이 시사하는 바는 크다. 서버는 굳이 애플의 서버와 통신(Network I/O)하기 전에도, 공개키를 활용해 이 영수증을 로컬에서 디코딩하고 조작 여부를 1차적으로 판별할 수 있다.




2. 앱스토어 인앱결제 아키텍처 흐름도

JWS의 특성을 반영하여 설계된 iOS 인앱결제의 아키텍처 시퀀스는 다음과 같다. 구글 플레이 흐름과 비슷해 보이지만, 서버의 검증 단계(Step 5)에서 디코딩 과정이 추가된 것을 볼 수 있다. 




서버는 클라이언트로부터 건네받은 jwsRepresentation(JWS 문자열)과 productId를 바탕으로 아주 깐깐한 검증 과정을 거친다.

1단계: 로컬 디코딩 및 페이로드 검사 (Local Decode) 네트워크 비용을 태우기 전에, JWS 페이로드를 열어본다. 클라이언트가 "A 상품을 샀다"며 API를 호출했는데, JWS를 뜯어보니 B 상품을 결제한 영수증이라면 즉시 에러(AppError)를 반환한다. 악의적인 페이로드 변조를  사전에 차단하는 것이다.

// 로컬 디코딩으로 페이로드 우선 확인
const decodedProof = await iosPurchaseVerifier.decodeProof(jwsRepresentation);
if (decodedProof.productId && decodedProof.productId !== productId) {
  throw new AppError("invalid-argument", "productId does not match signed transaction");
}

2단계: 멱등성을 위한 외부 체크 (Outside Check) 구글 플레이에서 Purchase Token을 고유 키로 썼던 것처럼, 애플은 TransactionId를 고유 식별자로 사용한다. 크로스 플랫폼 환경이므로 안드로이드와 ID 충돌을 막기 위해 DB에 저장할 때 ios:${transactionId} 형태로 네임스페이스를 분리하여 설계했다. 이 ID가 DB에 있다면 이미 재화가 지급된 것이므로 중복 처리를 방어한다.

function createPurchaseDocId(platform, purchaseId) {
  if (platform === "ios") return `ios:${purchaseId}`;
  return purchaseId;
}

const purchaseRef = db.collection("processed_purchases").doc(createPurchaseDocId("ios", decodedProof.transactionId));
const outsideCheck = await purchaseRef.get();
if (outsideCheck.exists) {
  return { success: true, message: "Already processed" }; // 멱등성 보장
}

3단계: App Store Server API 교차 검증 (Network Verify) 로컬 검증과 DB 체크를 통과했다면, 이제 이 JWS가 정말 애플이 서명한 유효한 영수증인지, 환불되거나 취소되지는 않았는지 애플 서버 측의 확인을 거쳐야 한다. 검증 결과로 돌아온 트랜잭션 ID와 내가 가진 트랜잭션 ID가 일치하는지 마지막으로 대조한다.

const verificationResult = await iosPurchaseVerifier.verify(jwsRepresentation);
if (verificationResult.transactionId !== decodedProof.transactionId) {
  throw new AppError("aborted", "Invalid transaction id");
}

4단계: DB 트랜잭션 내부 체크 (Inside Check) 마찬가지로 구글 인앱결제 아키텍처와 동일하게 동시성(Race Condition) 문제를 제어하기 위해 데이터베이스의 Transaction을 연다. 그 안에서 영수증 존재 여부를 한 번 더 확인하고, 유저의 S-Coin 재화를 증가시키고, 영수증 처리 상태를 마킹한다.


4. Flutter 클라이언트 구현

클라이언트 로직을 짜며 꽤 흥미로운 문제를 만났다. Flutter의 in_app_purchase 패키지는 구글과 애플의 결제 객체를 PurchaseDetails라는 하나의 클래스로 추상화해서 제공한다. 그런데 애플의 StoreKit 2가 반환하는 JWS(서버 인증용 데이터)를 안전하게 꺼내기 위해서는 특별한 처리가 필요했다.

String? _extractIosJws(PurchaseDetails p) {
  // 핵심: StoreKit 2 객체로 명시적 타입 캐스팅
  if (p is SK2PurchaseDetails) {
    return p.verificationData.serverVerificationData;
  }

  // 혹시 공통 PurchaseDetails에 이미 들어오는 경우 fallback
  final fallback = p.verificationData.serverVerificationData;
  if (fallback.isNotEmpty) {
    return fallback;
  }
  return null;
}

위 코드처럼 객체가 SK2PurchaseDetails 타입인지 런타임에 확인(is)하여 다운캐스팅(Down-casting)을 해야만 안전하게 JWS 문자열을 얻을 수 있다. (PurchaseDetails는 StoreKit 1, 2 모두 지원하므로 JWS가 포함되지 않을수도 있음)

마지막으로, 결제 완료 후의 처리 방식도 구글과 달랐다.

if (verified) {
  if (p.pendingCompletePurchase) {
    await _iap.completePurchase(p); // 트랜잭션 종료
  }
  if (Platform.isAndroid) {
    // 안드로이드는 소비(Consume)를 직접 호출해야 함
    final androidAddition = _iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
    await androidAddition.consumePurchase(p);
  }
}

안드로이드는 소모성 아이템 재구매를 위해 명시적으로 consumePurchase를 호출해야 하지만, 애플 환경에서는 completePurchase(p)로 트랜잭션을 마무리 지어주는 것만으로도 소모성 아이템의 처리가 완료된다.



5. 크로스 플랫폼 결제 시스템의 추상화

이렇게 iOS(앱스토어)와 Android(구글 플레이 스토어) 결제를 다뤄보며 분명 차이가 있지만, 공통점을 발견할 수 있었다. 바로 플랫폼마다 영수증의 규격(JWS vs Purchase Token)과 API 명세는 다르지만, 결국 우리가 지켜야 할 '핵심 도메인 로직'은 완전히 동일하다는 점이다.

  1. 상품이 유효한지 카탈로그를 조회한다.

  2. 영수증이 이미 처리되었는지 DB를 조회한다 (Outside Check).

  3. 플랫폼(Google/Apple) 서버에 영수증의 진위를 묻는다.

  4. DB 트랜잭션을 열어 영수증을 한 번 더 확인한다 (Inside Check).

  5. 재화를 지급하고 로그를 남긴다.

이러한 공통점을 발견하고 나니, 코드를 짤 때 플랫폼 종속적인 코드(JWS 디코딩, 구글 API 호출 등)는 어댑터(Adapter) 형태로 분리하고, 실제 재화를 지급하는 로직은 하나의 함수(grantCoinPurchase)로 통합하여 재사용할 수 있었다. 

객체 지향 프로그래밍에서 말하는 다형성(Polymorphism)과 관심사의 분리(Separation of Concerns)가 아키텍처 레벨에서 어떻게 쓰일 수 있는지 알 수 있었다.



마치며

결제라는 민감한 도메인에서 발생하는 데이터 변조 시도, 네트워크 지연, 동시성 문제라는 난관을 JWS 디코딩과 Firestore 트랜잭션으로 하나씩 통제해 나가는 과정은 본격적으로 비즈니스 활동을 하는데 있어 필수적인 요소일 것이다. 이렇게 플랫폼의 차이에 흔들리지 않는 견고한 결제 시스템 기반이 마련되었으니, 이제 본격적으로 세상에 내놓을 차례인가!?



추천글

[스타트업/기술] Node.js & Google Play 인앱결제 | 결제 시스템 아키텍처 설계 (Part 1)


[운영체제] Concurrency | 프로세스와 스레드를 통해 알아보기

hyeon_B

안녕하세요! AI 기술을 이용해 더 나은 세상을 만들어 나가고 싶은 과기원생 Hyeon이라고 합니다. 저는 앞으로 인공지능 시대에는 지식을 '활용'하는 능력이 중요해질 것이라고 생각합니다. 대부분의 일들은 인공지능이 뛰어난 모습을 보이지만, 인공지능은 데이터로 부터 연관관계를 학습하기 때문에 지식들을 새로 통합해서 활용하는 능력이 부족합니다. 인공지능이 뉴턴 전에 만들어졌다면 사과가 떨어지는 이유에 대답하지 못했을 것이고, 아인슈타인 전에 만들어졌다면 중력이 어떻게 생기는지 설명하지 못했을 것입니다. 따라서 앞으로 우리는 '본질'을 탐구하고 그 본질로부터 다른 곳에 적용하며 인공지능을 현명하게 활용해야 할 것입니다. 함께 인공지능 시대를 준비합시다!

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2