본문 바로가기

React

React 앱에서 브라우저 Payment Request API 통합하기

Payment Request API는 브라우저 네이티브 결제 시트를 통해 주소, 연락처, 결제수단을 빠르게 입력하게 해주는 웹 표준입니다. React 앱에서는 이를 프로그레시브 인핸스먼트로 도입하여 체크아웃 전환율을 올릴 수 있습니다. 다만 결제수단은 브라우저와 설치된 결제 핸들러에 따라 달라지며, 일부 메서드는 제한적 지원이므로 적절한 폴백을 함께 준비해야 합니다.

1. 지원과 제약 이해

- Payment Request API 자체는 크롬/엣지 등의 일부 환경에서 사용 가능합니다. Safari는 Apple Pay를 Payment Request를 통해 노출하지만 설정과 도메인 검증이 필요합니다.

- 과거 예제로 자주 쓰이던 basic-card는 대부분의 최신 브라우저에서 비활성화되었거나 더 이상 권장되지 않습니다. 실제 서비스에서는 결제 핸들러(예: Apple Pay) 또는 Secure Payment Confirmation(SPC) 같은 메서드를 사용하거나, 게이트웨이 SDK와 병행해야 합니다.

- 반드시 HTTPS 환경과 사용자 제스처(버튼 클릭) 내에서 show()를 호출해야 합니다. 서버 검증 흐름을 준비하고, Payment Request가 불가한 경우 전통적인 체크아웃으로 폴백합니다.

2. 최소 통합: 버튼 클릭으로 Payment Request 열기

아래 예제는 React 컴포넌트에서 Payment Request를 생성하고 결제 시트를 띄운 뒤 결과를 서버로 검증하는 흐름입니다. 데모 용도로 basic-card를 사용하지만, 실제 서비스에서는 6장에서 설명하는 결제 메서드로 바꾸는 것을 권장합니다.

import React, { useCallback, useState } from 'react';

export default function PaymentButton() {
  const [loading, setLoading] = useState(false);

  const createRequest = useCallback(() => {
    // 데모용: basic-card. 실제 서비스는 다른 메서드로 교체 권장
    const methodData = [
      {
        supportedMethods: 'basic-card',
        data: {
          supportedNetworks: ['visa', 'mastercard', 'amex'],
          supportedTypes: ['debit', 'credit']
        }
      }
    ];

    const details = {
      displayItems: [
        { label: '상품 합계', amount: { currency: 'KRW', value: '49000' } },
        { label: '배송비', amount: { currency: 'KRW', value: '0' } }
      ],
      total: {
        label: '총 결제금액',
        amount: { currency: 'KRW', value: '49000' }
      },
      shippingOptions: [
        { id: 'std', label: '일반 배송', amount: { currency: 'KRW', value: '0' }, selected: true },
        { id: 'exp', label: '익일 배송', amount: { currency: 'KRW', value: '3000' }, selected: false }
      ]
    };

    const options = {
      requestPayerName: true,
      requestPayerEmail: true,
      requestPayerPhone: false,
      requestShipping: true
    };

    const request = new PaymentRequest(methodData, details, options);

    // 배송지/옵션 변경 시 합계 업데이트
    request.addEventListener('shippingaddresschange', (event) => {
      event.updateWith(new Promise((resolve) => {
        const region = request.shippingAddress?.country || 'KR';
        let shippingCost = '0';
        if (region !== 'KR') shippingCost = '15000';
        const updatedDetails = {
          ...details,
          displayItems: [
            { label: '상품 합계', amount: { currency: 'KRW', value: '49000' } },
            { label: '배송비', amount: { currency: 'KRW', value: shippingCost } }
          ],
          total: {
            label: '총 결제금액',
            amount: { currency: 'KRW', value: (49000 + Number(shippingCost)).toString() }
          }
        };
        resolve(updatedDetails);
      }));
    });

    request.addEventListener('shippingoptionchange', (event) => {
      event.updateWith(new Promise((resolve) => {
        const selected = request.shippingOption;
        const shippingCost = selected === 'exp' ? '3000' : '0';
        const updatedDetails = {
          ...details,
          displayItems: [
            { label: '상품 합계', amount: { currency: 'KRW', value: '49000' } },
            { label: '배송비', amount: { currency: 'KRW', value: shippingCost } }
          ],
          total: {
            label: '총 결제금액',
            amount: { currency: 'KRW', value: (49000 + Number(shippingCost)).toString() }
          },
          shippingOptions: [
            { id: 'std', label: '일반 배송', amount: { currency: 'KRW', value: '0' }, selected: selected === 'std' },
            { id: 'exp', label: '익일 배송', amount: { currency: 'KRW', value: '3000' }, selected: selected === 'exp' }
          ]
        };
        resolve(updatedDetails);
      }));
    });

    return request;
  }, []);

  const onClick = useCallback(async () => {
    if (typeof window === 'undefined' || !('PaymentRequest' in window)) {
      // 폴백 체크아웃
      window.location.href = '/checkout';
      return;
    }

    setLoading(true);
    try {
      const request = createRequest();

      let canMake = true;
      try {
        canMake = await request.canMakePayment();
      } catch (err) {
        // 일부 브라우저는 에러를 던질 수 있음 - 사용자에게 시도 기회를 줌
        canMake = true;
      }

      if (!canMake) {
        window.location.href = '/checkout';
        return;
      }

      const response = await request.show();

      const ok = await fetch('/api/payments/confirm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          methodName: response.methodName,
          details: response.details,
          payerEmail: response.payerEmail,
          payerName: response.payerName,
          shippingAddress: response.shippingAddress,
          shippingOption: response.shippingOption
        })
      }).then((r) => r.ok);

      await response.complete(ok ? 'success' : 'fail');

      if (ok) {
        window.location.href = '/order/success';
      } else {
        alert('결제 승인에 실패했습니다. 다른 결제 수단을 시도해주세요.');
      }
    } catch (err) {
      // 사용자가 결제창을 닫았거나(AbortError) 기타 오류
      console.error(err);
    } finally {
      setLoading(false);
    }
  }, [createRequest]);

  return (
    <button type="button" onClick={onClick} disabled={loading}>
      {loading ? '처리 중…' : '빠른 결제'}
    </button>
  );
}

