| 구분1 | 구분2 | 설명 |
|---|---|---|
| 일회성제품 | - | 사용자가 결제 수단으로 단일 요금 지급하여 구매 |
| "" | 소비성 제품 | 사용자가 인앱 콘텐츠를 받기 위해 소비하는 제품 / 두 번 이상 구매할 수 있음 |
| "" | 비소비성 제품 | 한 번 구매하면 영구적인 혜택 제공, 사용자가 구매한 제품은 사용자의 Google 계정과 영구적으로 연결됨 |
| 정기결제제품 | - | 사용자가 지정된 기간 동안 액세스할 수 있는 일련의 혜택 의미 |
저는 설정 다 된 후에 바로 백엔드 개발에 투입되어 아래 개발자 계정 설정 방법은 잘 모르나 각 단계별로 상세히 나와있는 것 같습니다. 각 단계별로 구글링 해보시면 좋을 것 같아요.
API 및 서비스 > 라이브러리에서 Android Developer API 를 검색한 후 활성화(Enable) 버튼을 클릭합니다.
Play Console 시작하기 링크에서 바로 설정할 수 있는 링크로 이동할 수 있어 생략합니다.)Google Cloud 프로젝트 만들기
Google Cloud 프로젝트에 API 사용 설정
Google Play Developer API에 액세스할 수 있는 관련 Google Play Console 권한을 가진 서비스 계정설정
OAuth client나 서비스 계정을 사용하여 Google Play Developer API 액세스 구성해야 함
| 서비스 계정 | OAuth Client 계정 |
|---|---|
| 보안 소프트웨어 서비스가 API에 액세스 | 사용자가 API에 액세스 |
| SW(Server)가 API 호출할 때 사용 | 웹사이트에서 사용자를 대신하여 API에 액세스 해야 하면 서비스 계정이 아닌 Google 계정인 클리아언트 계정 인증(서비스 계정의 사용자 인증 정보를 노출하지 않고 사용자를 대신하여 API 호출 가능) |
API 사용 권한이 없는 사람에게 서비스 계정의 사용자 인증 정보가 공개되지 않도록 안전하게 관리 필수
Google Cloud 콘솔에서 서비스 계정 생성

서비스 계정 만들기 단계 따르기 (생략)

Google Play Console에서 신규 사용자 초대
재무데이터, 주문, 취소 설문조사 응답보기 + 주문 및 정기 결제 관리 권한 필수이제 서비스 계정과 앱을 연결시키는 설정을 완료하였습니다.
웹 애플리케이션과 Google 서비스 간 상호작용과 같은 서버 간 상호작용 지원서비스 계정 만들기는 위에서 작성했기에 Pass~
서비스 계정 키 만들기


서비스 계정 키를 만들면 아래와 같은 json 파일이 다운로드 됩니다!
{
"type": "service_account",
"project_id": "xxx",
"private_key_id": "xxx",
"private_key": "-----BEGIN PRIVATE KEY-----ㅌㅌㅌㅌㅌ\n-----END PRIVATE KEY-----\n",
"client_email": "xxxxxx@xxxxxxx-xxxxx.iam.gserviceaccount.com",
"client_id": "xxxx",
"auth_uri": "xxxx",
"token_uri": "xxxx",
"auth_provider_x509_cert_url": "xxxx",
"client_x509_cert_url": "xxxx",
"universe_domain": "xxxx"
}
위 파일을 Spring Boot 패키지에 넣되, Github에는 올리지마세요!!
이로써 웹에서 설정해야 할 것들은 끝났습니다~!~!
Google Play Android Developer API



