인증 결제 연동하기
PG 결제창을 이용하는 인증 결제를 연동합니다.
인증 결제란?
인증 결제는 결제 시 PG사로부터 결제에 대한 인증 결과 수신 이후 해당 인증키로 결제를 요청하는 결제 방식을 지칭합니다. 국내에서 제일 많이 볼 수 있는 결제 방식으로 결제 주문 페이지에서 결제가 요청되면 각 PG사의 결제창이 활성화되고, 그 후 고객이 선택한 카드사에 따른 카드사 전용 결제 모듈에서 인증이 완료되면 해당 인증값을 통해 결제를 요청하는 흐름으로 결제가 진행됩니다.
실제 결제 요청을 위한 통신은 고객사 서버와 PG사 서버 간에 직접적으로 이루어지며, 해당 결제 요청 과정에 카드 정보는 포함되지 않습니다.
인증 결제는 인증 방법에 따라 전통적으로 아래 두 가지 형태로 구분됩니다.
- ISP 결제 : 공개 키 기반의 전자인증서를 통해 사전에 등록된 카드 정보를 인증하는 방식
- MPI 결제 : 카드 번호, CVC, 안심클릭 비밀번호를 입력하여 카드 정보를 인증하는 방식
최근에는 대부분의 카드사에서 카드사 자체 간편결제를 지원하고 있으며, 고객은 사전에 카드를 등록하고 결제 시 결제 비밀번호를 이용하여 간편하게 결제를 요청할 수 있는 구조를 가지고 있습니다.
인증 결제 연동하기
1. 포트원 SDK 설치하기
포트원은 다양한 PG의 결제창을 통일된 방법으로 호출할 수 있도록 자바스크립트 SDK를 제공합니다. 브라우저에서 포트원 SDK를 호출하여 결제를 진행하게 됩니다.
2. 결제 요청하기
SDK의 PortOne.requestPayment()
함수를 호출하여 결제 수단에 따른 결제창을 열 수 있습니다.
먼저, 관리자 콘솔의 결제 연동 페이지에서 Store ID와 사용할 채널의 채널 키를 확인해 주세요.
그리고 아래와 같이 결제 요청 파라미터를
requestPayment()
함수의 첫 인자로 설정하여 호출합니다.
const response = await PortOne.requestPayment({
// Store ID 설정
storeId: "store-4ff4af41-85e3-4559-8eb8-0d08a2c6ceec",
// 채널 키 설정
channelKey: "channel-key-893597d6-e62d-410f-83f9-119f530b4b11",
paymentId: `payment-${crypto.randomUUID()}`,
orderName: "나이키 와플 트레이너 2 SD",
totalAmount: 1000,
currency: "CURRENCY_KRW",
payMethod: "CARD",
});
주문 고유 번호(paymentId
) 관련 유의사항
-
주문 고유 번호는 개별 결제 요청을 구분하기 위해 사용되는 문자열입니다.
-
따라서 주문 고유 번호는 결제 요청 시 항상 고유한 값으로 채번되어야 하며, 결제 완료 이후 결제 기록 조회나 위변조 대사 작업 시 사용되기 때문에 고객사 DB 상에 별도로 저장해야 합니다.
3. 결제 결과 처리하기
결제창이 활성화되는 방식에 따라 결제 결과를 획득하는 방법이 상이합니다.
일반적으로 PC 환경에서는 iframe 또는 팝업 방식으로 페이지 이동 없이 결제창이 활성화되며, 따라서 SDK의 반환값을 통해서 결제 결과를 받아 볼 수 있습니다. 반면, 모바일 환경에서는 일반적으로 새로운 페이지로 리다이렉트되는 방식으로 결제창이 활성화되고, SDK의 반환값 대신 URL의 쿼리 문자열 형태로 결제 결과를 받아볼 수 있습니다.
결제창이 활성화되는 방식은 windowType
파라미터를 통해 명시적으로 설정할 수 있습니다.
SDK 반환값으로 처리하기
PortOne.requestPayment()
함수의 반환값을 통해 결제 요청의 결과를 확인할 수 있습니다.
code
가 있으면 결제 과정에서 오류가 발생한 것이므로 적절히 처리하여야 합니다.
결제가 성공한 경우 paymentId
를 서버에 전달하여 서버 측에서 결제 완료 처리를 진행하도록 합니다.
(가상 계좌 결제의 경우 결제가 아직 완료되지 않은 상태일 수 있습니다)
async function requestPayment() {
const response = await PortOne.requestPayment({
/* 파라미터 생략 */
});
if (response.code !== undefined) {
// 오류 발생
return alert(response.message);
}
// 고객사 서버에서 /payment/complete 엔드포인트를 구현해야 합니다.
// (다음 목차에서 설명합니다)
const notified = await fetch(`${SERVER_BASE_URL}/payment/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// paymentId와 주문 정보를 서버에 전달합니다
body: JSON.stringify({
paymentId: paymentId,
// 주문 정보...
}),
});
}
결과값에 들어 있는 필드는 다음과 같습니다.
필드명 | 설명 | 비고 |
---|---|---|
paymentId | 결제 건 ID | 공통 |
code | 오류 코드 | 실패 시 포함 |
message | 오류 문구 | 실패 시 포함 |
URL 쿼리 문자열로 처리하기
모바일 환경에서의 결제는 대부분 리다이렉트 방식으로 이루어집니다.
리다이렉트 방식에서는 브라우저가 결제창으로 리다이렉트되었다가,
결제창에서의 작업이 끝나면 지정한
redirectUrl
로
다시 리다이렉트됩니다.
이 경우에는 함수 호출 결과를 이용할 수 없고,
결제 성공 여부 등은 쿼리 문자열로 전달받게 됩니다.
PortOne.requestPayment({
/* 기타 파라미터 생략 */
redirectUrl: `${BASE_URL}/payment-redirect`,
});
쿼리 문자열로 전달되는 내용은 다음과 같습니다.
키 | 설명 | 비고 |
---|---|---|
payment_id | 결제 건 ID | 공통 |
code | 오류 코드 | 실패 시 포함 |
message | 오류 문구 | 실패 시 포함 |
예를 들어 paymentId
가 payment-39ecfa97
, redirectUrl
이 https://example.com/payment-redirect
인 경우,
결제 성공 시에 https://example.com/payment-redirect?payment_id=payment-39ecfa97
로 리다이렉트됩니다.
4. 결제 완료 처리하기
paymentId
를 서버에 전달하면, 서버는 포트원의 결제 조회 API를
호출하여 해당 결제 건의 상태를 확인하고 결제 완료 처리를 진행하여야 합니다.
결제 검증 필수
인증 결제의 흐름상 결제 금액 등 정보가 고객의 브라우저 측에서 처리되므로, 의도한 결제 내용이 맞는지 서버 측에서 꼭 확인하여야 위변조를 막을 수 있습니다.
예시로, 위에서 사용했던 /payment/complete
엔드포인트를 다음과 같이 구현할 수 있습니다.
PORTONE_API_SECRET 은 V2 전용 시크릿으로, 포트원 콘솔 내 결제연동 탭에서 발급받을 수 있습니다.
Express// JSON 요청을 처리하기 위해 body-parser 미들웨어 세팅 app.use(bodyParser.json()); // POST 요청을 받는 /payments/complete app.post("/payment/complete", async (req, res) => { try { // 요청의 body로 paymentId가 전달되기를 기대합니다. const { paymentId, orderId } = req.body; // 1. 포트원 결제내역 단건조회 API 호출 const paymentResponse = await fetch( `https://api.portone.io/payments/${paymentId}`, { headers: { Authorization: `PortOne ${PORTONE_API_SECRET}` } }, ); if (!paymentResponse.ok) throw new Error(`paymentResponse: ${await paymentResponse.json()}`); const payment = await paymentResponse.json(); // 2. 고객사 내부 주문 데이터의 가격과 실제 지불된 금액을 비교합니다. const order = await OrderService.findById(orderId); if (order.amount === payment.amount.total) { switch (payment.status) { case "VIRTUAL_ACCOUNT_ISSUED": { // 가상 계좌가 발급된 상태입니다. // 계좌 정보를 이용해 원하는 로직을 구성하세요. break; } case "PAID": { // 모든 금액을 지불했습니다! 완료 시 원하는 로직을 구성하세요. break; } } } else { // 결제 금액이 불일치하여 위/변조 시도가 의심됩니다. } } catch (e) { // 결제 검증에 실패했습니다. res.status(400).send(e); } });