나의 개발 일지

Stripe, Paypal 결제기능 간편하게 구현하기 : JS + node.js

designer DK 2024. 11. 21. 22:32
728x90

기존 진행하던 예제에 간편한 카드결제를 위해

Stripe, Paypal을 붙여보았다.

 

 

이런저런 디테일한 우여곡절은 있었지만

막연히 결제 시스템 구현에 대해 두려웠던 것에 비해서는 생각보다 순조롭게 진행했다.

 

이번에 결제 시스템 구현을 하게 되면서 알게된 부분인데

페이팔의 경우 한국계정에서 한국계정으로는 결제 요청이 안된다고 한다.

(한국-해외 or 해외-한국은 가능)

 

반면 스트라이프의 경우는 한국 계정끼리도 가능하다.

그래서 일단 스트라이프로 최종 구축해 보았다.

 

먼저 스트라이프 계정생성을 해야한다.

 

계정생성 시 국가 선택에 한국이 없어서 당황스러웠는데;;

ai에게 물어보니 일단 미국이나 다른나라로 했다가 나중에 바꿀 수 있다고 한다.

 

스트라이프든 페이팔이든 모두 실제 결제 시스템 안정성을 위해

Sandbox라는 환경과 live라는 환경으로 나뉘어져있다.

 

Sandbox 환경에서는 실제 카드 정보나 실제 결제 금액이 아니어도

얼마든지 결제 테스트가 가능하다. 따라서 개발 단계에서는 샌드박스로 해야 안전하다.

 

 

그리고 개발을 완료하고 실제 결제 유저를 위한 약관이나 정책들도 다 붙으면

라이브로 환경으로 전환해야 실제 결제 기능이 작동한다.

 

 

스트라이프도 커스터마이즈 정도에 따라 여러 방식을 제공하는데

Stripe Checkout이라는게 가장 간단하다고해서 이걸로 해보았다.

 

Stripe Checkout의 특징은 주문 버튼 클릭 시

스트라이프에서 제공하는 완성된 ui가 나오는 페이지로 리디렉션을 시킨다.

거기서 사용자가 정보입력하고 최종 결제 버튼을 누르면

결제 성공 혹은 실패 페이지로 이동시킴.

 

‘스트라이프에서 제공하는 완성된 ui가 나오는 페이지로 리디렉션을 시킨다’는 부분이

간편하게 구축할 땐 훨씬 좋아보여서 체크아웃으로 구현해 보았다.

(ui 커스터마이즈를 하려면 스트라이프 intents로 해야함)

 

스트라이프 체크아웃 결제시스템 코드 구현을 살펴보면

 

일단 Html에는 버튼만 배치하면 된다.

헤더에 스트라이프 관련 스크립트를 가져오고

<script src="https://js.stripe.com/v3/"></script>

 

바디에는 handleStripePayment() 함수를 실행시키는 버튼 하나를 배치

<!-- 결제 섹션 -->
<div id="payment-section">
 
<button onclick="handleStripePayment()">스트라이프로 결제</button>
 
<!-- 에러 메시지 표시 영역 -->
<div id="error-message"></div>
</div>

 

이게 스트라이프로 주문 결제를 실행하는 버튼이다.

 

그럼 handleStripePayment()를 바로 이어서 살펴보면

 
// Stripe 초기화
const stripe = Stripe(CONFIG.STRIPE_PUBLISHABLE_KEY);

async function handleStripePayment() {
try {
// 로딩 상태 표시
const button = document.querySelector('button');
if (button) button.disabled = true;

// sessionStorage에서 데이터 가져오기
const overallTotalPrice = sessionStorage.getItem("overallTotalPrice");
const orderItems = JSON.parse(sessionStorage.getItem("orderItems") || "[]");

const amount = parseInt(overallTotalPrice);

const response = await fetch('/api/stripe/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
"Authorization": `Bearer ${sessionStorage.getItem("token")}`
},
body: JSON.stringify({
amount: amount,
orderItems: orderItems
})
});

if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '결제 세션 생성 실패');
}

const session = await response.json();

// Stripe Checkout으로 리다이렉트
const result = await stripe.redirectToCheckout({
sessionId: session.id
});

if (result.error) {
throw new Error(result.error.message);
}
} catch (error) {
console.error('결제 처리 중 오류:', error);
document.querySelector("#error-message").textContent =
"결제 처리 중 오류가 발생했습니다: " + error.message;
} finally {
// 로딩 상태 해제
const button = document.querySelector('button');
if (button) button.disabled = false;
}
}

 

 

