콘텐츠로 이동

Apple App Store 결제 구현하기

Apple App Store에서 소모성 상품을 판매하고 사용자에게 지급하려면 아래 절차를 순서대로 완료하세요.

시작하기 전에 공통 사전 준비를 마치세요.

전체 흐름

영수증 검증과 상품 지급은 9단계에서 앱 서버가 Hive Axyl 서버 API로 처리합니다. 각 단계에서 무엇을 조작하고 무엇을 호출하는지는 아래와 같습니다.

순서 구분 하는 일
1 스토어 콘솔 App Store Connect에서 인증 키 발급
2 Hive 콘솔 인증 정보 등록과 상품 등록
3 Hive Axyl SDK, 스토어 플러그인 SDK 모듈과 Apple 결제 플러그인 설치 및 등록
4 레시피 코드 레시피 폴더 복사
5 Hive Axyl SDK, 스토어 플러그인 상품 목록 조회
6 레시피 코드 PurchaseOrder 생성
7 레시피 코드 구매 시작
8 레시피 코드 구매 정보 준비
9 앱 코드, 앱 서버, Hive Axyl 서버 API 영수증 검증과 상품 지급
10 레시피 코드 거래 종료
11 Hive Axyl SDK 지급 결과 기록
12 Hive 콘솔, Hive Axyl 서버 API 구매 내역 확인
거래 종료는 상품 지급이 끝난 뒤에 호출하세요

10단계의 거래 종료는 이 구매가 끝났다고 표시하는 동작입니다. 미지급 구매 복구는 종료되지 않은 구매만 찾으므로, 지급보다 종료를 먼저 처리하면 도중에 앱이 중단되었을 때 해당 구매를 다시 찾을 수 없습니다.

1. App Store Connect 설정

App Store Connect에서 Private Key와 Private Key ID, Issuer ID를 발급받으세요. Hive Axyl 서버가 이 값으로 Apple에 영수증 검증을 요청합니다.

App Store Connect에 판매할 인앱 상품도 함께 등록하세요. 여기에 등록한 상품 ID를 2단계에서 그대로 사용합니다.

2. Hive 콘솔 설정

  • Hive 콘솔  Hive 콘솔에서 설정하거나 확인합니다.

1단계에서 받은 값을 Hive 콘솔에 등록하고 판매할 상품을 등록하세요.

설정 항목 필수 여부 확인할 곳
인증 키 등록 필수 App Store 인증 정보
상품 등록과 가격 입력 필수 상품 정보 등록
상품 ID를 두 콘솔에서 일치시키세요

App Store Connect에 등록한 상품 ID와 Hive 콘솔에 등록한 상품 ID가 같아야 합니다. 값이 다르면 영수증 검증이 실패합니다.

Apple App Store 결제에서 실제 청구 금액은 App Store가 관리합니다. Hive 콘솔의 상품 등록 화면에는 가격을 직접 입력하며, 이 값은 콘솔에서 금액을 확인하는 용도로 사용합니다.

3. SDK 모듈과 플러그인 준비

필요한 SDK 모듈과 Apple 결제 플러그인을 설치한 뒤 SDK를 초기화할 때 등록하세요. 결제는 로그인한 사용자를 기준으로 동작하므로 결제 모듈과 함께 인증 모듈 AddAuth()와 토큰 모듈 AddToken()도 등록해야 합니다. 결제 모듈을 등록하지 않으면 레시피가 FailedPrecondition 오류로 실패합니다.

  • 설치할 패키지
    • com.com2usplatform.hiveaxyl.core: SDK 초기화와 공통 기능
    • com.com2usplatform.hiveaxyl.auth: 결제 주체인 사용자의 로그인 세션과 토큰 발급
    • com.com2usplatform.hiveaxyl.payments: 결제 서버 호출
    • com.com2usplatform.hiveaxyl.payments.addon.apple: StoreKit 결제창 호출

아래는 SDK를 초기화할 때 인증, 토큰, 결제 모듈과 Apple 결제 플러그인을 등록하는 예제 코드입니다. 예제는 개발과 테스트를 위해 결제 모듈을 샌드박스 서버에 연결하므로, 실제 서비스 빌드에서는 sandbox: true 없이 AddPayments()로 등록하세요.

