학원 사이트 외주 개발을 진행하면서, 강의 결제 시스템을 구현해야 했다.
어떤 결제 대행사, 즉 PG(Payment Gateway)를 사용할지 고민하다가 현대적이고 나에게 익숙한 토스페이먼츠(TossPayments)를 사용하기로 결정하였다.
TossPayments 결제 위젯 Version 2를 사용하였다.

먼저 당연하게도 회원가입을 해야 한다.
이때 주의해야 할 점은, 나와 같이 외주를 받아 개발을 하는 상황이라면
클라이언트도 계정을 만들고, 나(개발자)도 계정을 만들어야 한다.

결제 기능 구현을 완료한 후에 클라이언트는 자신의 계정으로 매출을 확인해야 하고,
나(개발자)는 해당 상점의 API key등을 이용해 개발을 해야 하기 때문이다.
클라이언트의 관리자 계정을 개발자가 대신 사용할 수도 있지만, 클라이언트의 개인정보를 보호할 필요도 있고 로그인을 위한 본인인증을 매번 부탁할 수 없으니 각각의 계정을 만드는 것이 좋다.
우선적으로 클라이언트가 해줘야 할 일이 있다.
< 클라이언트의 TODO >
1. 상점 신청
2. 가입비 결제

'전체 상점 홈' 페이지에서 위와 같이 상점 신청 현황을 확인할 수 있다.
가입비를 결제하면, 관리자 계정으로 아래와 같은 메일이 도착할 것이다.



(메일 내용이 문제될 시 삭제하겠습니다.)
1번 내용은 클라이언트가 스스로 작성할 수 있는 내용이다.(단, [1]-1과 [1]-2를 기입하기 위해서는 미리 서비스 사이트를 배포한 상태여야 함. 그치만 2번을 채우기 위해서 어차피 사이트를 배포한 상태일 것임.)
2번을 위해서 개발자가 나서야 한다.
[2]-3과 [2]-4를 보면, 웹사이트에 결제창을 연동해야 하며, 상품 선택부터 결제 완료까지의 과정을 PPT로 제작하여 첨부해야 한다.
즉 토스페이먼츠의 심사를 통과하기 위해서는 이미 웹사이트의 모든 것이 만들어진 상태여야 한다.
"심사가 아직 통과되지 않았는데 결제창을 어떻게 연동하나요?"
이를 위해서 토스페이먼츠에서는 테스트용 API key를 제공한다.
출처: 토스페이먼츠
위 키를 사용하여 결제위젯을 띄우면 된다.
하단의 'API 개별 연동 키'는 백엔드 쪽에서 다뤄야할 것으로, 프론트엔드에서는 신경쓰지 않아도 된다.
그럼 이제 본격적으로 React와 TypeScript로 토스페이먼츠 결제를 구현하는 과정으로 들어가겠다.

결제를 구현하는 방법으로는 '결제위젯'과 '결제창' 중에서 선택을 할 수 있다.
결제위젯이 UI/UX 측면에서 더 깔끔하고, 개발 난이도도 쉽기 때문에 나는 결제위젯을 선택하였다.

결제 위젯은, 위 사진과 같이 그냥 토스페이먼츠 측에서 미리 만들어놓은 정형화된 UI 컴포넌트가 화면에 띄워진다고 보면 된다. 살면서 결제할 일이 있을 때 많이 보아왔을 것이다.
위에서 언급한 '결제위젯 연동 키'로 위젯을 띄울 수 있다.
[ 토스페이먼츠 개발자 센터 - 가이드 - 결제 위젯 - 결제 연동하기 ]
https://docs.tosspayments.com/guides/v2/get-started
위 가이드를 보고 따라하면 편리하다.
코드는 위 가이드에서 제공한 예시를 사용하도록 하겠다.
- 스크립트 태그 이용
<script src="https://js.tosspayments.com/v2/standard">- npm 설치
npm install @tosspayments/tosspayments-sdk --save
둘 중 자신에게 맞는 방법으로 결제위젯 SDK를 설치한다.
// ------ 결제위젯 초기화 ------
const clientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
const tossPayments = TossPayments(clientKey);
// 회원 결제
const customerKey = "r2ad3ZHrowV7QHc8mckku";
const widgets = tossPayments.widgets({
customerKey,
// 비회원 결제
// const widgets = tossPayments.widgets({ customerKey: TossPayments.ANONYMOUS });
});
TossPayments() 안에 위의 클라이언트 키를 넣어 토스페이먼츠 객체를 생성한다.
(test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm -> 상점 신청 전에는 토스페이먼츠에서 제공하는 해당 범용키를 사용할 수 있다.)
그리고 이렇게 초기화된 토스페이먼츠 객체로 widgets 객체를 생성한다. 이에 대한 파라미터로는 각 회원 구매자에게 발급한 고유 customerKey 값을 넣어준다. customerKey는 UUID와 같이 충분히 무작위적인 고유 값이다.
비회원 구매자의 경우에는 ANONYMOUS값을 넣어주면 되지만 내가 제작하는 사이트의 경우에는 비회원 구매자가 없을 예정이기에 따로 구현하지 않았다.
//랜덤 customerKey 생성하는 함수
const generateCustomerKey = (userId: number) => {
const uuid = uuidv4().split('-')[0]; // UUID의 일부만 사용
return `User_${userId}_${uuid}`;
}
나의 경우에는 이렇게 최초 회원가입 시 부여되는 userId를 포함하여 uuid를 생성하게끔 하였다.
// ------ 주문의 결제 금액 설정(예시) ------
await widgets.setAmount({
currency: "KRW",
value: 50000,
});
생성한 결제위젯 객체의 setAmount() 메서드로 주문의 통화와 결제 금액을 설정한다.
위 예시에서는 50,000원으로 하드코딩 되어있지만, 실제로는 변수를 넘겨주면 되겠다.
<!-- 결제 UI -->
<div id="payment-method"></div>
<!-- 이용약관 UI -->
<div id="agreement"></div>
<!-- 결제하기 버튼 -->
<button class="button" id="payment-button" style="margin-top: 30px">결제하기</button>
나는 React+TypeScript로 .tsx 파일을 사용하였기 때문에, js 코드와 위의 UI 코드를 같은 파일 안에 써주었다.
이 UI는 기존 틀을 건드리지 않는 선에서 본인 마음대로 커스터마이징 해도 된다.