먼저 이렇게 퍼블릭키를 가져와야한다.

config파일에 저장해서 가져오는게 간편한데 기본적으로 프론트엔드에서 보이는 퍼블릭키는 노출되어도 상관은 없는 키

const stripe = Stripe(CONFIG.STRIPE_PUBLISHABLE_KEY);

 

버튼 디스에이블드 on/off가 사용되었는데 이는 결제 중복요청을 방지하기 위한 장치임

const button = document.querySelector('button');
if (button) button.disabled = true;
finally {
// 로딩 상태 해제
const button = document.querySelector('button');
if (button) button.disabled = false;
}

 

즉 함수 실행과 동시에 주문버튼은 디스에이블드가 되고

결제가 성공이든 실패든 결론이 나면 버튼이 다시 인에이블드가 되는 코드이다.

 

그 다음은 카트에서 저장했던 총가격과 주문아이템 정보를 바디에 넣어서

/api/stripe/create-checkout-session 로 요청하는 코드

// sessionStorage에서 데이터 가져오기
const overallTotalPrice = sessionStorage.getItem("overallTotalPrice");
const orderItems = JSON.parse(sessionStorage.getItem("orderItems") || "[]");

const amount = parseInt(overallTotalPrice);
 
const response = await fetch('/api/stripe/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
"Authorization": `Bearer ${sessionStorage.getItem("token")}`
},
body: JSON.stringify({
amount: amount,
orderItems: orderItems
})
});

 

이후 응답을 받을 때 유저정보도 필요하면 authController를 미들웨어로 한번 거쳐야하기 때문에

이 경우는 "Authorization": `Bearer ${sessionStorage.getItem("token")}` 이렇게 토큰정보도 같이 주면 좋다.

 

이 정보들로 요청을 하게 되면 백엔드로부터 부터 받은 정보를 session이란 변수에 넣고 이걸 이용해서

체크아웃 페이지로 리디렉션 된다.

const session = await response.json();

// Stripe Checkout으로 리다이렉트
const result = await stripe.redirectToCheckout({
sessionId: session.id
});

 

 

다음은 백엔드.

 

일단 stripe.api.js라는 라우터 파일을 따로 만들어주고 이렇게 라우팅 처리를 했다.

나의경우 유저정보도 받고 싶었기 때문에 authController를 미들웨어로 추가

const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
const stripeController = require('../controllers/stripeController');

// Stripe 관련 라우트
router.post('/create-checkout-session', authController.athenticate, stripeController.createCheckoutSession);
router.get('/success', authController.athenticate,stripeController.handleSuccess);

module.exports = router;

 

그리고 stripeController라는 파일도 추가로 만들어주고 여기에 프론트에 대한 응답코드를 진행

이 컨트롤러는 크게 두개의 응답 메서드가 있는데

하나는 createCheckoutSession (요청 값을 받아 체크아웃 세션을 생성)

나머지 하나는 handleSuccess (결제 결과 페이지를 위한 응답)

 

먼저 createCheckoutSession을 살펴보면

const Order = require("../models/Order");
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { randomStringGenerator } = require("../utils/randomStringGenerator")

const stripeController = {
createCheckoutSession: async (req, res) => {
try {
const { amount, orderItems } = req.body;

if (!amount || amount <= 0) {
return res.status(400).json({ error: '유효하지 않은 결제 금액입니다.' });
}

const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: 'krw',
product_data: {
name: '주문 결제',
},
unit_amount: Math.round(amount),
},
quantity: 1,
}],
mode: 'payment',
success_url: `http://localhost:5001/order-success.html?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `http://localhost:5001/order/cancel`,
metadata: {
orderItems: JSON.stringify(orderItems || [])
}
});

res.json({ id: session.id });
} catch (error) {
console.error('Stripe session creation error:', error);
res.status(500).json({ error: error.message });
}
},

 

아까 프론트에서 보내준 총가격과 주문 아이템 정보를 가져오고

세션이라는 변수를 만들어서 스트라이프 자체 구조에 맞게 가져온 값들을 넣어주게 된다.

여기서 결제 수단이나 통화 등 여러 정보를 설정

 

그리고 성공 시 url, 실패시 url도 설정한다.

 

응답으로 session.id 라는걸 보내서 프론트에서 체크아웃 리디렉션을 정확하게 할 수 있도록 해준다.

res.json({ id: session.id });

 

