/verifyReceipt deprecated 되었는데도.. 아직 그 예시도 돌아다니고..인앱결제 영수증 생성 테스트를 하기 위해서 맥미니에서 더미 앱을 만드는 방법과(상품 조회, 결제하기 버튼 만 구현) 내가 할일은 애플 상품 Id 와 서버 주소만 입력할 수 있도록 만들어줘| 유형 | 설명 |
|---|---|
| 소모품 | 한번 사용하면 고갈되어 다시 구입 |
| 비소모품 | 한번 구입 후 사용 시 만료되거나 수량 감소하지 않는 제품 |
| 자동 갱신 구독 | 사용자가 정해진 기간 동안 동적 컨텐츠 구매할 수 있도록 하는 제품, 사용자 취소하지 않는 한 자동 갱신 |
| 비자동 갱신 구독 | 사용자가 제한된 기간 동안 컨텐츠 액세스 |
| Transaction Information | Refunds and Customer Satisfaction | App Store Server Notifications |
|---|---|---|
| Get Transaction History | Send Consumption Information | Get Notification History |
| Get Transaction Info | Get Refund History | Request a Test Notification |
| Get All Subscription Statuses | Extend a Subscription Renewal Date | Get Test Notification Status |
| Look Up Order ID | Extend Subscription Renewal Dates for All Active Subscribers | |
| Get Status of Subscription Renewal Date Extensions |

App Store Server API와 App Store 서버 알림 사용을 위한 기반
App Store 서버와의 통합을 간소화하도록 만들어짐
App Store 서버 API용 클라이언트를 제공하여 엔드포인트 사용을 쉽게 시작할 수 있습니다.
서명된 데이터 검증 기능 내장
지원이 중단된 영수증에서 거래 정보를 추출하여 지원이 중단된 verifyReceipt Original StoreKit 클라이언트 프레임워크에서 전환할 수 있는 경로를 제공합니다.
프로모션 특가 서명을 간단하게 생성할 수도 있습니다.
프로덕션에 바로 사용할 수 있는 1.0 버전을 Java, Python, Node.js, Swift 4개의 언어로 선보였습니다.
App Store 서버 API를 이러한 언어에서 사용할 때 App Store Server Library를 사용하는 것을 권장
시작하려면 각 언어에 해당하는 패키지 관리자를 통해 라이브러리를 다운로드 합니다.
라이브러리의 또 다른 장점은 오픈소스 라는 점
WWDC23 세션 App Store Server Library 알아보기 참고이 글의 후반부에서 저는 이 라이브러리를 사용한 코드를 첨부하겠습니다.

- (고객 -> App Store Server) 고객 소모품 구입
- (기기 -> Server) 고객 기기는 서명된 거래 수신하고 서버로 전송(거래의 유효성 검사 및 컨텐츠 제공 목적)
- ONE_TIME_CHANGE 알림
- (저는 이 ONE_TIME_CHANGE 알림보다는 APP에서 오는 영수증을 활용하였습니다. 혹시 저 알림에 대해 좀 찾아보고 구현하는 방식도 있긴 할 것 같습니다.)

- (App -> Server) 고객 결제 영수증 제출
- (Server -> App Store Server) 영수증을 통해 transactionId 추출 요청
- (App Store Server -> Server) 디코딩한 transactionId 반환
- (Server -> App Store Server) transactionId에 대한 history 요청
- (App Store Server -> Server) 서명된 Transaction(거래 정보) 반환
/verifyReceipt 가 deprecated 되고 ReceiptUtility 를 통해 transactionId를 추출하도록 아래와 같이 변경됨.
- (고객 -> App Store Server) 고객 환불 요청
- (App Store Server -> Server)
CONSUMPTION_REQUEST알림 전송- (Server -> App Store Server)
Consumption Info알림 전송
- 고객 제품 사용 정보 요청 알림 수신
- 제품 사용 여부 / 환불 승인(불가) 사유 제출할 수 있음
- 이는 최종 결정에 참고됨(무조건 Server의 의견이 안받아 들일 수 있는듯)
- (App Store Server -> Server) 승인이나 거부하면 서버에 결과 알려주는 알림 전송
Refund결과일 경우 DB에서 처리Refund Declined결과일 경우 DB에서 처리하지 않음- (저는 이 ONE_TIME_CHANGE 알림보다는 APP에서 오는 영수증을 활용하였습니다. 혹시 저 알림에 대해 좀 찾아보고 구현하는 방식도 있긴 할 것 같습니다.)