using Hive.Axyl.Core;
using Hive.Axyl.Core.Unity;
using Hive.Axyl.Auth;
using Hive.Axyl.Payments;
using Hive.Axyl.Payments.Addon.Apple;

var config = CoreConfig.CreateBuilder("{appId}").Build();

HiveBootstrap.Initialize(config, builder =>
{
    builder.AddAuth();
    builder.AddToken();
    builder.AddPayments(sandbox: true);
    builder.AddStoreKit();
});

4. 레시피 코드 설치

  • 레시피 코드  레시피 코드를 프로젝트에 복사합니다.

레시피는 패키지가 아니라 프로젝트에 복사해서 사용하는 소스 코드입니다. axyl-samples-unity 저장소에서 아래 항목을 Unity 프로젝트의 Assets/Recipes/ 아래로 복사하세요.

  • 복사 대상
    • Recipes.asmdef: 공통 어셈블리 정의
    • AssemblyInfo.cs: 내부 헬퍼를 다른 레시피 어셈블리에 공개하는 설정
    • Helper/: 여러 레시피가 함께 사용하는 공통 코드
    • Payments/: 레시피 본체와 공통 타입
    • Payments.Apple/: Apple App Store 담당 코드

앱 코드에서 레시피를 호출하려면 앱 쪽 어셈블리 정의의 references에 Hive.Axyl.Samples.Recipes, Hive.Axyl.Samples.Recipes.Payments, Hive.Axyl.Samples.Recipes.Payments.Apple을 추가하세요.

Apple 결제 플러그인이 없으면 빌드에서 제외됩니다

Payments.Apple/의 어셈블리 정의는 Apple 결제 플러그인 패키지가 설치되어 있을 때만 컴파일됩니다. 플러그인을 설치하지 않아도 컴파일 오류가 발생하지 않고 해당 어셈블리만 빌드에서 제외됩니다.

5. 상품 목록 조회

  • Hive Axyl SDK  Hive Axyl SDK를 앱에서 호출합니다.

    스토어 플러그인  스토어 결제 플러그인을 앱에서 호출합니다.

상품 목록 조회에는 레시피가 없습니다. Hive 콘솔에 등록한 상품 ID와 App Store가 가진 가격 정보를 합쳐야 하므로 아래 순서대로 직접 호출하세요.

순서 구분 호출 확인할 곳
1 Hive Axyl SDK ListStoreProductIdsAsync()로 서버에 등록된 상품 ID 조회 상품 ID 목록 조회
2 스토어 플러그인 GetProductsAsync()로 StoreKit에서 가격 조회 Apple App Store 상품 정보 조회
3 Hive Axyl SDK FetchAppleProductsAsync()로 서버가 두 결과를 병합 상품 상세 정보 조회

병합 결과의 ProductId, Price, Currency를 그대로 6단계의 PurchaseOrder에 넣으세요. 화면에 표시한 문자열을 다시 파싱하거나 값을 하드코딩하면 사용자가 본 가격과 결제 요청 금액이 달라집니다. Price가 비어 있는 상품은 가격을 확인하지 못한 상품이므로 판매하지 마세요.

6. PurchaseOrder 생성

  • 레시피 코드  레시피 코드를 앱에서 호출합니다.

PurchaseOrder는 사용자가 어떤 상품을 얼마에 구매하려는지를 담는 레시피 타입이며, 모든 결제 레시피의 시작점입니다.

using Hive.Axyl.Samples.Recipes;

// 앱이 한 번 정하고 절대 바꾸지 않는 네임스페이스 UUID
const string k_Namespace = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";

AccountUuid.TryCompute(k_Namespace, playerId, out var accountUuid);

if (product.Price == null)
{
    // 가격을 확인하지 못한 상품은 판매하지 않습니다.
    return;
}

var order = new PurchaseOrder(
    productId:     product.ProductId,     // 5단계에서 조회한 값
    price:         product.Price.Value,   // 화면에 표시한 가격
    currency:      product.Currency,
    country:       "KR",
    language:      "ko",
    storePlayerId: 0L,                    // Apple App Store는 사용하지 않습니다
    serverId:      "server01",
    iapPayload:    "{\"character\":\"A\"}",
    appVersion:    Application.version,
    accountUuid:   accountUuid);