이렇게 createCheckoutSession을 통해 체크아웃 페이지로 이동하게 되면

이렇게 결제정보를 입력할 수 있다.

스트라이프 결제창

 

여기서 가상 결제정보를 입력하고 결제를 시도 (센드박스는 실제 결제 이루어지지 않음)

 

success_url: `http://localhost:5001/order-success.html?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `http://localhost:5001/order/cancel`,

 

그럼 아까 백엔드의 이 url설정에 의해 order-success.html 라는 html로 이동하게 된다.

 

order-success.html 은 일단 별 세팅 없이 이렇게 빈 페이지를 구성하고

<script src="/js/index/orderSuccess.js"></script> 를 통해서 채워지게끔 만들었다.

 

orderSuccess.js 를 보면

결제 결과에 대한 요청을 하는 코드로 구성되어있는데

async function loadOrderSuccess() {
try {
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session_id');

const token = sessionStorage.getItem('token');
if (!token) {
throw new Error('로그인이 필요합니다.');
}

const response = await fetch(`/api/stripe/success?session_id=${sessionId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});

const orderData = await response.json();
 
const contentContainer = document.getElementById("content-container");
const orderContainer = document.getElementById("order-container");

contentContainer.innerHTML = "";
orderContainer.innerHTML = "";

// 데이터 유효성 검사 및 기본값 설정
const order = orderData.order || {};

//날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return 'N/A';

const date = new Date(dateString);
if (isNaN(date.getTime())) return 'Invalid Date';

return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
//주문 결과 표시
const orderResult = `
<div class="order-success">
<h2>결제가 완료되었습니다.</h2>
<div class="order-details">
<p>주문번호: ${order.orderNum || 'N/A'}</p>
<p>결제금액: ₩${order.totalPrice?.toLocaleString() || '0'}</p>
 
<h3>주문 상품</h3>
<ul>
${(order.items || []).map(item => `
<li class="order-item">
<div class="order-item-details">
<strong>${item.productName}</strong>
<br>사이즈: ${item.size}
<br>수량: ${item.qty || 0}
<br>가격: ₩${(item.price || 0).toLocaleString()}
</div>
</li>
`).join('')}
</ul>
<p>주문일시: ${formatDate(order.createdAt)}</p>
</div>
<a href="/" class="home-button">홈으로 돌아가기</a>
</div>
`;

contentContainer.insertAdjacentHTML("beforeend", orderResult);

// 주문 완료 후 장바구니 비우기
sessionStorage.removeItem("orderItems");
sessionStorage.removeItem("overallTotalPrice");

} catch (error) {
console.error('주문 정보 로딩 중 오류:', error);
document.getElementById("content-container").innerHTML = `
<div class="error-message">
<p>주문 정보를 불러오는데 실패했습니다.</p>
<p>${error.message}</p>
<a href="/" class="home-button">홈으로 돌아가기</a>
</div>
`;
}
}

// 페이지 로드 시 자동 실행
window.addEventListener('DOMContentLoaded', loadOrderSuccess);

 

먼저 이 페이지 url주소 쿼리로 부터 sessionId를 받아오게 되고 그 값으로

/api/stripe/success?session_id=${sessionId} 이 주소로 요청을 보낸다.

주문한 유저정보도 얻기 위해서 토큰값을 넣어서 요청

const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session_id');
 
const token = sessionStorage.getItem('token');
if (!token) {
throw new Error('로그인이 필요합니다.');
}

const response = await fetch(`/api/stripe/success?session_id=${sessionId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
 
const orderData = await response.json();
 

 

다시 잠깐 백엔드 stripeController로 돌아와보면

방금 요청한 부분을 처리하는 handleSuccess 가 실행되게 되는데 

이부분이 성공 및 실패 케이스에 대한 응답 처리 코드이다.

 

원래 초기 handleSuccess의 기본구조는 굉장히 심플한데

나의 경우 결제 성공 페이지에서 유저정보나 주문내역 정보도 모두 보이게 하고 싶어서 코드양이 좀 더 많아졌다.

그리고 주문내역 정보의 경우 Order라는 DB 컬렉션에 저장도 하게끔 짜보았다.

 

요청받은 주소 쿼리 값에서 session_id,

미들웨어로부터 userId를 가져오고,

마지막으로 아까 createCheckoutSession에서 metadata 쪽에 저장해 준 orderItems 정보도 가져왔다.

handleSuccess: async (req, res) => {
try {
const { session_id } = req.query;
const { userId } = req;

const session = await stripe.checkout.sessions.retrieve(session_id);
 
const orderItems = JSON.parse(session.metadata.orderItems || '[]');

const newOrder = new Order({
userId,
orderNum: randomStringGenerator(),
totalPrice: session.amount_total,
status: session.payment_status === 'paid' ? 'preparing' : 'pending',
items: orderItems.map(item => ({
productId: item.productId,
size: item.size,
qty: item.qty,
price: item.price
}))
});

await newOrder.save();

res.status(200).json({
status: 'success',
order: {
orderNum: newOrder.orderNum,
totalPrice: newOrder.totalPrice,
items: orderItems.map(item => ({
productName: item.name,
size: item.size,
qty: item.qty,
price: item.price,
})),
userId: newOrder.userId,
createdAt: newOrder.createdAt
}
});

} catch (error) {
console.error('Payment success handling error:', error);
res.status(400).json({
error: error.message,
details: error.stack
});
}
}

 

이걸 조합해서 새로운 주문 정보를 생성하는 코드를 만들고 이를 저장하는 코드.

const newOrder = new Order({
userId,
orderNum: randomStringGenerator(),
totalPrice: session.amount_total,
status: session.payment_status === 'paid' ? 'preparing' : 'pending',
items: orderItems.map(item => ({
productId: item.productId,
size: item.size,
qty: item.qty,
price: item.price
}))
});

await newOrder.save();

 

 

DB의 Order 스키마 부분도 고려해서 짜는게 중요.

const orderSchema = Schema({
orderNum: { type: String, required: true },
totalPrice: { type: Number, default: 0, required: true },
userId: { type: mongoose.ObjectId, ref: User, required: true },
status: { type: String, default: "preparing" },
items: [{
productId: { type: mongoose.ObjectId, ref: Product, required: true },
size: { type: String, required: true },
qty: { type: Number, default: 1, required: true },
price: { type: Number, required: true }
}]
}, { timestamps: true });

 

그리고나면 응답값으로 주문내역 정보, 생성일자, 유저정보 등을 모두 보내주면 된다.

res.status(200).json({
status: 'success',
order: {
orderNum: newOrder.orderNum,
totalPrice: newOrder.totalPrice,
items: orderItems.map(item => ({
productName: item.name,
size: item.size,
qty: item.qty,
price: item.price,
})),
userId: newOrder.userId,
createdAt: newOrder.createdAt
}
});

 

참고로 주문번호인 orderNum는 지난번 포스팅에서 언급한 randomStringGenerator()라는 함수를 실행해서 만들어 냄

orderNum: randomStringGenerator(),

 

이제 마지막으로 다시 orderSuccess.js로 돌아와서 나머지 코드를 살펴보면

응답받을 값들로 ui를 채워가는 구조인데

 

이 orderResult라는 것을 만들어서 정보를 넣어줌으로써 결제 과정이 마무리되게 된다.

const orderResult = `
<div class="order-success">
<h2>결제가 완료되었습니다.</h2>
<div class="order-details">
<p>주문번호: ${order.orderNum || 'N/A'}</p>
<p>결제금액: ₩${order.totalPrice?.toLocaleString() || '0'}</p>
 
<h3>주문 상품</h3>
<ul>
${(order.items || []).map(item => `
<li class="order-item">
<div class="order-item-details">
<strong>${item.productName}</strong>
<br>사이즈: ${item.size}
<br>수량: ${item.qty || 0}
<br>가격: ₩${(item.price || 0).toLocaleString()}
</div>
</li>
`).join('')}
</ul>
<p>주문일시: ${formatDate(order.createdAt)}</p>
</div>
<a href="/" class="home-button">홈으로 돌아가기</a>
</div>
`;

contentContainer.insertAdjacentHTML("beforeend", orderResult);

 

참고로 이 코드의 경우 주문 생성일자를 로컬방식으로 전환하기 위해서 나오게 된 코드로 전체 맥락상 중요하진 않다.

//날짜 포맷팅
function formatDate(dateString) {
if (!dateString) return 'N/A';

const date = new Date(dateString);
if (isNaN(date.getTime())) return 'Invalid Date';

return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});

 

두번의 api 소통이 이루어져야해서 꽤 복잡해보이지만

제공한 코드에 내가 원하는 방식을 차근차근 대입해나가면서 해봤을 때 구현이 너무 어렵진 않았다.

특히 다음 포스팅에서 설명할 부분이긴 한데 cursor라는 ai툴을 이용해서 함께 이 부분을 고민해서 풀어보았는데

굉장히 사용성이 좋고 똑똑해서 많은 도움이 됐다.

 

 

참고로 결제기능 구현 과정 중 페이팔도 시도 및 성공은 했었어서 그 코드들을 남겨둔다.

 

html 코드 부분

 

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>

    <script src="https://www.paypal.com/sdk/js?client-id=클라이언트아이디=USD&components=buttons,hosted-fields"></script>

</head>

<body>
    <!-- 페이팔 컨테이너 -->
    <div id="paypal-button-container"></div>

    <script src="/js/index/paypal.js"></script>
</body>
</html>

 

 

 

프론트엔드 paypal.js

 

const overallTotalPrice = (Number(sessionStorage.getItem("overallTotalPrice")) || 0).toFixed(2);

paypal.Buttons({
    fundingSource: undefined,  // 모든 결제 옵션 허용
    style: {
        layout: 'vertical',
        color:  'blue',
        shape:  'rect',
    },

    createOrder: function(data, actions) {
        return actions.order.create({
            purchase_units: [{
                amount: {
                    currency_code: 'USD',
                    value: overallTotalPrice
                }
            }]
        });
    },
    
    onApprove: function(data, actions) {
        return actions.order.capture()
            .then(function(orderData) {
                console.log('Capture result', orderData, JSON.stringify(orderData, null, 2));
                const transaction = orderData.purchase_units[0].payments.captures[0];
                alert(`결제가 완료되었습니다! Transaction ${transaction.status}: ${transaction.id}`);
                // 성공 페이지로 리다이렉트
                // window.location.href = '/order/success';
            });
    },
    
    onError: function(err) {
        console.error('PayPal error:', err);
        alert('PayPal 결제 처리 중 오류가 발생했습니다.');
    },
    
    onCancel: function() {
        alert('결제가 취소되었습니다.');
    }
}).render('#paypal-button-container');

 

 

백엔드 apiRouter.js

 

const express = require("express");
const apiRouter = express.Router();

const userApi = require("./user.api");
const authApi = require("./auth.api");
const productApi = require("./product.api");
const cartApi = require("./cart.api");
const orderApi = require("./order.api");
const paypalApi = require("./paypal.api"); 

apiRouter.use("/user", userApi);
apiRouter.use("/auth", authApi);
apiRouter.use("/product", productApi);
apiRouter.use("/cart", cartApi);
apiRouter.use("/order", orderApi);
apiRouter.use("/paypal", paypalApi);

module.exports = apiRouter;

 

 

 

백엔드 paypal.api.js

 

const express = require("express");
const paypalController = require("../controllers/paypalController");
const router = express.Router();

// 결제 생성
router.post("/create-payment", paypalController.createPayment);

// 결제 성공 처리
router.get("/success", paypalController.executePayment);

// 결제 취소 처리
router.get("/cancel", paypalController.cancelPayment);

module.exports = router;

 

 

 

백엔드 paypalController.js

 

const checkoutNodeJssdk = require('@paypal/checkout-server-sdk');

const environment = new checkoutNodeJssdk.core.SandboxEnvironment(
    process.env.PAYPAL_CLIENT_ID,
    process.env.PAYPAL_CLIENT_SECRET
);
const client = new checkoutNodeJssdk.core.PayPalHttpClient(environment);

const paypalController = {
    // 결제 생성
    createPayment: async (req, res) => {
        try {
            const { totalAmount } = req.body;
            
            const request = new checkoutNodeJssdk.orders.OrdersCreateRequest();
            request.requestBody({
                intent: 'CAPTURE',
                purchase_units: [{
                    amount: {
                        currency_code: 'USD',
                        value: totalAmount
                    }
                }]
            });

            const response = await client.execute(request);
            res.json(response.result);
        } catch (error) {
            console.error('PayPal create payment error:', error);
            res.status(500).json({ error: 'Failed to create order' });
        }
    },

    // 결제 실행
    executePayment: async (req, res) => {
        try {
            const { orderId } = req.body;
            const request = new checkoutNodeJssdk.orders.OrdersCaptureRequest(orderId);
            const response = await client.execute(request);
            res.json(response.result);
        } catch (error) {
            console.error('PayPal execute payment error:', error);
            res.status(500).json({ error: 'Failed to capture order' });
        }
    },

    // 결제 취소
    cancelPayment: async (req, res) => {
        res.status(200).json({ status: "cancelled", message: "Payment was canceled" });
    }
};

module.exports = paypalController;