앱인토스(Apps in Toss) 미니앱에 인앱결제(IAP)를 붙일 때, “결제 완료됐다”는 클라이언트 신호를 그대로 믿고 아이템을 지급해도 될까요? 결론부터 말하면 안 됩니다. 반드시 서버가 재검증해야 하고, 그 검증 결과를 저장할 DB가 없으면 구조 자체가 성립하지 않습니다. 실제로 API 문서를 확인하고 설계까지 진행하면서 정리한 내용을 공유합니다.
결제 상태 조회 API, 어떤 방식으로 동작하는가
앱인토스 개발자센터는 결제 상태를 확인할 수 있는 서버 API를 별도로 제공합니다. 엔드포인트는 POST /api-partner/v1/apps-in-toss/order/get-order-status이며, 구매 건의 고유 식별자인 orderId를 전달하면 해당 주문의 현재 상태를 응답받습니다.
핵심은 호출 주체입니다. 이 API는 앱(클라이언트)에서 직접 호출하는 방식이 아니라, 개발사가 운영하는 자체 서버가 앱인토스 서버를 호출하는 서버 간(Server-to-Server) 통신 구조로 설계되어 있습니다. 호출 시에는 앱인토스에서 발급받은 파트너 클라이언트 인증서로 mTLS(상호 TLS) 인증을 맺어야 합니다. 인증서를 클라이언트 환경에 심으면 유출 위험이 커지기 때문에, 서버 전용으로 제한해둔 것으로 보입니다.
클라이언트 신호를 그대로 믿을 수 없는 이유
“결제 성공” 이벤트를 클라이언트가 서버로 보내고, 서버가 그걸 그대로 신뢰해 아이템을 지급하는 구조는 위변조에 취약합니다. 네트워크 요청은 조작이 가능한 영역이기 때문에, 실제 결제 여부와 무관하게 거짓 신호를 보낼 여지가 남습니다.
그래서 IAP 검증의 기본 흐름은 다음과 같아야 합니다.
- 클라이언트는 결제 완료 이벤트를 트리거만 한다
- 서버가 앱인토스 서버에 직접 재질의(get-order-status)해서 실제 상태를 확인한다
- 검증이 끝난 뒤에만 서버가 유저에게 권한(entitlement)을 부여한다
이런 “클라이언트 주장을 서버가 재검증한다”는 구조는 특정 플랫폼에 국한된 것이 아니라, 앱스토어·구글플레이 등 인앱결제를 다루는 플랫폼 전반에서 공통적으로 쓰이는 방식입니다.
조회(Pull)와 웹훅(Push)을 같이 써야 하는 이유
앱인토스는 조회 API 외에 웹훅(결제 알림 URL) 방식도 지원합니다. 콘솔에서 URL을 등록해두면 결제 완료나 환불 등으로 상태가 바뀔 때마다 등록된 서버로 알림이 전달됩니다.
문제는 웹훅 하나에만 의존하기 어렵다는 점입니다. 네트워크 이슈나 서버 상태에 따라 웹훅이 유실될 가능성은 항상 존재합니다. 실제로 개발자 커뮤니티에는 서버에서 조회 API 호출 시 응답을 받지 못하고 연결이 끊기는 사례가 보고돼 있고, 이 때문에 자동 환불 처리까지 이어진 경우도 확인됩니다. 이런 사례를 보면 조회 API와 웹훅 중 하나만 믿는 방식은 리스크가 크다는 걸 알 수 있습니다.
실무적으로는 다음과 같은 이중화 패턴이 권장됩니다.
- 웹훅을 1차 트리거로 사용해 실시간성을 확보한다
- 웹훅 유실이나 유저 문의 상황에 대비해 get-order-status로 재확인(reconciliation)한다
서버 DB가 필수인 두 가지 이유
여기서 자연스럽게 드는 질문이 있습니다. “굳이 우리 서버에 결제 이력 DB를 따로 둬야 하나? 앱인토스가 조회해주면 되지 않나?” 확인해보면 DB는 사실상 필수입니다.
첫째, get-order-status는 단건 조회만 지원합니다. orderId 하나를 넣으면 해당 주문 상태만 응답할 뿐, 특정 유저의 전체 구매 이력을 리스트업해주는 기능은 제공하지 않습니다. 앱 실행마다 유저의 전체 구매 목록을 외부 API에 물어보는 구조 자체가 불가능합니다.
둘째, 권한(entitlement) 판단은 개발사의 책임입니다. 예를 들어 “광고 제거” 아이템을 구매한 유저는 이후 모든 앱 실행에서 광고가 노출되지 않아야 합니다. 이 판단을 매번 앱인토스에 물어보는 것은 비효율적이고, 클라이언트가 orderId를 들고 다니며 매번 검증을 요청하는 방식도 위변조 위험이 남습니다. 결국 결제 완료 시점에 서버가 검증을 마친 뒤 자체 DB에 “이 유저가 이 아이템을 보유하고 있다”는 사실을 기록해두는 것이 가장 안전하고 빠릅니다.
환불도 마찬가지입니다. 웹훅으로 환불 알림이 왔을 때, 애초에 그 유저가 무엇을 갖고 있었는지 기록이 없다면 무엇을 취소해야 할지조차 판단할 수 없습니다.
최소 스키마 설계
Supabase 기준 최소 스키마 예시는 다음과 같습니다.
create table iap_orders (
order_id text primary key,
user_key text not null,
item_id text not null,
status text not null, -- 'pending' | 'completed' | 'refunded'
amount integer,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table user_entitlements (
user_key text not null,
item_id text not null,
granted_at timestamptz default now(),
revoked_at timestamptz,
primary key (user_key, item_id)
);
iap_orders는 결제 이력 전체를, user_entitlements는 현재 유효한 권한만을 관리합니다. 두 테이블을 분리해두면 이력 추적과 권한 판단 로직을 깔끔하게 나눌 수 있습니다.
전체 흐름은 다음과 같이 정리됩니다.
- 결제 완료 웹훅 수신 → get-order-status로 재검증 →
iap_orders에 기록 - 검증 통과 시
user_entitlements에 권한 부여 - 앱 실행 시 서버는 앱인토스에 재질의하지 않고
user_entitlements테이블만 조회해 즉시 응답 - 환불 웹훅 수신 시
revoked_at을 채우고 해당 권한을 제거
서버 API, 정말 이것 하나뿐인가
개발자센터 문서를 기준으로 보면 서버 간 조회용 API는 get-order-status 하나가 맞습니다. 다만 혼동하기 쉬운 지점이 있는데, 클라이언트 SDK 쪽에는 완료·환불 주문 목록을 가져오는 별도 함수가 존재합니다. 이 함수는 유저 화면에 “내 구매내역”을 보여주는 용도로는 유용하지만, 클라이언트에서 호출되기 때문에 조작 가능성이 있어 서버 신뢰용 검증 근거로는 사용할 수 없습니다.
또한 개발자 커뮤니티에는 파트너 인증 방식이 문서만으로는 명확하지 않다는 문의, 특정 헤더를 생략했을 때의 응답 형식에 대한 질문 등이 계속 올라오고 있습니다. 실제 연동 전에는 최신 문서와 커뮤니티 답변을 한 번 더 확인하는 편이 안전합니다.
자주 묻는 질문
Q. 클라이언트에서 직접 결제 상태를 조회하면 안 되나요?
mTLS 인증서를 앱 환경에 안전하게 심을 방법이 없어 사실상 서버 전용으로 설계돼 있습니다. 클라이언트에서 검증 로직을 처리하면 위변조에 취약해집니다.
Q. 웹훅만 써도 충분하지 않나요?
네트워크 유실 가능성이 있으므로, 조회 API를 통한 재확인(reconciliation) 절차를 함께 두는 것이 안전합니다.
Q. DB 없이 앱인토스 API 응답만으로 서비스하면 안 되나요?
전체 구매 목록 조회 기능이 없고, 매 요청마다 외부 API에 의존하면 응답 속도와 안정성이 떨어집니다. 최소한의 결제 이력·권한 테이블은 두는 것이 현실적입니다.
마무리
앱인토스 인앱결제 검증은 “클라이언트의 주장을 서버가 재확인하고, 그 결과를 우리 DB에 기록해 권한을 관리한다”는 흐름으로 요약됩니다. get-order-status와 웹훅은 진실을 검증하는 도구일 뿐이고, 실제 서비스 로직(권한 판단, 이력 관리, 환불 처리)은 개발사의 DB가 담당해야 합니다. 미니앱에 인앱결제를 처음 붙이는 단계라면 이 구조를 먼저 잡아두는 것이 이후 트러블슈팅 비용을 크게 줄여줍니다.