Google Play 인앱 결제 영수증 검증과 처리
Spring Boot와 Google Cloud API를 사용해 Google Play 인앱 결제 영수증을 검증하고 처리하는 과정과 오류 해결 방법을 정리합니다.
현재 요구 사항
- Google Play 인앱 결제: 광고 제거용 일회성 상품이며 소비성 상품은 아님
처리 흐름

- 서버가 시작할 때 Google Cloud API를 통해 Google Play API 접근용 Access Token을 받습니다. 만료되면 자동 갱신합니다.
- 클라이언트에서 결제가 발생하면 영수증 정보를 받습니다.
- 영수증 정보로 Google Play Console API에 접근해 유효한 구매 영수증인지 확인합니다.
- 유효한 영수증이면 Google Play Console API에 다시 접근해 영수증을 승인 처리합니다.
- 영수증 처리 후 사용자를 광고 제거 사용자 테이블에 추가하고 구매 처리를 마칩니다.
이 과정을 진행하기 전에 두 가지를 알아야 합니다.
첫째, Google Play Console API에 접근하려면 Google이 발급한 인증 정보가 필요합니다. 이 정보는 Google Play Console이 아니라 Google Cloud Console에서 받아야 합니다.
둘째, Google Cloud Console에서 받은 인증 정보로 Google Play API에 접근하려면 Google Cloud 계정과 Google Play 계정을 연결해야 합니다.
코드를 작성하기 전에 설정 작업을 먼저 해야 합니다. 코드가 아닌 설정에 관한 내용이므로 중요한 부분만 간단히 정리합니다.
1. Google Cloud Console 설정
Google Cloud Console에서 계정을 만들고 APIs & Services 탭에서 Credentials를 생성합니다. 이때 OAuth 2.0 Client ID가 아니라 Service Account를 만듭니다.
OAuth 2.0 Client ID도 사용할 수 있지만, 여기서는 서버 간 통신으로 영수증을 인증합니다. 로그인에 특화된 OAuth 2.0 Client ID보다 서버 간 서비스에 적합한 Service Account를 사용합니다.
Service Account를 만들면 계정 정보를 JSON으로 받을 수 있습니다. 이후에 사용할 수 있도록 내려받습니다. 파일 이름은 보통 project_id-xxxxxxxxx.json 형태지만 여기서는 편의상 profile.json이라 부르겠습니다.
생성한 Service Account의 역할을 Owner 또는 Editor로 변경합니다. 권한 범위를 더 구체적으로 제한하려면 영수증 처리에는 Service Account Token Creator 역할이면 충분합니다.
마지막으로 API Library 탭에서 Google Play Android Developer API를 Enabled 상태로 변경합니다.
2. Google Play Console 설정
Google Cloud Service Account와 연결하고 앱을 등록합니다. 그런 다음 결제 테스트에 사용할 임시 상품을 등록합니다.
위 과정에서 현재 Spring 서버 주소를 Redirect URL로 Google Cloud Console에 등록해야 합니다. Google Play Console에 등록하는 것은 아닙니다.
이제 실제 코드를 작성해 보겠습니다. Google API를 사용하므로 위 설정을 제대로 마쳤다면 코드 자체는 어렵지 않습니다. 적절한 파라미터를 전달하는 부분이 조금 까다로웠는데, 코드 뒤에서 설명하겠습니다.
프로젝트 안에 인앱 결제 관련 코드를 담당하는 InAppPurchaseService.java 클래스를 따로 만들었습니다. Google API와의 모든 통신은 이 클래스가 담당하고, 기존 메인 서비스인 Service.java에서 구매 처리 로직을 구현했습니다.