AccountUuid.TryCompute()는 로그인한 사용자의 playerId로 UUIDv5를 만드는 레시피 헬퍼입니다. 레시피는 이 값을 StoreKit의 AppAccountToken으로 전달하고, Hive Axyl 서버는 영수증을 검증할 때 두 값을 대조합니다. 생성 규칙은 AccountUuid 생성을 참조하세요.

iapPayload에는 지급 대상을 특정할 정보를 넣으세요. 검증 응답에 그대로 돌아오므로, 나중에 구매를 복구할 때 누구에게 무엇을 지급해야 하는지 판단하는 근거가 됩니다.

7. 구매 시작

  • 레시피 코드  레시피 코드를 앱에서 호출합니다.

사용자가 결제 버튼을 누르면 레시피의 InitiatePurchaseAsync()를 호출하세요. 이 메서드 하나가 구매 의사를 서버에 기록하고, StoreKit 결제창을 열어 결과를 받는 것까지 처리합니다.

구매를 시작하기 전후로 아래 두 가지를 앱이 직접 기록하세요. 예제 코드의 RecordOrderAsync()와 pendingStore는 앱이 만드는 기록 메서드와 저장소입니다. pendingStore의 저장과 삭제는 사용 예제의 IPurchaseApplication에서 SavePendingAsync()와 MarkClosedAsync()가 맡는 역할입니다. 영수증과 토큰 값은 로그에 남기지 마세요.

기록 기록 시점 키 용도
주문 기록 InitiatePurchaseAsync() 호출 전 계정과 상품 ID 보호자 승인을 기다리는 결제는 started.Pending 없이 끝났다가 나중에 완료되므로, 이런 구매를 복구할 때 구매 당시 금액을 채웁니다. 앱을 다시 설치해도 남도록 앱 서버에 기록하세요.
구매 정보 Pending을 받을 때마다, 결과 상태를 확인하기 전 StoreTransactionId 검증이나 종료가 실패해도 이 구매의 영수증과 주문 정보를 잃지 않습니다. 앱을 다시 실행해도 남아 있어야 하며, 거래 종료에 성공한 뒤에만 지웁니다.
var recipe = new ConsumablePurchaseRecipe(new ApplePurchaseSource());

// 구매를 시작하기 전에 구매 당시의 주문 정보를 기록합니다.
await RecordOrderAsync(order);

InitiatePurchaseOutcome started = await recipe.InitiatePurchaseAsync(order, token);

if (started.Pending != null)
{
    // 결과 상태와 관계없이 먼저 저장합니다.
    await pendingStore.SaveAsync(started.Pending);
}

switch (started.Status)
{
    case InitiatePurchaseStatus.Success:
        // 8단계로 진행합니다.
        break;

    case InitiatePurchaseStatus.UserCanceled:
        // 결제창만 닫혔습니다. 아무것도 청구되지 않았습니다.
        break;

    case InitiatePurchaseStatus.BusinessOutcome
        when started.BusinessOutcome == PurchaseBusinessOutcome.StorePurchasePending:
        // 보호자 승인을 기다리는 중입니다. 지급하지 마세요.
        // 승인되면 미지급 구매 복구에서 처리합니다.
        break;

    default:
        Debug.LogError($"{started.FailedStep}: {started.BusinessOutcome} {started.Error?.Message}");
        break;
}

Apple App Store는 복수 결제를 지원하지 않습니다. ApplePurchaseSource는 위 예시처럼 인자 없이 생성하세요. 사용자가 같은 상품을 여러 개 사려면 결제를 그 횟수만큼 따로 진행해야 합니다.

레시피가 내부에서 수행하는 작업은 아래와 같습니다.

구분 호출 확인할 곳
Hive Axyl SDK CreatePrePurchaseAsync()로 구매 의사를 서버에 기록 구매 사전 정보 저장
스토어 플러그인 PurchaseAsync()로 결제창 호출과 결과 수신 Apple App Store 결제 요청