Java용 Google Play Android 개발자 API 클라이언트 라이브러리
repositories {
mavenCentral()
}
dependencies {
implementation 'com.google.apis:google-api-services-androidpublisher:v3-rev20250227-2.0.0'
}
API Console에서 클라이언트 이메일 주소와 비공개 키를 가져온 후 Java용 Google API 클라이언트 라이브러리를 사용하여 서비스 계정의 사용자 인증 정보와 애플리케이션에 액세스해야 하는 범위에서 GoogleCredential 객체를 만듭니다.
도메인 전체 권한 위임은 제공하지 않겠습니다.
GoogleCredential 객체를 만들어야 합니다.repositories {
mavenCentral()
google()
}
dependencies {
compile 'com.google.api-client:google-api-client:1.33.0'
}
HttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
JsonFactory jsonFactory = GsonFactory.getDefaultInstance();
//Build service account credential
GoogleCredentials googleCredentials = GoogleCredentials.
fromStream(new FileInputStream("/path/to/file"));
HttpRequestInitializer requestInitializer = new HttpCredentialsAdapter(googleCredentials);
Storage storage = new Storage.Builder(httpTransport, jsonFactory, requestInitializer)
.setApplicationName("MyProject-1234")
.build();
GoogleCredential 객체를 사용하여 API를 호출합니다.GoogleCredential 객체를 사용하여 호출하려는 API의 서비스 객체를 만듭니다.SQLAdmin sqladmin =
new SQLAdmin.Builder(httpTransport, JSON_FACTORY, credential).build();SQLAdmin.Instances.List instances =
sqladmin.instances().list("exciting-example-123").execute();GoogleCredential 객체를 사용하여 호출하려는 API의 서비스 객체인 AndroidPublisher를 생성합니다. implementation 'com.google.apis:google-api-services-androidpublisher:v3-rev20250102-2.0.0' // `GoogleCredential` 객체를 사용하여 호출하려는 API의 서비스 객체 AndroidPublisher 생성용
implementation 'com.google.auth:google-auth-library-oauth2-http:1.6.0' // 서비스 계정 인증 및 `GoogleCredential` 객체를 만들기 위한 의존성
@Service
@Slf4j
@RequiredArgsConstructor
public class AndroidPurchaseService {
private String secretFilePath = "android/secret.json" // 시크릿키 json 파일 경로
// 호출하려는 서비스의 AndroidPublisher 객체 저장
private AndroidPublisher publisher;
// TODO : 실제 앱의 패키지명으로 변경
private String packageName = "com.example.app";
@PostConstruct
public void init() {
try {
// resources 디렉토리에 있는 파일은 클래스패스를 통해 접근
InputStream inputStream = getClass().getClassLoader().getResourceAsStream(secretFilePath);
if (inputStream == null) {
throw new FileNotFoundException("secret 파일이 없어용~!");
}
// JSON 키 파일을 읽어 서비스 계정 자격증명을 생성
GoogleCredentials credentials = GoogleCredentials.fromStream(inputStream)
.createScoped(Collections.singleton("https://www.googleapis.com/auth/androidpublisher"));
// AndroidPublisher 객체 생성
publisher = new AndroidPublisher.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance(),
new HttpCredentialsAdapter(credentials))
.setApplicationName("example") // TODO : 실제 앱 이름으로 변경
.build();
log.info("AndroidPurchaseService 초기화 완료");
} catch (Exception e) {
throw new RuntimeException("AndroidPurchaseService 초기화에 실패했습니다.", e);
}
}
Google Play Android Developer API
| 메서드 | 본문 |
|---|---|
| acknowledge | 인앱 상품의 구매를 확인합니다. |
| consume | 인앱 상품 구매를 소비합니다. |
| get | 인앱 상품의 구매 및 소비 상태를 확인합니다. |
사용자의 인앱 상품 구매 상태를 나타냅니다.
검증에 사용된 속성 (딱 결제 검증, DB 정보 저장에 필요한 것만 확인했는데 추가로 더 필요하시면 문서에 나와있는 다른 속성 활용하시면 좋을 것 같습니다!)
| 필드 | 설명 | 사용처 |
|---|---|---|
| purchaseState | 주문의 구매 상태/ 0 : 구매함, 1: 취소됨, 2:대기 중 | 구매함 상태의 구매 토큰일 경우만 검증하는 로직 |
| consumptionState | 인앱 상품의 소비 상태/ 0: 아직 소비되지 않음, 1: 소비함 | 소비되지 않은 경우의 구매 토큰인 경우 백엔드에서 소비 처리용 |
| orderId | 인앱 상품 구매와 연결된 주문 ID | 유일한 구매 Id로 환불 Notification 받았을 때 처리 + 중복 구매 처리 막기 위함 |
| purchaseType | 인앱결제 상품 구매 유형, 0:테스트, 1:프로모션, 2:리워드 | 테스트 환경에서 결제는 처리하지 않도록 하기 위함 |
| acknowledgementState | 인앱 상품의 확인 상태입니다./ 0(아직 확인되지 않음). 확인됨 | 비소비성 제품 구매 처리 여부 검사 |
인앱 상품의 구매 및 소비 상태를 확인합니다.
HTTP 요청
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/**{packageName}**/purchases/products/**{productId}**/tokens/**{token}**
| 매개변수 | 내용 |
|---|---|
| pacakgeName | 인앱 상품이 판매된 애플리케이션의 패키지 이름 |
| productId | 인앱상품 Id |
| token | 인앱 상품 구매 토큰 |
응답으로 ProductPurchase 인스턴스 포함
인앱 상품 구매를 소비합니다.
HTTP 요청
POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/**{packageName}**/purchases/products/**{productId}**/tokens/**{token}**:consume
| 매개변수 | 내용 |
|---|---|
| pacakgeName | 인앱 상품이 판매된 애플리케이션의 패키지 이름 |
| productId | 인앱상품 Id |
| token | 인앱 상품 구매 토큰 |
응답 본문 비어 있음.
인앱 상품 구매를 확인합니다.
HTTP 요청
POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/**{packageName}**/purchases/products/**{productId}**/tokens/**{token}**:acknowledge
| 매개변수 | 내용 |
|---|---|
| pacakgeName | 인앱 상품이 판매된 애플리케이션의 패키지 이름 |
| productId | 인앱 상품 Id |
| token | 구매 토큰 |
요청 본문
{
"developerPayload": "string"
}
예시 코드
ProductPurchasesAcknowledgeRequest requestBody = new
// 또는 추가 정보가 필요 없다면 생략할 수 있습니다.
ProductPurchasesAcknowledgeRequest();
requestBody.setDeveloperPayload("추가 정보를 전달하고 싶다면 여기에 입력");
AndroidPublisher.Purchases.Products.Acknowledge request =
publisher.purchases().products().acknowledge(packageName, productId, purchaseToken, requestBody);
request.execute();
인앱 상품입니다. InappproductsService의 리소스입니다.
검증에 사용된 속성 (딱 결제 검증, DB 정보 저장에 필요한 것만 확인했는데 추가로 더 필요하시면 문서에 나와있는 다른 속성 활용하시면 좋을 것 같습니다!)
| 필드 | 설명 | 사용처 |
|---|---|---|
| defaultPrice | 기본가격 / 무료인 경우 없어 0원일 수 없음 | 구매 토큰 검증 시 결제 금액 확인할 수 없어 인앱 상품 가격 확인 |
인앱 상품 구매토큰 검증 시 결제 금액이 없기 때문에 인앱상품 금액을 꺼내서 DB 상에 결제금액으로 넣어버립니다.
하나의 인앱 상품을 가져옵니다.
HTTP 요청
GET
https://androidpublisher.googleapis.com/androidpublisher/v3/applications/**{packageName}**/inappproducts/**{sku}**
| 매개변수 | 내용 |
|---|---|
| pacakgeName | 인앱 상품이 판매된 애플리케이션의 패키지 이름 |
| sku | 인앱상품 Id |
응답 본문에 InAppProduct 인스턴스가 포함됨

@PostMapping("/verify/android/receipts")
public ResponseEntity<ResultDto> verifyReceiptForAndroid(@RequestBody Request request) throws Exception {
log.info("request: {}", request);
// 구매 토큰으로 ProductPurchase 정보 추출
ProductPurchase productPurchase = androidPurchaseService.verifyProductPurchase(request.productId(), request.purchaseToken());
// ProductPurchase 여기에 실제 결제 금액 없으므로 인앱상품의 금액 추출
Long productPrice = androidPurchaseService.getProductPrice(request.productId());
// requst 내부에 있던 비즈니스 로직 정보 + 구매 정보 + 인앱상품 금액 => 비즈니스로직 (DB상 구매 처리)
ResultDto appResultDto = examService.registerForAndroidAppPurchase(request, productPurchase, productPrice);
// 소비 호출 => 구매 처리되었음을 알려야함 (안하면 3일 이내 자동 환불 처리됨)
androidPurchaseService.consumeProductPurchase(request.productId(), request.purchaseToken(), productPurchase);
return ResponseEntity.ok(appResultForLevelTestDto);
}
앱에서 새 구매 또는 완료된 구매를 감지하면 다음을 실행해야 합니다.
- 구매를 인증합니다.
- 완료된 구매에 대해 사용자에게 콘텐츠를 부여합니다.
- 사용자에게 알립니다.
- 앱에서 완료된 구매를 처리했음을 Google에 알립니다.
이러한 단계는 다음 섹션에서 자세히 설명한 후 모든 단계를 요약하는 섹션이 이어집니다.
앱에서 사용자에게 사용 권한을 부여하고 거래가 완료되었음을 알린 후, 앱에서 구매가 처리되었음을 Google에 알리기 위해 구매를 확인합니다.
- 이후 Google에 구매가 처리되었다고 알려 3일 이내에 구매가 자동으로 환불되고 사용 권한이 취소되지 않도록 처리해야 합니다.
- 다양한 유형의 구매를 확인하는 프로세스는 다음 섹션에 설명되어 있습니다.
위와 같은 문구가 또 숨어있었답니다..하하 (약간의 문장을 가독성 좋게 수정함)
- 소비성 제품의 경우 보안 백엔드가 있으면
Purchases.products:consume을 사용하여 안정적으로 구매를 소비하는 것이 좋습니다.
consumptionState를 확인하여 구매가 아직 소비되지 않았는지 확인- 이러한 방법을 사용하면 앱에서 입력 구매 토큰에 해당하는 일회성 제품을 재구매할 수 있습니다.
- 소비 요청이 때로 실패할 수 있으므로 보안 백엔드 서버를 확인하여 각 구매 토큰이 사용되지 않았는지 확인해야 합니다. 그래야 앱이 동일한 구매에 대해 여러 번 자격을 부여하지 않습니다.
- 또는 자격을 부여하기 전에 앱이 Google Play에서 성공적인 소비 응답을 받을 때까지 기다릴 수 있습니다.
- 비소비성 구매를 확인하려면 앱에 보안 백엔드가 있는 경우
Purchases.products:acknowledge를 사용하여 구매를 안정적으로 확인하는 것이 좋습니다.
acknowledgementState를 확인하여 이전에 구매를 확인하지 않았는지 확인합니다.- 확인하지 않은 경우에만
Purchases.products:acknowledge호출
- 정기 결제 구매는 비소비성 구매와 유사하게 처리됩니다.
- 가능하면 Google Play Developer API의
Purchases.subscriptions.acknowledge를 사용하여 보안 백엔드에서 구매를 안정적으로 확인하세요.Purchases.subscriptions:get의 구매 리소스에서 acknowledgementState를 확인하여 구매가 이전에 확인되지 않았는지 점검합니다.
// 구매 토큰에서 정보 추출하는 메서드 - 외부 네트워크 통신이라 실패할 수 있으므로 3번까지 재시도
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public ProductPurchase verifyProductPurchase(String productId, String purchaseToken) throws Exception {
AndroidPublisher.Purchases.Products.Get request =
publisher.purchases().products().get(packageName, productId, purchaseToken);
return request.execute();
}
Purchases.Products.Get 이 형태는 위에서 API로 인앱 상품의 구매 및 소비 상태를 확인하는 API였고 그 객체로 만들어서 execute() 하는 형태로 라이브러리를 사용합니다. // 인앱상품 정보 추출하는 메서드 - 외부 네트워크 통신이라 실패할 수 있으므로 3번까지 재시도
// Android의 경우 결제 금액을 구매 토큰 내부에 저장하지 않아서 상품 정보에서 정가 부분으로 결제 금액 저장하기 위함
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public Long getProductPrice(String productId) throws Exception {
AndroidPublisher.Inappproducts.Get request = publisher.inappproducts().get(packageName, productId);
InAppProduct product = request.execute();
String defaultPrice = product.getDefaultPrice().getPriceMicros();
long price = Long.parseLong(defaultPrice);
// 100만 나눈것이 정가라고 공식문서에 나와있음
return price / 1_000_000;
}
Inappproducts.Get API는 위에서 하나의 인앱 상품을 가져오는 API라고 작성했습니다.@AllArgsConstructor
@Getter
public enum ConsumptionStatusAndroid {
NOT_CONSUMED(0), // 소비하지 않음
CONSUMED(1); // 소비함
private Integer value;
}
// productId와 purchaseToken을 받아 소비 요청을 실행 (소모품) - 외부 네트워크 통신이라 실패할 수 있으므로 3번까지 재시도
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public void consumeProductPurchase(String productId, String purchaseToken, ProductPurchase productPurchase) throws Exception {
if (productPurchase.getConsumptionState() == null || productPurchase.getConsumptionState().equals(ConsumptionStatusAndroid.NOT_CONSUMED.getValue())) {
AndroidPublisher.Purchases.Products.Consume request =
publisher.purchases().products().consume(packageName, productId, purchaseToken);
request.execute();
}
}
purchases.products.consume 는 위에서 인앱상품 소비하는 API라고 작성했습니다.(소모성 제품)
- 실시간 개발자 알림(RTDN)은 앱 내에서 사용자의 자격이 변경될 때마다 Google의 알림을 수신하는 메커니즘입니다.
- RTDN은 Google Cloud Pub/Sub를 활용합니다.
- 이를 통해 설정된 URL로 푸시되거나 클라이언트 라이브러리를 사용하여 폴링되는 데이터를 수신할 수 있습니다.




만료기간 : 만료되지 않음
재시도 정책 : 지수 백오프 지연 후 재시도
출처 : Google Play 인앱 구독 상품, 실시간 상태 추적 시스템 구축. RTDN (Real-Time Develop Notification)
주제에 권한 google-play-developer-notifications@system.gserviceaccount.com 계정 추가

pub/sub 게시자 역할 부여




파란색 부분 복사

테스트 알림 보내기 하면 수신 될겁니다!
- 정기 결제 및 모든 무효화된 구매에 관한 알림 받기
- 정기 결제 및 무효화된 구매와 관련된 실시간 개발자 알림을 받습니다.
- 일회성 제품 구매에 대한 알림은 전송되지 않습니다.
- 정기 결제 및 일회성 제품에 관한 모든 알림 받기: 모든 정기 결제 및 무효화된 구매 이벤트에 관한 알림을 받습니다.
- ONE_TIME_PRODUCT_PURCHASED 및 ONE_TIME_PRODUCT_CANCELED와 같은 일회성 제품 구매 이벤트도 수신됩니다.
- 이러한 구매 이벤트에 대해 자세히 알아보려면 일회성 구매 수명 주기를 참고하세요.
정기 결제 및 모든 무효화된 구매에 관한 알림 받기 옵션으로 설정했습니다.{
"message": {
"attributes": {
"key": "value"
},
"data": "eyAidmVyc2lvbiI6IHN0cmluZywgInBhY2thZ2VOYW1lIjogc3RyaW5nLCAiZXZlbnRUaW1lTWlsbGlzIjogbG9uZywgIm9uZVRpbWVQcm9kdWN0Tm90aWZpY2F0aW9uIjogT25lVGltZVByb2R1Y3ROb3RpZmljYXRpb24sICJzdWJzY3JpcHRpb25Ob3RpZmljYXRpb24iOiBTdWJzY3JpcHRpb25Ob3RpZmljYXRpb24sICJ0ZXN0Tm90aWZpY2F0aW9uIjogVGVzdE5vdGlmaWNhdGlvbiB9",
"messageId": "136969346945"
},
"subscription": "projects/myproject/subscriptions/mysubscription"
}
{
"version": string,
"packageName": string,
"eventTimeMillis": long,
"oneTimeProductNotification": OneTimeProductNotification,
"subscriptionNotification": SubscriptionNotification,
"voidedPurchaseNotification": VoidedPurchaseNotification,
"testNotification": TestNotification
}
| 속성 이름 | 값 | 설명 | 실제 사용 사례 |
|---|---|---|---|
| version | 문자열 | 알림의 버전 | x |
| packageName | 문자열 | 알림과 관련된 애플리케이션 패키지 이름 | x |
| eventTimeMillis | long | 이벤트가 발생한 타임스탬프(밀리초) | 환불 시간 저장 |
| subscriptionNotification | SubscriptionNotification | 정기결제관련 | 사용x |
| oneTimeProductNotification | OneTimeProductNotification | 일회성 구매 관련 정보 | 사용x |
| voidedPurchaseNotification | VoidedPurchaseNotification | 무효화된 구매와 관련 | 무효화 정보 추출 사용o |
| testNotification | TestNotification | 테스트 알림이라 | ok 바로 보냄 |
oneTimeProductNotification, subscriptionNotification, voidedPurchaseNotification, testNotification 은 서로 상호 배타적
(즉 하나만 존재함)
{
"version":"1.0",
"packageName":"com.some.app",
"eventTimeMillis":"1503349566168",
"voidedPurchaseNotification":
{
"purchaseToken":"PURCHASE_TOKEN",
"orderId":"GS.0000-0000-0000",
"productType":1
"refundType":1
}
}
| 속성 이름 | 값 | 설명 | 사용 |
|---|---|---|---|
| purchaseToken | String | 무효ㅕ화된 구매와 관련된 토큰 | 사용x |
| orderId | String | 무효화 거래 고유 주문 Id | 일회성 구매는 유일한 주문 Id라 그대로 사용(토큰에서 추출x) |
| productType | int | 무효화된 구매의 productType : PRODUCT_TYPE_SUBSCRIPTION(정기결제) or PRODUCT_TYPE_ONE_TIME (일회성) | 일회성 상품만 사용하기에 따로 사용 안함 |
| refundType | int | 무효화된 구매 REFUND_TYPE_FULL_REFUND(완전 무효) or REFUND_TYPE_QUANTITY_BASED_PARTIAL_REFUND(부분적으로 무효화) | 다중 수량 구매 막았기 때문에 REFUND_TYPE_FULL_REFUND 얘만 오기에 사용하지 않음 |
public record AndroidNotificationDto(
AndroidNotificationMessage message,
String subscription
) {
}
public record AndroidNotificationMessage(
String data,
String message_id,
LocalDateTime publishTime,
LocalDateTime publish_time
) {
}
@PostMapping("/android/server-notification")
public ResponseEntity<Void> verifyServerNotification(@RequestBody AndroidNotificationDto dto) {
log.info("verifyServerNotificationForAndroid");
VoidedPurchaseNotification voidedPurchaseNotification = androidPurchaseService.processRtdnMessage(dto.message().data());
// 환불일때만 환불 처리
if (voidedPurchaseNotification != null) {
androidPurchaseService.handleRefund(voidedPurchaseNotification);
}
return ResponseEntity.ok().build();
}
androidPurchaseService.processRtdnMessage 메서드의 경우 환불에 대한 알림인지 검증합니다. 이는 위에서 oneTimeProductNotification, subscriptionNotification, voidedPurchaseNotification, testNotification 은 서로 상호 배타적 이라고 했기에 voidedPurchaseNotification 일때만 반환하고 나머지는 Null로 반환합니다. /**
* subscriptionNotification, oneTimeProductNotification, voidedPurchaseNotification 은 상호 베타적인 관계
* 환불에 대한 알림일때만 객체 반환하고 나머지 알림은 모두 null 반환
*/
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 200)
)
public VoidedPurchaseNotification processRtdnMessage(String base64Data) {
byte[] decodedBytes = Base64.getDecoder().decode(base64Data);
String jsonStr = new String(decodedBytes);
JsonObject jsonObject = JsonParser.parseString(jsonStr).getAsJsonObject();
// 알림 수신 시간 없으면 현재 시간으로 초기화
String refundTimeMillis = jsonObject.has("eventTimeMillis") ? jsonObject.get("eventTimeMillis").getAsString() : null;
if (!StringUtils.hasText(refundTimeMillis)) {
refundTimeMillis = String.valueOf(System.currentTimeMillis());
}
// voidedPurchaseNotification 객체화 시킨 결과 저장
JsonObject notification = jsonObject.getAsJsonObject("voidedPurchaseNotification");
// 객체 없으면 환불에 대한 알림이 아니니깐 Null로 반환
if (notification == null) {
return null;
}
// 환불 알림에 대한 객체로 생성해서 반환
return VoidedPurchaseNotification.of(notification, refundTimeMillis);
}
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 200)
)
@Transactional
public void handleRefund(VoidedPurchaseNotification voidedPurchaseNotification) {
log.info("1 - 환불 처리된 Android In App Purchase orderId 추출");
String orderId = voidedPurchaseNotification.orderId();
LocalDateTime nowDateTime = LocalDateTime.now();
Exam exam1 = null;
try {
log.info("2 - 해당 transactionId에 해당하는 거래건 추출");
exam1 = levelTestService.findByAndroidOrderId(orderId);
if (levelTestApply != null) {
log.info("3-case1 - levelTest 인앱결제 환불 처리");
examService.refundForAndroidPurchase(exam1, nowDateTime);
}
} catch (Exception e) {
log.info("환불처리 DB 저장 실패 orderId = {} 확인 후 거래 내역 명시적 환불 처리 요망", orderId);
throw new RuntimeException("Server Error Please Recall");
}
}
그러면 이제 진짜 끝!


