현재 요구 사항
- 구글 플레이 인앱 결제 (소모성 아이템이 아닌 광고 제거라는 일회성 제품)
처리 프로세스
- 서버 구동시 구글 클라우드 API를 통해 구글 플레이 API에 접근할 때 사용할 Access Token을 받아 놓는다 (추후 만료되면 자동 갱신)
- 결제가 진행되면 클라이언트로부터 영수증 정보를 받아온다
- 영수증 정보를 가지고 구글 플레이 콘솔 API에 접근해서 결제가 진행된 영수증인지 확인한다.
- 결제가 진행된 영수증임을 확인하면, 다시 구글 플레이 콘솔 API에 접근하여 영수증 처리를 진행한다.
- 영수증 처리까지 진행되면, 광고 제거 사용자 테이블에 해당 유저를 추가하고 구매 프로세스를 마무리한다.
이 과정을 진행하기 위해 몇가지 알아두어야 할 것이 있습니다.
우선 첫번째로 조금 헤맸던 부분인데, 구글 플레이 콘솔 API에 접근하기 위해서는 구글에서 발급해주는 credential이 있어야 합니다.
근데 이건 구글 플레이 콘솔이 아니라 구글 클라우드 콘솔을 통해서 받아와야 합니다.
두번째로는, 위에서 얘기한 구글 클라우드 콘솔을 통해 받은 credential을 가지고 구글 플레이 API에 접근하기 위해서는 구글 클라우드의 계정과 구글 플레이의 계정이 연동되어 있어야 합니다.
위의 두가지를 인지하고 계속 진행해보겠습니다.
우선 코드를 작성하기 전에 가장 먼저 선행되어야 하는 부분이 있습니다.
코드와 관련된 부분이 아니고 설정과 관련된 부분이므로 중요한 내용만 간략히 적겠습니다.
검색하면 관련 내용이 아주 많이 나옵니다.
1. 구글 클라우드 콘솔 설정
구글 클라우드 콘솔에서 계정을 만들고 'API 및 서비스' 탭에서 '사용자 인증 정보'를 생성하는데 이 때 'OAuth 2.0 클라이언트 ID'가 아니라 '서비스 계정'을 생성합니다.
'OAuth 2.0 클라이언트 ID'도 사용할 수 있긴 하지만, 지금 제가 하려는 것은 서버-서버간의 통신을 통한 영수증 인증 처리이기 때문에 로그인에 특화된 'OAuth 2.0 클라이언트 ID'가 아닌 서버-서버간의 서비스 제공에 특화된 '서비스 계정'을 사용합니다.
이 때 서비스 계정을 생성한 뒤에 계정 정보를 JSON으로 받아 올 수 있는데, 나중에 사용해야하니깐 우선 다운 받아놓는게 좋습니다. 아마 이름은 project_id-xxxxxxxxx.json 같은 형태일텐데 편의상 여기서는 profile.json이라고 하겠습니다.
그리고 생성한 서비스 계정의 역할을 '소유자' 혹은 '편집자'로 변경하거나, 좀 더 세분화하고 싶으면 '서비스 계정 재무 권한'만 있어도 영수증 처리는 가능합니다.
그리고 'API 라이브러리'탭에서 Google Play Android Developer API를 '사용'으로 변경합니다.
2. 구글 플레이 콘솔 설정
구글 클라우드의 서비스 계정과 연동하고 앱을 등록합니다.
그리고 구매를 테스트하고자 하는 임시 아이템을 등록합니다.
그리고 위의 과정에서 구글 클라우드 콘솔에 리다이렉트 URL을 현재 자신의 스프링 서버 주소로 등록해주어야합니다.
(구글 플레이 콘솔은 안해줘도 됩니다.)
이제 본격적으로 코드를 작성해보겠습니다.
사실 구글 API를 사용하기 때문에 위의 설정 과정을 잘 진행했다면 코드 작성 자체는 어려운 것이 아니고, 적절한 파라미터를 입력하는 것이 헷갈려서 조금 어려운 부분이 있었습니다. (이 부분에 관해서는 코드를 설명한 다음에 설명하겠습니다.)
저는 프로젝트 내에서 InAppPurchaseService.java라는 클래스를 따로 만들고 여기에서 인앱결제 관련 코드를 작성하여 구글 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); // Required for automatic token refresh
// Create AndroidPublisher instance using the credentials
this.androidPublisher =
new AndroidPublisher.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance(),
requestInitializer)
.setApplicationName("MyApplication") //It must match the project name in Google Cloud Console
.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);
}
}
/**
* Custom exception class for receipt verification errors.
*/
public static class ReceiptVerificationException extends IOException {
public ReceiptVerificationException(String message, Throwable cause) {
super(message, cause);
}
}
/**
* Custom exception class for purchase processing errors.
*/
public static class PurchaseProcessingException extends Exception {
public PurchaseProcessingException(String message) {
super(message);
}
public PurchaseProcessingException(String message, Throwable cause) {
super(message, cause);
}
}
/**
* Custom exception class for receipt acknowledgement errors.
*/
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
}
더 자세한 내용을 보고자 하면 아래의 링크를 참고하시면 됩니다.
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products?hl=ko#ProductPurchase
여기서 purchaseState
로 현재 구매 상태를 구분하는데, 0이 구매가 완료되었음을 나타냅니다.
반면에 acknowledgeReceipt
메소드는 반환값이 없는데, 그 이유는 acknowledge는 정상적으로 진행되면 응답 본문이 비어있기 때문입니다.
다만, 정상적으로 진행됐는지 체크하기 위해 응답코드에 대한 예외처리만 해주면 충분합니다.
이제 위에서 살펴본 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 {
// Verify the purchase receipt
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);
}
}
/**
* Process the purchase after receipt verification
*
* @param productPurchase Verified purchase information
* @param inAppPurchaseDTO DTO containing purchase details
* @return Result of purchase processing
* @throws PurchaseProcessingException If there's an error during purchase processing
*/
private int processPurchase(ProductPurchase productPurchase, InAppPurchaseDTO inAppPurchaseDTO) throws PurchaseProcessingException {
// 1. Check purchase state
if(productPurchase.getPurchaseState() != 0){
throw new PurchaseProcessingException("Purchase not completed. State: " + productPurchase.getPurchaseState());
}
log.info("Purchase completed. State: " + productPurchase.getPurchaseState());
// 2. Prevent duplicate processing
if(isPurchaseAlreadyProcessed(productPurchase.getOrderId())){
throw new PurchaseProcessingException("This purchase has already been processed.");
}
log.info("Duplicate processing prevention completed");
try {
// 3. Update user status (e.g., remove ads)
// Implementation details omitted
// 4. Acknowledge the receipt
inAppPurchaseService.acknowledgeReceipt(
inAppPurchaseDTO.getPackageName(),
inAppPurchaseDTO.getProductId(),
inAppPurchaseDTO.getReceipt(),
inAppPurchaseDTO.getPlayerId()
);
log.info("Receipt acknowledgement completed");
// 5. Save receipt record
// Implementation details omitted
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);
}
}
/**
* Check if a purchase has already been processed
*
* @param orderId The order ID to check
* @return true if the purchase has already been processed, false otherwise
*/
private boolean isPurchaseAlreadyProcessed(String orderId) {
return mapper.isPurchaseAlreadyProcessed(orderId);
}
여기선 그냥 호출하고 응답값 체크하고 프로세스를 이어가기만 하면 되기 때문에 큰 문제가 없습니다.
다만 주의할 점은 위에 코드에서 사용되는 InAppPurchaseDTO
에서 어떠한 정보를 사용해서 구글 API에 요청해야 하는지를 명확히 해야합니다.
public class InAppPurchaseDTO {
private String playerId; // unique value that distinguishes the user
private String packageName; // app package name. ex) com.test.app
private String transactionId; // order id. ex) GPA.XXXX.XXXXX.XXXXX.XXX
private String productId; // purchase product ID. ex) ad_remover
private String receipt; // token. ex) dipjcnhompnmpbkokmjjdfam.AO-XXXwKJo931-qI0crOddpS1hKm2-vOfXGS2cxJfRsiEepNF4lfjqYTU6deUs6_hAhofJdBD9Az_CbfKm6xQJ5hAhofJdVNXH7zGzxfWt0gd2y3_pehztA
}
여러 관련 글들을 검색했는데, 얘기들이 모호해서 저는 계속 packageName, productId, transactionId를 보냈는데, 아마 기존에는 transactionId를 보내는게 맞았던거 같은데, 현재는 그렇게 보내면 안됩니다.
아래의 글을 읽어보고 문제를 파악할 수 있었습니다.
https://stackoverflow.com/questions/46055214/google-play-developer-api-400-invalid-value-inapppurchases
ProductPurchase productPurchase = inAppPurchaseService.verifyReceipt(
inAppPurchaseDTO.getPackageName(),
inAppPurchaseDTO.getProductId(),
inAppPurchaseDTO.getReceipt()
);
이런식으로 packageName, productId, receipt(token)을 구글 API에 전송해야 정상적인 응답을 받을 수 있습니다.
지금까지 구글 API를 통해 인앱 결제 영수증을 검증하고 처리하는 과정을 알아보았습니다.
아래는 제가 이 과정에서 막혔던 부분들을 정리해놓았습니다. (이 글을 쓰게 된 이유)
1. 서비스 계정 생성 & 권한 부여와 인앱 상품 등록의 순서 문제 (401 Error)
이걸로도 상당히 고생했습니다.
모든걸 제대로 진행했는데, 아래처럼 401 에러가 나왔습니다.
결론은, 제목에서처럼 서비스 계정에 권한을 부여하는 시점이 인앱 상품 등록 시점보다 앞서야 한다는 말입니다.
쉽게 말하면, 이미 상품이 등록된 상태로 서비스 계정을 만들고 권한을 부여하면, 이미 등록된 상품에 대해서는 권한이 없는 것으로 취급하기 때문에 401 에러가 발생하게 됩니다.
만약 이미 이런 상황이 되었다면, 인앱 상품을 다시 생성하지말고, 인앱 상품 보기에 가서 이름이나 상품 설명을 대충 바꿔서 변경 사항을 저장해주고 다시 해보면 됩니다.
2. 구글 API 매개변수 문제 (400 Error)
이전에 얘기했던 거처럼, 버전의 변경과 여러 잘못된 정보들로 인해 매개변수로 어떤 값을 넣어야 하는지가 헷갈립니다.
이 때 잘못된 매개변수를 사용하면 400 Error가 나오니깐 잘 확인해봐야 합니다.
위와 같은 문제들이 나올 때는 역시 공식 문서를 보는게 명쾌합니다!
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products
'방구석 컴퓨터 > 방구석 스프링' 카테고리의 다른 글
JPA, JDBC (4) | 2024.11.20 |
---|---|
@RestController (0) | 2024.11.19 |
스프링 시큐리티 Basic Auth (0) | 2023.12.07 |
스프링 시큐리티와 JPA를 활용한 Basic Auth (0) | 2023.12.06 |
@RequestMapping (0) | 2023.09.13 |