back-end/🐾 삼냥이즈 개발일지

[삼냥이즈 개발일지] Google 인앱 결제 서버 구현

삼냥이즈 기술 블로그 2025. 10. 15. 23:18

들어가며

인앱 결제를 구현하려고 구글링을 해보면 대부분 클라이언트 개발자를 위한 글들이 나온다. BillingClient를 어떻게 초기화하고, 구매 플로우를 어떻게 실행하는지. 하지만 막상 서버 개발자 입장에서 해야 할 일이 명확하게 정리되지 않았다.

서버의 역할은 단순히 '검증'이라고들 하지만, 실제로 구현하려면 전체 플로우를 이해해야 했고, Google Cloud와 Play Console의 관계부터 파악해야 했다. 이 글은 서버 개발자 관점에서 인앱 결제를 구현하며 겪은 시행착오와 해결 과정을 개발 순서대로 정리했다.


1. Google Cloud와 Play Console 설정

Google Cloud와 Play Console의 관계 이해하기

먼저 두 시스템의 역할을 이해해야 한다:

  • Google Cloud Platform (GCP): 서비스 계정을 생성하고 API 키를 발급하는 곳. 서버가 Google API를 호출할 때 사용할 신분증을 만드는 곳이다.
  • Google Play Console: 실제 앱과 상품을 관리하는 곳이다. 여기서 서비스 계정에게 "이 앱의 결제 정보를 확인할 수 있는 권한"을 부여한다.

쉽게 말해, GCP에서 로봇(서비스 계정)을 만들고, Play Console에서 그 로봇에게 출입증(권한)을 주는 것이다.

발생한 문제

Google Play Console의 "API 액세스" 메뉴에서 Google Cloud 프로젝트를 연결하라고 안내하는 자료들이 많았다. 하지만 실제로 Play Console에 들어가보면 API 액세스 메뉴가 없다

 

인터넷 가이드: Play Console → 설정 → API 액세스 → 프로젝트 연결
실제: "API 액세스" 메뉴 자체가 없음

 

Google Play Console의 UI가 변경되면서 API 엑세스 메뉴가 제거되었다는 포스팅을 찾을 수 있었다. 하지만 인앱 결제를 어떻게 처리하면 되는지에 대해 설명하는 글은 없었다.
UI가 변경되면서 일부 기능이 자동화 되기도 한다고 하여 일단 해당 부분을 건너뛰고 진행하였다. android publisher api의 사용 처리는 GCP에서 할 수 있었고, 이미 필요한 api를 사용 가능한 계정을 Google play console에 등록하기만 하면 api 이용이 가능해야 하지 않나...? 라는 생각이 들어 일단 생략했다.

실제로 한 방법

Step 1: Google Cloud에서 서비스 계정 생성

  1. 프로젝트 생성
    • 테스트 환경과 배포 환경을 분리하기 위해 ritty-in-app-purchase-devritty-in-app-purchase-prod 두 개의 프로젝트를 생성했다.
    • 결제 로직을 환경별로 분리하지 않으면 나중에 실수로 테스트 결제가 프로덕션에 들어갈 수 있다.
  2. Google Play Android Developer API 활성화
    • API 및 서비스API 라이브러리에서 Google Play Android Developer API를 검색한다.
    • 해당 API의 "사용" 버튼을 클릭한다.
    • 프로젝트를 2개 만들었으므로 각각 들어가서 활성화해야 한다.
  3. 서비스 계정 및 키 생성
    • IAM 및 관리자서비스 계정에서 새 서비스 계정을 만든다.
    • 계정 생성 후 키 관리키 추가JSON을 선택하여 키 파일을 다운로드한다.
    • 이 JSON 파일이 서버에서 Google API를 호출할 때 필요한 인증 파일.

Step 2: Play Console에서 권한 부여

  1. 서비스 계정 초대
    • Play Console에 로그인 후 사용자 및 권한 메뉴로 이동한다.
    • 새 사용자 초대를 클릭한다.
    • 다운 받은 키 파일(JSON 파일)의 client_email 필드 값(예: 프로젝트이름@프로젝트ID.~~)을 입력한다.
  2. 권한 설정
    필수 권한 3가지를 체크한다:
    • 앱 정보 보기 및 보고서 일괄 다운로드
    • 재무 데이터, 주문, 취소 설문조사 응답 보기
    • 주문 및 구독 관리
    • 다른 사람들의 블로그 글을 보니 마지막 주문 및 구독 관리에 권한을 주지 않는 경우도 있었음.
  3. 앱 액세스 권한
    • 인앱 결제를 구현할 앱을 선택한다.
  4. 권한 적용까지 약간의 시간이 걸리니 기다린다(2분 정도?)