StoreKit이 트랜잭션 서명을 확인하지 못한 경우에도 레시피는 구매를 중단하지 않고 진행하며, StoreKit이 남긴 오류를 started.Pending의 StoreVerificationError에 담아 전달합니다. 최종 판단은 앱 서버가 요청하는 Hive Axyl 서버의 영수증 검증이 내리며, 이 검증은 확인되지 않는 영수증을 거절합니다. 앱 정책으로 따로 판단하려면 상품을 지급하기 전에 이 값을 확인하세요.

성공하면 started.Pending에 PendingPurchase가 담깁니다. 이 객체를 그대로 다음 단계에 전달하세요. 영수증 문자열만 따로 꺼내어 전달하면 거래를 종료할 때 필요한 트랜잭션 ID가 사라집니다.

8. 구매 정보 준비

  • 레시피 코드  레시피 코드를 앱에서 호출합니다.

PreparePurchaseAsync()를 호출해 구매 결과를 Hive Axyl 서버에 기록하고, 앱 서버에 보낼 구매 정보를 준비하세요. 레시피는 이 단계에서 영수증 검증을 요청하지 않으므로, 성공 결과를 받아도 아직 상품을 지급하면 안 됩니다.

PreparePurchaseOutcome prepared = await recipe.PreparePurchaseAsync(started.Pending, token);

if (prepared.Pending != null)
{
    // 준비 단계가 돌려준 값으로 저장한 구매 정보를 교체합니다.
    await pendingStore.SaveAsync(prepared.Pending);
}

if (prepared.Status != PreparePurchaseStatus.Success)
{
    // 저장한 구매 정보는 지우지 않습니다. 미지급 구매 복구에서 마무리합니다.
    return;
}

// 9단계에서 prepared.Pending을 앱 서버에 보냅니다.

레시피가 내부에서 수행하는 작업은 아래와 같습니다.

구분 호출 확인할 곳
Hive Axyl SDK RecordStorePurchaseAsync()로 구매 결과 저장 결제 결과 데이터 저장

9. 영수증 검증과 상품 지급

  • 앱 코드  앱에서 직접 구현합니다.

    앱 서버  앱 서버에서 직접 구현합니다.

    Hive Axyl 서버 API  앱 서버에서 Hive Axyl 서버 API를 호출합니다.


    상세 절차: Hive Axyl Server API로 영수증 검증

영수증 검증과 상품 지급은 앱 서버가 처리하며, 레시피는 이 단계에 관여하지 않습니다. 앱이 8단계의 prepared.Pending을 앱 서버에 보내면, 앱 서버는 Hive Axyl 서버 API로 영수증을 검증하고 상품을 한 번만 지급한 뒤 그 결과를 앱에 응답합니다.

9.1. 앱 서버에 검증과 지급 요청

앱에서 앱 서버를 호출해 영수증 검증과 상품 지급을 요청하세요. 앱 서버에는 prepared.Pending의 Market, AxylReceipt, VerifyToken, OrderId, StoreTransactionId와 Order에 담긴 주문 정보를 보내세요. 앱 서버를 호출하는 방법과 응답 형식은 앱이 정합니다.

예제 코드의 VerifyAndGrantOnceAsync()는 앱 서버를 호출하는 앱의 메서드입니다. 사용 예제에서는 IPurchaseApplication.VerifyAndGrantOnceAsync()가 같은 역할을 합니다. 이 메서드는 아래와 같이 구현하세요.

  • 두 번째 인자 requestType: 신규 구매인지 구매 복구인지를 나타내는 값. 인자 형식은 앱이 정함
    • 예제 코드: 영수증 검증 요청의 requestType에 넣을 값 1을 그대로 전달
    • 사용 예제: PurchaseVerificationKind.NewPurchase 또는 PurchaseVerificationKind.Recovery를 전달하고, 앱 서버 연동 코드가 이 값을 각각 1과 2로 바꿈
  • 반환값: 앱 서버가 지급 완료로 응답하면 new PurchaseDeliveryConfirmation(), 거절이나 실패로 지급을 확인하지 못하면 null
  • hiveAxylTransactionId 보관: PurchaseDeliveryConfirmation에는 이 값을 담는 필드가 없으므로, 앱 서버 응답에서 따로 보관해 11단계의 지급 결과 기록에 사용