나는 이런 식으로 윗부분에 주문 상품 정보와 주문자 정보가 함께 출력되게 하였다.
await Promise.all([
// ------ 결제 UI 렌더링(예시) ------
widgets.renderPaymentMethods({
selector: "#payment-method",
variantKey: "DEFAULT",
}),
// ------ 이용약관 UI 렌더링(예시) ------
widgets.renderAgreement({ selector: "#agreement", variantKey: "AGREEMENT" }),
]);
DOM이 생성된 이후에 renderPaymentMethods() 메서드로 결제 UI를 렌더링한다.
즉, .tsx 파일에서는 useEffect() 안에 해당 메서드를 사용해주면 된다.
파라미터 중 selector에는 위에서 만든 결제 UI의 div CSS 선택자 즉 해당 예시에서는 "#payment-method"를 넣어주면 된다. 나는 실제 코드에서도 이 선택자명을 그대로 사용하였다.
두 번째 파라미터인 varaintKey는 결제 UI가 여러 개일 경우 사용하는 파라미터인데, 나의 경우에는 한 가지의 결제 UI만 존재하기에 "DEFAULT" 그대로 사용해주었다.
이용약관 UI의 경우에도 유사하게 파라미터를 넘겨주면 된다.
// ------ '결제하기' 버튼 누르면 결제창 띄우기(예시) ------
button.addEventListener("click", async function () {
await widgets.requestPayment({
orderId: "hTyL-dMjgvyBUVU_-61S3",
orderName: "토스 티셔츠 외 2건",
successUrl: window.location.origin + "/success.html",
failUrl: window.location.origin + "/fail.html",
customerEmail: "customer123@gmail.com",
customerName: "김토스",
customerMobilePhone: "01012341234",
});
});
}
'결제하기' 버튼을 눌렀을 때 결제창을 띄우기 위한 이벤트를 정의해준다.
여기서 '결제하기' 버튼은 토스페이먼츠에서 따로 정해놓은 것이 아니라, 본인이 스스로 버튼 디자인을 커스터마이징하고 클릭했을 때 requestPayment()가 호출되도록만 하면 된다.
중요한 것은, 이렇게 결제를 요청하기 전에 orderId와 amount를 서버에 임시로 저장해야한다. 결제 요청과 승인 사이에 데이터 무결성 확인을 위해서이다.
예시를 들어 설명하면, 상품 상세 정보 페이지에서 결제 페이지로 넘어가는 그 시점에, 즉 결제 페이지가 처음 렌더링되는 시점에 orderId와 amount를 미리 서버로 보내놓으면 된다. 물론 해당 API는 백엔드(서버)에서 만들어주어야 하고, 무결성 확인도 백엔드 측에서 로직을 구현해야 한다. 프론트(클라이언트)에서는 값들을 보내기만 하면 된다는 말!
토스페이먼츠 개발자센터 - API&SDK - 결제위젯
https://docs.tosspayments.com/sdk/v2/js#widgetsrequestpayment
requestPayment()의 파라미터로는 여러가지가 있으며, 위 링크에서 각 파라미터에 대한 설명을 볼 수 있다.
내가 사용한 파라미터들은 아래와 같다.
orderId(필수) : 주문번호
orderName(필수) : 구매상품명
customerName(선택) : 구매자명
customerMobilePhone(선택) : 구매자 휴대폰 번호
successUrl : 결제 성공 시 리다이렉트 될 Url
failUrl : 결제 실패 시 리다이렉트 될 Url