Step 3: 환경변수 설정

개발 환경과 배포 환경을 분리해 다른 키를 사용하기 위해 환경변수를 설정한다. 환경에 따라 알맞은 서비스 계정의 JSON 키를 참조하도록 했다.

// .env.local (개발 환경)
GOOGLE_SERVICE_ACCOUNT_KEY=개발 키 경로

// .env.prod (프로덕션 환경)
GOOGLE_SERVICE_ACCOUNT_KEY=배포 키 경로

2. Google Publisher API 클라이언트 생성

Android Publisher API란?

Google Play Console의 기능을 프로그래밍 방식으로 사용할 수 있게 해주는 API이다. 서버에서 구매 검증을 하려면 이 API를 사용해야 한다.
아래 코드에서 androidPublisherAndroid Publisher API를 사용하는 클라이언트 객체로 이를 이용해 Android Publisher API를 이용한다.

androidPublisher 객체를 사용하기 위해선 구글 클라우드 플랫폼에서 Google play android developer api를 사용 처리 해 주어야 한다. 이름이 달라 조금 헷갈리는데 Google play android developer apiandroid publisher api는 동일한 api이다.

  1. Google Cloud Console 접속 (https://console.cloud.google.com)
  2. API 및 서비스 → 라이브러리 메뉴 이동
  3. "Google Play Android Developer API" 검색
  4. "사용" 버튼 클릭
  5. 각 프로젝트별로 활성화 (dev, prod 둘 다)

NestJS에서 클라이언트 초기화

// google-play.service.ts
import { google } from 'googleapis';

private async initializeGoogleClient() {
  // 1. 서비스 계정 키 파일 읽기
  const keyPath = this.configService.get<string>('GOOGLE_SERVICE_ACCOUNT_KEY_PATH');
  const keyData = JSON.parse(fs.readFileSync(keyPath, 'utf8'));

  // 2. JWT 클라이언트 생성 (서버 인증용)
  const auth = new google.auth.JWT({
    email: keyData.client_email,      // 서비스 계정 이메일
    key: keyData.private_key,          // 비밀 키
    scopes: ['https://www.googleapis.com/auth/androidpublisher'],  // 권한 범위
  });

  // 3. Android Publisher API 클라이언트 생성
  this.androidPublisher = google.androidpublisher({
    version: 'v3',
    auth: auth,
  });
}

JWT 인증

앞서 환경에 알맞게 가져오는 JSON KEY를 이용해 JWT 키를 만든다. 이 JWT는 생성자에서 android publisher api(Google play android developer api와 동일)를 이용할 수 있는 androidPublisher 객체를 생성하는데 사용된다.

사용 가능한 API 메서드

// 일반 상품 (consumable, non-consumable)
this.androidPublisher.purchases.products.get()        // 구매 조회
this.androidPublisher.purchases.products.acknowledge() // 구매 승인
this.androidPublisher.purchases.products.consume()     // 구매 소비

// 구독 상품
this.androidPublisher.purchases.subscriptions.get()        // 구독 조회
this.androidPublisher.purchases.subscriptions.acknowledge() // 구독 승인
this.androidPublisher.purchases.subscriptions.cancel()      // 구독 취소

3. 클라이언트에서 받는 데이터

구매 토큰(Purchase Token)이란?

클라이언트가 구매를 완료하면 Google Play가 발급하는 영수증이다. 이 토큰이 진짜 구매를 증명하는 유일한 증거이다.

Request DTO 구조

// google-verify-purchase.request.dto.ts
export class GoogleVerifyPurchaseRequestDto {
  @IsString()
  packageName: string;  // 앱의 패키지명 (예: com.sammeows.ritty)

  @IsString()
  productId: string;    // 상품 ID (Play Console에 등록한 ID)

  @IsString()
  purchaseToken: string;  // Google이 발급한 구매 토큰 (긴 문자열)

  @IsEnum(PurchaseType)
  purchaseType: PurchaseType;  // 'consumable' | 'subscription'

  @IsString()
  @IsOptional()
  orderId?: string;  // 주문 번호 (GPA.1234-5678-9012-34567 형태)
}

각 필드의 역할:

  • packageName: 앱을 식별하는 고유 ID
  • productId: Play Console에 등록한 상품 ID와 정확히 일치해야 함. 구독 상품이면 Subscription ID
  • purchaseToken: 이것만 있으면 Google에게 구매 확인 가능
  • purchaseType: 상품 타입에 따라 서버 처리 방법이 달라짐
    • consumable: 일회성 제품 중 소비하는 상품 (코인, 아이템 등)
      • 서버에서 acknowledge + consume 처리 → 재구매 가능
    • subscription: 구독 상품 (월간/연간 멤버십 등)
      • 서버에서 acknowledge만 처리 → 주기적 갱신 확인 필요
    • 참고: 비소비성 상품(광고 제거 등)도 API상으로는 일회성 제품이며, consume을 안 하면 재구매 불가
  • orderId: 구매 추적용 (선택사항)

4. Google API를 통한 검증 과정

검증 플로우

async verifyPurchase(dto: GoogleVerifyPurchaseRequestDto): Promise<any> {
  // 1. 중복 구매 체크 (멱등성 보장)
  const existingPurchase = await this.checkDuplicatePurchase(dto.purchaseToken);
  if (existingPurchase) {
    return this.buildResponseFromHistory(existingPurchase);
  }

  // 2. 상품 타입별 검증
  let verificationData;
  if (dto.purchaseType === PurchaseType.CONSUMABLE) {
    verificationData = await this.verifyProduct(dto);
  } else if (dto.purchaseType === PurchaseType.SUBSCRIPTION) {
    verificationData = await this.verifySubscription(dto);
  }

  // 3. 구매 이력 저장
  const purchaseHistory = await this.savePurchaseHistory(dto, verificationData, userId);

  // 4. 후처리 (Consume/Acknowledge)
  await this.postProcessPurchase(dto, verificationData);

  return this.buildResponse(purchaseHistory);
}

소비성 상품(Consumable) 검증

private async verifyProduct(dto: GoogleVerifyPurchaseRequestDto) {
  const response = await this.androidPublisher.purchases.products.get({
    packageName: dto.packageName,
    productId: dto.productId,
    token: dto.purchaseToken,
  });

  const data = response.data;

  // purchaseState 확인
  // 0 = 구매완료, 1 = 취소됨
  if (data.purchaseState !== 0) {
    throw new BadRequestException('구매가 취소되었거나 유효하지 않습니다');
  }

  // Acknowledge 상태 확인
  // 0 = 미승인, 1 = 승인됨
  if (data.acknowledgementState === 0) {
    await this.acknowledgePurchase(dto);
  }

  return data;
}

구독 상품(Subscription) 검증

private async verifySubscription(dto: GoogleVerifyPurchaseRequestDto) {
  const response = await this.androidPublisher.purchases.subscriptions.get({
    packageName: dto.packageName,
    subscriptionId: dto.productId,
    token: dto.purchaseToken,
  });

  const data = response.data;

  // 만료 시간 체크
  const expiryTime = parseInt(data.expiryTimeMillis);
  if (expiryTime < Date.now()) {
    throw new BadRequestException('구독이 만료되었습니다');
  }

  // paymentState 확인
  // 0 = 대기중, 1 = 결제완료, 2 = 무료체험
  if (data.paymentState === 0) {
    throw new BadRequestException('결제가 아직 처리중입니다');
  }

  return data;
}

5. 검증 후 처리 과정

Acknowledge와 Consume

acknowledge와 consume은 클라에서도 할 수는 있지만 보안상 서버에서 처리하는 것이 안전하다. 서버가 구매를 검증한 후에만 acknowledge/consume을 처리하면 가짜 구매를 방지할 수 있다.

Acknowledge (모든 상품 필수)

3일 내에 하지 않으면 자동 환불된다. 서버에서 구매 검증 직후 바로 처리해야 한다.

// 소비성 상품 Acknowledge
await this.androidPublisher.purchases.products.acknowledge({
  packageName: dto.packageName,
  productId: dto.productId,
  token: dto.purchaseToken,
});

// 구독 상품 Acknowledge  
await this.androidPublisher.purchases.subscriptions.acknowledge({
  packageName: dto.packageName,
  subscriptionId: dto.productId,
  token: dto.purchaseToken,
});

Consume (소비성 상품만)

상품을 "사용 완료" 처리하여 재구매 가능하게 만든다. 서버에서 아이템 지급 후 처리한다.

await this.androidPublisher.purchases.products.consume({
  packageName: dto.packageName,
  productId: dto.productId,
  token: dto.purchaseToken,
});

처리 순서

  1. 서버가 Google API로 구매 검증
  2. 검증 성공 시 acknowledge 처리
  3. DB에 구매 이력 저장 & 아이템 지급
  4. 소비성 상품인 경우 consume 처리
  5. 클라이언트에 성공 응답 전송

6. 환불 처리

환불 정책

  • Google: 48시간 내 사용자가 직접 환불 가능 (개발자 승인 불필요)
  • 환불되어도 서버에 별도 알림이 오지 않음

환불 확인 방법

방법 1: 주기적으로 구매 상태 확인

@Cron('0 */6 * * *')  // 6시간마다
async checkPurchaseStatus() {
  const recentPurchases = await this.purchaseHistoryRepository.find({
    where: {
      createdAt: MoreThan(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)),  // 7일 이내
      status: PurchaseStatus.VERIFIED
    }
  });

  for (const purchase of recentPurchases) {
    try {
      const verification = await this.verifyProduct({
        packageName: purchase.packageName,
        productId: purchase.productId,
        purchaseToken: purchase.purchaseToken
      });

      // purchaseState가 1이면 취소/환불됨
      if (verification.purchaseState === 1) {
        await this.handleRefund(purchase);
      }
    } catch (error) {
      if (error.response?.status === 404) {
        // 구매가 없어짐 = 환불됨
        await this.handleRefund(purchase);
      }
    }
  }
}

