← 목록으로

스트라이프 없이 달러와 루피를? 인도 서비스 결제 시스템 구축기가 알려준 뼈아픈 교훈 (PayPal + UPI)

2026. 8. 3.

스트라이프 없이 달러와 루피를? 인도 서비스 결제 시스템 구축기가 알려준 뼈아픈 교훈 (PayPal + UPI)

"그냥 스트라이프(Stripe) 붙이면 돼!" 이 말은 돈을 받아야 하는 1인 개발자에게 늘 따라붙는 만능 조언처럼 들립니다. 샌프란시스코의 구매자와 벵갈루루의 구매자에게 같은 날 오후에 동시에 판매해야 하는 인도 판매자 입장이라면, 스트라이프만으로는 한계가 명확해지죠. 단일 결제 제공업체로는 두 가지 상황을 모두 처리할 수 없다는 사실을 깨닫는 순간, 예상보다 훨씬 많은 코드 수정 작업이 필요하다는 걸 알게 될 겁니다.

제가 재사용 가능한 AI 기술 마켓플레이스인 Skill Exchange(링크는 글 마지막에 있습니다)를 구축하면서 바로 이런 상황에 직면했습니다. 여기서 중요한 건 마켓플레이스 자체가 아니라, 미국 법인 없이, 스트라이프 없이 전 세계 양 끝에 있는 두 사람에게서 돈을 받는 방법을 알아내기 위해 제가 사용한 일종의 '실험장'이었다는 점입니다. 다른 개발자들이 이 길을 걷기 전에 제가 미리 알려주고 싶은 것들이 바로 여기에 있습니다.

인도 PayPal은 '국경 간 거래'만 지원합니다 — 인도 내 구매자에게는 서비스 불가능

가장 먼저 깨야 할 고정관념은 바로 이것입니다. PayPal은 인도 내수 결제 수단이 아닙니다. 2021년 4월, PayPal은 인도 내 결제를 중단했습니다. 현재 남아있는 기능은 오직 '국경 간 거래'뿐이죠. 즉, 인도 판매자는 해외에서 돈을 받을 수 있지만, 인도에 있는 루피 사용 구매자가 인도 기반 판매자에게 PayPal을 통해 결제할 수는 없습니다.

이 사실은 예상치 못한 방식으로 배웠습니다. PayPal을 연동하고, 제 개인(인도) 계정으로 제 상점(인도) 계정에 결제를 테스트했는데, "현재 작업이 제대로 진행되지 않는 것 같습니다." 라는 명랑하지만 아무 도움도 안 되는 메시지만 받았습니다. 에러 코드도, 문서에도 이런 내용은 없었죠. 거래는 단순히 내수 거래로 분류되어 거부될 뿐이었습니다. 구매자가 해외 사용자일 때는 완벽하게 작동하니, 자국에서 테스트할 때는 이 실패 원인을 파악하는 게 정말 헷갈릴 수밖에 없었습니다. 제가 직접 실무에서 이런 문제에 부딪혔을 때, 디버깅 과정에서 정말 멘붕이 오더군요.

결론은 이렇습니다. 구매자 중 한 명이라도 PayPal 판매자 계정과 같은 국가에 있다면, PayPal만으로는 이들을 놓치게 됩니다. 두 번째 결제 수단이 필요합니다.

두 개의 결제 제공업체가 필요하며, 분할 기준은 '통화'이지 '기능'이 아닙니다

결국 아키텍처는 구매자의 통화에 따라 선택되는 두 개의 제공업체로 구성됩니다.

  • 해외 구매자 → PayPal: USD로 결제
  • 인도 구매자 → Razorpay: INR로 UPI (원 탭 결제, 거의 제로에 가까운 마찰, 인도 내 카드 결제보다 훨씬 높은 전환율)를 통해 결제

여기서 중요한 설계 결정은 구매자가 결제 시 통화를 선택함으로써 어떤 결제 수단을 사용할지 결정한다는 점입니다. 저는 시간대에 따라 기본값을 설정하고 사용자가 이를 재정의할 수 있도록 했습니다. IP 조회나 외부 호출은 일절 사용하지 않았습니다.

