아무리 견고하고 회복력 있는 백엔드를 구축했다 하더라도, 사용자가 마주하는 것은 오직 브라우저 화면에 렌더링된 프론트엔드뿐입니다. 만약 결제 버튼이 버벅거리거나, 로딩 상태를 알 수 없거나, 결제 후 결과가 명확하지 않다면 사용자는 불안감을 느끼고 서비스에 대한 신뢰를 잃게 됩니다.
이 글에서는 React를 사용하여 어떻게 사용자에게는 매끄러운 경험을, 개발자에게는 유지보수가 용이한 코드를 제공하는 결제 프론트엔드 시스템을 구축하는지 알아볼 것입니다.
왜 React로 결제 시스템을 만들까요?
React는 사용자 인터페이스(UI)를 만들기 위한 선언적이고 컴포넌트 기반의 라이브러리입니다. 이러한 특성은 복잡한 상태 변화가 잦은 결제 시스템을 만들 때 다음과 같은 강력한 이점을 제공합니다.
OrderSummary
(주문 요약), PaymentButton
(결제 버튼), PaymentResult
(결제 결과)와 같은 독립적이고 재사용 가능한 컴포넌트로 분리하여 개발할 수 있습니다. 이는 코드의 재사용성을 높이고 관리를 용이하게 만듭니다.if/else
로 DOM을 직접 조작하는 것보다 훨씬 직관적이고 오류 발생 가능성을 줄여줍니다.useState
, useEffect
와 같은 Hook을 통해 컴포넌트 내의 상태(로딩 여부, 백엔드로부터 받은 결제 정보, 에러 메시지 등)를 쉽고 명확하게 관리할 수 있습니다.핵심 설계 철학: 백엔드는 유일한 진실의 공급원이다
본격적인 구현에 앞서, 우리가 반드시 지켜야 할 단 하나의 황금률이 있습니다.
프론트엔드 결제 시스템 설계의 제1원칙: 백엔드는 신뢰할 수 있는 유일한 진실의 공급원(Single Source of Truth)이다.
이것은 프론트엔드가 절대 스스로 중요한 판단을 내려서는 안 된다는 의미입니다.
프론트엔드의 역할은 '똑똑한 중개자'입니다. 사용자의 요청을 받아 백엔드에 안전하게 전달하고, 백엔드의 검증과 처리를 거친 최종 결과를 사용자에게 명확하게 보여주는 것에만 집중해야 합니다.
이제 이 철학을 바탕으로, 다음 파트부터는 실제 React 컴포넌트를 작성하며 안전하고 현대적인 결제 시스템을 만들어 보겠습니다.
본격적인 코드 작성에 앞서, 우리가 만들 결과물의 구체적인 모습과 그것을 구현하는 데 사용할 도구들을 명확히 정의하겠습니다.
구현 시나리오:
PaymentPage
컴포넌트)에 접속합니다.PaymentResultPage
컴포넌트)로 자동 이동하여 최종 성공 또는 실패 메시지를 확인합니다.이 모든 과정이 사용자가 보기에 끊김 없이 매끄럽게 이어지는 단일 페이지 애플리케이션(SPA) 경험으로 제공될 것입니다.
핵심 기술 스택:
react-router-dom
: 결제 페이지와 결과 페이지 간의 클라이언트 사이드 라우팅 처리.axios
(또는 fetch
API): 백엔드 서버와의 비동기 HTTP 통신.useState
, useEffect
): 컴포넌트의 상태 관리 및 사이드 이펙트 처리 (예: 외부 SDK 로딩).결제 프로세스의 첫 단추는 사용자가 '결제하기' 버튼을 눌렀을 때, 우리 백엔드 서버와 안전하게 소통하는 것입니다.
1. 결제 페이지 컴포넌트 생성 (PaymentPage.jsx
)
// src/pages/PaymentPage.jsx
import React, { useState } from 'react';
import axios from 'axios';
function PaymentPage() {
// API 요청 로딩 상태를 관리합니다.
const [loading, setLoading] = useState(false);
// 결제 요청을 처리하는 함수
const handlePayment = async () => {
setLoading(true); // 요청 시작 시 로딩 상태로 변경
try {
const orderData = {
orderId: 'order-id-12345', // 실제로는 props나 Context API로 받아와야 합니다.
amount: 15000,
};
// 백엔드의 '결제 의도 생성' API를 호출합니다.
const response = await axios.post('/api/payments/intent', orderData);
// 성공적으로 응답을 받으면, 다음 단계인 결제창 호출 함수를 실행합니다.
// (이 함수는 다음 Step 2에서 구현할 예정입니다.)
requestTossPayment(response.data);
} catch (error) {
console.error('결제 의도 생성 실패:', error);
alert('결제 정보를 생성하는 데 실패했습니다. 잠시 후 다시 시도해주세요.');
} finally {
setLoading(false); // 요청 완료 시 로딩 상태 해제
}
};
// ... (requestTossPayment 함수와 return 문은 다음 단계에서 추가)
return (
<div>
<h1>주문 확인</h1>
<p>상품명: 멋진 React 후드티</p>
<p>결제 금액: 15,000원</p>
<button onClick={handlePayment} disabled={loading}>
{loading ? '처리 중...' : '15,000원 결제하기'}
</button>
</div>
);
}
export default PaymentPage;
useState
로 로딩 상태 관리: loading
상태를 만들어 API 요청 중일 때 버튼을 비활성화하고 텍스트를 "처리 중..."으로 바꿉니다. 이는 사용자가 버튼을 중복으로 클릭하는 것을 방지하는 매우 중요한 사용자 경험(UX) 처리입니다.async/await
와 axios
: axios.post
를 사용하여 백엔드의 /api/payments/intent
엔드포인트로 비동기 요청을 보냅니다. async/await
문법을 사용하여 코드를 동기적으로 보이게 만들어 가독성을 높입니다.try...catch
블록으로 API 요청 중 발생할 수 있는 오류를 잡아내고, 사용자에게 피드백을 제공합니다.finally
블록: API 요청이 성공하든 실패하든 항상 setLoading(false)
를 호출하여, 에러가 발생해도 버튼이 영원히 비활성화되는 문제를 방지합니다.백엔드로부터 결제에 필요한 정보(PaymentIntent
)를 받아왔다면, 이제 그 정보를 이용해 사용자에게 실제 결제창을 보여줄 차례입니다.
1. 토스페이먼츠 SDK 스크립트 동적 로드
외부 JavaScript 라이브러리인 토스페이먼츠 SDK를 우리 React 앱에 연동하기 위해 useEffect
Hook을 사용합니다.
// src/pages/PaymentPage.jsx
import React, { useState, useEffect } from 'react'; // useEffect 임포트
// ...
function PaymentPage() {
const [loading, setLoading] = useState(false);
// 컴포넌트가 렌더링될 때 토스페이먼츠 SDK 스크립트를 동적으로 로드합니다.
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://js.tosspayments.com/v1';
script.async = true;
document.head.appendChild(script);
// 컴포넌트가 언마운트될 때 스크립트를 제거합니다 (클린업).
return () => {
document.head.removeChild(script);
};
}, []); // 빈 배열을 전달하여 최초 렌더링 시에만 실행되도록 합니다.
// ... (handlePayment 함수, requestTossPayment 함수, return 문)
}
💡 왜
useEffect
를 사용하나요?React는 가상 DOM을 통해 실제 DOM을 관리하기 때문에,
public/index.html
에 직접 스크립트를 추가하는 것보다 해당 스크립트가 필요한 컴포넌트 내에서 생명주기에 맞춰 로드하고 제거하는 것이 더 안전하고 효율적인 방법입니다.
2. 결제창 호출 로직 구현
handlePayment
함수를 수정하여, 백엔드 응답을 받은 후 실제 결제창을 호출하는 requestTossPayment
함수를 실행하도록 연결합니다.
// src/pages/PaymentPage.jsx
// ...
function PaymentPage() {
// ... (useState, useEffect 부분)
const handlePayment = async () => { /* 이전과 동일 */ };
// 결제창을 호출하는 새로운 함수입니다.
const requestTossPayment = (paymentInfo) => {
// window.TossPayments 객체가 로드되었는지 확인합니다.
if (!window.TossPayments) {
alert("결제 모듈이 로드되지 않았습니다. 잠시 후 다시 시도해주세요.");
return;
}
// 백엔드에서 받은 clientKey로 TossPayments 객체를 초기화합니다.
const tossPayments = window.TossPayments(paymentInfo.clientKey);
// requestPayment를 호출하여 결제창을 띄웁니다.
tossPayments.requestPayment('카드', {
amount: paymentInfo.amount,
orderId: paymentInfo.intentId, // 백엔드에서 생성한 결제 의도 ID
orderName: paymentInfo.orderName,
customerName: paymentInfo.customerName,
successUrl: `${window.location.origin}/api/payments/confirm`, // ★ 백엔드의 최종 승인 URL
failUrl: `${window.location.origin}/payment-result?status=fail`, // 실패 시 이동할 프론트엔드 URL
}).catch(error => {
// 사용자가 결제를 취소한 경우는 에러로 처리하지 않습니다.
if (error.code !== 'USER_CANCEL') {
console.error("결제창 호출 실패:", error);
alert(`결제 중 오류가 발생했습니다: ${error.message}`);
}
});
};
// ... (return 문)
}
successUrl
: 다시 한번 강조하지만, 이 URL은 우리 백엔드의 최종 승인 API 엔드포인트여야 합니다. 사용자가 결제창에서 인증을 완료하면, 토스페이먼츠는 이 주소로 사용자를 리다이렉트시켜 백엔드가 서버 간 통신을 통해 최종적으로 결제를 확정하도록 합니다.결제 과정의 마지막 단계는 백엔드가 모든 처리를 완료한 뒤, 사용자를 최종 결과 페이지로 안전하게 안내하는 것입니다.
1. react-router-dom
라우터 설정
// src/App.jsx
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import PaymentPage from './pages/PaymentPage';
import PaymentResultPage from './pages/PaymentResultPage'; // 새로 만들 컴포넌트
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<PaymentPage />} />
<Route path="/payment-result" element={<PaymentResultPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
2. 결과 페이지 컴포넌트 생성 (PaymentResultPage.jsx
)
백엔드는 최종 처리 후 https://our-app.com/payment-result?status=success&orderId=...
와 같은 형태로 사용자를 리다이렉트시킵니다. 이 URL의 쿼리 파라미터를 읽어 동적으로 다른 내용을 보여주는 컴포넌트를 만듭니다.
// src/pages/PaymentResultPage.jsx
import React from 'react';
import { useSearchParams, Link } from 'react-router-dom';
function PaymentResultPage() {
// useSearchParams 훅을 사용하여 URL 쿼리 파라미터를 가져옵니다.
const [searchParams] = useSearchParams();
const status = searchParams.get('status');
const orderId = searchParams.get('orderId');
const reason = searchParams.get('reason');
return (
<div>
{status === 'success' ? (
// 성공 UI
<div>
<h1>🎉 결제가 성공적으로 완료되었습니다.</h1>
<p>주문번호: {orderId}</p>
<p>주문 내역 및 배송 상태는 마이페이지에서 확인하실 수 있습니다.</p>
</div>
) : (
// 실패 UI
<div>
<h1>😢 결제에 실패했습니다.</h1>
<p>실패 사유: {reason || '알 수 없는 오류가 발생했습니다.'}</p>
<p>문제가 지속되면 고객센터로 문의해주세요.</p>
</div>
)}
<Link to="/">홈으로 돌아가기</Link>
</div>
);
}
export default PaymentResultPage;
useSearchParams
Hook: react-router-dom
에서 제공하는 이 Hook은 URL 쿼리 스트링을 쉽게 다룰 수 있게 해줍니다.status
값에 따라 성공 또는 실패 UI를 선택적으로 렌더링하여 사용자에게 명확한 피드백을 제공합니다.1. 환경 변수로 Key 안전하게 관리하기
토스페이먼츠 clientKey
와 같은 민감한 정보를 코드에 직접 하드코딩하는 것은 위험합니다. React 프로젝트에서는 .env
파일을 사용하여 이를 안전하게 관리할 수 있습니다.
# .env 파일 (프로젝트 루트에 생성)
# Create React App 기준, 변수명은 REACT_APP_ 접두사로 시작해야 합니다.
REACT_APP_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxxxxxxxx
이제 코드에서는 process.env.REACT_APP_TOSS_CLIENT_KEY
로 키 값에 안전하게 접근할 수 있습니다.
💡 Vite 사용자라면?
Vite 기반 프로젝트에서는 접두사
VITE_
를 사용하며, 코드에서는import.meta.env.VITE_TOSS_CLIENT_KEY
로 접근합니다.
2. 커스텀 Hook으로 로직 분리하기 (고급)
PaymentPage.jsx
컴포넌트는 UI 렌더링뿐만 아니라 SDK 로드, API 통신, 로딩 상태 관리 등 많은 로직을 담당하고 있어 다소 복잡합니다. 이 로직을 별도의 커스텀 Hook으로 분리하면, 컴포넌트는 훨씬 깔끔해지고 결제 로직을 다른 곳에서 쉽게 재사용할 수 있습니다.
// src/hooks/useTossPayments.js (새 파일)
import { useState } from 'react';
import axios from 'axios';
export function useTossPayments() {
const [loading, setLoading] = useState(false);
// 결제 시작 로직을 이 Hook 안에 캡슐화합니다.
const startPayment = async (orderData) => {
setLoading(true);
try {
const response = await axios.post('/api/payments/intent', orderData);
const paymentInfo = response.data;
// ... (TossPayments.requestPayment 호출 로직) ...
} catch (error) {
// ... (에러 처리) ...
} finally {
setLoading(false);
}
};
return { startPayment, isPaymentLoading: loading };
}
간결해진 PaymentPage.jsx
import React, { useEffect } from 'react';
import { useTossPayments } from '../hooks/useTossPayments'; // 커스텀 Hook 임포트
function PaymentPage() {
// 로직을 커스텀 Hook에서 가져옵니다.
const { startPayment, isPaymentLoading } = useTossPayments();
// ... (SDK 로드 useEffect) ...
const handlePaymentClick = () => {
const orderData = { orderId: 'order-id-12345', amount: 15000 };
startPayment(orderData); // Hook에서 반환된 함수 호출
};
return (
<div>
{/* ... UI ... */}
<button onClick={handlePaymentClick} disabled={isPaymentLoading}>
{isPaymentLoading ? '처리 중...' : '15,000원 결제하기'}
</button>
</div>
);
}
이제 PaymentPage
컴포넌트는 결제 로직의 상세한 내용을 알 필요 없이, useTossPayments
Hook이 제공하는 startPayment
함수를 호출하기만 하면 됩니다. 훨씬 깔끔하고 선언적인 코드가 완성되었습니다.
지금까지 우리는 견고한 백엔드 아키텍처 위에서 동작하는 안전하고 현대적인 React 결제 프론트엔드 시스템을 구현하는 전체 과정을 살펴보았습니다. 핵심은 프론트엔드의 역할을 '판단'이 아닌 '중개'와 '안내'에 집중시키고, 모든 중요한 검증과 처리는 백엔드를 신뢰하고 위임하는 것입니다.
이 가이드가 여러분의 다음 결제 프로젝트에 훌륭한 출발점이 되기를 바랍니다.