작성일: 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에서 서비스 계정 생성
- 프로젝트 생성: 테스트(dev)와 배포(prod) 환경 분리
- Google Play Android Developer API 활성화: API 및 서비스 → API 라이브러리에서 검색 후 활성화
- 서비스 계정 및 키 생성: IAM 및 관리자 → 서비스 계정에서 JSON 키 파일 다운로드
Step 2: Play Console에서 권한 부여
- 서비스 계정 초대: JSON 파일의
client_email값 입력 - 필수 권한 설정:
- 앱 정보 보기 및 보고서 일괄 다운로드
- 재무 데이터, 주문, 취소 설문조사 응답 보기
- 주문 및 구독 관리
- 앱 액세스 권한: 인앱 결제 대상 앱 선택
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,
});처리 순서
- 서버가 Google API로 구매 검증
- 검증 성공 시 acknowledge 처리
- DB 저장 및 아이템 지급
- 소비성 상품인 경우 consume 처리
- 클라이언트에 성공 응답
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에서 상품 등록하기
상품 등록 과정
- 결제 권한을 APK에 추가
- Play Console 접속
- 앱 선택
- 수익 창출 → 제품 → 인앱 상품 메뉴 이동
- “제품 만들기” 버튼 클릭
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가 계속 변경될 수 있으므로 당시 상황에 따라 다른 방법이 있을 수 있다.
