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

문제 안드로이드와 영수증 형식이 전혀 다른 iOS(StoreKit 2 JWS)를 같은 원칙, 같은 지급 로직 위에 올려야 했다.
결정 JWS는 로컬에서 풀어 값싼 사전 거절에만 쓰고, 진위는 App Store Server API 조회로 판단한다. 플랫폼별 검증은 따로 두고 재화 지급은 공통 함수 하나로 모았다.
대가 결제마다 애플 서버 왕복이 필요하고, 플랫폼별 검증 코드라는 유지보수 대상이 하나 늘었다.
결과 두 플랫폼이 같은 멱등성(외부 체크, 트랜잭션 내부 체크)과 같은 지급 코드를 쓴다. 로컬 서명 검증과 환불 이후의 처리는 이 글의 범위 밖에 두었다.

지난 글에서는 안드로이드에서 구글 플레이 API로 서버 검증 아키텍처를 만드는 과정을 다뤘다. 클라이언트의 상태를 믿지 않고 서버가 주도권을 쥔다는 원칙은 iOS 앱스토어(App Store) 결제에도 그대로 적용된다.

하지만 Flutter로 크로스 플랫폼 앱을 만들면서, 구글과 애플이 구매 증명(Proof of Purchase)을 다루는 방식이 꽤 다르다는 걸 알게 됐다. 애플은 WWDC 2021에서 StoreKit 2를 도입하면서 암호학적 서명이 들어간 JWS(JSON Web Signature) 방식을 전면에 내세웠다.
(iOS 18부터 기존 StoreKit의 인앱결제 API는 deprecated가 됐다)

이번 글에서는 앱스토어 인앱결제가 어떻게 동작하는지, JWS 기반 증명이 서버 아키텍처에 어떤 영향을 주는지 정리한다.


1. StoreKit 2와 JWS

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

JWS는 Header, Payload, Signature 세 부분으로 된 규격화된 데이터다. 그래서 서버는 애플 서버에 묻기 전에도 페이로드를 열어 어떤 상품의 어떤 트랜잭션인지 읽을 수 있다. 원리상으로는 헤더에 든 인증서 체인으로 서명까지 확인하면 네트워크 없이 위조 여부도 가릴 수 있다.

다만 우리 서버는 서명 검증은 하지 않는다. 로컬에서는 페이로드를 읽기만 하고, 진위는 애플 서버에 다시 물어 판단한다. 이 선택은 2절에서 설명한다.


2. 검토한 선택지

안 내용 판단
A 기존 영수증 + verifyReceipt 엔드포인트 기각. 애플이 이미 deprecated로 표시했고 새 기능이 붙지 않는다
B JWS 서명을 로컬에서만 검증하고 서버 조회는 생략 하지 않음. 인증서 체인 검증을 직접 구현해야 하고, 로컬 검증만으로는 애플 쪽의 최신 상태(환불 등)를 알 수 없다
C JWS는 로컬에서 읽기만 하고, 진위는 App Store Server API 조회로 판단 채택
D 안드로이드 검증 코드 안에 if (ios) 분기를 추가 기각. 영수증 형식과 검증 절차가 달라 분기가 함수 곳곳으로 퍼진다. 5절처럼 검증만 나누고 지급은 공통으로 뒀다

C의 대가는 결제마다 애플 서버 왕복이 한 번 붙는다는 것이다. 안드로이드도 구글 API를 부르므로 두 플랫폼의 검증 비용 구조는 같아졌다. App Store Server API 클라이언트(ES256 JWT 발급, 조회, 타임아웃)는 라이브러리 없이 직접 짰다.



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

JWS의 특성을 반영한 iOS 인앱결제 시퀀스는 다음과 같다. 구글 플레이 흐름과 비슷하지만, 서버의 검증 단계(Step 5)에 디코딩 과정이 추가됐다.

App Store 인앱결제 시퀀스. Step 5에서 JWS를 먼저 풀어 본 뒤 애플 서버에 재조회한다.


서버는 클라이언트가 보낸 jwsRepresentation(JWS 문자열)과 productId로 네 단계를 거친다.

1단계: 로컬 디코딩과 페이로드 검사 (Local Decode) 네트워크를 타기 전에 JWS 페이로드를 열어 본다. 클라이언트는 "A 상품을 샀다"고 호출했는데 JWS에는 B 상품이 들어 있으면 바로 에러(AppError)를 돌려준다. 이 단계는 서명을 확인하지 않으므로 보안 검사가 아니다. 앞뒤가 맞지 않는 요청을 애플 서버에 묻기 전에 걸러 내고, 다음 단계에서 쓸 transactionId를 꺼내는 용도다.

