REST API 기반 인게임 상점과 결제 영수증 서버 검증 파이프라인 설계

인게임 상점 결제에서 클라이언트 영수증만 신뢰하면 위조, 중복 지급, 환불 악용이 발생한다. REST API, 스토어 검증, 멱등성 키, 트랜잭션 로그를 결합해 안전하게 재화를 지급하는 서버 검증 파이프라인을 설계한다.

✓ 복사 완료!
챕터Steam 상점 최적화 (Steam SEO)
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
REST API 기반 인게임 상점과 결제 영수증 서버 검증 파이프라인 설계

게임의 인게임 결제는 클라이언트가 보낸 ‘구매 성공’ 신호를 신뢰해서는 안 된다. 서버가 Apple App Store, Google Play 등 판매처의 영수증 또는 구매 토큰을 검증하고 검증 결과를 멱등하게 기록한 뒤에만 재화를 지급해야 한다. 핵심은 판매처 검증·데이터베이스 트랜잭션·중복 요청 차단·환불 상태 반영을 하나의 파이프라인으로 묶는 것이다.

인게임 상점 결제에서 서버 영수증 검증은 왜 필요한가?

모바일 클라이언트는 사용자가 제어하는 환경이다. 프록시 도구로 HTTP 요청을 재전송하거나 앱 메모리를 변조해 상품 ID, 수량, 구매 완료 플래그를 바꿀 수 있다. 따라서 클라이언트는 결제를 시작하고 영수증을 전달하는 역할만 맡으며, 지급 권한은 서버에 둔다.

검증 위치장점치명적인 문제
클라이언트만 검증구현이 빠름요청 위조와 재전송으로 재화 지급 가능
서버 자체 형식 검증만 수행기본 필드 검사는 가능실제 결제 여부와 환불 상태를 보장하지 못함
서버가 판매처 API로 검증실제 구매 상태를 기준으로 지급인증 정보 보관, 재시도, 상태 동기화 설계 필요

특히 소비성 상품은 같은 결제 토큰으로 지급 API를 여러 번 호출하는 재전송 공격에 취약하다. 이를 막으려면 결제 식별자에 데이터베이스 고유 제약 조건을 걸고 지급 처리를 트랜잭션으로 묶어야 한다.

REST API 기반 결제 검증 파이프라인은 어떻게 구성할까?

아래 흐름에서 클라이언트는 재화 수량이나 가격을 서버에 전달하지 않는다. 서버는 내부 상품 카탈로그와 판매처 검증 응답을 기준으로 지급할 보상을 결정한다.

flowchart TD
    A[게임 클라이언트] -->|purchaseToken 또는 signedTransactionInfo| B[POST /v1/purchases/verify]
    B --> C[인증 및 요청 형식 검증]
    C --> D[DB에서 결제 식별자 잠금 또는 조회]
    D -->|이미 처리됨| E[기존 지급 결과 반환]
    D -->|미처리| F[App Store 또는 Google Play 서버 검증]
    F -->|유효| G[DB 트랜잭션: 구매 기록 및 지갑 지급]
    F -->|무효 또는 취소| H[거절 코드와 함께 응답]
    G --> I[지급 완료 응답]

Step 1. 클라이언트는 영수증 식별자만 서버에 전송한다

예를 들어 Google Play Billing에서는 purchaseToken, Apple StoreKit 2에서는 서명된 거래 정보 또는 거래 ID를 전달한다. sku, price, currency, rewardAmount 같은 값은 클라이언트 입력값으로 지급 판단에 사용하지 않는다.

POST /v1/purchases/verify HTTP/1.1
Authorization: Bearer <access-token>
Content-Type: application/json
Idempotency-Key: 5f7f2ad4-1c42-4b8a-94c9-2ae9af5d7d1c

{
  "platform": "google_play",
  "productId": "gem_pack_500",
  "purchaseToken": "encrypted-store-purchase-token"
}

Idempotency-Key는 네트워크 재시도 구분에 쓰고 판매처가 발급한 구매 토큰 또는 거래 ID는 실제 결제의 고유 식별자로 쓴다. 둘 중 하나만으로 모든 중복 상황을 해결하려 하지 않는 편이 안전하다.

게임 클라이언트, 검증 API, 스토어 서버, 게임 데이터베이스 사이의 영수증 검증 요청 흐름

Step 2. 판매처 응답과 서버 상품 카탈로그를 대조한다

