본문 바로가기
Tech Notes

앱인토스 결제 프로세스

by miracle-tech 2026. 5. 18.
728x90
반응형

앱인토스의 SDK 를 보고 결제모듈을 작성하던 중, 에러가 발생했고 앱인토스 개발자 커뮤니티에 질문을 남겨서 친절하게 답변을 받았다. 다시 로직을 보니 결제 성공 후에 우리 서버에서 '그래서 이 주문이 맞냐고?' 란 검증을 한번 더 해서 NOT_FOUND 가 계속 나오는 문제가 발생하고 있었다. 나름의 추측으로는 응답시간이 너무 짧거나(?) 트랜잭션(음...) 등의 이유로 직전 성공 건에 대해서fail 이 난 것 같았다. 

 

문제의 로직> /api/purchase/grant

 

앱인토스 미니앱에 인앱결제를 붙이면서 가장 헷갈렸던 부분은 "어느 시점에 우리 서버가 개입해야 하는가" 였다. 공식 문서를 읽어도 흐름이 머릿속에 잘 그려지지 않아서, 직접 구현하며 정리한 내용을 공유한다.

앱인토스 결제 프로세스

 

전체 흐름 한눈에 보기

 
 
사용자 탭 → createOneTimePurchaseOrder()
              ↓
         앱마켓 결제창
              ↓
    토스 영수증 검증 (자동)
              ↓
  processProductGrant() 콜백 ← 이 시점이 핵심
              ↓
     /api/purchase/grant (우리 서버)
              ↓
       ok: true 리턴
              ↓
     토스가 PURCHASED 마킹
              ↓
   onEvent({ type: 'success' })

1단계 — 결제 시작: 서버는 구경만 한다

 
 
ts
// 클라이언트
toss.createOneTimePurchaseOrder({ sku: 'premium_reading_x1' });

버튼을 누르면 토스 SDK가 결제창을 띄우고, 사용자는 Google Play 또는 App Store에서 실제 결제를 진행한다. 이 단계에서 우리 서버가 할 일은 없다. 결제창 렌더링부터 앱마켓 통신까지 SDK가 전부 처리한다.


2단계 — 결제 성공: processProductGrant 콜백이 전부다

앱마켓 결제가 완료되면 토스가 영수증을 자동으로 검증한 뒤, SDK가 processProductGrant 콜백을 호출한다.

 
 
ts
toss.registerPurchaseCallback({
  async processProductGrant({ orderId }) {
    const res = await fetch('/api/purchase/grant', {
      method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}` },
      body: JSON.stringify({
        orderId,
        categoryId,   // 토스는 모른다. 우리만 안다.
        cardId,
        questionId,
      }),
    });

    return res.ok; // true 리턴 → 토스가 PURCHASED 마킹
  },
});

서버(/api/purchase/grant)가 할 일

 
 
ts
// 서버 의사코드
async function grant(req) {
  const user = verifyJWT(req.headers.authorization); // 본인 확인

  await db.orders.insertOrIgnore({         // orderId 기준 멱등 insert
    orderId: req.body.orderId,
    userId: user.id,
    categoryId: req.body.categoryId,       // 컨텐츠 컨텍스트 함께 저장
    cardId: req.body.cardId,
    grantedAt: new Date(),
  });

  return { ok: true };
}

이 콜백이 호출됐다는 것 자체가 토스의 결제 완료 보증이다.

콜백이 true를 반환하면 SDK가 해당 주문을 PURCHASED로 마킹하고 onEvent({ type: 'success' })를 발사한다. 이후 로직(UI 업데이트, 컨텐츠 잠금 해제 등)은 이 이벤트를 받아서 처리하면 된다.


3단계 — 실패 처리: 에러 코드에 따라 전략이 갈린다

모든 에러는 onError로 넘어오지만, 에러의 성격은 크게 둘로 나뉜다.

결제는 됐을 수 있는 에러 → 즉시 회수

코드의미
PAYMENT_PENDING 결제 승인 대기 중
PRODUCT_NOT_GRANTED_BY_PARTNER 결제는 됐는데 우리 서버 grant 실패
TOSS_SERVER_VERIFICATION_FAILED 결제 완료 후 토스 측 검증 실패

이 세 경우는 사용자가 이미 돈을 냈을 가능성이 높기 때문에 미지급 상태로 방치하면 안 된다. 즉시 회수를 시도한다.

 
 
ts
async function recover() {
  const pendingOrders = await toss.getPendingOrders();

  for (const order of pendingOrders) {
    const res = await fetch('/api/purchase/grant', {
      method: 'POST',
      body: JSON.stringify({ orderId: order.orderId, ...context }),
    });

    if (res.ok) {
      await toss.completeProductGrant({ orderId: order.orderId });
      // 토스가 PURCHASED로 마킹
    }
  }
}

결제 자체가 안 된 에러 → 안내만

USER_CANCELED, INVALID_PRODUCT_ID 등은 회수할 주문 자체가 없다. 토스트 메시지나 모달로 사용자에게 상황을 안내하면 충분하다.


흔한 실수: processProductGrant 안에서 mTLS 검증하기

구현 초기에 이런 생각을 할 수 있다.

"결제가 진짜인지 우리 서버에서 한 번 더 확인해야 하지 않나? mTLS로 토스 read API를 찔러보자."

하지 마라. 이유가 있다.

processProductGrant 콜백이 발사되는 시점에, 토스 read API는 같은 orderId를 아직 인덱싱하지 못한 상태일 수 있다. 그 상태에서 조회하면 NOT_FOUND가 반환되고, 정상적으로 완료된 결제가 실패로 처리된다.

콜백 호출 자체가 토스의 보증이다. 추가 검증이 필요하다면, 유료 컨텐츠를 실제로 제공하는 시점 — LLM 호출 직전이나 다운로드 시작 전 — 에 mTLS 검증을 붙이는 것이 올바른 위치다.


핵심 한 줄 요약

processProductGrant 콜백 = 토스의 결제 완료 보증.
서버 grant 로직은 본인 확인과 DB insert만 하면 된다. 그것으로 충분하다.

 

 

728x90