// 로컬 디코딩으로 페이로드 우선 확인 (서명 검증 없음)
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를 고유 식별자로 쓴다. 두 플랫폼이 같은 처리 완료 영수증 컬렉션을 쓰므로 안드로이드 토큰과 겹치지 않게 ios:${transactionId}로 네임스페이스를 나눴다. 이 ID가 이미 있으면 재화가 지급된 것이므로 그대로 성공을 돌려준다.

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) 여기서 진위를 판단한다. 서버가 transactionId로 App Store Server API의 트랜잭션 조회를 호출하면, 애플이 그 트랜잭션 정보를 다시 서명해서 돌려준다. 우리 서버는 이 응답의 transactionId, productId, bundleId가 클라이언트가 보낸 JWS와 일치하는지 대조한다. 클라이언트가 JWS를 위조했다면 애플 서버에 그 트랜잭션이 없거나 값이 어긋나서 여기서 걸린다.

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


4단계: DB 트랜잭션 내부 체크 (Inside Check) 구글 플레이와 똑같이 Firestore 트랜잭션을 열고, 그 안에서 영수증 존재 여부를 한 번 더 확인한 뒤 S-Coin을 올리고 영수증을 기록한다. 이 단계는 안드로이드와 같은 함수를 쓴다.



4. 구현에서 걸린 것

ES256 서명 인코딩. App Store Server API를 부르려면 서버가 ES256으로 서명한 JWT를 직접 만들어야 한다. Node.js crypto의 ECDSA 서명은 기본이 DER 인코딩인데, JWT의 ES256 서명은 r과 s를 이어 붙인 고정 길이 형식이어야 한다. 처음 구현은 기본값으로 서명했고, 팀원이 dsaEncoding: "ieee-p1363"을 지정하는 수정을 넣었다.

signer.sign({ key: privateKey, dsaEncoding: "ieee-p1363" }, "base64url");

운영과 sandbox. 앱 심사나 TestFlight에서 일어나는 결제는 sandbox 환경의 트랜잭션이다. 그래서 운영 서버도 production 조회에서 404가 나면 sandbox 주소로 한 번 더 조회하고, 그때 provider_fallback_to_sandbox 로그를 남긴다. JWS 페이로드가 처음부터 Sandbox라고 하면 sandbox만 조회한다.


5. 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)해 다운캐스팅해야 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)로 트랜잭션을 마무리하는 것만으로 소모성 아이템 처리가 끝난다.



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

두 스토어를 다뤄 보니 차이는 분명하지만 공통점도 보였다. 영수증 규격(JWS와 Purchase Token)과 API 명세는 달라도, 지켜야 할 도메인 로직은 같았다.

  1. 상품이 유효한지 카탈로그를 조회한다.
  2. 영수증이 이미 처리됐는지 DB를 조회한다 (Outside Check).
  3. 플랫폼(Google/Apple) 서버에 영수증의 진위를 묻는다.
  4. DB 트랜잭션을 열어 영수증을 한 번 더 확인한다 (Inside Check).
  5. 재화를 지급하고 로그를 남긴다.

그래서 플랫폼에 묶인 코드(JWS 디코딩과 애플 서버 조회, 구글 API 호출)는 따로 두고, 재화를 지급하는 4~5단계는 공통 지급 함수 하나로 모았다. 이 함수는 플랫폼 이름과 구매 ID, 그리고 플랫폼별 영수증 기록을 만드는 함수만 받는다. 이후 Google Play 프로모 코드나 App Store Offer Code 같은 플랫폼별 예외도 각 플랫폼의 검증 단계에 붙었다(Part 3).

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


7. 남은 한계

  • 로컬 서명 검증을 하지 않는다. 진위를 전적으로 애플 서버 조회에 맡긴다. 애플 서버가 느리거나 장애가 나면 iOS 결제 검증도 같이 멈춘다.
  • 환불·취소. 트랜잭션 조회 결과에는 환불되면 revocationDate가 들어온다. 이 값과 스토어 서버 알림(App Store Server Notifications V2)을 재화 회수에 연결하는 일은 이 글의 범위 밖에 두었다.
  • 영수증이 "이 사람 것"인지는 이 글 범위에서 보지 않았다. 이후 appAccountToken으로 소유권을 대조하도록 바꿨고, Offer Code 때문에 생긴 예외도 Part 3에 정리했다.
  • 동시 경합 검증. 트랜잭션 내부 체크는 안드로이드와 같은 코드지만, iOS 영수증 경로를 동시 부하로 검증한 적은 없다.

마치며

결제라는 민감한 도메인에서 데이터 변조 시도, 네트워크 지연, 동시성 문제를 JWS 디코딩, 애플 서버 재조회, Firestore 트랜잭션으로 하나씩 통제해 나가는 과정은 본격적으로 비즈니스를 하려면 거쳐야 하는 일이었다. 다시 정리하면서, 원래 이 글에 "로컬에서 조작 여부를 1차로 판별한다"고 썼던 부분이 실제 코드와 다르다는 것도 알게 됐다. 규격이 할 수 있는 일과 우리 코드가 하는 일은 따로 적어야 한다.

이렇게 플랫폼 차이에 흔들리지 않는 결제 기반이 마련됐으니, 이제 세상에 내놓을 차례인가!?


추천글

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

[스타트업/기술] 인앱결제 Part 3 | 검증에 성공한 뒤에 시작되는 문제들


hyeon_B

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

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2