앱인토스 결제 프로세스
앱인토스의 SDK 를 보고 결제모듈을 작성하던 중, 에러가 발생했고 앱인토스 개발자 커뮤니티에 질문을 남겨서 친절하게 답변을 받았다. 다시 로직을 보니 결제 성공 후에 우리 서버에서 '그래서 이 주문이 맞냐고?' 란 검증을 한번 더 해서 NOT_FOUND 가 계속 나오는 문제가 발생하고 있었다. 나름의 추측으로는 응답시간이 너무 짧거나(?) 트랜잭션(음...) 등의 이유로 직전 성공 건에 대해서fail 이 난 것 같았다.
문제의 로직> /api/purchase/grant
앱인토스 미니앱에 인앱결제를 붙이면서 가장 헷갈렸던 부분은 "어느 시점에 우리 서버가 개입해야 하는가" 였다. 공식 문서를 읽어도 흐름이 머릿속에 잘 그려지지 않아서, 직접 구현하며 정리한 내용을 공유한다.

전체 흐름 한눈에 보기
사용자 탭 → createOneTimePurchaseOrder()
↓
앱마켓 결제창
↓
토스 영수증 검증 (자동)
↓
processProductGrant() 콜백 ← 이 시점이 핵심
↓
/api/purchase/grant (우리 서버)
↓
ok: true 리턴
↓
토스가 PURCHASED 마킹
↓
onEvent({ type: 'success' })
1단계 — 결제 시작: 서버는 구경만 한다
// 클라이언트
toss.createOneTimePurchaseOrder({ sku: 'premium_reading_x1' });
버튼을 누르면 토스 SDK가 결제창을 띄우고, 사용자는 Google Play 또는 App Store에서 실제 결제를 진행한다. 이 단계에서 우리 서버가 할 일은 없다. 결제창 렌더링부터 앱마켓 통신까지 SDK가 전부 처리한다.
2단계 — 결제 성공: processProductGrant 콜백이 전부다
앱마켓 결제가 완료되면 토스가 영수증을 자동으로 검증한 뒤, SDK가 processProductGrant 콜백을 호출한다.
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)가 할 일
// 서버 의사코드
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 | 결제 완료 후 토스 측 검증 실패 |
이 세 경우는 사용자가 이미 돈을 냈을 가능성이 높기 때문에 미지급 상태로 방치하면 안 된다. 즉시 회수를 시도한다.
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만 하면 된다. 그것으로 충분하다.