3. 동적 합계/배송 업데이트 포인트

- shippingaddresschange: 사용자가 배송지를 변경할 때 호출됩니다. 국가/우편번호에 따라 배송비와 총합을 재계산합니다.

- shippingoptionchange: 사용자가 배송 옵션을 바꿨을 때 호출됩니다. 선택 상태와 총합을 업데이트합니다.

- updateWith는 Promise 또는 객체를 받습니다. 비동기 계산(배송비 API 조회 등)에도 안전합니다.

4. 서버 검증과 영수증 처리

- response.details는 결제수단에 따라 다른 형태를 가집니다. 실제 결제 승인/정산은 반드시 서버에서 수행합니다.

- 서버에서 승인 후 response.complete('success')를 호출해야 UI가 정상 종료됩니다. 실패 시 'fail'을 전달하고 폴백 경로를 안내합니다.

5. 폴백 전략

- PaymentRequest 미지원, canMakePayment=false, 사용자 취소 시 전통적인 체크아웃 페이지로 폴백합니다.

- 버튼 문구를 “빠른 결제”로 유지하고 옆에 “다른 결제” 링크를 제공하면 이탈을 줄일 수 있습니다.

6. 실제 결제 메서드 연결 예시

아래는 서비스 적용 시 methodData를 교체하는 예시입니다. 각 메서드는 별도 상점 등록/인증이 필요합니다.

- Apple Pay(Safari):

const methodData = [
  {
    supportedMethods: 'https://apple.com/apple-pay',
    data: {
      version: 12, // Safari 버전에 맞춰 설정
      merchantIdentifier: 'merchant.com.example',
      merchantCapabilities: ['supports3DS'],
      supportedNetworks: ['visa', 'masterCard'],
      countryCode: 'KR',
      currencyCode: 'KRW'
    }
  }
];

- Secure Payment Confirmation(SPC): FIDO/WebAuthn 기반 강인증. 선행 등록된 credentialIds가 필요합니다.

const methodData = [
  {
    supportedMethods: 'secure-payment-confirmation',
    data: {
      action: 'authenticate',
      credentialIds: [/* Uint8Array 목록 */],
      payeeName: 'My Store',
      payeeOrigin: 'https://yourdomain.com',
      instrument: {
        displayName: 'Visa ••34',
        icon: 'https://yourdomain.com/card.png'
      },
      timeout: 60000
    }
  }
];

- 결제 게이트웨이 연동: 일부 게이트웨이는 Payment Handler 또는 자체 JS SDK를 제공합니다. Payment Request로 가능한 환경에서는 methodData를 사용하고, 불가할 때는 SDK 폼으로 폴백하세요.

7. React/SSR 실무 팁

- PaymentRequest 인스턴스는 사용자 제스처 안에서 생성하고 show()를 호출합니다. 렌더 단계에서 생성하지 마세요.

- SSR 환경에서는 typeof window 체크로 안전하게 가드합니다.

- 상태 관리: 결제 중 버튼 비활성화, 중복 클릭 방지, 에러 알림을 명확히 합니다.

- 금액은 소수/통화 형식에 주의합니다. KRW는 소수점이 없지만 API는 문자열을 요구합니다.

8. 보안과 접근성

- HTTPS, 엄격한 CSP, 서버 측 승인/검증을 필수로 적용합니다.

- 스크린리더 친화적 버튼 라벨과 키보드 포커스 관리로 접근성을 지킵니다.

- 로깅 시 결제 상세 정보(카드 PAN 등)는 절대 저장/출력하지 않습니다.

9. 테스트 체크리스트

- 실제 단말(안드로이드, iOS Safari)에서 테스트합니다. 결제 핸들러/지갑이 설치되어 있어야 canMakePayment가 true가 됩니다.

- 사용자 취소, 네트워크 오류, 서버 승인 실패, 배송지 변경 등 엣지 케이스를 모두 시뮬레이션합니다.

- 지원 불가 환경에서도 폴백이 매끄럽게 동작하는지 확인합니다.

정리하면, Payment Request API는 지원되는 환경에서 체크아웃을 빠르게 만들어주는 훌륭한 도구입니다. React에서는 사용자 제스처 기반으로 동적으로 생성하고, 서버 검증과 폴백을 함께 구성하면 안정적으로 도입할 수 있습니다. 실제 결제수단은 서비스와 브라우저 지원에 맞춰 Apple Pay, SPC 등으로 구성하고, 기본 카드 입력은 게이트웨이 SDK 폼으로 보완하는 전략이 현실적입니다.