const defaultCurrency = (() => {
  try {
    const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
    return tz === "Asia/Kolkata" || tz === "Asia/Calcutta" ? "INR" : "USD";
  } catch { return "USD"; }
})();

이 시간대 휴리스틱은 비거주 인도인(NRI)이나 여행객에게는 정확하지 않을 수 있습니다. 그래서 이는 단순히 기본값일 뿐, 절대 잠금장치가 아니죠. 통화 전환 토글은 항상 존재합니다.

그리고 buy 엔드포인트는 이 하나의 필드를 기반으로 라우팅됩니다.

async function buySkill(user, skillId, body) {
  const skill = await getSkill(skillId);
  const wantsInr = String(body?.currency).toUpperCase() === "INR";

  if (wantsInr) {
    const paise = usdCentsToInrPaise(skill.priceCents);
    const order = await razorpay.createOrder({ amount: paise, currency: "INR",
      notes: { skillId, buyerId: user.id } });        // notes로 구매자와 연결
    return { provider: "razorpay", orderId: order.id, amount: paise, currency: "INR" };
  }
  const order = await paypal.createOrder({ amountUsd: skill.priceCents / 100,
    skillId, buyerId: user.id });                     // custom_id로 연결
  return { provider: "paypal", orderId: order.id, amount: skill.priceCents, currency: "USD" };
}

정확한 환율 변환 대신 '현지 가격대'에 맞춰 반올림하세요

가장 단순한 방법은 USD 가격을 특정 환율로 변환하여 보여주는 것입니다. 하지만 그렇게 하지 마세요. 12달러짜리 기술을 86루피/달러로 환산하면 1,032루피가 됩니다. 이는 마치 버그처럼 보이고 비싸게 느껴집니다. 인도 내 디지털 상품에 대한 구매 의향은 달러 환산 가격이 암시하는 것보다 낮습니다. 인도를 대상으로 하는 모든 진지한 판매자들은 대신 '지역별 가격대'를 사용합니다.

따라서 변환은 usd * rate가 아니라, 친숙한 ₹x99 형태로 반올림됩니다.

const INR_RATE = 86; // 조절용 단일 값; 전체 카탈로그 가격을 재설정
function usdCentsToInrPaise(usdCents) {
  if (!usdCents) return 0;
  const rupees = (usdCents / 100) * INR_RATE;
  const clean = Math.max(1, Math.round(rupees / 100) * 100 - 1); // → ₹x99
  return clean * 100; // paise 단위
}
// $5→₹399  $6→₹499  $9→₹799  $12→₹999  $15→₹1,299

이 단일 INR_RATE 상수는 200개 이상의 상품 가격을 한 번에 재조정하며, 반올림은 상품별 작업 없이도 가벼운 구매력 할인의 효과를 줍니다. 사실 저도 처음에는 단순히 환율을 적용했다가, 사용자 이탈률이 급증하는 것을 보고 깜짝 놀랐습니다. 현지화된 가격 전략이 얼마나 중요한지 몸소 깨달은 부분이죠.

거래 시 통화와 수수료를 함께 저장하고, 절대 다시 계산하지 마세요

이것은 나중에 문제가 될 수 있는 부분이니 놓치지 마세요. 통화가 둘 이상이고 플랫폼 수수료가 있는 순간, 나중에 금액을 다시 계산할 수 없습니다. 두 가지 규칙이 있습니다.

  1. 모든 구매 기록에 통화를 저장하세요. amountCents만으로는 일부가 파이세(paise) 단위인 경우 모호해집니다.
  2. 플랫폼 수수료는 구매 시점에 계산하여 저장하세요. 수수료율이 변경되더라도 (제 경우 10%에서 5%로 변경되었습니다), 과거 기록은 판매 당시의 요율을 유지해야 합니다. 읽을 때마다 다시 계산하는 수수료는 결국 잘못된 수수료가 될 것입니다.
purchase = {
  skillId, buyerId,
  amount: chargedMinorUnits,        // USD는 센트, INR은 파이세
  currency,                         // "USD" | "INR"  — 절대 생략하지 마세요
  commission: Math.round(chargedMinorUnits * 0.05), // 판매 시점 고정
  provider, providerPaymentId, purchasedAt: now,
};