InAppPurchaseService.java
다음은 인앱 결제 처리의 핵심 코드입니다.
/**
* Service class for handling in-app purchases using Google Play Billing Library.
* This class provides methods for verifying and acknowledging purchase receipts.
*/
public class InAppPurchaseService {
private final AndroidPublisher androidPublisher;
/**
* Constructor that initializes the AndroidPublisher client.
* It sets up the necessary credentials and builds the AndroidPublisher instance.
*
* @throws GeneralSecurityException if there's a security-related exception
* @throws IOException if there's an I/O error when reading the credentials file
*/
public InAppPurchaseService() throws GeneralSecurityException, IOException {
String serviceAccountKeyFilePath =
getClass().getClassLoader().getResource("profile.json").getPath();
try {
GoogleCredentials credentials =
GoogleCredentials.fromStream(new FileInputStream(serviceAccountKeyFilePath))
.createScoped(
Collections.singleton("https://www.googleapis.com/auth/androidpublisher"));
log.info("Credentials successfully created: " + credentials);
HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(credentials);
this.androidPublisher =
new AndroidPublisher.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance(),
requestInitializer)
.setApplicationName("MyApplication")
.build();
credentials.refresh();
log.info("Access token: " + credentials.getAccessToken().getTokenValue());
} catch (IOException e) {
log.info("Error occurred while creating credentials: " + e.getMessage());
e.printStackTrace();
throw e;
}
}
/**
* Verifies the purchase receipt with Google Play.
*
* @param packageName The package name of the app
* @param productId The ID of the product that was purchased
* @param purchaseToken The token that was provided to the user after the purchase (receipt)
* @return ProductPurchase object containing the purchase details
* @throws ReceiptVerificationException if there's an error during verification
*/
public ProductPurchase verifyReceipt(String packageName, String productId, String receipt)
throws ReceiptVerificationException {
try{
return androidPublisher
.purchases()
.products()
.get(packageName, productId, receipt)
.execute();
} catch (IOException e) {
if (e instanceof GoogleJsonResponseException) {
GoogleJsonResponseException gjre = (GoogleJsonResponseException) e;
if (gjre.getStatusCode() == 401) {
throw new ReceiptVerificationException("Authentication error: Please check your API key or permissions.", e);
} else if (gjre.getStatusCode() == 404) {
throw new ReceiptVerificationException("Receipt not found: Please check the package name, product ID, and purchase token.", e);
}
}
throw new ReceiptVerificationException("Error occurred during receipt verification", e);
}
}
/**
* Acknowledges the purchase receipt with Google Play.
*
* @param packageName The package name of the app
* @param productId The ID of the product that was purchased
* @param receipt The token that was provided to the user after the purchase
* @param userId The ID of the user who made the purchase
* @throws ReceiptAcknowledgementException if there's an error during acknowledgement
*/
public void acknowledgeReceipt(String packageName, String productId, String receipt, String userId) throws ReceiptAcknowledgementException {
try {
ProductPurchasesAcknowledgeRequest request = new ProductPurchasesAcknowledgeRequest()
.setDeveloperPayload(userId);
androidPublisher.purchases().products()
.acknowledge(packageName, productId, receipt, request)
.execute();
} catch (IOException e) {
if (e instanceof GoogleJsonResponseException) {
GoogleJsonResponseException gjre = (GoogleJsonResponseException) e;
if (gjre.getStatusCode() == 401) {
throw new ReceiptAcknowledgementException("Authentication error: Please check your API key or permissions.", e);
} else if (gjre.getStatusCode() == 404) {
throw new ReceiptAcknowledgementException("Receipt not found: Please check the package name, product ID, and purchase token.", e);
}
}
throw new ReceiptAcknowledgementException("Error occurred during receipt acknowledgement", e);
}
}
public static class ReceiptVerificationException extends IOException {
public ReceiptVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
public static class PurchaseProcessingException extends Exception {
public PurchaseProcessingException(String message) {
super(message);
}
public PurchaseProcessingException(String message, Throwable cause) {
super(message, cause);
}
}
public static class ReceiptAcknowledgementException extends Exception {
public ReceiptAcknowledgementException(String message) {
super(message);
}
public ReceiptAcknowledgementException(String message, Throwable cause) {
super(message, cause);
}
}
}
verifyReceipt 메서드는 인앱 상품의 구매 상태를 나타내는 ProductPurchase를 반환합니다. 구조는 다음과 같습니다.
{
"kind": string,
"purchaseTimeMillis": string,
"purchaseState": integer,
"consumptionState": integer,
"developerPayload": string,
"orderId": string,
"purchaseType": integer,
"acknowledgementState": integer,
"purchaseToken": string,
"productId": string,
"quantity": integer,
"obfuscatedExternalAccountId": string,
"obfuscatedExternalProfileId": string,
"regionCode": string,
"refundableQuantity": integer
}
자세한 내용은 Google Android Publisher API 문서를 참고하세요.
purchaseState는 현재 구매 상태를 구분하며 값이 0이면 구매가 완료된 상태입니다. 반면 acknowledgeReceipt 메서드는 정상 처리 시 응답 본문이 비어 있으므로 반환값이 없습니다. 응답 코드에 따른 예외를 처리하면 정상 처리 여부를 확인할 수 있습니다.
이제 InAppPurchaseService.java의 메서드를 호출하는 부분을 살펴보겠습니다. 이 글의 목적은 영수증 검증과 처리이므로 그 이후의 실제 구매 반영 로직은 생략합니다. 애플리케이션마다 구매 처리 방식이 달라 공통적인 의미가 크지 않기 때문입니다.
/**
* Process in-app purchase and verify receipt
*
* @param inAppPurchaseDTO DTO containing purchase information
* @return Result of purchase processing
* @throws GeneralSecurityException If there's a security-related exception
* @throws IOException If there's an I/O error
*/
@Transactional(rollbackFor = Exception.class)
public int inAppPurchase(InAppPurchaseDTO inAppPurchaseDTO)
throws GeneralSecurityException, IOException {
try {
ProductPurchase productPurchase = inAppPurchaseService.verifyReceipt(
inAppPurchaseDTO.getPackageName(),
inAppPurchaseDTO.getProductId(),
inAppPurchaseDTO.getReceipt()
);
int purchaseProcessResult = processPurchase(productPurchase, inAppPurchaseDTO);
return purchaseProcessResult;
} catch (ReceiptVerificationException e) {
log.error("Error occurred during receipt verification: " + e.getMessage());
throw new RuntimeException("Receipt verification failed", e);
} catch (InAppPurchaseService.PurchaseProcessingException e) {
log.error("Error occurred during purchase processing: " + e.getMessage());
throw new RuntimeException("Purchase processing failed", e);
} catch (Exception e) {
log.error("Unexpected error occurred: " + e.getMessage());
throw new RuntimeException("Error during in-app purchase", e);
}
}
private int processPurchase(ProductPurchase productPurchase, InAppPurchaseDTO inAppPurchaseDTO) throws PurchaseProcessingException {
if(productPurchase.getPurchaseState() != 0){
throw new PurchaseProcessingException("Purchase not completed. State: " + productPurchase.getPurchaseState());
}
log.info("Purchase completed. State: " + productPurchase.getPurchaseState());
if(isPurchaseAlreadyProcessed(productPurchase.getOrderId())){
throw new PurchaseProcessingException("This purchase has already been processed.");
}
log.info("Duplicate processing prevention completed");
try {
// Update user status (e.g., remove ads)
inAppPurchaseService.acknowledgeReceipt(
inAppPurchaseDTO.getPackageName(),
inAppPurchaseDTO.getProductId(),
inAppPurchaseDTO.getReceipt(),
inAppPurchaseDTO.getPlayerId()
);
log.info("Receipt acknowledgement completed");
// Save receipt record
return 1;
} catch(InAppPurchaseService.ReceiptAcknowledgementException e){
log.error("Error occurred during receipt acknowledgement: " + e.getMessage());
throw new RuntimeException("Receipt acknowledgement failed", e);
} catch(Exception e){
log.error("Unexpected error occurred: " + e.getMessage());
throw new RuntimeException("Error during in-app purchase processing", e);
}
}
private boolean isPurchaseAlreadyProcessed(String orderId) {
return mapper.isPurchaseAlreadyProcessed(orderId);
}
응답값을 확인하고 다음 처리를 이어 가면 되므로 호출 자체는 어렵지 않습니다. 다만 Google API를 요청할 때 위 코드의 InAppPurchaseDTO에서 어떤 정보를 사용해야 하는지는 분명히 알아둘 필요가 있습니다.
public class InAppPurchaseDTO {
private String playerId; // 사용자를 구분하는 고유 값
private String packageName; // 앱 패키지 이름. 예: com.test.app
private String transactionId; // 주문 ID. 예: GPA.XXXX.XXXXX.XXXXX.XXX
private String productId; // 구매 상품 ID. 예: ad_remover
private String receipt; // 구매 토큰
}
관련 글을 여러 개 찾아봤지만 설명이 모호해 처음에는 packageName, productId, transactionId를 계속 전송했습니다. 과거에는 transactionId를 보내는 방식이 맞았던 것 같지만 현재는 그렇게 보내면 안 됩니다. 관련 Stack Overflow 글을 통해 문제를 확인했습니다.
ProductPurchase productPurchase = inAppPurchaseService.verifyReceipt(
inAppPurchaseDTO.getPackageName(),
inAppPurchaseDTO.getProductId(),
inAppPurchaseDTO.getReceipt()
);
Google API에 packageName, productId, receipt 즉 구매 토큰을 보내야 정상적인 응답을 받을 수 있습니다.
지금까지 Google API를 이용해 인앱 결제 영수증을 검증하고 처리하는 과정을 살펴봤습니다. 마지막으로 이 과정에서 막혔던 부분을 정리합니다. 이것이 이 글을 작성한 이유이기도 합니다.
1. Service Account 권한 부여와 인앱 상품 등록 순서로 인한 401 오류

설정을 모두 제대로 마쳤는데도 401 오류가 발생해 꽤 오래 어려움을 겪었습니다.
결론부터 말하면 Service Account에 권한을 부여하는 시점이 인앱 상품을 등록하는 시점보다 앞서야 합니다. 이미 상품을 등록한 상태에서 Service Account를 만들고 권한을 부여하면, 기존에 등록된 상품에 대한 권한이 없는 것으로 처리되어 401 오류가 발생합니다.
이미 이런 상황이라면 인앱 상품을 새로 만들 필요는 없습니다. 인앱 상품 보기로 이동해 이름이나 상품 설명을 조금 변경한 뒤 저장하고 다시 시도하면 됩니다.
2. Google API 파라미터로 인한 400 오류

앞서 설명했듯 버전 변경과 잘못된 정보 때문에 어떤 값을 파라미터로 사용해야 하는지 혼란스러울 수 있습니다. 잘못된 파라미터를 사용하면 400 오류가 발생하므로 주의 깊게 확인해야 합니다.