준비하고 있는 서비스의 정책을 짜던 중 깊은 고민에 빠졌었다.
글로벌 서비스로 준비 중인데, 부가가치세(VAT) 부분이 각국마다 세법이 다르다는 점!
장기적으로 봤을 때 이 부분을 해결하지 않고서는 비즈니스에 차질이 생길 것으로 예상되었다.
그래서 gpt와 이런저런 상담을 하던 중 Paddle이라는 서비스를 알게 되었다.
이 서비스를 사용하면 vat 부분을 Paddle이 알아서 처리해준다는 것이었다!
이것은 Paddle이 MoR(상점 기록자, Merchant of Record)이라는 서비스를 제공하기 때문인데
상점과 고객 간의 거래에 있어서의 모든 세무 처리나 법적 책임, 통화 처리 등을 대신해서 처리해주는 비즈니스이다.
이런 MoR서비스는 현재 나와같이 국제 상거래 비즈니스를 함에 있어
여러가지 법적, 세무적 문제로 힘들어하는 상인들이 쉽게 결제 서비스를 만들 수 있게 도와준다.
이런 MoR 서비스 제공 업체 중 가장 유명한 기업이 바로 Paddle이다.
2012년 설립된 영국에 본사를 둔 회사라고 한다.
Paddle 결제서비스를 이용하면 이러한 MoR역할을 해주는 것 뿐만 아니라
구독관리, 환불관리, 영수증관리, 사기방지 등 결제와 관련된 모든 까다로운 문제를 해결해준다.
나의 경우 Paddle을 알게 되고서 가지고 있던 문제 중 한가지를 더 해결할 수 있었는데
바로 국내외 카드결제를 한번에 진행할 수 있다는 부분이었다. (이게 vat문제 해결보다 더 강력했음)
사실 서비스 결제를 받음에 있어서 글로벌 결제가 필요했고 그에따라 페이팔 결제를 구축했었다.
그런데 페이팔은 국내계정을 가진 유저의 결제를 받을 수 없다. (이 규제는 좀 빨리 풀렸으면...;;)
참고로 스트라이프의 경우 아예 한국 사업자가 이용할 수 없다고 한다. (이건 더 빨리 좀 풀렸으면...ㅜㅜ)
결국 페이팔로 해외결제를 받고 국내결제는 이후 카카오페이나 네이버페이같은 것으로 추가적으로 붙이려고했는데
페이먼트가 여러개로 쪼개지는게 영 찜찜했었다. (개인적으로 KG이니시스는 쓰기 싫었다.)
그런 고민을 하던 나에게 Paddle은 종합선물세트였다.
국내외 통합결제 문제, 세금문제, 그에 이어서 영수증, 환불까지 한큐에 해결되어서 너무나 훌륭했다.
그리고 나중에 진행하려고 한 구독서비스로의 전환도 굉장히 쉽게 구축할 수 있게 도와준다.
단 하나의 큰 단점이 바로 수수료이다.
저 많은 장점들을 제공해주는 만큼 수수료가 꽤 부담되었는데
일반 PG들이 0.35정도의 수수료인데 Paddle의 경우 0.5%의 수수료 + 0.5달러를 추가로 내야한다.
이런 저런 고민을 해보았는데 수수료가 좀 부담이긴 하지만 그래도 Paddle을 사용하는 게 현재로써는 최선이라고 판단했다.
(세무전문가, 법률전문가 고용비 보다는 싸다고 판단)
그래서 이번 포스팅에서는 Paddle로 결제시스템 구축 방법을 남겨보고자 한다.
(구독 방식이 아닌 여러 개의 상품을 장바구니에 담아서 결제하는 방식)
Paddle에 가입하고 로그인을하면 이런 대시보드형 인터페이스 페이지에 진입할 수 있다.
아직은 실제 서비스용 구축이 아니여서 샌드박스로 진행.
가운데 Try Sandbox 버튼을 누르면 샌드박스모드로 진입 가능하다.
그러면 실제 Paddle과 똑같은 로그인 페이지가 뜨는데 여기서 샌드박스용 계정을 또 만들어서 로그인을 하면 된다.
url주소를 보면 sandbox가 적혀있음.
로그인하면 실제 Paddle과 똑같은 대시보드형 페이지로 진입한다.
하지만 여기는 샌드박스 환경임.
모든 결제 서비스들이 그렇지만 가장 중요한건 내 API 키를 설정하는 것이다.
좌측 메뉴 중 Developer Tools에 Authentication으로 들어가면 API관련 키들을 설정할 수 있다.
상단에 Seller ID라는 것이 자동 설정되어있고, API key, Client-side token 이렇게 3가지 정보가 있다.
예전 Paddle은 저 Seller ID로 진행을 한 것 같은데
최근 버전은 클라이언트 측에서 주로 Client-side token으로 진행한다고 한다.
나도 이번에 Client-side token로 진행.
그리고 서버쪽에서는 API key가 필요하며 이는 매우 중요한 보안키이니 env파일로 진행 필요.
메뉴 하단을 보면 Documentaton이라는 메뉴가 있는데 이걸 누르면 아래 DOCS페이지로 진입할 수 있다.
개발자의 개발을 위한 문서들이 모아져있는 곳이다.
참고로 Stripe나 Payapl 보다 Paddle로 결제 구축이 간단한 편인데도 가장 해맸었다.
왜냐하면 내가 cursor ai에 꽤 의존하면서 코드를 짜고있는데 Stripe나 Paypal은 기존 자료가 많아서그런지
ai가 제대로된 자료를 제시해주는데 Paddle의 경우 아직 그렇게까지 안알려져서그런지 ai가 잘못된 코드 제시를 너무 많이했다.
특히 구버전(Paddle Classic)과 섞어서 제시해주면서 제대로된 코드 구현이 힘들었다.
그래서 Paddle로 결제 시스템을 구축하려면 이 Docs를 잘 보는게 무엇보다 중요하다.
나도 세부적인 파악이 힘들 땐 ai에게 이 문서를 직접 실시간으로 학습 시켜가면서 문제를 해결해나갔다.
Paddle의 경우 Paddle Classic과 Paddle Billing으로 구분되어있고 클래식이 구버전이다.
따라서 지금부터 구축하는 유저는 Paddle Billing으로 구축하는게 옳다.
그래서 Docs 상단 드랍다운이 Paddle Billing으로 되어있는지 확인하고 진행해야한다. (서로 Docs가 다름)
좌측 메뉴 구조를 잘 이해하는 게 매우 중요하다.
먼저 Build 페이지는 Paddle의 전체적인 구축 방법에 대한 개요를 적어놓았다.
미리 상품목록을 등록해서 Paddle에서 간편하게 관리하는 방식을 카탈로그라고 하는데
카탈로그 관리방법과 체크아웃 창에 대한 설명, 영수증, 구독서비스, 트랜젝션 등
결제 및 구독 서비스 관련해서 어떻게 구축하는지에 대한 설명서라고 볼 수 있다. (코드에 대한 내용은 아님)
트랜젝션 GET API 규칙 정리된 부분
다음으로 API reference 페이지는 코드로 짜야할 데이터 구조를 알려준다.
각 기능마다 GET, POST 등으로 분류되어 API 규칙이 잘 정리되어있다.
마지막으로 Paddle.js페이지도 중요한데 여기는 Paddle 고유 메서드인 Paddle.js 사용방법을 알려준다.
이 중에서도 가장 중요한건 Paddle.Initialize() 그리고 Paddle.Checkout.open()
Paddle.Initialize()는 SDK를 초기화하고
체크아웃에 대한 전체적인 환경설정, 그리고 이벤트 처리(성공, 실패 감지 등)와 같은 중요한 역할을 한다.
Paddle.Checkout.open()은 결제 창을 열고 결제 정보를 전달한다.
살펴본것처럼 DOCS의 여러 글들 중에 특히 Build, API reference, Paddle.js
이 3개 페이지에서 필요한 정보를 꼭 읽어보고 구축하는게 중요.
아무튼 고분분투 끝에 구축한 코드는 아래와 같다.
프론트엔드 (카트에서 장바구니 목록을 결제 버튼을 통해 요청하는 부분)
const assets = orderAssets.map(asset => asset._id);
// 패들 데이터 구조로 변경
const paddleData = orderAssets.map(asset => ({
quantity: 1,
price: {
quantity: {
minimum: 1,
maximum: 1
},
description: 'graffin',
unit_price: { // amount 대신 unit_price 사용
amount: String(asset.price * 100), // 문자열로 변환
currency_code: "USD"
},
product: {
name: asset.name,
tax_category: "standard"
}
}
}));
console.log('paddleData', paddleData, totalPrice, assets);
// 결제
document.addEventListener('click', async (e) => {
const orderBtn = e.target.closest('#paddle-btn');
const agreeCheckbox = document.getElementById('order-agree-checkbox');
if (orderBtn && agreeCheckbox && agreeCheckbox.checked) {
// router.navigateTo('/payment');
paddlePayment(totalPrice, assets);
payWithPaddle(paddleData, totalPrice, assets);
}
});
여기서 중요한 건 Paddle 데이터 구조이다.
Paddle이 요즘 구독서비스 방식 구축을 밀고 있어서 그걸로 구축하는 방식은 굉장히 메뉴얼을 잘 제공하는데
내가 지금 하려고하는 장바구니로 여러 상품을 담아서 결제하는 방식은 자료를 찾기 매우 어려웠다.
Docs에서 비카탈로그방식 부분을 잘 읽어보고 데이터구조를 잘 파악해서 구축해야 한다.
(데이터구조가 조금이라도 틀리면 에러가 남)
일단 나의 경우 디지털상품 특성 상 갯수 설정이 의미가 없어서 미니멈, 맥시멈 1로 유저에 의한 설정을 막아놓았다.
그리고 가격의 경우 Paddle은 달러단위 * 100을해서 소수점이 없는 상태로 진행해야 한다.
결제 버튼을 누르면 설정해놓은 함수를 실행.
프론트엔드 (Paddle을 초기화하고 결제창을 띄워서 백엔드에 요청하는 코드)
export async function paddlePayment(totalPrice, assets) {
try {
// Paddle 스크립트 로드
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
// Paddle 초기화
Paddle.Environment.set("sandbox"); // 샌드박스 환경 설정 추가
Paddle.Initialize({
token: 'test 토큰',
// 진행 중인 결제 이벤트 콜백
eventCallback: async function(event) {
console.log('Paddle event:', event); // 모든 이벤트 로깅
// 성공시 주문 생성
if (event.name === "checkout.completed") {
console.log('Checkout completed event:', event);
const token = localStorage.getItem("token");
const orderResponse = await fetch(`${URI}/api/paddle/order-success`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
totalPrice: totalPrice,
assets: assets,
transactionId: event.data.transaction_id
})
});
const orderResult = await orderResponse.json();
console.log('Order created successfully:', orderResult);
// 성공 페이지로 리다이렉션
window.location.href = `/order-success.html?orderId=${orderResult.orderId}`;
}
// 실패는 패들 기본 설정 따르므로 따로 설정 X
}
});
} catch (error) {
console.error('Payment page error:', error);
alert('결제 페이지 로드 중 오류가 발생했습니다: ' + error.message);
}
}
PaddlePayment함수를 보면 아까 Paddle.js 문서에서 보았던 초기화를 진행한다.
여기 token부분에 아까 설정한 client-side token을 적용해줘야한다.
원래 html에서 script.src = 'https://cdn.paddle.com/paddle/v2/paddle.js'; 이걸 스크립트 태그로 넣어줘야하는데
그냥 위 방식으로 진행했다.
event callback 부분은 결제를 진행하면서 생기는 모든 이벤트를 감지하고 처리하는 부분이다.
나는 성공시 오더를 생성하고 성공페이지로 이동 시키기 위해 이 부분을 위와 같이 진행했다.
(이 부분은 꼭 진행하지 않아도 결제는 문제없이 진행된다.)
다음으로 payWithPaddle함수를 살펴보면
// 결제 버튼
export async function payWithPaddle(paddleData, totalPrice, assets) {
try {
// 트랜잭션 생성
const token = localStorage.getItem("token");
const transactionResponse = await fetch('/api/paddle/create-transaction', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
items: paddleData,
totalPrice: totalPrice,
assets: assets
})
});
const transactionData = await transactionResponse.json();
console.log('Transaction response:', transactionData);
if (!transactionData.success) {
throw new Error(transactionData.error || '트랜잭션 생성 실패');
}
// 유저 정보 가져오기
const userResponse = await fetch(`${URI}/api/user/me`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
const userData = await userResponse.json();
// 체크아웃 열기
await Paddle.Checkout.open({
transactionId: transactionData.transactionId,
customer: {
email: userData.data.email
}
});
} catch (error) {
console.error('Checkout error:', error);
alert('결제창을 열 수 없습니다: ' + error.message);
}
}
여기서 실제 결제를 진행하는데
백엔드에 트랜젝션을 요청하면서 체크아웃 창을 연다.
패들은 사용자에게 이메일로 영수증을 바로 쏴주는 기능과 국가별 vat 처리 부분 때문에
결제창에서 이메일정보와 국가정보를 요구하는데
위와같이 코드를 짜면 기존에 내가 받았던 유저 이메일을 미리 넣어 줄 수 있다.
(국가정보도 Paddle이 ip주소 등으로 감지 후 자동 설정)
계속 버튼을 누르면 아래와 같이 카드정보 입력을 진행할 수 있다.
참고로 이런식으로 vat처리가 알아서 진행되는데
아까봤던 대시보드쪽에서 vat 처리 방식도 세부 설정을 할 수 있다. (부가세 포함, 부가세 별도)
다음은 백엔드 코드
const Order = require("../models/Order");
const { Paddle, Environment } = require('@paddle/paddle-node-sdk');
const createTransaction = async (req, res) => {
try {
if (!process.env.PADDLE_API_KEY) {
return res.status(500).json({
success: false,
error: 'PADDLE_API_KEY is not set in environment variables'
});
}
const paddle = new Paddle(process.env.PADDLE_API_KEY, {
environment: Environment.sandbox
});
const { items } = req.body;
if (!items || !Array.isArray(items)) {
return res.status(400).json({
success: false,
error: 'Invalid items data'
});
}
// API 요구사항에 맞게 데이터 구조 수정
const transactionData = {
items: items, // 클라이언트에서 받은 데이터 그대로 사용
currency_code: "USD",
collection_mode: "automatic"
};
const transaction = await paddle.transactions.create(transactionData);
console.log('Transaction created:', transaction);
res.status(200).json({
success: true,
transactionId: transaction.id
});
} catch (error) {
console.error('Transaction creation error:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to create transaction'
});
}
};
const orderSuccess = async (req, res) => {
try {
const { totalPrice, assets, transactionId } = req.body;
if (!totalPrice || !assets || !transactionId) {
return res.status(400).json({
success: false,
error: 'Missing required fields'
});
}
const newOrder = new Order({
orderNum: transactionId,
totalPrice: totalPrice,
userId: req.userId,
assets: assets,
});
const savedOrder = await newOrder.save();
res.status(200).json({
success: true,
order: savedOrder,
orderId: savedOrder._id,
orderNum: savedOrder.orderNum
});
} catch (error) {
console.error('Order creation error:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to create order'
});
}
};
const downloadInvoice = async (req, res) => {
try {
const { transactionId } = req.params;
console.log('Requesting invoice for transaction:', transactionId);
// 먼저 트랜잭션 상태 확인
const paddle = new Paddle(process.env.PADDLE_API_KEY, {
environment: Environment.sandbox
});
const transaction = await paddle.transactions.get(transactionId);
console.log('Transaction status:', transaction.status);
// sandbox API URL 사용
method: 'GET',
headers: {
'Authorization': `Bearer ${process.env.PADDLE_API_KEY}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
console.error('Error response:', errorText);
throw new Error(`API request failed: ${response.status}`);
}
const invoiceData = await response.json();
console.log('Invoice data:', invoiceData);
res.json({
data: {
url: invoiceData.data.url
},
meta: invoiceData.meta
});
} catch (error) {
console.error('Invoice download error:', error);
res.status(400).json({
error: {
message: error.message
}
});
}
};
module.exports = {
createTransaction,
orderSuccess,
downloadInvoice
};
주문 생성과 영수증 처리 때문에 코드가 길어보이지만 최소 백엔드 구조는 간단하게 처리할 수 있다.
여기도 마찬가지로 아까 강조했던 데이터구조가 중요하며 프론트엔드와 똑같은 구조로 받아야 에러가 나지 않는다.
API key는 env를 통해 가져온다.
// 영수증
async function downloadInvoice(transactionId) {
try {
const token = localStorage.getItem("token");
const response = await fetch(`/api/paddle/invoice/${transactionId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('인보이스 다운로드에 실패했습니다.');
}
const result = await response.json();
console.log('Invoice response:', result); // 응답 데이터 확인용 로그
if (!result.data?.url) {
throw new Error('PDF URL을 찾을 수 없습니다.');
}
// PDF URL을 새 창에서 열기
window.open(result.data.url, '_blank');
} catch (error) {
console.error('Invoice download error:', error);
alert('영수증 다운로드 중 오류가 발생했습니다: ' + error.message);
}
}
// 영수증 버튼 이벤트
document.addEventListener('click', (e) => {
const receiptButton = e.target.closest('#get-receipt-btn');
if (receiptButton) {
console.log('주문 데이터:', data.data); // 디버깅용 로그
downloadInvoice(data.data.orderNum);
}
});
참고로 발행한 영수증을 내 서비스에서도 제공해줄 수 있다.
이렇게 프론트엔드를 짜고 위 백엔드코드 downloadInvoice로 요청하면
Paddle이 발행해주는 영수증을 내 서비스에서도 제공 가능하다.
이렇게 구축 후 결제를 진행하면 Paddle 서비스에서 아래와 같이 트랜젝션이 잘 쌓인 것을 확인할 수 있으며
그 데이터를 한눈에 관리할 수 있는 대시보드도 활용할 수 있다.
이렇게 Paddle로 카드 결제 시스템 구축 과정을 정리해보았다.
사실 Paddle이 워낙 방대해서 지금 내가 구축한 부분은 빙산의 일각이며 구독시스템, 카탈로그 등 활용 가능성은 무궁무진하다.
추후에 다른 활용 방식도 포스팅 해보려고한다.