Logo냥냠감자기술 블로그
Skip to Content
Dev Log삼냥이즈Google 인앱 결제 서버 구현
작성일: 2025-10-15

들어가며

인앱 결제 구현 시 대부분의 자료는 클라이언트 개발자 대상이다. 서버 개발자 관점에서 구현하며 겪은 시행착오를 정리했다.

1. Google Cloud와 Play Console 설정

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

  • Google Cloud Platform (GCP): 서비스 계정 생성 및 API 키 발급
  • Google Play Console: 앱과 상품 관리, 서비스 계정에 권한 부여

발생한 문제

Play Console의 UI 변경으로 “API 액세스” 메뉴가 사라졌다. 기존 가이드와 실제 인터페이스가 맞지 않았다.

실제 해결 방법

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

  1. 프로젝트 생성: 테스트(dev)와 배포(prod) 환경 분리
  2. Google Play Android Developer API 활성화: API 및 서비스 → API 라이브러리에서 검색 후 활성화
  3. 서비스 계정 및 키 생성: IAM 및 관리자 → 서비스 계정에서 JSON 키 파일 다운로드

Step 2: Play Console에서 권한 부여

  1. 서비스 계정 초대: JSON 파일의 client_email 값 입력
  2. 필수 권한 설정:
    • 앱 정보 보기 및 보고서 일괄 다운로드
    • 재무 데이터, 주문, 취소 설문조사 응답 보기
    • 주문 및 구독 관리
  3. 앱 액세스 권한: 인앱 결제 대상 앱 선택

Step 3: 환경변수 설정

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

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

Android Publisher API란?

Google Play Console 기능을 프로그래밍 방식으로 사용하는 API. 구매 검증에 필수다.

NestJS에서 클라이언트 초기화

// google-play.service.ts import { google } from 'googleapis'; private async initializeGoogleClient() { const keyPath = this.configService.get<string>('GOOGLE_SERVICE_ACCOUNT_KEY_PATH'); const keyData = JSON.parse(fs.readFileSync(keyPath, 'utf8')); const auth = new google.auth.JWT({ email: keyData.client_email, key: keyData.private_key, scopes: ['https://www.googleapis.com/auth/androidpublisher'], }); this.androidPublisher = google.androidpublisher({ version: 'v3', auth: auth, }); }

사용 가능한 API 메서드

// 일반 상품 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 구조

export class GoogleVerifyPurchaseRequestDto { @IsString() packageName: string; // 앱의 패키지명 @IsString() productId: string; // 상품 ID @IsString() purchaseToken: string; // Google이 발급한 구매 토큰 @IsEnum(PurchaseType) purchaseType: PurchaseType; // 'consumable' | 'subscription' @IsString() @IsOptional() orderId?: string; // 주문 번호 }

각 필드의 역할

  • packageName: 앱 식별 고유 ID
  • productId: Play Console 등록 상품 ID와 일치 필수
  • purchaseToken: Google에게 구매 확인 가능
  • purchaseType: 상품 타입별 처리 방법 결정
    • consumable: 일회성 소비 상품 (코인, 아이템)
    • subscription: 구독 상품 (멤버십)
  • 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. 후처리 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 (모든 상품 필수)

3일 내에 하지 않으면 자동 환불. 구매 검증 직후 처리 필수.

// 소비성 상품 await this.androidPublisher.purchases.products.acknowledge({ packageName: dto.packageName, productId: dto.productId, token: dto.purchaseToken, }); // 구독 상품 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)), 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) { purchase.status = PurchaseStatus.REFUNDED; await this.purchaseHistoryRepository.save(purchase); await this.revokeItems(purchase.userId, purchase.productId); this.logger.warn(`환불 처리: userId=${purchase.userId}, productId=${purchase.productId}`); }

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

상품 등록 과정

  1. 결제 권한을 APK에 추가
  2. Play Console 접속
  3. 앱 선택
  4. 수익 창출 → 제품 → 인앱 상품 메뉴 이동
  5. “제품 만들기” 버튼 클릭

8. 에러

에러 1: “No application was found”

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

에러 2: “Insufficient permissions”

원인: 서비스 계정 권한 미부여 해결: Play Console에서 권한 설정 (권한 적용에 시간 소요)

에러 3: “Invalid Value”

원인: 가짜 토큰 또는 잘못된 상품 ID 해결: 토큰과 상품 ID 검증


추가 노트 (2025.10.16)

Google Play Console에서 API access 페이지가 사라진 이유: 기존에는 Google Play 개발자 계정에 단 하나의 GCP 프로젝트만 연결 가능했으나, 여러 앱을 관리하는 개발자들은 앱마다 별도 프로젝트 필요. UI 변경으로 자동화되어 별도 API access 설정 불필요.

P.S. Play Console의 UI가 계속 변경될 수 있으므로 당시 상황에 따라 다른 방법이 있을 수 있다.