// 앱 서버에 영수증 검증과 상품 지급을 요청합니다.
PurchaseDeliveryConfirmation delivery = await VerifyAndGrantOnceAsync(
    prepared.Pending,
    requestType: 1,   // 1: 신규 구매. 사용 예제의 PurchaseVerificationKind.NewPurchase
    token);

if (delivery == null)
{
    // 앱 서버가 지급을 확인하지 않았습니다.
    // 저장한 구매 정보를 그대로 두고 거래를 종료하지 않습니다.
    return;
}

// 앱 서버 응답의 hiveAxylTransactionId는 VerifyAndGrantOnceAsync()가 따로 보관합니다.
// 11단계에서 axylTransactionId로 사용합니다.

9.2. Hive Axyl 서버 API로 영수증 검증

앱 서버는 Hive Axyl 서버 API의 POST /payment/v1/purchase/verify를 호출해 영수증을 검증하세요. 요청 헤더 X-App-Id에는 Hive 콘솔에 등록한 App ID를 넣고, 요청을 보낼 주소는 기본 URL을 참조하세요. 호출에 필요한 앱 서버용 액세스 토큰은 토큰 발급으로 받습니다. 요청과 응답의 모든 필드는 소모성 상품 영수증 검증을 참조하세요.

요청 필드에는 앱이 보낸 PendingPurchase 값을 아래와 같이 넣습니다.

  • providerId: APPLE
  • axylReceipt: VerifyToken에 담긴 StoreKit 트랜잭션 JWS
  • productId, price, currency, country, language, serverId, iapPayload, accountUuid, appVersion: Order의 같은 이름 값
  • orderId: OrderId
  • storeTransactionId: StoreTransactionId
  • requestType: 신규 구매를 나타내는 1

9.3. 상품 지급과 지급 완료 응답

앱 서버는 검증 응답을 확인한 뒤 상품을 한 번만 지급하고 그 결과를 앱에 응답하세요. 앱 서버가 해야 할 일은 아래와 같습니다.

  1. 검증 응답의 hiveAxylProductId, hiveAxylPrice, hiveAxylCurrency가 사용자에게 판매한 상품, 금액과 같은지 확인합니다. hiveAxylPurchaseCancelState가 1이면 취소된 결제이므로 지급하지 않습니다.
  2. 앱 서버의 지급 기록에서 이미 지급한 구매인지 확인합니다. hiveAxylDuplicated가 true이면 반드시 확인해야 합니다. 이미 지급한 구매라면 3번을 건너뜁니다.
  3. 상품을 지급하고 지급 완료를 기록합니다.
  4. 앱에 지급 완료로 응답합니다. 이미 지급한 구매도 지급 완료로 응답해야 앱이 10단계에서 거래를 종료합니다. 11단계의 지급 결과 기록에 쓰도록 검증 응답의 hiveAxylTransactionId도 함께 돌려줍니다.

지급을 판단할 때 함께 확인하는 응답 값은 아래와 같습니다.

  • hiveAxylAccountUuidCompare: 결제한 계정과 요청한 계정의 대조 결과. 1은 일치, 2는 불일치, 9는 대조할 수 없음
  • hiveAxylPayload: 6단계에서 전달한 iapPayload가 그대로 돌아오는 값
앱이 보낸 값만 믿고 지급하지 마세요

앱 서버는 요청한 사용자의 계정을 직접 인증하고, 상품과 금액은 검증 응답으로 확인하세요. 앱이 만드는 PurchaseDeliveryConfirmation은 결제를 증명하는 값이 아닙니다. 지급 여부는 언제나 앱 서버가 판단합니다.

10. 거래 종료

  • 레시피 코드  레시피 코드를 앱에서 호출합니다.

앱 서버가 지급 완료로 응답한 뒤에 ClosePurchaseAsync()를 호출하세요. 9단계에서 만든 delivery가 없으면 레시피는 거래를 종료하지 않고 Failure를 돌려줍니다.

ClosePurchaseOutcome closed = await recipe.ClosePurchaseAsync(
    prepared.Pending,   // 8단계가 돌려준 값
    delivery,           // 9단계에서 앱 서버의 지급 완료 응답으로 만든 값
    token);