@Service
@Slf4j
public class AppleService {
// 번들은 앱마다 고유
public static final String bundleId = "example";
// 이슈어, 키는 사용자별(apple developer) 고유
public static final String issuerId = "example";
public static final String keyId = "example";
// 환경 설정
public static final Environment environment = Environment.SANDBOX; // 실제 배포 시 PRODUCTION
// 앱의 Apple Id
public static final Long appAppleId = 123123213213213213213213213213123213123123123213exanL;
// 온라인 검증 여부 - 방금 수신한건 true, 몇달 몇년 전꺼라면 인증서 만료로 false 여야함
// 항상 최근꺼를 받으니 true
public static final boolean onLineChecks = true;
// 영수증에서 productId 추출기
public static final ReceiptUtility receiptUtil = new ReceiptUtility();
// 공통 객체 (인스턴스 변수)
private AppStoreServerAPIClient appStoreServerAPIClient;
private SignedDataVerifier signedDataVerifier;
@PostConstruct
public void init() {
try {
// 비공개 키 로드 - 2026년 1월 21일 만료
String encodedKey = loadPrivateKey("example.p8");
// App Store Server API 클라이언트 초기화
this.appStoreServerAPIClient = new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, environment);
// Apple PKI에서 인증서 다운(Root CA), 인증서 만료기간 있어서 확인 하고 만료 전 변경 필요
Set<InputStream> rootCAs = Set.of(
// 18:10:09 Apr 30, 2039
getResourceAsStream("AppleRootCA-G2.cer"),
// 18:19:06 Apr 30, 2039
getResourceAsStream("AppleRootCA-G3.cer"),
// 00:18:14 Feb 10, 2025
getResourceAsStream("AppleComputerRootCertificate.cer"),
// 21:40:36 Feb 9, 2035
getResourceAsStream("AppleIncRootCertificate.cer")
);
// SignedDataVerifier 초기화
this.signedDataVerifier = new SignedDataVerifier(rootCAs, bundleId, appAppleId, environment, onLineChecks);
log.info("ApplePurchaseService 초기화 완료");
} catch (FileNotFoundException e) {
log.error("비공개키, Root CA 중 하나 이상의 파일이 누락되었습니다.");
} catch (IOException e) {
log.error("비공개키, Root CA 파일을 로드하고 ApplePurchaseService를 초기화 하던 중 오류가 발생했습니다.");
}
}
// App Store Server API를 호출하는 외부 네트워크 통신이라 실패할 수 있으므로 3번까지 재시도
@Retryable(
value = {APIException.class, IOException.class, VerificationException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public JWSTransactionDecodedPayload decodedPayloadByAppleReceiptForNotSubscription(String receipt) throws APIException, IOException, VerificationException {
// 1. 영수증에서 TransactionId 추출
String transactionId = receiptUtil.extractTransactionIdFromAppReceipt(receipt);
// 2. 단일 거래 정보 추출(영수증에서 transationId 추출 & 해당 거래건에서 productionId 추출)
TransactionInfoResponse transactionInfo = appStoreServerAPIClient.getTransactionInfo(transactionId);
// 3. 거래정보에서 signedTransaction 정보를 얻어서 이거를 다시 디코딩해야 정보 확인 가능
String signedTransactionInfo = transactionInfo.getSignedTransactionInfo();
// 4. SignedDataVerifier를 사용하여 디코딩한 거래 정보 객체 반환
return signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
}
// 클래스패스에 있는 비공개 키 파일을 읽어 Base64 인코딩 문자열로 반환.
private String loadPrivateKey(String resourcePath) throws IOException {
ClassLoader classLoader = ApplePurchaseService.class.getClassLoader();
try (var inputStream = classLoader.getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new IOException("비공개 키 파일을 찾을 수 없습니다: " + resourcePath);
}
byte[] keyBytes = inputStream.readAllBytes();
return new String(keyBytes);
}
}
private InputStream getResourceAsStream(String path) throws IOException {
InputStream is = ApplePurchaseService.class.getClassLoader().getResourceAsStream(path);
if (is == null) {
throw new IOException("인증서를 찾을 수 없습니다: " + path);
}
return is;
}
}
Apple Root CertificatesObtaining an In-App Purchase key from App Store Connect 부분 참고transactionId 를 ReceiptUtility 로 추출한다.JWSTransactionDecodedPayload{
originalTransactionId='12312321312321',
// 이거 저장
transactionId='123123132312',
webOrderLineItemId='null',
bundleId='example',
// 이거 상품 정보에 있어야 함 -> DB에서 어떤 상품을 산건지 찾기
productId='example',
subscriptionGroupIdentifier='null',
// 구매일자 이거 활용하기 -> 밀리초 단위 Instant 객체 -> LocalDateTime
purchaseDate=1737776773000,
originalPurchaseDate=1737776773000,
expiresDate=null,
quantity=1,
type='Consumable',
appAccountToken=null,
inAppOwnershipType='PURCHASED',
signedDate=1737789629012,
revocationReason=null,
revocationDate=null,
isUpgraded=null,
offerType=null,
offerIdentifier='null',
environment='Sandbox',
storefront='KOR',
storefrontId='143466',
transactionReason='PURCHASE',
// 금액 - 저장할 필요 없음, 공식 문서에서 price 1000배 한거라 나옴
price=1100000,
currency='KRW',
offerDiscountType='null',
unknownFields=null
}
originalTransactionId = transactionId 이더라~~
Consumption_Request 를 수신하고 Server의 의견을 담아서 Consumption_Info 응답// CONSUMPTION_REQUEST norification (docoded)
{
"notificationType" : "CONSUMPTION_REQUEST",
"data" : {
// 새 필드 : 고객이 환불한 이유
// 아래 예시는 의도치 않게 구입함
"consumptionRequestReason" : "UNITENDED_PURCHASE",
"status" : 1,
"signedTransactionInfo" : {
"type" : "Auto-Renewable Subscription",
"appAccountToken" : "23a91ca7-...",
"purchaseDate" 171800000000,
"expiresDate" : 172000000000,
... // Additional fields not shown
},
"signedRenewalInfo" : {
"renewalDate" : 1720630800000,
"autoRenewStatus" : 1,
... // Additional data truncated
}
}
// Consumption request workflow
SignedDataVerifier verifier = new SignedDataVerifier(...);
AppStoreServerAPIClient apiClient = new AppStoreServerAPIClient(...);
String signedNotification = "ey...";
var notification = verifier.verifyAndDecodeNotification(signedNotification);
if(notification.getNotificationType() == NotificationTypeV2.CONSUMPTION_REQUEST) {
String signedTransactionInfo = notification.getData().getSingedTransactionInfo();
var transaction = verifier.verifyAndDecodeTransaction(signedTransactionInfo);
// -------- 알림 디코딩 & 거래 정보 가져오는 코드 위 부분은 동일 ---------
// ConsumptionRequestReason 포함됨
ConsumptionRequestReason reason = notification.getData().getConsumptionRequestReason();
// 환불 수락 또는 거부에 대한 선호 사항 표현하려는 경우 자체 로직에 따라 해당 사항 결정 가능 예 : determineRefundPreference
// 소비 요청 이유와 거래 고려할 수 있음
var refundPreference = determineRefundPreference(reason, transaction);
ConsumptionRequest consumptionRequest = new ConsumptionRequest()
.sampleContentProvided(true)
...
// ConsumptionRequest 객체에서 refundPreference 설정
.refundPreference(refundPreference);
// 이전과 마찬가지로 App Store 서버로 전송
apiClient.sendConsumptionData(transaction.getTransactionId(), consumptionRequest);
}
앱 -> 일반 -> App Store 서버 알림
ngrok 를 사용하면 편리하다.
Notification 날라올 때 형식
{
"signedPayload" : "asdkasdkjas,,..."
}
public void handleSignedNotification(String signedNotification) throws Exception {
ResponseBodyV2DecodedPayload payload = signedDataVerifier.verifyAndDecodeNotification(signedNotification);
NotificationTypeV2 type = payload.getNotificationType();
// Apple Server Notification의 경우 테스트 환경에서 ONE_TIME_CHARGE 알림만 활성화 되기에 테스트 용으로 한번 아래와 같이 바꿔서 비즈니스 로직 도는지 테스트
type = NotificationTypeV2.REFUND;
log.info("Apple NotificationType : {}", type);
switch (type) {
case CONSUMPTION_REQUEST:
handleConsumptionRequest(payload);
break;
case REFUND:
handleRefund(payload);
break;
case REFUND_DECLINED:
handleRefundDeclined(payload);
break;
// 그 외 필요 알림들은 로그만 찍기
default:
log.info("Unhandled notification: {}", type);
}
}
ONE_TIME_CHARGE 무시REFUND, REFUND_DECLINED 의 경우 비즈니스 로직만 돌리면 되니 다루지 않고 CONSUMPTION_REQUEST를 다루겠습니다. private void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification) throws Exception {
// 2) 알림 내 트랜잭션정보 (signedTransactionInfo) 추출
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
// 2-1) 검증 & 디코딩
JWSTransactionDecodedPayload decodedTransaction = signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
String transactionId = decodedTransaction.getTransactionId();
Sample sample = sampleService.findByAppleTransactionId(transactionId);
Example example = exampleService.findByAppleTransactionId(transactionId);
Boolean canRefund = false;
if (sample != null) {
canRefund = checkSampleStatusForApplePurchaseRefund(sample);
} else if (itemPurchaseInfo != null) {
canRefund = checkExampleStatusForApplePurchaseRefund(example);
}
// // 3. 환불 신청 이유 확인
// // "APPSTORE_INITIATED", "FAMILY_SHARING", "REQUESTED_REFUND" 등등
// ConsumptionRequestReason reason = notification.getData().getConsumptionRequestReason();
// log.info("ConsumptionRequest reason: {}", reason);
// 4. 환불 선호도
RefundPreference refundPreference = determineRefundPreference(canRefund);
// 5. ConsumptionRequest 객체 생성
ConsumptionRequest request = new ConsumptionRequest()
.accountTenure(AccountTenure.UNDECLARED) // 가입한지 얼마나 됐는지 제공 안함
// .appAccountToken() - null일때 빈값으로 채워주는 JSON 설정 있어 패스
.consumptionStatus(ConsumptionStatus.UNDECLARED) // 소비정보 제공 안함
.customerConsented(true) // true : 고객이 소비정보 제공하기를 동의, false : 제공 동의안했으면 알림 자체 응답하지 않아야함
// 상품 제공 여부 : 둘중 하나 null이 아니면 상품 일단 제공한 것, 그게 아니라면 다른 이유로 제공하지 못했음을 명시
.deliveryStatus((example != null || sample != null) ? DeliveryStatus.DELIVERED_AND_WORKING_PROPERLY : DeliveryStatus.DID_NOT_DELIVER_FOR_OTHER_REASON )
.lifetimeDollarsPurchased(LifetimeDollarsPurchased.UNDECLARED) // 여태 얼마나 결제 했냐? 제공 안함
.lifetimeDollarsRefunded(LifetimeDollarsRefunded.UNDECLARED) // 앱에서 받은 환불 총액? 제공 안함
.platform(Platform.UNDECLARED) // Apple로 소비했는지 여부? 이거 PC인지 구분할 수 없기에 제공 안함
.playTime(PlayTime.UNDECLARED) // 고객이 앱을 사용한 시간 ? 제공 안함
.refundPreference(refundPreference)
// 구매하기 전에 콘텐츠의 무료 샘플이나 평가판, 기능에 대한 정보를 제공했는지 여부 (학진테 정보 제공, 상품 미리보기 주니까 true)
.sampleContentProvided(true)
.userStatus(UserStatus.UNDECLARED); // 회원의 상태 ? 제공 안함
// 6. App Store로 전송
appStoreServerAPIClient.sendConsumptionData(transactionId, request);
}
CONSUME_INFO의 필수값 모두 채워서 메서드 호출하면 API 호출됨
Chat GPT, Claud, 뤼튼 모두 뭐.. 필수값이 2개라더니만;; 저 링크 주면서 어딜봐서 2개냐고 따져도 무슨 예시가 있다고하고;; 진짜 그냥 다 넣어야 합니다 공식문서에 있는거!
12시간 안에 응답하면 된다고도 나와있어서 수신한 ResponseBodyV2DecodedPayload 와 환불 신청 사유를 저장하고, 관리자가 12시간 안에 응답할 수 있도록 페이지를 구성해도 좋을 것 같긴한데.. 상당히 복잡할 것 같습니다.
Get Transaction Info 이걸 활용하는 거였음 ㅋㅋ고객이 Apple에 정보 제공 동의했는지를 꼭 물어봐야 하므로 약관에 넣거나 등 방안을 모색해보시길 바랍니다!