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

이제 실제로 사용자에게 가치를 제공하고 그에 대한 정당한 대가를 받는 '결제 시스템'을 구축해야 할 시점이 왔다. 현재 개발 중인 서비스에 S-Coin이라는 재화 시스템을 도입하면서, 구글 플레이 스토어(Android)와 앱스토어(iOS)의 인앱결제(In-App Purchase) 프로세스를 연동하기로 했다.

이 글에서는 2편에 걸쳐 구성될 결제 시스템 구축기의 첫 번째로, 구글 플레이 스토어 인앱결제의 아키텍처와 검증 원리에 대해 톺아보고자 한다.


1. 클라이언트 주도 결제의 함정

결제 시스템을 설계할 때 가장 경계해야 할 것은 '클라이언트의 응답을 신뢰하는 것'이다.

만약 클라이언트가 구글 플레이 스토어와 통신하여 결제를 완료한 후, 서버로 단순히 "유저 A가 100 코인을 샀으니 지급해주세요"라고 요청한다면 어떻게 될까? 악의적인 사용자는 중간에서 패킷을 가로채거나 앱을 변조하여, 실제 결제가 이루어지지 않았음에도 서버에 재화 지급 요청을 무한히 보낼 수 있다.

따라서 아키텍처의 대전제는 다음과 같다.

클라이언트는 영수증(Purchase Token)만 전달할 뿐, 결제의 진위 여부 판단과 재화 지급은 서버가 구글 서버와 직접 교차 검증한 후에 이루어진다.



2. 인앱결제 시스템 아키텍처 흐름도

이러한 원칙 아래 설계된 전체 시스템의 동작 흐름을 시퀀스 다이어그램으로 도식화하면 다음과 같다.

위 다이어그램에서 볼 수 있듯, 핵심은 클라이언트가 구글로부터 발급받은 Purchase Token을 서버로 넘기고, 서버가 직접 Google Play Developer API를 호출하여 해당 토큰의 상태를 확인하는 구간(Step 4~6)이다.



3. 구글 플레이 인앱결제의 핵심 개념과 검증 원리

구글 플레이의 결제 시스템을 이해하기 위해서는 세 가지 핵심 식별자를 명확히 인지해야 한다.

  1. ProductId: 스토어에 등록된 상품의 고유 ID (예: coin.tier_01).

  2. PackageName: 앱의 고유 식별자 (예: com.XXXXXX.XXXXXX).

  3. PurchaseToken: 사용자가 상품을 구매했을 때 구글이 발급하는 일회성 영수증. 이 토큰은 특정 사용자가 특정 상품을 구매했다는 고유한 증명서 역할을 한다.

서버는 클라이언트로부터 이 세 가지 정보를 전달받는다. 그리고 이 정보를 바탕으로 구글의 purchases.products.get API를 호출한다. 구글 서버는 해당 토큰의 현재 상태 데이터를 반환하는데, 여기서 가장 중요하게 확인해야 할 필드가 바로 purchaseState이다.

공식 문서에 따르면 purchaseState의 값은 다음과 같은 의미를 가진다.

  • 0: 구매 완료 (Purchased)

  • 1: 구매 취소됨 (Canceled)

  • 2: 결제 대기 중 (Pending)

서버는 오직 이 값이 0 (구매 완료)일 때만 정상적인 결제로 인정하고 다음 프로세스(재화 지급)로 넘어가야 한다.



4. 멱등성(Idempotency)과 무결성 보장

단순히 구글 서버에서 "정상 결제입니다"라는 응답을 받았다고 해서 끝나는 것이 아니다. 네트워크 문제나 클라이언트의 오류로 인해, 이미 처리가 완료된 영수증이 서버로 다시 전송되는 상황(Replay Attack 또는 Retry)을 대비해야 한다.

동일한 Purchase Token으로 두 번 요청이 왔을 때, 재화가 두 번 지급되는 일은 서비스 경제를 무너뜨리는 치명적인 버그다. 이를 방지하기 위해 데이터베이스(Firestore) 수준에서 멱등성을 보장해야 한다.

  1. 외부 체크 (Outside Check): 구글 API를 호출하기 전에, 우리 DB의 processed_purchases 컬렉션에 해당 토큰이 이미 존재하는지 확인한다.

  2. 트랜잭션 내부 체크 (Inside Check): 구글 API 검증이 끝난 후, 실제 재화를 지급하는 트랜잭션 내부에서 다시 한번 해당 토큰이 존재하는지 읽어 들인다(Read). 동시성 문제(Race Condition)로 인해 두 개의 쓰레드가 동시에 외부 체크를 통과하더라도, 트랜잭션 락(Lock)을 통해 오직 하나의 요청만 재화를 지급하고 영수증을 기록할 수 있게 강제하는 것이다.



5. Node.js 서버 구현: 검증과 멱등성의 완성

설계한 아키텍처를 바탕으로 아래 핵심 로직을 살펴보자. 코드를 작성하며 가장 신경 썼던 부분은 예외 상황에 대한 방어적 프로그래밍과 데이터의 무결성 유지였다.

5.1. 외부 체크와 Google Play API 호출

가장 먼저 수행하는 것은 Firestore를 통한 1차 중복 검증(Outside Check)이다. 구글 API는 호출 할당량(Quota)이 존재하므로, 이미 처리된 결제건이라면 굳이 구글 서버까지 다녀올 필요 없이 바로 응답을 반환한다.

