Android 결제 앱 개발자 가이드

Android 결제 앱이 웹 결제와 호환되도록 조정하고 고객에게 더 나은 사용자 환경을 제공하는 방법을 알아보세요.

아라키 유이치
아라키 유이치

Payment Request API는 사용자가 필요한 결제 정보를 그 어느 때보다 쉽게 입력할 수 있는 브라우저 기반 내장 인터페이스를 웹에 제공합니다. API는 플랫폼별 결제 앱을 호출할 수도 있습니다.

브라우저 지원

  • 60
  • 15
  • 11.1

소스

웹 결제를 사용하는 플랫폼별 Google Pay 앱의 결제 과정

Android 인텐트만 사용하는 것에 비해 웹 결제는 브라우저, 보안, 사용자 환경과 더 나은 통합을 지원합니다.

  • 결제 앱이 판매자 웹사이트의 컨텍스트에서 모달로 실행됩니다.
  • 이 구현은 기존 결제 앱을 보완하므로 사용자층을 활용할 수 있습니다.
  • 사이드로드를 방지하기 위해 결제 앱의 서명이 확인됩니다.
  • 결제 앱은 여러 결제 수단을 지원할 수 있습니다.
  • 암호화폐, 은행 송금 등의 모든 결제 수단을 통합할 수 있습니다. Android 기기의 결제 앱은 기기의 하드웨어 칩에 액세스해야 하는 수단도 통합할 수 있습니다.

Android 결제 앱에서 웹 결제를 구현하려면 다음 네 단계를 따르세요.

  1. 판매자가 결제 앱을 찾을 수 있도록 허용합니다.
  2. 고객에게 결제할 준비가 된 등록된 결제 수단 (예: 신용카드)이 있는지 판매자에게 알립니다.
  3. 고객이 결제할 수 있도록 합니다.
  4. 호출자의 서명 인증서를 확인합니다.

웹 결제의 실제 작동 방식을 확인하려면 android-web-payment 데모를 확인하세요.

1단계: 판매자가 결제 앱을 찾을 수 있도록 허용하기

판매자가 결제 앱을 사용하려면 Payment Request API를 사용하고 결제 수단 식별자를 사용하여 지원하는 결제 수단을 지정해야 합니다.

결제 앱에 고유한 결제 수단 식별자가 있는 경우 브라우저에서 앱을 검색할 수 있도록 자체 결제 수단 매니페스트를 설정할 수 있습니다.

2단계: 고객에게 결제할 준비가 된 등록된 결제 수단이 있는지 판매자에게 알림

판매자는 hasEnrolledInstrument()를 호출하여 고객이 결제할 수 있는지 쿼리할 수 있습니다. IS_READY_TO_PAY를 Android 서비스로 구현하여 이 쿼리에 답변할 수 있습니다.

AndroidManifest.xml

org.chromium.intent.action.IS_READY_TO_PAY 작업을 사용하여 인텐트 필터로 서비스를 선언합니다.

<service
  android:name=".SampleIsReadyToPayService"
  android:exported="true">
  <intent-filter>
    <action android:name="org.chromium.intent.action.IS_READY_TO_PAY" />
  </intent-filter>
</service>

IS_READY_TO_PAY 서비스는 선택사항입니다. 결제 앱에 이러한 인텐트 핸들러가 없으면 웹브라우저에서는 앱이 항상 결제할 수 있다고 가정합니다.

AIDL

IS_READY_TO_PAY 서비스의 API는 AIDL로 정의됩니다. 다음 콘텐츠로 두 개의 AIDL 파일을 만듭니다.

app/src/main/aidl/org/chromium/IsReadyToPayServiceCallback.aidl

package org.chromium;
interface IsReadyToPayServiceCallback {
    oneway void handleIsReadyToPay(boolean isReadyToPay);
}

app/src/main/aidl/org/chromium/IsReadyToPayService.aidl

package org.chromium;
import org.chromium.IsReadyToPayServiceCallback;

interface IsReadyToPayService {
    oneway void isReadyToPay(IsReadyToPayServiceCallback callback);
}

IsReadyToPayService 구현

IsReadyToPayService의 가장 간단한 구현은 다음 예입니다.

class SampleIsReadyToPayService : Service() {
  private val binder = object : IsReadyToPayService.Stub() {
    override fun isReadyToPay(callback: IsReadyToPayServiceCallback?) {
      callback?.handleIsReadyToPay(true)
    }
  }

  override fun onBind(intent: Intent?): IBinder? {
    return binder
  }
}

응답