if (closed.Status == ClosePurchaseStatus.Success)
{
    // 구매가 끝났으므로 저장한 구매 정보를 지웁니다.
    await pendingStore.DeleteAsync(prepared.Pending);
}
else
{
    // 검증과 지급은 이미 끝났고 구매만 열려 있는 상태입니다.
    // 저장한 구매 정보를 그대로 두고 다시 호출하거나 미지급 구매 복구에서 마무리합니다.
}

레시피가 내부에서 수행하는 작업은 아래와 같습니다.

구분 호출 확인할 곳
Hive Axyl SDK RequestPurchaseAsync()로 결제 확정 결제 확정 요청
스토어 플러그인 FinishTransactionAsync()로 StoreKit 트랜잭션 완료 거래 완료 처리
트랜잭션 완료 처리는 선택이 아닙니다

완료 처리하지 않은 트랜잭션은 앱을 실행할 때마다 StoreKit이 다시 전달합니다.

11. 지급 결과 기록

지급이 끝나면 그 결과를 Hive Axyl 서버에 기록하세요. 이 호출은 결제를 확정하거나 지급 여부를 결정하지 않고 운영용 기록만 남깁니다.

AxylTransactionId에는 9.1의 VerifyAndGrantOnceAsync()가 앱 서버 응답에서 따로 보관한 hiveAxylTransactionId를 넣으세요.

using Hive.Axyl.Payments;

var payments = HiveCore.Resolve<IPaymentsService>();

await payments.ItemResultAsync(new ItemResultBody
{
    AxylTransactionId = axylTransactionId,   // 9.1에서 앱 서버 응답으로 받아 따로 보관한 hiveAxylTransactionId
    Status = 1,   // 1: 지급 성공
    Assets = new[]
    {
        new ItemResultAsset { AssetId = "gold", AssetName = "골드", Quantity = 100 },
    },
});

12. 구매 내역 확인

  • Hive 콘솔  Hive 콘솔에서 설정하거나 확인합니다.

    Hive Axyl 서버 API  앱 서버에서 Hive Axyl 서버 API를 호출합니다.

운영 중에는 구매 내역을 조회해 결제 상태와 지급 상태를 확인하세요.

구분 방법 확인할 곳
Hive 콘솔 콘솔에서 조회 조건을 지정해 확인 조회 조건 지정
Hive Axyl 서버 API 앱 서버에서 구매 내역 조회 API 호출 구매 내역 조회

실패 처리

레시피 메서드는 예외를 던지지 않고 결과 객체를 돌려줍니다. 언제나 Status부터 확인하세요.

Status 의미 앱이 할 일
Success 해당 단계가 정상적으로 끝났습니다. 다음 단계로 진행합니다.
BusinessOutcome Hive Axyl 서버나 마켓이 요청을 거절했습니다. BusinessOutcome 값에 따라 분기합니다. 값별 대응 방법은 결제 레시피의 거절 사유를 참조하세요.
Failure 네트워크 오류나 타임아웃 같은 기술적 실패입니다. Error를 기록하고 재시도 여부를 판단합니다.
UserCanceled 사용자가 결제창을 닫았습니다. 오류가 아니므로 상점으로 돌아갑니다.

UserCanceled는 구매를 시작하는 단계에만 있습니다. 준비와 종료 단계의 결과 타입에는 없으므로 그 값으로 분기하면 컴파일되지 않습니다.

실패해도 PendingPurchase를 버리지 마세요

영수증을 받은 시점에는 이미 사용자에게 청구가 끝난 상태입니다. PreparePurchaseOutcome과 ClosePurchaseOutcome은 실패할 때도 전달받은 Pending을 그대로 돌려줍니다. 돌아온 Pending으로 7단계에서 저장한 구매 정보를 교체해 두어야 나중에 그 구매를 마무리할 수 있습니다. 9단계에서 앱 서버가 지급을 확인하지 못한 경우에도 구매 정보를 그대로 두세요. 저장한 구매 정보는 거래 종료에 성공한 뒤에만 지우세요.

다음 단계

결제는 끝났지만 지급이 마무리되지 않은 구매를 처리하려면 Apple App Store 미지급 구매 복구를 참조하세요.