인앱결제, 서버에서 검증하기: Google Play & App Store
- 인앱결제
- 구글 플레이
- 앱스토어
- 보안
앱에서 구독이나 일회성 상품을 판매한다고 해봅시다. 사용자가 결제를 마쳤고, 앱에도 "결제 성공"이라는 결과가 왔습니다. 자, 이제 바로 기능을 열어주면 될까요?
그렇게 하면 문제가 생길 수 있습니다. 앱이 서버에 알려주는 결과만 믿으면, 변조된 앱이나 루팅·탈옥된 기기에서 실제로 돈을 내지 않고도 "결제 성공"이라고 속일 수 있기 때문입니다. 그래서 필요한 것이 서버 측 검증(server-side verification)입니다.
먼저, 서버 검증이 뭔가요?
앱에서 결제가 끝나면 Google Play나 App Store가 구매를 확인할 수 있는 정보를 앱에 전달합니다. 하지만 앱에서 그 정보만 확인하고 바로 유료 기능을 열어주는 것은 안전하지 않습니다.
대신 앱은 구매 정보를 내 서버로 보내고, 내 서버가 다시 Google 또는 Apple에게 실제 결제가 맞는지 확인합니다.
쉽게 말하면:
앱
↓ "결제했어요."
내 서버
↓ "Google / Apple, 정말 결제된 게 맞나요?"
Google / Apple
이 과정이 서버 측 결제 검증입니다. 이제 이 흐름을 Google Play와 App Store에서 각각 어떻게 구현하는지 살펴보겠습니다.
기본 흐름
Google Play와 App Store 모두 큰 흐름은 비슷합니다.
- 사용자가 앱 안에서 결제합니다 (Android는 Google Play Billing, iOS는 StoreKit).
- 앱이 결제 정보를 내 서버로 보냅니다 — Android는 주로 구매 토큰(purchase token), iOS는 트랜잭션 정보를 전달합니다.
- 내 서버가 Google 또는 Apple의 API를 호출해 실제 구매인지 확인합니다.
- 정상적인 구매이고 아직 처리하지 않은 결제라면, 사용자에게 산 것(이용 권한, entitlement)을 부여합니다. "Pro 잠금 해제"를 샀다면 Pro 기능을 켜주고, 구독이라면 구독 기간 동안 서비스를 이용하게 하는 것이죠.
핵심은 하나입니다.
클라이언트를 결제의 최종 증거로 믿지 마세요. 앱은 구매 정보를 전달할 뿐이고, 최종 판단은 Google이나 Apple이 확인해 준 결과를 기준으로 해야 안전합니다.
Google Play
서버가 Google Play에 접근할 준비하기
먼저 내 서버가 Google Play에 구매 정보를 물어볼 수 있어야 합니다. 이를 위해 Google Cloud 서비스 계정(Service Account)을 사용합니다 — 내 서버가 Google Play API에 접근할 때 쓰는 전용 계정입니다.
서비스 계정에 Play Console 접근 권한을 연결하고 Google Play Developer API를 켭니다. 코드에서는 androidpublisher 권한(scope)을 사용합니다 — 내 서버가 Google Play의 앱·결제 API를 쓸 수 있게 해주는 권한입니다.
import { google } from "googleapis";
const auth = new google.auth.GoogleAuth({
keyFile: "service-account.json",
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const androidpublisher = google.androidpublisher({ version: "v3", auth });
서비스 계정의 키 파일은 서버에서만 관리하세요. 앱 안에 넣거나 공개 저장소에 올리면 안 됩니다.
일회성 상품 확인하기
사용자가 일회성 상품을 구입하면 Google Play가 구매 토큰(purchase token)을 발급합니다. 앱은 이 토큰을 내 서버로 전달하고, 서버는 purchases.products.get API로 Google Play에 "이 구매가 실제로 존재하고 결제가 완료된 게 맞나요?"라고 확인합니다.
const res = await androidpublisher.purchases.products.get({
packageName: "com.example.app",
productId: "pro_unlock",
token: purchaseToken,
});
if (res.data.purchaseState === 0) {
// 실제 구매가 완료된 경우
grantEntitlement(); // 중복 처리 방지를 위해 purchaseToken을 저장해 둡니다
}
purchaseState는 구매 상태를 나타냅니다.
0: 구매 완료1: 취소2: 결제 대기 중
결제가 대기 중(pending)이라면 아직 기능을 열어주면 안 됩니다. 실제 구매 완료 상태가 된 뒤에 이용 권한을 부여하세요.
확인 후에는 acknowledge가 필요합니다
Google Play에서는 구매를 확인하고 기능을 제공한 뒤 한 가지 작업이 더 필요합니다. 바로 acknowledge입니다. Google에 "이 구매를 정상적으로 처리했습니다"라고 알려주는 과정이죠. 사용자에게 다시 결제 승인을 받는다는 뜻이 아닙니다.
- 비소모성 상품이라면 서버에서
purchases.products.acknowledge를 호출합니다. - 소모성 상품은
consume처리를 하면 acknowledge도 함께 처리됩니다.
3일 안에 acknowledge하지 않으면 Google Play가 해당 구매를 자동으로 환불하므로, 빠뜨리지 마세요.
구독은 조금 다릅니다
구독은 한 번 확인하고 끝나는 결제가 아닙니다. 오늘은 정상이어도 이후에 상태가 바뀔 수 있습니다 — 갱신, 결제 실패, 유예 기간, 취소, 만료, 재구독처럼요.
서버에서는 purchases.subscriptionsv2.get으로 현재 구독 상태를 확인합니다.
const sub = await androidpublisher.purchases.subscriptionsv2.get({
packageName: "com.example.app",
token: purchaseToken,
});
// sub.data.subscriptionState 확인 — 활성, 만료, 유예 기간 등
유예 기간(grace period)은 구독 결제가 실패했지만 Google이 다시 결제를 시도하는 동안 구독을 바로 끊지 않고 잠시 유지해 주는 기간입니다. 그래서 구독은 최초 결제 때 한 번만 확인해서는 안 됩니다.
구독 상태가 나중에 바뀌면 어떻게 알까요?
사용자가 앱을 열 때만 구독 상태를 확인하면 문제가 생깁니다. 사용자가 앱을 쓰지 않는 동안에도 구독이 갱신·취소되거나 환불이 일어날 수 있으니까요.
Google은 이를 위해 Real-Time Developer Notifications(RTDN)를 제공합니다 — 구매나 구독 상태가 바뀌면 Google이 내 서버에 알려주는 기능입니다. 이 알림은 Cloud Pub/Sub를 통해 전달됩니다 — Google에서 내 서버까지 알림을 실어 나르는 서비스죠.
구독 상태 변경
↓
Google Play
↓
RTDN
↓
Cloud Pub/Sub
↓
내 서버
서버는 알림을 받으면 Google Play API에서 현재 상태를 다시 확인하고 사용자의 이용 권한을 업데이트합니다.
App Store (iOS)
Apple도 기본 원리는 같습니다. 앱이 보내온 결제 결과만 믿지 않고, 서버에서 Apple이 제공하는 거래 정보를 확인합니다. 다만 Google과 사용하는 방식과 용어가 조금 다릅니다.
StoreKit 2와 JWS
iOS 앱에서 인앱결제를 구현할 때는 StoreKit 2를 사용할 수 있습니다 — iOS 앱의 결제를 담당하는 Apple의 프레임워크입니다. StoreKit 2에서 얻는 거래 정보에는 Apple의 전자서명이 포함될 수 있습니다.
이때 자주 등장하는 용어가 JWS(JSON Web Signature)입니다 — Apple이 전자서명한 결제 정보죠. 서명을 확인할 수 있기 때문에, 앱이 만들어낸 가짜 결제 데이터가 아닌지 구분할 수 있습니다.
서버에서는 크게 두 가지 방식으로 확인합니다.
- Apple이 서명한 거래 정보를 직접 검증하거나,
- App Store Server API로 Apple에 구매·구독 상태를 확인합니다.
App Store Server API 호출
예를 들어 특정 트랜잭션의 정보를 조회할 수 있습니다.
GET https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}
Authorization: Bearer <JWT>
여기서 JWT(JSON Web Token)가 등장합니다 — 내 서버가 Apple API를 사용할 권한이 있다는 것을 보여주는 인증 토큰입니다. App Store Connect에서 발급한 API 키로 서버에서 JWT를 만들어 요청과 함께 보냅니다. Apple은 이 JWT에 ES256이라는 서명 방식을 씁니다 — "Apple API용 토큰에 서명하는 방식" 정도로 이해하면 충분합니다. 인앱결제를 만들기 위해 ES256의 암호학까지 알 필요는 없습니다.
Apple이 보내준 JWS는 어떻게 확인할까요?
App Store Server API에서 받은 거래 정보에도 Apple의 서명이 들어 있습니다. JWS 안의 내용을 읽으면 이런 정보를 확인할 수 있습니다.
- 어떤 상품을 구매했는지
- 언제 구매했는지
- 구독이 언제 만료되는지
- 구매가 취소되거나 환불되었는지
그리고 이 서명이 실제 Apple에서 만든 것인지 확인해야 합니다. 이때 인증서 체인(certificate chain)이 사용됩니다 — JWS의 서명이 진짜 Apple의 것인지 검증하는 데 쓰입니다. 직접 검증 코드를 만들 수도 있지만, Apple이 제공하는 App Store Server Library를 쓰면 JWS 검증과 내용 확인을 함께 처리할 수 있습니다.
예전 verifyReceipt 방식은?
기존 Apple 인앱결제 예제를 보면 verifyReceipt라는 API가 많이 나옵니다. 예전에는 앱에서 받은 영수증 데이터를 서버가 Apple에 보내고 검증 결과를 받는 방식을 많이 썼습니다. 이 방식은 두 환경을 구분합니다.
- Production: 실제 결제 환경
- Sandbox: 테스트 결제 환경
예전 구현에서는 Production으로 먼저 보내고, 21007이 반환되면 Sandbox로 다시 요청했습니다.
하지만 새로 인앱결제를 구현한다면 이 방식으로 시작하는 것은 권장하지 않습니다. Apple은 verifyReceipt를 deprecated 상태로 두고, 새로운 구현에서는 App Store Server API와 Apple이 서명한 거래 정보를 권장합니다. 기존 시스템을 유지보수할 때는 알아둘 필요가 있지만, 처음 만든다면 최신 방식부터 보는 편이 좋습니다.
App Store에서도 상태 변경 알림을 받을 수 있습니다
구독은 iOS에서도 시간이 지나면서 상태가 계속 바뀝니다 — 갱신, 취소, 환불, 결제 실패 등이 사용자가 앱을 열지 않는 동안에도 일어날 수 있죠. 이를 위해 Apple은 App Store Server Notifications V2를 제공합니다 — 구독이나 결제 상태가 바뀌면 Apple이 내 서버에 알려주는 기능으로, Google의 RTDN과 목적이 같습니다.
이처럼 외부 서비스가 어떤 일이 생겼을 때 내 서버에 자동으로 알려주는 방식을 흔히 웹훅(webhook)이라고 합니다.
Google Play App Store
↓ ↓
RTDN / Cloud Pub/Sub App Store Server Notifications V2
↓ ↓
내 서버 내 서버
구현 방식은 서로 다르지만 목적은 같습니다. 사용자가 앱을 열지 않아도 내 서버가 최신 결제 상태를 알고 있어야 합니다.
자주 걸리는 함정
1. Sandbox와 Production을 혼동하기. 테스트 결제와 실제 결제는 서로 다른 환경입니다. 테스트 데이터를 실제 구매로 취급하거나, 실제 결제를 테스트 환경에서 찾으려 하면 정상 사용자가 서비스를 못 쓰게 될 수 있습니다. 두 환경을 명확히 구분하세요.
2. 같은 구매를 두 번 처리하기. 같은 구매 정보가 서버에 여러 번 들어올 수 있습니다 — 예를 들어 네트워크 오류로 앱이 같은 정보를 다시 보낼 수 있죠. 결제가 확인될 때마다 코인 += 100을 실행한다면, 한 번 결제한 사용자가 같은 정보로 아이템을 여러 번 받을 수 있습니다. 그래서 이미 처리한 구매인지 반드시 확인해야 합니다. 구매 식별 정보 — Android는 purchaseToken, iOS는 transactionId — 를 저장해 중복을 막으세요. Android에서 orderId만을 중복 확인 키로 쓰는 것은 피하세요. 모든 구매에 orderId가 있는 것은 아니기 때문입니다.
3. Android에서 acknowledge를 잊기. 검증에 성공했다고 끝이 아닙니다. 기능을 제공한 뒤 Google에도 구매 처리가 끝났다고 알려야 합니다 — acknowledge 단계죠. 이를 놓치면 정상 구매가 나중에 환불될 수 있습니다.
4. 구독을 한 번 켜면 계속 유효하다고 생각하기. 구독은 한 번 발생하고 끝나는 이벤트가 아니라 상태가 계속 바뀌는 결제입니다. 오늘 정상이라고 다음 달에도 정상인 것은 아닙니다. 갱신·만료·환불·취소·결제 실패를 계속 반영해야 합니다. 그러지 않으면 결제를 멈춘 사용자에게 계속 서비스를 주고, 반대로 잘 내고 있는 사용자의 권한을 끊게 됩니다.
5. 사용자 기기의 시간을 믿기. 구독 만료 여부를 사용자 스마트폰의 현재 시간만으로 판단하지 마세요. 사용자는 기기의 시간을 바꿀 수 있습니다. Google이나 Apple에서 확인한 구독 상태와 만료 시간을 기준으로 서버에서 판단하는 것이 안전합니다.
결국 서버에서 하는 일은 이렇습니다
처음에는 단순해 보입니다.
결제 성공
↓
기능 열기
하지만 실제 서비스에서는 조금 다릅니다.
앱에서 결제
↓
구매 정보
↓
내 서버
↓
Google / Apple에 실제 구매인지 확인
↓
이미 처리한 구매인지 확인
↓
사용자에게 이용 권한 부여
↓
구매 처리 완료
구독이라면 여기서 끝나지 않습니다.
Google / Apple
↓
갱신 · 취소 · 환불 · 결제 실패
↓
서버 알림
↓
내 서버의 구독 상태 업데이트
이 구조를 이해하면 Google과 Apple의 API 이름이 서로 달라도 전체 흐름은 어렵지 않습니다.
이건 만드는 것도, 유지하는 것도 일입니다
Google Play와 App Store는 사용하는 API도 다르고, 서버를 인증하는 방법도 다르며, 결제 상태가 바뀌었을 때 알려주는 방식도 서로 다릅니다. 여기에 테스트/실제 환경 구분, 같은 구매의 중복 처리 방지, 구독 상태 관리, 취소·환불 처리, 인증 키 관리까지 필요합니다.
직접 구현할 수 없는 기능은 아닙니다. 하지만 한 번 만들면 끝나는 코드도 아닙니다 — Google과 Apple의 정책이나 API가 바뀌면 계속 관리해야 하는 백엔드입니다.
바로 이 부분을 Unveily의 IAP API가 대신 처리합니다. 스토어별 결제 백엔드를 직접 만들지 않고, 하나의 API로 구매 검증과 결제 상태 처리를 연결할 수 있습니다. 스토어별 백엔드를 만들고 유지하기보다 앱의 기능을 만드는 데 집중하고 싶을 때, 이럴 때 쓰라고 있는 겁니다.