Android 결제 앱을 웹 결제와 호환되도록 조정하고 고객에게 더 나은 사용자 환경을 제공하는 방법을 알아보세요.
게시: 2020년 5월 5일, 최종 업데이트: 2025년 5월 27일
결제 요청 API는 사용자가 이전보다 쉽게 필수 결제 정보를 입력할 수 있는 내장 브라우저 기반 인터페이스를 웹에 제공합니다. API는 플랫폼별 결제 앱을 호출할 수도 있습니다.
Android 인텐트만 사용하는 것과 비교할 때 웹 결제를 사용하면 브라우저, 보안, 사용자 환경과 더 잘 통합할 수 있습니다.
- 결제 앱은 판매자 웹사이트의 컨텍스트에서 모달로 실행됩니다.
- 이 구현은 기존 결제 앱을 보완하여 사용자층을 활용할 수 있도록 지원합니다.
- 결제 앱의 서명이 사이드로드를 방지하기 위해 확인됩니다.
- 결제 앱은 여러 결제 수단을 지원할 수 있습니다.
- 암호화폐, 은행 송금 등 모든 결제 수단을 통합할 수 있습니다. Android 기기의 결제 앱은 기기의 하드웨어 칩에 대한 액세스가 필요한 메서드를 통합할 수도 있습니다.
Android 결제 앱에서 웹 결제를 구현하는 데는 네 단계가 필요합니다.
- 판매자가 결제 앱을 찾을 수 있도록 지원합니다.
- 고객에게 결제 준비가 된 등록된 수단 (예: 신용카드)이 있는지 판매자에게 알립니다.
- 고객이 결제할 수 있도록 합니다.
- 호출자의 서명 인증서를 확인합니다.
웹 결제가 실제로 작동하는지 확인하려면 android-web-payment 데모를 확인하세요.
1단계: 판매자가 결제 앱을 찾을 수 있도록 지원
결제 수단 설정의 안내에 따라 웹 앱 매니페스트에서 related_applications 속성을 설정합니다.
판매자가 결제 앱을 사용하려면 Payment Request API를 사용하고 결제 수단 식별자를 사용하여 지원하는 결제 수단을 지정해야 합니다.
결제 앱에 고유한 결제 수단 식별자가 있는 경우 브라우저가 앱을 검색할 수 있도록 자체 결제 수단 매니페스트를 설정할 수 있습니다.
2단계: 고객에게 결제 준비가 된 등록된 수단이 있는지 판매자에게 알림
판매자는 hasEnrolledInstrument()를 호출하여 고객이 결제할 수 있는지 쿼리할 수 있습니다. 이 쿼리에 응답하는 Android 서비스로 IS_READY_TO_PAY를 구현할 수 있습니다.
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 파일 두 개를 만듭니다.
org/chromium/IsReadyToPayServiceCallback.aidl
package org.chromium;
interface IsReadyToPayServiceCallback {
oneway void handleIsReadyToPay(boolean isReadyToPay);
}
org/chromium/IsReadyToPayService.aidl
package org.chromium;
import org.chromium.IsReadyToPayServiceCallback;
interface IsReadyToPayService {
oneway void isReadyToPay(IsReadyToPayServiceCallback callback, in Bundle parameters);
}
IsReadyToPayService 구현
IsReadyToPayService의 가장 간단한 구현은 다음 예에 나와 있습니다.
Kotlin
class SampleIsReadyToPayService : Service() {
private val binder = object : IsReadyToPayService.Stub() {
override fun isReadyToPay(callback: IsReadyToPayServiceCallback?, parameters: Bundle?) {
callback?.handleIsReadyToPay(true)
}
}
override fun onBind(intent: Intent?): IBinder? {
return binder
}
}
자바
import org.chromium.IsReadyToPayService;
public class SampleIsReadyToPayService extends Service {
private final IsReadyToPayService.Stub mBinder =
new IsReadyToPayService.Stub() {
@Override
public void isReadyToPay(IsReadyToPayServiceCallback callback, Bundle parameters) {
if (callback != null) {
callback.handleIsReadyToPay(true);
}
}
};
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
}
응답
서비스는 handleIsReadyToPay(Boolean) 메서드를 사용하여 응답을 전송할 수 있습니다.
Kotlin
callback?.handleIsReadyToPay(true)
자바
if (callback != null) {
callback.handleIsReadyToPay(true);
}
권한
Binder.getCallingUid()를 사용하여 호출자를 확인할 수 있습니다. Android OS는 서비스 연결을 캐시하고 재사용할 수 있으므로 onBind() 메서드를 트리거하지 않는 onBind 메서드가 아닌 isReadyToPay 메서드에서 이를 실행해야 합니다.
Kotlin
override fun isReadyToPay(callback: IsReadyToPayServiceCallback?, parameters: Bundle?) {
try {
val untrustedPackageName = parameters?.getString("packageName")
val actualPackageNames = packageManager.getPackagesForUid(Binder.getCallingUid())
// ...
자바
@Override
public void isReadyToPay(IsReadyToPayServiceCallback callback, Bundle parameters) {
try {
String untrustedPackageName = parameters != null
? parameters.getString("packageName")
: null;
String[] actualPackageNames = packageManager.getPackagesForUid(Binder.getCallingUid());
// ...
프로세스 간 통신 (IPC) 호출을 수신할 때는 항상 null의 입력 매개변수를 확인하세요. 이는 Android OS의 다양한 버전이나 포크가 처리되지 않으면 예기치 않은 방식으로 작동하여 오류가 발생할 수 있기 때문에 특히 중요합니다.
packageManager.getPackagesForUid()는 일반적으로 단일 요소를 반환하지만 호출자가 여러 패키지 이름을 사용하는 드문 시나리오를 코드에서 처리해야 합니다. 이렇게 하면 애플리케이션이 견고하게 유지됩니다.
호출 패키지에 올바른 서명이 있는지 확인하는 방법은 호출자의 서명 인증서 확인을 참고하세요.
매개변수
parameters 번들은 Chrome 139에서 추가되었습니다. 항상 null에 대해 확인해야 합니다.
다음 매개변수가 parameters 번들에서 서비스로 전달됩니다.
packageNamemethodNamesmethodDatatopLevelOriginpaymentRequestOrigintopLevelCertificateChain
packageName는 Chrome 138에서 추가되었습니다. 이 매개변수를 Binder.getCallingUid()와 비교하여 확인한 후에 값을 사용해야 합니다. 이 확인은 parameters 번들은 호출자가 완전히 제어하는 반면 Binder.getCallingUid()는 Android OS에서 제어하기 때문에 필수입니다.
topLevelCertificateChain는 WebView 및 일반적으로 로컬 테스트에 사용되는 비 HTTPS 웹사이트(예: http://localhost)에서 null입니다.
3단계: 고객이 결제하도록 허용
판매자는 고객이 결제할 수 있도록 show()를 호출하여 결제 앱을 실행합니다. 결제 앱은 인텐트 매개변수에 거래 정보가 포함된 Android 인텐트 PAY를 사용하여 호출됩니다.
결제 앱은 결제 앱에만 해당하고 브라우저에는 불투명한 methodName 및 details로 응답합니다. 브라우저는 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/chromium_payment_method_names" />
</activity>
android:resource는 문자열 목록이어야 하며 각 문자열은 다음과 같이 HTTPS 스키마가 있는 유효한 절대 URL이어야 합니다.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="chromium_payment_method_names">
<item>https://alicepay.com/put/optional/path/here</item>
<item>https://charliepay.com/put/optional/path/here</item>
</string-array>
</resources>
매개변수
다음 매개변수는 활동에 Intent 추가로 전달됩니다.
methodNamesmethodDatamerchantNametopLevelOrigintopLevelCertificateChainpaymentRequestOrigintotalmodifierspaymentRequestIdpaymentOptionsshippingOptions
Kotlin
val extras: Bundle? = getIntent()?.extras
자바
Bundle extras = getIntent() != null ? getIntent().getExtras() : null;
methodNames
사용 중인 메서드의 이름입니다. 요소는 methodData 사전의 키입니다. 결제 앱에서 지원하는 메서드입니다.
Kotlin
val methodNames: List<String>? = extras.getStringArrayList("methodNames")
자바
List<String> methodNames = extras.getStringArrayList("methodNames");
methodData
각 methodNames에서 methodData로의 매핑
Kotlin
val methodData: Bundle? = extras.getBundle("methodData")
자바
Bundle methodData = extras.getBundle("methodData");
merchantName
판매자 결제 페이지의 <title> HTML 태그 콘텐츠 (브라우저의 최상위 탐색 컨텍스트)입니다.
Kotlin
val merchantName: String? = extras.getString("merchantName")
자바
String merchantName = extras.getString("merchantName");
topLevelOrigin
스키마가 없는 판매자의 출처입니다 (최상위 탐색 컨텍스트의 스키마가 없는 출처). 예를 들어 https://mystore.com/checkout은 mystore.com로 전달됩니다.
Kotlin
val topLevelOrigin: String? = extras.getString("topLevelOrigin")
자바
String topLevelOrigin = extras.getString("topLevelOrigin");
topLevelCertificateChain
판매자의 인증서 체인 (최상위 탐색 컨텍스트의 인증서 체인)입니다. 값은 WebView, localhost 또는 디스크의 파일인 경우 null입니다.
각 Parcelable는 certificate 키와 바이트 배열 값이 있는 번들입니다.
Kotlin
val topLevelCertificateChain: Array<Parcelable>? =
extras.getParcelableArray("topLevelCertificateChain")
val list: List<ByteArray>? = topLevelCertificateChain?.mapNotNull { p ->
(p as Bundle).getByteArray("certificate")
}
자바
Parcelable[] topLevelCertificateChain =
extras.getParcelableArray("topLevelCertificateChain");
if (topLevelCertificateChain != null) {
for (Parcelable p : topLevelCertificateChain) {
if (p != null && p instanceof Bundle) {
((Bundle) p).getByteArray("certificate");
}
}
}
paymentRequestOrigin
JavaScript에서 new
PaymentRequest(methodData, details, options) 생성자를 호출한 iframe 탐색 컨텍스트의 스킴 없는 출처입니다. 최상위 컨텍스트에서 생성자가 호출된 경우 이 매개변수의 값은 topLevelOrigin 매개변수의 값과 같습니다.
Kotlin
val paymentRequestOrigin: String? = extras.getString("paymentRequestOrigin")
자바
String paymentRequestOrigin = extras.getString("paymentRequestOrigin");
total
거래의 총액을 나타내는 JSON 문자열입니다.
Kotlin
val total: String? = extras.getString("total")
자바
String total = extras.getString("total");
다음은 문자열의 콘텐츠 예시입니다.
{"currency":"USD","value":"25.00"}
modifiers
JSON.stringify(details.modifiers)의 출력으로, 여기서 details.modifiers에는 supportedMethods, data, total만 포함됩니다.
paymentRequestId
'푸시 결제' 앱이 거래 상태와 연결해야 하는 PaymentRequest.id 필드입니다. 판매자 웹사이트는 이 필드를 사용하여 대역 외 거래 상태에 대해 '푸시 결제' 앱을 쿼리합니다.
Kotlin
val paymentRequestId: String? = extras.getString("paymentRequestId")
자바
String paymentRequestId = extras.getString("paymentRequestId");
응답
활동은 RESULT_OK을 사용하여 setResult를 통해 응답을 다시 보낼 수 있습니다.
Kotlin
setResult(Activity.RESULT_OK, Intent().apply {
putExtra("methodName", "https://bobbucks.dev/pay")
putExtra("details", "{\"token\": \"put-some-data-here\"}")
})
finish()
자바
Intent result = new Intent();
Bundle extras = new Bundle();
extras.putString("methodName", "https://bobbucks.dev/pay");
extras.putString("details", "{\"token\": \"put-some-data-here\"}");
result.putExtras(extras);
setResult(Activity.RESULT_OK, result);
finish();
다음 두 매개변수를 인텐트 추가 데이터로 지정해야 합니다.
methodName: 사용 중인 메서드의 이름입니다.details: 판매자가 거래를 완료하는 데 필요한 정보가 포함된 JSON 문자열입니다. 성공이true이면JSON.parse(details)이 성공하도록details를 구성해야 합니다. 반환해야 하는 데이터가 없는 경우 이 문자열은"{}"일 수 있으며, 이는 판매자 웹사이트에서 빈 JavaScript 사전으로 수신합니다.
사용자가 결제 앱에서 거래를 취소하는 경우 RESULT_CANCELED를 전달할 수 있습니다. 이렇게 하면 request.show()가 판매자 웹사이트에서 AbortError로 거부되어 사용자 취소를 나타냅니다.
Kotlin
setResult(Activity.RESULT_CANCELED)
finish()
자바
setResult(Activity.RESULT_CANCELED);
finish();
Chrome 149부터 다음과 같은 결과 값이 지원됩니다.
Kotlin
Activity.RESULT_CANCELED // 0 (0x00000000)
Activity.RESULT_OK // -1 (0xffffffff)
const val INTERNAL_PAYMENT_APP_ERROR = Activity.RESULT_FIRST_USER // 1 (0x00000001)
자바
Activity.RESULT_CANCELED // 0 (0x00000000)
Activity.RESULT_OK // -1 (0xffffffff)
static final int INTERNAL_PAYMENT_APP_ERROR = Activity.RESULT_FIRST_USER; // 1 (0x00000001)
내부 오류로 인해 결제 앱이 실패하면 결과 코드로 Activity.RESULT_FIRST_USER를 전달하여 이를 나타낼 수 있습니다.
INTERNAL_PAYMENT_APP_ERROR가 반환되면 request.show()는 판매자 웹사이트에서 OperationError로 거부되어 결제 앱에 오류가 있음을 나타냅니다.
AbortError를 유발하는 사용자 취소의 경우 RESULT_CANCELED (0)와 OperationError를 유발하는 내부 앱 오류의 경우 INTERNAL_PAYMENT_APP_ERROR (1)를 구분하면 판매자가 더 나은 사용자 흐름을 구축할 수 있습니다.
Kotlin
setResult(Activity.RESULT_FIRST_USER)
finish()
자바
setResult(Activity.RESULT_FIRST_USER);
finish();
호출된 결제 앱에서 수신된 결제 응답의 활동 결과가 RESULT_OK로 설정되면 Chrome은 추가 항목에서 비어 있지 않은 methodName 및 details를 확인합니다. 유효성 검사에 실패하면 Chrome은 다음 개발자 대상 오류 메시지 중 하나와 함께 request.show()에서 거부된 프로미스를 반환합니다.
'Payment app returned invalid response. Missing field "details".'
'Payment app returned invalid response. Missing field "methodName".'
권한
활동은 getCallingPackage() 메서드로 호출자를 확인할 수 있습니다.
Kotlin
val caller: String? = callingPackage
자바
String caller = getCallingPackage();
마지막 단계는 호출 패키지에 올바른 서명이 있는지 확인하기 위해 호출자의 서명 인증서를 확인하는 것입니다.
4단계: 호출자의 서명 인증서 확인
IS_READY_TO_PAY의 Binder.getCallingUid()와 PAY의 Activity.getCallingPackage()로 호출자의 패키지 이름을 확인할 수 있습니다. 호출자가 실제로 염두에 둔 브라우저인지 확인하려면 서명 인증서를 확인하고 올바른 값과 일치하는지 확인해야 합니다.
API 수준 28 이상을 타겟팅하고 단일 서명 인증서가 있는 브라우저와 통합하는 경우 PackageManager.hasSigningCertificate()를 사용할 수 있습니다.
Kotlin
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
)
자바
String packageName = … // The caller's package name
byte[] certificate = … // The correct signing certificate
boolean verified = packageManager.hasSigningCertificate(
callingPackage,
certificate,
PackageManager.CERT_INPUT_SHA256);
PackageManager.hasSigningCertificate()는 인증서 순환을 올바르게 처리하므로 단일 인증서 브라우저에 적합합니다. (Chrome에는 단일 서명 인증서가 있습니다.) 서명 인증서가 여러 개인 앱은 서명 인증서를 순환할 수 없습니다.
API 수준 27 이하를 지원해야 하거나 서명 인증서가 여러 개인 브라우저를 처리해야 하는 경우 PackageManager.GET_SIGNATURES를 사용할 수 있습니다.
Kotlin
val packageName: String = … // The caller's package name
val expected: Set<String> = … // The correct set of signing certificates
val packageInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
val sha256 = MessageDigest.getInstance("SHA-256")
val actual = packageInfo.signatures.map {
SerializeByteArrayToString(sha256.digest(it.toByteArray()))
}
val verified = actual.equals(expected)
자바
String packageName = … // The caller's package name
Set<String> expected = … // The correct set of signing certificates
PackageInfo packageInfo =
packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
Set<String> actual = new HashSet<>();
for (Signature it : packageInfo.signatures) {
actual.add(SerializeByteArrayToString(sha256.digest(it.toByteArray())));
}
boolean verified = actual.equals(expected);
디버그
다음 명령어를 사용하여 오류 또는 정보 메시지를 확인합니다.
adb logcat | grep -i pay