모든 과정을 거쳐 구매자들의 실결제가 이루어지면 위 화면과 같이 토스페이먼츠 내 관리자 페이지에서 결제 내역 표를 볼 수 있는데, 파라미터로 해당하는 정보를 넘겨 주어야 구매자명, 이메일, 휴대폰번호와 같은 정보가 같이 저장되게 된다.
환불을 해주는 것과 같은 상황이 발생할 수 있으니, 구매자 식별을 위해 구매자명 정도는 기본으로 저장해주는 것이 좋다.
여기까지 완료하면 구매자가 '결제하기' 버튼을 누르고 실제 결제 페이지로 이동하여 결제를 하는 과정까지는 구현이 된 것이다. 하지만 여기까지는 결제 요청에 불과하고, 결제가 정상적으로 마무리되게 하려면 추가적인 작업이 필요하다.
다음으로는 그 이후에, 결제 성공/실패 여부에 따라 어떤 페이지로 리다이렉트되게 할지를 설정해주어야 한다.
결제가 성공하면 위 requestPayment()의 파라미터로 넘겨준 것 중 successUrl로, 실패하면 failUrl로 리다이렉트되게 된다.(결제가 실패하는 경우에는 잔액부족, 시간만료, 카드사 점검 등이 있을 수 있다.)
// 예시
/success?paymentType={PAYMENT_TYPE}&orderId={ORDER_ID}
&paymentKey={PAYMENT_KEY}&`={AMOUNT}
successUrl로 리다이렉트될 때, 위와 같이 뒤에 네 가지 쿼리 파라미터(Query Parameter)가 추가된다.
paymentType: 결제타입(일반결제는 NORMAL, 브랜드페이는 BRANDPAY)orderId: (이전에 넘겨준)주문번호paymentKey: 해당 결제 건을 고유하게 식별하는 키(토스페이먼츠에서 자동 생성)amount: (이전에 넘겨준)결제 금액
해당 값들을 URL에서 받아서, 백엔드로 넘겨주면 된다.
// 1. URL에서 값 추출하기(예시)
const urlParams = new URLSearchParams(window.location.search);
const paymentKey = urlParams.get("paymentKey");
const orderId = urlParams.get("orderId");
const amount = urlParams.get("amount");
// 2. 백엔드로 결제 정보 넘겨주기(예시)
async function confirm() {
const requestData = {
paymentKey: paymentKey,
orderId: orderId,
amount: amount,
};
const response = await fetch("/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
});
const json = await response.json();
if (!response.ok) {
// 결제 실패 비즈니스 로직을 구현하세요.
console.log(json);
window.location.href = `/fail?message=${json.message}&code=${json.code}`;
}
// 결제 성공 비즈니스 로직을 구현하세요.
console.log(json);
}
confirm();
위 예시에서는 confirm() 함수로 백엔드에 결제 정보를 전달하고 있다.
이후에는 이제 백엔드에서 해당 정보들을 받아 주문 정보를 저장하는 등의 동작을 취할 것이다.
confirm() 함수의 응답으로 오는 성공/실패 response에 따라 마이페이지로 이동한다든가, 다시 상품 상세 정보 페이지로 이동한다는가 하는 로직을 구현해주면 프론트엔드에서 해야 하는 역할은 끝이다!

위 이미지를 토대로 결제 흐름을 다시한번 이해하면 좋다.
천천히 해보면 그렇게 어렵지 않다!
나 같은 코린이도 처음 해보는데 의외로 어렵지 않게 구현에 성공했다.
이후에는 토스페이먼츠 관리자 페이지에서,
사용할 간편결제의 종류(네이버페이, 카카오페이 등)을 지정하거나
사용할 카드사를 지정할 수도 있으니 토스페이먼츠 관리자 페이지를 적극 탐구해보길 바란다.
토스페이먼츠의 결제 위젯 모듈은 정말 간단하다..!
처음에 '결제' 라는 것을 구현해야 한다고 생각했을 때 보안도 중요하고, 이런저런 여러가지 과정이 들어가니 굉장히 복잡할 것이라고 생각했는데, 의외로 이해한 후에는 쉽게 구현해낼 수 있었다.
토스페이먼츠 내의 개발자 분들이 이 SDK와 공식 문서를 훌륭하게 만들어주셔서이지 않을까 싶다... 얼마나 큰 노력이 있었을까? SaaS 개발자 분들은 참 대단한 것 같다.
물론 이렇게 간단하고 깔끔한 만큼 사용료가 비싸긴 하지만... 클라이언트 분께서 내주셨으니
우연한 기회로 '결제'를 구현하는 흔치 않은 경험을 할 수 있었다. 경험이 적은 나를 믿고 맡겨주신 클라이언트 분께 감사하다.
추가적인 외주 관련 회고는 다른 글에서 하도록 하겠다.
읽어주셔서 감사합니다!
너무 멋져요!! 😮😮