const purchaseRef = db.collection("processed_purchases").doc(purchaseToken);
const outsideCheck = await purchaseRef.get();

// 1. Outside Check: 이미 처리된 영수증인지 확인
if (outsideCheck.exists) {
  eventLogger.info({
    event_name: "iap.verify.duplicate_outside",
    uid,
    request_id: requestId,
    purchase_token_hash: hashValue(purchaseToken),
  });
  return { success: true, message: "Already processed" };
}

// 2. Google Play Developer API 호출
const verificationResult = await androidPublisher.purchases.products.get({
  packageName,
  productId,
  token: purchaseToken,
});

// 3. 결제 상태 확인 (0: Purchased)
if (verificationResult.data.purchaseState !== 0) {
  throw new AppError("aborted", "Invalid purchase state");
}


5.2. 트랜잭션과 내부 체크 (Inside Check)

구글의 검증이 끝났다면 유저에게 재화를 지급해야 한다. 여기서 Race Condition(경쟁 상태)을 제어하기 위해 Firestore의 runTransaction을 사용한다. 동시에 2개의 요청이 들어와 Outside Check를 동시에 통과하더라도, 트랜잭션 내부에서 다시 한번 영수증의 존재 여부를 확인(Inside Check)하여 중복 지급을 원천 차단한다.

await db.runTransaction(async (txn) => {
  // 1. Inside Check: 트랜잭션 내부에서 한 번 더 확인 (Race condition 방어)
  const insideCheck = await txn.get(purchaseRef);
  if (insideCheck.exists) {
    return; // 이미 다른 트랜잭션에서 처리됨
  }

  const userRef = db.collection("users").doc(uid);
  
  // 2. 유저 재화(S-Coin) 증가 (fieldValue.increment 활용)
  txn.set(userRef, { sCoin: fieldValue.increment(totalCoins) }, { merge: true });

  // 3. 처리된 영수증 기록
  txn.set(purchaseRef, buildPurchaseRecord({ ... }));

  // 4. 지갑 로그(Wallet Log) 생성
  const logRef = userRef.collection("wallet_logs").doc();
  txn.set(logRef, { type: "BUY_SCOIN", deltaSCoin: totalCoins, ... });
});

재화를 증가시킬 때는 클라이언트에서 값을 계산하여 덮어씌우는 대신, 서버 사이드에서 fieldValue.increment 연산자를 사용하여 동시 업데이트 시 발생할 수 있는 데이터 유실을 방지했다.



6. Flutter 클라이언트 구현: 구매 완료와 소비(Consume)

이제 클라이언트에서는 서버의 검증을 요청하고, 그 결과에 따라 후속 처리를 진행해야 한다. 특히 소비성(Consumable) 아이템인 S-Coin의 경우 '소비(Consume) 처리'가 논리적 흐름의 마침표를 찍는다.

Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchases) async {
  for (var p in purchases) {
    if (p.status == PurchaseStatus.purchased || p.status == PurchaseStatus.restored) {
      
      // 1. 서버에 검증 요청 (_verifyOnServer 내에서 REST API 호출)
      final verified = await _verifyOnServer(p);

      if (verified) {
        // 2. 결제 완료 처리
        if (p.pendingCompletePurchase) {
          await _iap.completePurchase(p);
        }
        
        // 3. 안드로이드의 경우 Consume 처리 (매우 중요)
        if (Platform.isAndroid) {
          final androidAddition = _iap.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
          await androidAddition.consumePurchase(p);
        }
        _onPurchaseSuccess?.call(p);
      }
    }
  }
}

여기서 왜 굳이 안드로이드에서 consumePurchase를 호출해야 할까? 구글 플레이는 기본적으로 한 번 구매한 상품을 '보유(Owned)' 상태로 취급한다. 보유 상태인 상품은 중복해서 구매할 수 없다. 즉, 100 S-Coin을 구매한 유저가 이를 전부 사용하고 다시 100 S-Coin을 구매하려고 할 때, 이전에 샀던 영수증이 '소비(Consume)'되지 않았다면 스토어 자체에서 결제를 막아버린다. 따라서 서버 검증과 재화 지급이 완전히 끝난 후, 클라이언트에서 스토어 측에 "이 아이템을 무사히 소모했으니, 다시 구매할 수 있게 해달라"고 알리는 과정이 반드시 수반되어야 한다.



마치며

결제 아키텍처를 설계하며 가장 크게 느낀 점은, 시스템은 언제나 실패할 수 있다는 가정을 깔고 가야 한다는 것이다. 통신은 끊어질 수 있고, 앱은 예기치 않게 종료될 수 있다.

이러한 불확실성 속에서 Outside Check, Inside Check, 트랜잭션, 명확한 상태 코드 확인, 그리고 클라이언트의 Consume 처리까지. 단계마다 다중의 안전장치를 마련하고 엣지 케이스를 방어하는 과정은, 운영체제 수업에서 배운 동시성 제어 메커니즘을 실제 비즈니스 로직에서 이렇게 활용될 수 있구나라는 사실을 깨닫게 해주었다.

이제 비로소 외부 요인에 흔들리지 않는, 단단하고 신뢰할 수 있는 결제 파이프라인이 완성되었다는 생각이 든다.

다음 편에서는 애플 앱스토어의 결제 시스템에 대해 다루어 보겠다.



추천글


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


hyeon_B

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

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2