서버는 판매처 API 응답에서 다음 값을 확인한다.

  • 구매 토큰 또는 거래 ID가 요청한 플랫폼에서 유효한지
  • 스토어 상품 ID가 서버 카탈로그의 상품 ID와 일치하는지
  • 구매 상태가 PURCHASED처럼 지급 가능한 상태인지
  • 취소, 만료, 보류, 환불 상태가 아닌지
  • 해당 거래가 현재 로그인한 게임 계정에 귀속되어도 되는지

상품 보상은 코드나 관리 도구에서 관리하는 서버 카탈로그로 결정한다.

const catalog: Record<string, { gems: number }> = {
  gem_pack_500: { gems: 500 },
  gem_pack_1200: { gems: 1200 },
};

const reward = catalog[verifiedStorePurchase.productId];
if (!reward) {
  throw new Error("UNREGISTERED_PRODUCT");
}

가격 검증이 필요한 경우에는 국가, 통화, 세금을 고려해야 한다. 실시간 현지 가격을 클라이언트의 숫자로 비교하기보다 판매처의 상품 메타데이터나 백오피스 기준 가격 정책을 사용한다.

Step 3. 구매 기록과 재화 지급을 하나의 DB 트랜잭션으로 처리한다

가장 중요한 제약은 platform + store_transaction_id의 유일성이다. 동일 거래를 두 번 처리하려는 요청은 이미 저장된 결과를 반환해야 한다.

CREATE TABLE purchase_receipts (
  id BIGSERIAL PRIMARY KEY,
  user_id UUID NOT NULL,
  platform VARCHAR(32) NOT NULL,
  store_transaction_id VARCHAR(255) NOT NULL,
  product_id VARCHAR(128) NOT NULL,
  status VARCHAR(32) NOT NULL,
  granted_gems INTEGER NOT NULL DEFAULT 0,
  verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (platform, store_transaction_id)
);