@RestController
@Slf4j
@RequiredArgsConstructor
public class AndroidPurchaseController {
private final AndroidPurchaseService androidPurchaseService;
private final ExamService examService;
@PostMapping("/verify/android/receipts/level-test")
public ResponseEntity<AppResultForLevelTestDto> verifyReceiptForAndroid(@RequestBody AndroidAppPurchaseRequest request) throws Exception {
log.info("verifyReceiptForAndroid request: {}", request);
ProductPurchase productPurchase = androidPurchaseService.verifyProductPurchase(request.productId(), request.purchaseToken());
Long productPrice = androidPurchaseService.getProductPrice(request.productId());
AppPurchaseResult appResultForLevelTestDto = examService.resisterForAndroidAppPurchase(request, productPurchase, productPrice);
// 소비 호출
androidPurchaseService.consumeProductPurchase(request.productId(), request.purchaseToken(), productPurchase);
return ResponseEntity.ok(appResultForLevelTestDto);
}
@PostMapping("/android/server-notification")
public ResponseEntity<Void> verifyServerNotification(@RequestBody AndroidNotificationDto dto) {
log.info("verifyServerNotificationForAndroid");
VoidedPurchaseNotification voidedPurchaseNotification = androidPurchaseService.processRtdnMessage(dto.message().data());
// 환불일때만 환불 처리
if (voidedPurchaseNotification != null) {
androidPurchaseService.handleRefund(voidedPurchaseNotification);
}
return ResponseEntity.ok().build();
}
}
@Service
@Slf4j
@RequiredArgsConstructor
public class AndroidPurchaseService {
private String secretFilePath = "android/secret.json" // 시크릿키 json 파일 경로
// 호출하려는 서비스의 AndroidPublisher 객체 저장
private AndroidPublisher publisher;
// TODO : 실제 앱의 패키지명으로 변경
private String packageName = "com.example.app";
@PostConstruct
public void init() {
try {
// resources 디렉토리에 있는 파일은 클래스패스를 통해 접근
InputStream inputStream = getClass().getClassLoader().getResourceAsStream(secretFilePath);
if (inputStream == null) {
throw new FileNotFoundException("secret 파일이 없어용~!");
}
// JSON 키 파일을 읽어 서비스 계정 자격증명을 생성
GoogleCredentials credentials = GoogleCredentials.fromStream(inputStream)
.createScoped(Collections.singleton("https://www.googleapis.com/auth/androidpublisher"));
// AndroidPublisher 객체 생성
publisher = new AndroidPublisher.Builder(
GoogleNetHttpTransport.newTrustedTransport(),
GsonFactory.getDefaultInstance(),
new HttpCredentialsAdapter(credentials))
.setApplicationName("example") // TODO : 실제 앱 이름으로 변경
.build();
log.info("AndroidPurchaseService 초기화 완료");
} catch (Exception e) {
throw new RuntimeException("AndroidPurchaseService 초기화에 실패했습니다.", e);
}
}
// 구매 토큰에서 정보 추출하는 메서드 - 외부 네트워크 통신이라 실패할 수 있으므로 3번까지 재시도
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public ProductPurchase verifyProductPurchase(String productId, String purchaseToken) throws Exception {
AndroidPublisher.Purchases.Products.Get request =
publisher.purchases().products().get(packageName, productId, purchaseToken);
return request.execute();
}
// 인앱상품 정보 추출하는 메서드 - 외부 네트워크 통신이라 실패할 수 있으므로 3번까지 재시도
// Android의 경우 결제 금액을 구매 토큰 내부에 저장하지 않아서 상품 정보에서 정가 부분으로 결제 금액 저장하기 위함
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public Long getProductPrice(String productId) throws Exception {
AndroidPublisher.Inappproducts.Get request = publisher.inappproducts().get(packageName, productId);
InAppProduct product = request.execute();
String defaultPrice = product.getDefaultPrice().getPriceMicros();
long price = Long.parseLong(defaultPrice);
// 100만 나눈것이 정가라고 공식문서에 나와있음
return price / 1_000_000;
}
// productId와 purchaseToken을 받아 소비 요청을 실행 (소모품) - 외부 네트워크 통신이라 실패할 수 있으므로 3번까지 재시도
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public void consumeProductPurchase(String productId, String purchaseToken, ProductPurchase productPurchase) throws Exception {
if (productPurchase.getConsumptionState() == null || productPurchase.getConsumptionState().equals(ConsumptionStatusAndroid.NOT_CONSUMED.getValue())) {
AndroidPublisher.Purchases.Products.Consume request =
publisher.purchases().products().consume(packageName, productId, purchaseToken);
request.execute();
}
}
/**
* subscriptionNotification, oneTimeProductNotification, voidedPurchaseNotification 은 상호 베타적인 관계
* 환불에 대한 알림일때만 객체 반환하고 나머지 알림은 모두 null 반환
*/
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 200)
)
public VoidedPurchaseNotification processRtdnMessage(String base64Data) {
byte[] decodedBytes = Base64.getDecoder().decode(base64Data);
String jsonStr = new String(decodedBytes);
JsonObject jsonObject = JsonParser.parseString(jsonStr).getAsJsonObject();
// 알림 수신 시간 없으면 현재 시간으로 초기화
String refundTimeMillis = jsonObject.has("eventTimeMillis") ? jsonObject.get("eventTimeMillis").getAsString() : null;
if (!StringUtils.hasText(refundTimeMillis)) {
refundTimeMillis = String.valueOf(System.currentTimeMillis());
}
// voidedPurchaseNotification 객체화 시킨 결과 저장
JsonObject notification = jsonObject.getAsJsonObject("voidedPurchaseNotification");
// 객체 없으면 환불에 대한 알림이 아니니깐 Null로 반환
if (notification == null) {
return null;
}
// 환불 알림에 대한 객체로 생성해서 반환
return VoidedPurchaseNotification.of(notification, refundTimeMillis);
}
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 200)
)
@Transactional
public void handleRefund(VoidedPurchaseNotification voidedPurchaseNotification) {
log.info("1 - 환불 처리된 Android In App Purchase orderId 추출");
String orderId = voidedPurchaseNotification.orderId();
LocalDateTime nowDateTime = LocalDateTime.now();
Exam exam1 = null;
try {
log.info("2 - 해당 transactionId에 해당하는 거래건 추출");
exam1 = levelTestService.findByAndroidOrderId(orderId);
if (levelTestApply != null) {
log.info("3-case1 - levelTest 인앱결제 환불 처리");
examService.refundForAndroidPurchase(exam1, nowDateTime);
}
} catch (Exception e) {
log.info("환불처리 DB 저장 실패 orderId = {} 확인 후 거래 내역 명시적 환불 처리 요망", orderId);
throw new RuntimeException("Server Error Please Recall");
}
}
}