Stripe 없이 달러와 루피 처리하기: Skill Exchange의 결제 시스템 구축을 통해 배운 점 (PayPal + UPI)
요약
글로벌 마켓플레이스 구축 시 Stripe 단일 솔루션의 한계를 극복하기 위해 PayPal과 Razorpay를 병행 사용하는 결제 시스템 설계 방안을 다룹니다. 구매자의 통화와 위치에 따라 결제 경로를 분기하는 아키텍처와 구현 팁을 제공합니다.
핵심 포인트
- 인도 내 국내 결제를 위해 PayPal 대신 Razorpay와 UPI 활용 필요
- 구매자의 통화(USD/INR)를 기준으로 결제 제공업체를 분기하는 설계
- IP 조회 대신 시간대(Timezone) 휴리스틱을 활용한 통화 기본값 설정
- 사용자가 직접 통화를 선택할 수 있는 토글 UI 제공의 중요성
"그냥 Stripe를 추가하세요"는 결제가 필요한 1인 개발자에게 주어지는 기본 조언입니다. 이 조언은 당신이 같은 오후에 샌프란시스코의 구매자와 방갈로르의 구매자에게 동시에 물건을 팔려는 인도 상인인 상황이 오기 전까지만 유효합니다. 그 시점이 되면 단일 제공업체로는 두 가지를 모두 처리할 수 없다는 사실을 깨닫게 되며, 이를 해결하는 과정은 예상보다 훨씬 더 많은 코드를 재배치하게 만듭니다.
저는 Skill Exchange(재사용 가능한 AI 기술 마켓플레이스 — 링크는 마지막에 있음)를 구축하면서 이 문제를 겪었습니다. 마켓플레이스 자체가 여기서 핵심은 아닙니다. 핵심은 미국 법인도 없고 Stripe도 없는 상태에서, 지구 반대편에 있는 두 사람으로부터 어떻게 돈을 받을 수 있는지 알아내기 위해 사용한 방법론입니다. 이것들은 다른 개발자가 시작하기 전에 제가 해주고 싶은 이야기들입니다.
인도에서 PayPal은 해외 결제 전용입니다 — 따라서 인도 구매자에게는 전혀 도움이 되지 않습니다
가장 먼저 버려야 할 가정은 이것입니다: PayPal은 인도의 국내 결제 수단이 아닙니다. PayPal은 2021년 4월에 인도-인도 간 국내 결제 서비스를 중단했습니다. 남은 기능은 오직 해외 결제(cross-border)로만 작동합니다. 즉, 인도 상인이 해외로부터 돈을 받을 수는 있지만, 루피를 보유한 인도 내 구매자는 PayPal을 통해 인도 기반 상인에게 결제할 수 없습니다.
저는 예상 가능한 방식으로 이를 배웠습니다. PayPal을 연결하고, 제 (인도) 계정으로 제 (인도) 상인 계정에 테스트를 진행했더니, 아주 밝고 쓸모없는 "현재 작동하지 않는 것 같습니다(Things don't appear to be working at the moment)."라는 메시지를 받았습니다. 에러 코드도 없고, 문서 기록도 없었습니다. 거래가 단순히 국내 거래로 분류되어 거부된 것입니다. 구매자가 국제 사용자라면 완벽하게 작동하는데, 바로 이 점 때문에 자신의 국가에서 테스트할 때 실패 원인이 매우 혼란스럽게 느껴집니다.
교훈: 만약 구매자 중 누군가가 당신의 PayPal 상인 계정과 같은 국가에 있다면, PayPal만으로는 그들을 놓치게 됩니다. 두 번째 결제 경로(rail)가 필요합니다.
두 개의 제공업체가 필요하며, 분기 기준은 기능이 아니라 통화입니다
따라서 아키텍처는 구매자의 통화에 따라 선택되는 두 개의 제공업체로 구성됩니다:
- 해외 구매자 (International buyers) → PayPal, USD로 결제.
- 인도 구매자 (Indian buyers) → Razorpay, UPI를 통해 INR로 결제 (원터치 방식, 마찰이 거의 없음, 인도 내 카드 결제보다 훨씬 높은 전환율을 보임).
중요한 설계 결정은 결제 시점에 _구매자_가 통화를 선택함으로써 결제 경로 (rail)를 결정한다는 점입니다. 저는 시간대 (timezone)를 기준으로 토글 (toggle)의 기본값을 설정하고 사용자가 이를 변경할 수 있도록 했습니다. IP 조회나 외부 호출은 필요하지 않습니다:
const defaultCurrency = (() => {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
...
시간대 휴리스틱 (heuristic)은 인도 거주 외국인 (NRI)이나 여행객에게는 맞지 않을 수 있습니다. 이것이 바로 이 설정이 결코 고정값이 아닌, 단지 _기본값 (default)_일 뿐인 이유입니다. 토글은 항상 제공됩니다.
그 다음 buy 엔드포인트는 해당 필드 하나를 기준으로 라우팅합니다:
async function buySkill(user, skillId, body) {
const skill = await getSkill(skillId);
const wantsInr = String(body?.currency).toUpperCase() === "INR";
...
정확한 환율 변환을 보여주지 마세요 — 현지 가격 포인트로 반올림하세요
단순한 방식은 USD 가격을 특정 환율로 변환하여 그 결과를 보여주는 것입니다. 그렇게 하지 마세요. $12의 기술이 ₹86/$ 환율로 계산되면 ₹1,032가 되는데, 이는 버그처럼 보이고 비싸게 느껴집니다. 디지털 상품에 대한 인도의 지불 의사 (willingness-to-pay)는 달러 변환이 암시하는 것보다 낮습니다. 인도 시장에 진출한 모든 진지한 판매자들은 대신 지역별 가격 포인트 (regional price points)를 사용합니다.
따라서 변환은 usd * rate가 아니라, 익숙한 ₹x99 형태로 반올림됩니다:
const INR_RATE = 86; // 하나의 노브 (knob); 전체 카탈로그의 가격을 재설정하려면 이 값을 변경하세요
function usdCentsToInrPaise(usdCents) {
...
이 단일 INR_RATE 상수는 200개 이상의 리스팅 가격을 한 번에 재설정하며, 반올림은 개별 항목에 대한 작업 없이도 가벼운 구매력 할인 (purchasing-power discount) 역할을 겸합니다.
통화와 수수료를 트랜잭션에 저장하세요 — 절대 다시 계산하지 마세요
이 단계를 건너뛰면 나중에 뼈아픈 대가를 치르게 됩니다. 통화가 두 개 이상이고 (플랫폼 수수료가 있는 경우) 발생하는 순간, 사후에 금액을 유도해낼 수 없습니다. 두 가지 규칙이 있습니다:
- 모든 구매 행(row)에 통화 정보를 유지하세요. 일부 금액이 파이세(paise) 단위인 순간, 단순한
amountCents만으로는 모호해집니다. - 플랫폼 수수료(commission)를 구매 시점에 계산하여 저장하세요. 수수료율이 변경되는 경우(예: 제 경우 10% → 5%로 변경됨), 과거의 데이터는 판매 당시의 요율을 그대로 유지해야 합니다. 데이터를 읽을 때마다 수수료를 재계산하면 결국 잘못된 금액을 산출하게 됩니다.
purchase = {
skillId, buyerId,
amount: chargedMinorUnits, // USD의 경우 cents, INR의 경우 paise
...
그리고 판매자 수익 합계(aggregates)를 통화별로 관리하세요. 센트(cents)와 파이세(paise)를 하나의 정수로 합산하는 것은 아름답지만 아무런 의미 없는 숫자를 만들어내는 지름길입니다.
클라이언트가 말하는 내용이 아니라, 생성한 주문과 대조하여 서버 측에서 결제를 검증하세요
두 방식 모두 브라우저가 거짓말을 할 수 있게 허용하므로, 확인(confirm) 단계에서는 클라이언트가 보내는 금액이나 "성공했다"는 플래그를 신뢰해서는 안 됩니다. 대신 결제 제공자(provider)로부터 진실을 다시 도출하여, 그것이 정확히 해당 구매자 + 아이템에 속하는지 확인해야 합니다:
- PayPal: 서버 측에서 주문을 캡처(capture)하고,
status === "COMPLETED"를 요구하며, 생성 시 설정한custom_id가skillId|buyerId와 일치하는지 확인하세요. 캡처된 주문은 다른 구매 건에 대해 재사용(replay)될 수 없습니다. - Razorpay: 체크아웃 HMAC 서명(signature)을 검증한 다음, 주문 정보를 다시 가져와서(fetch) 해당 주문의
notes가 생성 시 지정한 기술(skill) 및 구매자와 일치하는지 확인하세요. 기록할 주문 금액과 통화는 요청 본문(request body)이 아니라, 해당 fetch를 통해 가져온 값이어야 합니다.
// Razorpay 확인
if (!verifyCheckoutSignature({ orderId, paymentId, signature })) return forbid();
const order = await razorpay.fetchOrder(orderId); // 신뢰할 수 있는 원천 (source of truth)
...
두 방식 모두 규칙은 동일합니다. 장부(ledger)에 기록하는 금액과 통화는 클라이언트가 결제했다고 말하는 정보가 아니라, 구매자와 암호학적으로 연결된, 당신이 생성한 주문에 대한 결제 제공자의 기록으로부터 가져와야 합니다.
다음 사람에게 해주고 싶은 말
- PayPal 구매자와 판매자가 같은 국가에 거주하더라도, PayPal은 그들을 고립시킬 수 있습니다. 첫날부터 두 번째 결제 경로 (second rail)를 계획하십시오.
- 통화 (currency) 기준으로 라우팅하고, 이를 기본값으로 설정하되 구매자가 언제든 변경할 수 있도록 하십시오.
- 정확한 환율 (FX)이 아닌, **현지 가격대 (local price points)**로 변환하십시오.
- 각 트랜잭션에 통화와 수수료를 고정 (freeze) 하십시오. 통화별로 수익을 집계해야 합니다.
- 구매자와 연결되어 당신이 생성한 주문으로부터, 서버 측에서 결제 금액을 재도출 (re-derive) 하십시오. 클라이언트의 말이 아닌, 제공자의 기록을 신뢰하십시오.
이 중 어느 것도 생소한 것이 아닙니다. 단지 해결책이 "Stripe를 추가하세요"일 때 아무도 언급하지 않는 내용들일 뿐이며, 이 모든 것들은 당신의 테스트 계정에서 첫 번째 "작동하지 않는 것 같습니다"라는 메시지가 나오기 전까지는 보이지 않습니다.
저는 모든 리스팅이 작동함을 증명해야 하는 재사용 가능한 AI 기술 마켓플레이스인 Skill Exchange를 만들면서 이 시스템을 구축했습니다. 댓글로 결제 관련 질문을 주시면 기꺼이 답변해 드리겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기