CREATE TABLE wallets (
  user_id UUID PRIMARY KEY,
  gems INTEGER NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

PostgreSQL에서는 거래 기록 삽입과 지갑 업데이트를 같은 트랜잭션에서 수행한다. 고유 제약 충돌은 오류로 끝내기보다 기존 처리 결과를 조회하는 분기로 바꾼다.

// 검증된 영수증 정보와 서버 상품 카탈로그의 보상을 전달하는 함수입니다.
async function grantValidatedPurchase(
  userId: string, platform: string, transactionId: string,
  productId: string, reward: { gems: number },
) {
const outcome = await db.transaction(async (tx) => {
  const receipt = await tx.query(
    `INSERT INTO purchase_receipts
       (user_id, platform, store_transaction_id, product_id, status, granted_gems)
     VALUES ($1, $2, $3, $4, 'GRANTED', $5)
     ON CONFLICT (platform, store_transaction_id) DO NOTHING
     RETURNING id`,
    [userId, platform, transactionId, productId, reward.gems],
  );

  if (receipt.rowCount === 0) {
    const existing = await tx.query(
      `SELECT user_id, product_id, status, granted_gems
       FROM purchase_receipts
       WHERE platform = $1 AND store_transaction_id = $2`,
      [platform, transactionId],
    );
    if (existing.rowCount !== 1) {
      throw new Error("RECEIPT_RESULT_UNAVAILABLE");
    }

    const prior = existing.rows[0];
    if (prior.user_id !== userId || prior.product_id !== productId) {
      throw new Error("TRANSACTION_ALREADY_CLAIMED");
    }
    return { status: prior.status, grantedGems: prior.granted_gems };
  }

  const walletUpdate = await tx.query(
    `UPDATE wallets
     SET gems = gems + $1, updated_at = NOW()
     WHERE user_id = $2`,
    [reward.gems, userId],
  );
  if (walletUpdate.rowCount !== 1) {
    throw new Error("WALLET_NOT_FOUND"); // 영수증 INSERT도 함께 롤백한다.
  }
  return { status: "GRANTED", grantedGems: reward.gems };
});

return outcome;
}

지갑 행이 아직 없을 수 있다면 사용자 생성 시 미리 만들거나 INSERT ... ON CONFLICT DO UPDATE로 원자적 업서트를 사용한다. 애플리케이션 메모리에서 먼저 잔액을 읽고 다시 쓰는 방식은 동시 요청에서 잔액 손실을 만들 수 있다.

멱등성은 중복 지급을 어떻게 막는가?

멱등성은 같은 요청을 여러 번 실행해도 최종 결과가 한 번 실행한 것과 같게 만드는 성질이다. 결제 검증 API에서는 다음 두 층을 함께 둔다.

층식별자막는 문제
API 요청 멱등성Idempotency-Key타임아웃 뒤 같은 요청을 재시도하는 상황
거래 멱등성판매처 거래 ID 또는 구매 토큰다른 키로 동일 영수증을 재전송하는 공격

재화 잔액 변화량을 Δgems라고 하면 동일 거래에 대한 누적 지급량은 다음 조건을 만족해야 한다.

∑i=1nΔgemsi=reward(transactionId)\sum_{i=1}^{n} \Delta gems_i = reward(transactionId)

즉 요청 횟수 n이 늘어나도 한 거래가 만들 수 있는 총 보상은 상품 카탈로그의 보상량을 넘으면 안 된다.

환불과 비동기 알림까지 처리해야 하는 이유

구매 시점 검증만으로는 충분하지 않다. 결제 후 환불, 구독 해지, 지불 거절은 나중에 발생할 수 있다. Apple의 서버 알림과 Google Play의 실시간 개발자 알림 같은 웹훅을 수신해 거래 상태를 갱신하는 작업이 필요하다.

웹훅 처리도 지급 API와 같은 원칙을 적용한다.

  1. HTTP 서명, JWT 서명, 발신자 인증서를 판매처 문서에 맞춰 검증한다.
  2. 이벤트 ID 또는 거래 ID에 유일 제약을 적용해 중복 전달을 무해하게 만든다.
  3. 원본 구매 기록을 찾아 REFUNDED, REVOKED, CANCELED 상태로 변경한다.
  4. 회수 정책에 따라 미사용 유료 재화를 차감하거나 계정 제한 플래그를 기록한다.

이미 소비한 재화를 무조건 음수로 차감하면 정상 플레이어 경험을 해칠 수 있다. 서비스 정책에 따라 음수 잔액을 허용할지, 이후 지급을 보류할지, 고가치 아이템만 회수할지를 먼저 정해야 한다. 결제 공급자의 정책과 플랫폼 약관도 함께 검토한다.

운영 환경에서 반드시 남겨야 할 감사 로그

영수증 원문에는 토큰과 개인 정보가 포함될 수 있다. 원문 전체를 일반 애플리케이션 로그에 남기지 말고 필요한 식별자는 해시 또는 마스킹해 보관한다.

로그 필드용도
request_idAPI 요청 추적
user_id계정 귀속 확인
platform, product_id상품과 판매처 분석
transaction_id_hash원본 노출 없이 중복 거래 추적
verification_result유효, 중복, 취소, 검증 실패 분류
grant_status지급 성공과 롤백 여부 확인
store_response_code외부 API 장애 분석

모니터링은 분당 검증 실패율, 중복 거래 비율, 판매처 API 지연 시간, 환불 이벤트 수를 기준으로 경보를 구성한다. 특히 특정 사용자나 IP에서 DUPLICATE_TRANSACTION이 급증하면 자동화된 재전송 공격 신호일 수 있다.

자주 묻는 질문 (FAQ)

클라이언트에서 이미 영수증 검증을 했는데 서버 검증도 필요한가?

필요하다. 클라이언트 검증은 사용자 경험 개선에는 도움이 되지만 변조 가능한 실행 환경에 있으므로 지급 권한의 근거가 될 수 없다.

영수증 검증 API가 타임아웃되면 결제를 다시 실행해야 하는가?

아니다. 같은 Idempotency-Key와 같은 거래 식별자로 검증 요청을 재시도한다. 서버는 기존 결과 또는 아직 처리 중인 상태를 반환해야 한다.

검증이 성공한 뒤 재화 지급에 실패하면 어떻게 하는가?

구매 상태를 VERIFIED와 GRANTED로 분리하고 트랜잭션 롤백 또는 재처리 가능한 작업 큐를 사용한다. 판매처에 재검증을 반복하기보다 저장된 검증 결과를 근거로 지급을 복구하는 편이 안정적이다.

정리

안전한 인게임 상점은 REST API 하나로 완성되지 않는다. 판매처의 실제 거래를 서버에서 검증하고 거래 ID 유일성 제약과 데이터베이스 트랜잭션으로 한 번만 지급하며 웹훅으로 환불 상태까지 동기화해야 한다. 이 구조를 처음부터 적용하면 결제 위조와 중복 지급을 줄이면서 운영 중 장애 복구와 감사 대응도 훨씬 단순해진다.