그리고 판매자 수익 집계는 통화별로 유지하세요. 센트와 파이세를 하나의 정수로 합산하는 것은 아름답지만 무의미한 숫자를 만들어낼 뿐입니다.

클라이언트가 말하는 것이 아닌, 생성한 주문을 기반으로 서버 측에서 결제를 검증하세요

두 결제 수단 모두 브라우저가 거짓말을 할 수 있으므로, 확정 단계에서는 클라이언트로부터 오는 금액이나 "성공했습니다" 플래그를 신뢰하지 마세요. 대신, 제공업체로부터 진실을 다시 파악하고 해당 구매자 및 아이템에 정확히 속하는지 확인해야 합니다.

  • PayPal: 서버 측에서 주문을 캡처하고, status === "COMPLETED"를 요구하며, 생성 시 설정한 custom_id가 skillId|buyerId와 일치하는지 확인합니다. 캡처된 주문은 다른 구매에 대해 재사용될 수 없습니다.
  • Razorpay: 체크아웃 HMAC 서명을 확인한 다음, 주문을 다시 가져와서 notes가 생성한 기술 및 구매자와 일치하는지 확인합니다. 기록할 주문 금액/통화는 요청 본문에서 가져오는 것이 아니라, 이 조회 결과에서 가져와야 합니다.
// Razorpay 결제 확인
if (!verifyCheckoutSignature({ orderId, paymentId, signature })) return forbid();
const order = await razorpay.fetchOrder(orderId);            // 신뢰의 원천
if (order.notes.skillId !== skillId || order.notes.buyerId !== user.id) return forbid();
await recordPurchase({ ...order fields..., currency: order.currency, amount: order.amount });

두 결제 수단 모두 동일한 규칙이 적용됩니다. 원장에 기록할 금액과 통화는 생성한 주문에 대한 제공업체의 기록에서 가져와야 하며, 이는 구매자와 암호화 방식으로 연결되어야 합니다. 클라이언트가 지불했다고 말하는 것을 그대로 믿어서는 안 됩니다. 이 부분은 보안과 직접적으로 연결되기에, 제가 실무에서 가장 심혈을 기울여 테스트하고 또 테스트했던 지점입니다. 클라이언트를 맹신하는 순간, 악의적인 공격에 취약해질 수 있다는 걸 늘 염두에 둬야 합니다.

다음에 올 개발자에게 해주고 싶은 말

  • PayPal 구매자와 판매자가 같은 국가에 있다면 PayPal은 그들을 놓치게 됩니다. 처음부터 두 번째 결제 수단을 계획하세요.
  • 통화를 기준으로 라우팅하고, 기본값을 설정하되, 항상 구매자가 전환할 수 있도록 허용하세요.
  • 정확한 환율 변환 대신 현지 가격대로 전환하세요.
  • 각 거래에 통화와 수수료를 고정하여 저장하고, 수익은 통화별로 집계하세요.
  • 클라이언트의 말을 믿지 말고, 구매자와 연결된 생성한 주문을 기반으로 서버 측에서 결제를 재확인하세요. 제공업체의 기록을 신뢰해야 합니다.

이 모든 것이 특별하거나 복잡한 내용은 아닙니다. 단지 "스트라이프 붙여!"라는 답만 들었을 때는 아무도 언급하지 않는 내용들이고, 자신의 테스트 계정에서 처음으로 "현재 작업이 제대로 진행되지 않는 것 같습니다."라는 메시지를 보기 전까지는 전혀 눈에 띄지 않는 것들입니다.


저는 이 모든 것을 Skill Exchange를 만들면서 경험했습니다. Skill Exchange는 모든 목록이 실제로 작동함을 증명해야 하는 재사용 가능한 AI 기술 마켓플레이스입니다. 결제 관련 질문이 있다면 댓글로 기꺼이 답변해 드리겠습니다.


원문: https://dev.to/mohanvenkatakrishnan/dollars-and-rupees-without-stripe-what-building-skill-exchanges-checkout-taught-me-paypal-upi-3i8p 수집일: 2026-08-03 01:25:48