서비스는 handleIsReadyToPay(Boolean) 메서드를 통해 응답을 보낼 수 있습니다.

callback?.handleIsReadyToPay(true)

권한

Binder.getCallingUid()를 사용하여 호출자를 확인할 수 있습니다. 이 작업은 onBind 메서드가 아닌 isReadyToPay 메서드에서 수행해야 합니다.

override fun isReadyToPay(callback: IsReadyToPayServiceCallback?) {
  try {
    val callingPackage = packageManager.getNameForUid(Binder.getCallingUid())
    // …

호출 패키지에 올바른 서명이 있는지 확인하는 방법은 발신자의 서명 인증서 확인을 참고하세요.

3단계: 고객이 결제하도록 허용하기

판매자는 show()를 호출하여 고객이 결제할 수 있도록 결제 앱을 실행합니다. 결제 앱은 트랜잭션 정보가 인텐트 매개변수에 있는 Android 인텐트 PAY를 통해 호출됩니다.

결제 앱은 methodNamedetails로 응답합니다. 이는 결제 앱에 따라 다르며 브라우저에 불투명합니다. 브라우저는 JSON 역직렬화를 통해 details 문자열을 판매자의 JavaScript 객체로 변환하지만, 그 이상의 유효성을 적용하지는 않습니다. 브라우저는 details를 수정하지 않습니다. 매개변수 값이 판매자에게 직접 전달됩니다.

AndroidManifest.xml

PAY 인텐트 필터가 있는 활동에는 앱의 기본 결제 수단 식별자를 식별하는 <meta-data> 태그가 있어야 합니다.

여러 결제 수단을 지원하려면 <string-array> 리소스가 포함된 <meta-data> 태그를 추가합니다.

<activity
  android:name=".PaymentActivity"
  android:theme="@style/Theme.SamplePay.Dialog">
  <intent-filter>
    <action android:name="org.chromium.intent.action.PAY" />
  </intent-filter>

  <meta-data
    android:name="org.chromium.default_payment_method_name"
    android:value="https://bobbucks.dev/pay" />
  <meta-data
    android:name="org.chromium.payment_method_names"
    android:resource="@array/method_names" />
</activity>

resource는 문자열 목록이어야 하며, 각 문자열은 여기에 표시된 것과 같이 HTTPS 스키마를 사용하는 유효한 절대 URL이어야 합니다.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="method_names">
        <item>https://alicepay.com/put/optional/path/here</item>
        <item>https://charliepay.com/put/optional/path/here</item>
    </string-array>
</resources>

매개변수

다음 매개변수는 인텐트 추가 항목으로 활동에 전달됩니다.

  • methodNames
  • methodData
  • topLevelOrigin
  • topLevelCertificateChain
  • paymentRequestOrigin
  • total
  • modifiers
  • paymentRequestId
val extras: Bundle? = intent?.extras

methodNames

사용 중인 메서드의 이름입니다. 이러한 요소는 methodData 사전의 키입니다. 다음은 결제 앱에서 지원하는 수단입니다.

val methodNames: List<String>? = extras.getStringArrayList("methodNames")

methodData

methodNames에서 methodData로의 매핑

val methodData: Bundle? = extras.getBundle("methodData")

merchantName

판매자의 결제 페이지에 있는 <title> HTML 태그의 콘텐츠 (브라우저의 최상위 탐색 컨텍스트)입니다.

val merchantName: String? = extras.getString("merchantName")

topLevelOrigin

스키마가 없는 판매자의 출처 (스키마 없는 최상위 탐색 컨텍스트의 출처) 예를 들어 https://mystore.com/checkoutmystore.com로 전달됩니다.

val topLevelOrigin: String? = extras.getString("topLevelOrigin")

topLevelCertificateChain

판매자의 인증서 체인 (최상위 탐색 컨텍스트의 인증서 체인)입니다. localhost 및 디스크의 파일(둘 다 SSL 인증서가 없는 보안 컨텍스트)의 경우 null입니다. 각 Parcelablecertificate 키와 바이트 배열 값이 포함된 번들입니다.

val topLevelCertificateChain: Array<Parcelable>? =
    extras.getParcelableArray("topLevelCertificateChain")
val list: List<ByteArray>? = topLevelCertificateChain?.mapNotNull { p ->
  (p as Bundle).getByteArray("certificate")
}

paymentRequestOrigin

JavaScript에서 new PaymentRequest(methodData, details, options) 생성자를 호출한 iframe 탐색 컨텍스트의 스키마 없는 출처입니다. 생성자가 최상위 컨텍스트에서 호출된 경우 이 매개변수의 값은 topLevelOrigin 매개변수의 값과 동일합니다.

val paymentRequestOrigin: String? = extras.getString("paymentRequestOrigin")

total

거래의 총 금액을 나타내는 JSON 문자열입니다.

val total: String? = extras.getString("total")

다음은 문자열의 콘텐츠 예입니다.

{"currency":"USD","value":"25.00"}

modifiers

JSON.stringify(details.modifiers)의 출력입니다. 여기서 details.modifiers에는 supportedMethodstotal만 포함됩니다.

paymentRequestId

'푸시 결제' 앱이 트랜잭션 상태와 연결해야 하는 PaymentRequest.id 필드 판매자 웹사이트는 이 필드를 사용하여 '푸시 결제' 앱에 대역 외 거래 상태를 쿼리합니다.

val paymentRequestId: String? = extras.getString("paymentRequestId")

응답

활동은 setResult를 통해 RESULT_OK를 사용하여 응답을 다시 보낼 수 있습니다.

setResult(Activity.RESULT_OK, Intent().apply {
  putExtra("methodName", "https://bobbucks.dev/pay")
  putExtra("details", "{\"token\": \"put-some-data-here\"}")
})
finish()

매개변수 2개를 인텐트 추가 항목으로 지정해야 합니다.

  • methodName: 사용 중인 메서드의 이름입니다.
  • details: 판매자가 거래를 완료하는 데 필요한 정보가 포함된 JSON 문자열입니다. 성공이 true인 경우 detailsJSON.parse(details)이 성공할 수 있는 방식으로 구성되어야 합니다.

결제 앱에서 거래가 완료되지 않은 경우(예: 사용자가 결제 앱에서 계정의 올바른 PIN 코드를 입력하지 않은 경우) RESULT_CANCELED를 전달할 수 있습니다. 브라우저에서 사용자가 다른 결제 앱을 선택하도록 허용할 수 있습니다.

setResult(RESULT_CANCELED)
finish()

호출된 결제 앱에서 수신한 결제 응답의 활동 결과가 RESULT_OK로 설정된 경우 Chrome은 추가 항목에서 비어 있지 않은 methodNamedetails를 확인합니다. 유효성 검사에 실패하면 Chrome은 request.show()에서 거부된 프로미스를 반환하고 개발자에게 다음 오류 메시지 중 하나를 표시합니다.

'Payment app returned invalid response. Missing field "details".'
'Payment app returned invalid response. Missing field "methodName".'

권한

활동은 getCallingPackage() 메서드를 사용하여 호출자를 확인할 수 있습니다.

val caller: String? = callingPackage

마지막 단계는 호출자의 서명 인증서를 확인하여 호출 패키지에 올바른 서명이 있는지 확인하는 것입니다.

4단계: 발신자의 서명 인증서 확인하기

IS_READY_TO_PAYBinder.getCallingUid(), PAYActivity.getCallingPackage()를 사용하여 호출자의 패키지 이름을 확인할 수 있습니다. 호출자가 원하는 브라우저인지 실제로 확인하려면 서명 인증서를 확인하고 올바른 값과 일치하는지 확인해야 합니다.

API 수준 28 이상을 타겟팅하고 단일 서명 인증서가 있는 브라우저와 통합하는 경우 PackageManager.hasSigningCertificate()를 사용할 수 있습니다.

val packageName: String = … // The caller's package name
val certificate: ByteArray = … // The correct signing certificate.
val verified = packageManager.hasSigningCertificate(
  callingPackage,
  certificate,
  PackageManager.CERT_INPUT_SHA256
)

PackageManager.hasSigningCertificate()는 인증서 순환을 올바르게 처리하므로 단일 인증서 브라우저에 적합합니다. (Chrome에는 단일 서명 인증서가 있습니다.) 여러 서명 인증서가 있는 앱은 순환할 수 없습니다.

이전 API 수준 27 이하를 지원해야 하거나 여러 서명 인증서가 있는 브라우저를 처리해야 한다면 PackageManager.GET_SIGNATURES를 사용하면 됩니다.

val packageName: String = … // The caller's package name
val certificates: Set<ByteArray> = … // The correct set of signing certificates

val packageInfo = getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
val sha256 = MessageDigest.getInstance("SHA-256")
val signatures = packageInfo.signatures.map { sha256.digest(it.toByteArray()) }
val verified = signatures.size == certificates.size &&
    signatures.all { s -> certificates.any { it.contentEquals(s) } }