방법 2: Real-time Developer Notifications (권장)

Play Console에서 Cloud Pub/Sub을 설정하면 실시간으로 환불 알림을 받을 수 있다.

// 환불 처리
private async handleRefund(purchase: PurchaseHistory) {
  // 1. 구매 상태 업데이트
  purchase.status = PurchaseStatus.REFUNDED;
  await this.purchaseHistoryRepository.save(purchase);

  // 2. 지급한 아이템 회수
  await this.revokeItems(purchase.userId, purchase.productId);

  // 3. 로그 기록
  this.logger.warn(`환불 처리: userId=${purchase.userId}, productId=${purchase.productId}`);
}

7. Play Console에서 상품 등록하기

상품 등록

  1. 결제 권한을 APK에 추가
  2. Play Console 접속 (https://play.google.com/console)
  3. 앱 선택
  4. 수익 창출 → 제품 → 인앱 상품 메뉴로 이동
  5. "제품 만들기" 버튼 클릭

8. 에러

에러 1: "No application was found"

{
  "error": {
    "code": 404,
    "message": "No application was found for the given package name."
  }
}

원인: 잘못된 패키지명
해결: Play Console에서 정확한 패키지명 확인

에러 2: "Insufficient permissions"

{
  "error": {
    "code": 401,
    "message": "The current user has insufficient permissions"
  }
}

원인: 서비스 계정 권한 미부여
해결: Play Console에서 권한 설정. 권한을 부여한지 시간이 얼마 안지났다면 시간이 조금 소요될 수 있음.

에러 3: "Invalid Value"

{
  "error": {
    "code": 400,
    "message": "Invalid Value"
  }
}

원인: 가짜 토큰 또는 잘못된 상품 ID


[2025.10.16] Google Play Console에서 API access 페이지가 사라졌다는 블로그 발견

https://medium.com/@berteodosio/you-no-longer-need-to-have-a-gcp-project-associated-with-your-google-play-developer-account-to-c81e75ee1aff

 

You no longer need to have a GCP project associated with your Google Play Developer Account to…

To access Google Play APIs, such as the Google Play Developer API required for subscription management from a backend, you need to use…

medium.com

해당 블로그에 의하면 기존 GCP에 만든 계정에 여러개의 프로젝트를 만들어 사용하는 경우가 많았음. 여러개의 앱을 가진 계정의 경우 앱마다 별도의 GCP 프로젝트를 사용하는 경우가 많았지만, Google Play 개발자 계정에는 단 하나의 GCP 프로젝트만 연결할 수 있어서 문제가 되었음. 그래서 API access를 하지 않게 되었음.


P.S. Play Console의 UI가 또 바뀌었을 수도 있습니다. 이 글을 읽는 시점에는 또 다른 방법이 있을 수도 있습니다.