인증결제 연동하기

PG결제창을 이용한 인증결제를 손쉽게 연동할 수 있습니다.

인증결제 정의

인증 결제는 신용카드 결제시 PG사로 부터 결제에 대한 인증 결과 수신 이후 해당 인증키로 결제를 요청하는 결제 방식을 지칭합니다. 국내에서 제일 많이 볼수 있는 결제방식으로 결제 주문 페이지에서 결제가 요청되면 각 PG사의 결제창이 활성화되고 그후 고객이 선택한 카드사에 따른 카드사 전용 결제모듈에서 인증이 완료되면 해당 인증값을 바탕으로 결제를 요청하는 흐름으로 결제가 진행됩니다.

(NHN KCP 인증결제 신한카드 간편 결제 화면)

실 결제요청을 위한 통신은 가맹점 서버와 PG사 서버간에 직접적으로 이루어지며 해당 결제 요청 과정에서 카드정보는 포함되어 있지 않습니다.

인증결제는 인증방법에 따라 전통적으로 아래 두가지 형태 구분됩니다.

  • ISP 결제 : 공개키 기반의 전자인증서를 통해 사전에 등록된 카드정보를 인증하는 방식
  • MPI 결제 : 카드번호, CVC, 안심클릭 비밀번호를 입력하여 카드정보를 인증하는 방식

최근에는 대부분의 카드사에서 카드사 자체 간편결제를 지원하고 있으며 고객은 사전에 카드를 등록하고 결제시 결제 비밀번호 6자리를 이용하여 간편하게 결제를 요청할 수 있는 구조를 가지고 있습니다.

(NHN KCP 인증결제 신한카드 간편 결제 화면)

아임포트를 통해 인증결제를 연동하시면 매우 손쉽게 결제연동을 완료하실 수 있습니다.

PortOne SDK 추가

결제창 연동을 진행할 주문 페이지에 아래 JS 라이브러리를 추가 합니다.

<script src="https://cdn.portone.io/sdk/v2"></script>

결제 요청하기

아임포트 라이브러리를 추가하고 나면 이를 통해 결제를 요청할 수 있습니다. 결제창 호출시 필요한 파라미터를 requestPayment 함수 첫번째 파라미터 인자로 설정합니다.

<script>
  // JS example
  function requestPay() {
    PORT.requestPay({
      storeId: "store-9bf6076d-28cd-4729-9521-ae66c14e0569",
      orderName: "나이키 와플 트레이너 2 SD",
      totalAmount: 1000,
      pgProvider: "TOSSPAYMENTS",
      isTestTransaction: false,
      payMethod: "CARD",
      paymentId: "paymentId_${now}",
      taxFreeAmount: 300,
      customer: {
        customerId: "customerId_${now}",
        fullName: "신현성",
        phoneNumber: "1670-5176",
        email: "test@iamport.kr",
        address: "성수이로 길 16 JK타워 3층",
        zipcode: "04783",
      },
      windowType: "IFRAME",
      noticeUrls: ["www.naver.com"],
      confirmUrl: "www.naver.com",
      appScheme: "iamport://",
      isCulturalExpense: false,
      currency: "KRW",
      locale: "KO_KR",
    });
  }
</script>

주문번호(payment_id) 생성 시 유의사항

주문번호는 결제창 요청 시 항상 고유 값으로 채번 되어야 합니다. 결제 완료 이후 결제 위변조 대사 작업시 주문번호를 이용하여 검증이 필요하므로 주문번호는 가맹점 서버에서 고유하게(unique)채번하여 DB에 저장해주세요

결제결과 처리(iframe)

결제가 정상적으로 완료되면 결제창 형태에 따라 아래와 같이 결제결과를 받아 볼 수 있습니다.

  • iframe: 콜백 함수
  • 팝업: redirectUrl
  • 리디렉션: redirectUrl

iframe 이란?

효과적으로 다른 HTML 페이지를 현재 페이지에 포함시키는 중첩된 브라우저로 iframe 요소를 이용하면 해당 웹 페이지 안에 어떠한 제한 없이 다른 페이지를 불러와서 삽입 할 수 있습니다.

PC 환경에서 일어나는 대부분의 결제는 requestPayment() 함수 두번째 인자인 callback 함수를 통해 결제 결과 수신이 가능합니다.

아래 예제 코드는 결제창 형태가 iframe 으로 활성화 되는 대부분의 PC 환경에서의 결제요청에 대한 응답을 처리하는 부분입니다.

PORT.requestPayment({
  /* ...중략... */
}, function (rsp) { // callback
  if (rsp.isSuccess) { // 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우
    // 가맹점의 서버로 결제 정보 저장
    await fetch("{서버의 결제 정보를 받는 가맹점 endpoint}", {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        data: {
          payment_id: rsp.payment_id,
          tx_id: rsp.tx_id
        }
      })
    })
    ...
  } else {
    alert("결제에 실패하였습니다. 에러 내용: " +  rsp.message);
  }
});

결제가 완료되면 반환되는 응답 객체(rsp)의 결제 성공 여부에 따라 처리 로직을 callback 함수에 작성합니다. 요청이 성공했을 경우에 결제번호(tx_id)와 주문번호(payment_id)를 서버에 전달하는 로직을 위와같이 작성합니다.

callback 함수로 내려가는 응답 파라미터 확인은 여기서 가능합니다.

최종 결제결과 로직처리는 반드시 웹훅을 이용하여 안정적으로 처리해 주셔야 합니다. 웹훅연동을 생략하시는 경우 결제결과를 정상적으로 수신받지 못하는 상황이 발생합니다.

결제결과 처리(redirect)

아래 예제 코드는 결제창 형태가 새로운 페이지로 리디렉션되어 결제가 진행되는 대부분의 모바일 환경에서의 결제요청애 대한 응답을 처리하는 부분입니다.

PORT.requestPayment({
  /* ...중략... */,
  redirectUrl: "{리디렉션 될 URL}"
}, function callback() {
  // callback은 실행 안됨
});

위와같이 requestPayment 함수 파라미터로 redirectUrl 을 설정하면 결제 완료 이후 해당 URL 주소로 결제 결과를 query string 형태로 전송해 드립니다.

아래는 쿼리스트링으로 리디렉션되는 URL 예제입니다.

curl https://myservice.com/payments/complete?tx_id=결제건을_특정하는_아임포트_번호&payment_id=가맹점_고유_주문번호
파라미터명 설명 비고
tx_id 아임포트 결제거래번호 공통
payment_id 가맹점 주문번호 공통
code 오류코드 실패 시
message 오류메세지 실패 시

(TODO redirectUrl의 query parameter에 isSuccess 넣을지 고민)

결제창이 리디렉션되어 새로운 페이지에서 활성화되는 경우 결제 결과는 callback 으로 받을 수 없습니다.

결제 완료의 의미

결제완료는 아래의 모든 경우를 포함합니다.

  1. 결제 성공 (결제 상태: paid)
  2. 결제 실패 (결제 상태: failed)
  3. PG 모듈 설정이 올바르지 않아, 결제 창이 열리지 않음
  4. 사용자가 임의로 X 버튼이나 취소 버튼을 눌러 결제를 종료
  5. 카드 정보 불일치, 한도 초과, 잔액 부족 등의 사유로 결제가 중단
  6. 가상계좌 발급 완료(결제 상태: ready)

최종 결제결과 로직처리는 반드시 웹훅을 이용하여 안정적으로 처리해 주셔야 합니다.

웹훅연동을 생략하시는 경우 결제결과를 정상적으로 수신받지 못하는 상황이 발생합니다.

결제정보 검증하기

운영중인 서버에서 클라이언트로 부터 전달 받은 결제 결과 데이터를 바탕으로 결제금액 위변조 여부를 검증하고 필요시 데이터베이스에 저장합니다. 결제 정보를 검증하는 과정은 크게 아래와 같은 단계로 진행합니다.

아임포트 결제고유번호(tx_id), 주문번호(payment_id)를 서버단에서 수신

결제 상세내역 조회를 위해 아임포트 **결제 단건 조회 API** 요청

응답받은 내용을 바탕으로 실 결제 금액과 결제요청금액(가맹점 자체 데이터베이스)을 비교

STEP 01 결제결과 서버 수신

결제정보를 받은 가맹점 endpoint URL 에 대한 POST 요청을 수신하는 예제

app.use(bodyParser.json());
// "{서버의 결제 정보를 받는 가맹점 endpoint}" POST 요청 수신부
app.post("/payments/complete", async (req, res) => {
  try {
    // req의 body에서 tx_id, payment_id 추출
    const { tx_id, payment_id } = req.body;
  } catch (e) {
    res.status(400).send(e);
  }
});

STEP 02 결제내역 단건 조회

수신받은 아임포트 결제고유번호(tx_id)결제단건조회 API 를 호출하여 결제정보 획득 예제

app.use(bodyParser.json());
    ...
    app.post("/payments/complete", async (req, res) => {
      try {
	      // req의 body에서 tx_id, payment_id 추출
        const { tx_id, payment_id } = req.body;
        ...
        // 액세스 토큰(access token) 발급 받기
        const getToken = await axios({
          url: "https://api.iamport.kr/v2/sigin/api-key", // TODO
          method: "post", // POST method
          headers: { "Content-Type": "application/json" },
          data: {
            api_key: "ekKoeW8RyKuT0zgaZsUtXXTLQ4AhPFW3ZGseDA6bkA5lamv9OqDMnxyeB9wqOsuO9W3Mx9YSJ4dTqJ3f"
          }
        });
        const { access_token } = getToken.data.response; // 인증 토큰
        ...
        // payment_id로 아임포트 서버에서 결제 정보 조회
        const getPaymentData = await fetch({
          // payment_id 전달
          url: `https://api.iamport.kr/v2/payments/${payment_id}`,
          // GET method
          method: "GET",
          // 인증 토큰 Authorization header에 추가
          headers: { "Authorization": access_token }
        });
        const paymentData = getPaymentData.data.response; // 조회한 결제 정보
        ...
      } catch (e) {
        res.status(400).send(e);
      }
    });

STEP 03 결제정보 검증

결제금액의 위변조 검증 이유

결제 요청은 클라이언트 환경에서 이루어지기 때문에 클라이언트 스크립트를 조작해 금액을 위 변조하여 결제를 요청할 수 있습니다. 따라서 결제완료 후 처음 요청했던 금액과 실제로 결제된 금액을 반드시 비교해야 합니다. 예를 들어, 100,000원짜리 상품을 결제할 때에는 amount: 100000으로 결제요청을 하게 되는데, 공격자가 스크립트를 조작하여 해당 속성을 실제 금액보다 낮은 값으로 변조할 수 있습니다. 클라이언트에서의 스크립트 조작은 원천적으로 막을 수 없는 기술적 특징이 있기 때문에 결제 후 서버에서 결제금액의 위변조 여부를 반드시 검증해야 합니다.

app.use(bodyParser.json());
...
app.post("/payments/complete", async (req, res) => {
  try {
    // req의 body에서 tx_id, payment_id 추출
    const { tx_id, payment_id } = req.body;
    // 액세스 토큰(access token) 발급 받기
    /* ...중략... */
    // tx_id로 아임포트 서버에서 결제 정보 조회
    /* ...중략... */
    const paymentData = getPaymentData.data.response; // 조회한 결제 정보
    const transaction = paymentData.transactions.filter(transaction => transaction.id === tx_id) // 승인된 결제건을 선택
    ...
    // DB에서 결제되어야 하는 금액 조회
    const order = await Orders.findById(paymentData.payment_id);
    const amountToBePaid = order.amount; // 결제 되어야 하는 금액
    ...
    // 결제 검증하기
    const { amount, status } = transaction;
    // 결제금액 일치. 결제 된 금액 === 결제 되어야 하는 금액
    if (amount === amountToBePaid) {
      await Orders.findByIdAndUpdate(payment_id, { $set: paymentData }); // DB에 결제 정보 저장
      ...
      switch (status) {
        case "VIRTUAL_ACCOUNT_ISSUED": // 가상계좌 발급
          // DB에 가상계좌 발급 정보 저장
          const { virtual_account } = tranasction.payment_method_detail;

          await Users.findByIdAndUpdate("/* 고객 id */", { $set: virtual_account });

          // 가상계좌 발급 안내 문자메시지 발송
          SMS.send({ text: `가상계좌 발급이 성공되었습니다. 계좌 정보: ${virtual_account}`});
          res.send({ status: "virtualAccountIssued", message: "가상계좌 발급 성공" });
          break;
        case "PAID": // 결제 완료
          res.send({ status: "success", message: "일반 결제 성공" });
          break;
      }
    } else { // 결제금액 불일치. 위/변조 된 결제
      throw { status: "forgery", message: "위조된 결제시도" };
    }
  } catch (e) {
    res.status(400).send(e);
  }
});

가맹점의 DB에 저장된 값과 아임포트에 저장된 값을 비교합니다. 검증이 성공하면 결제정보를 데이터베이스에 저장한 뒤 결제 상태(status)에 따라 알맞은 응답을 반환하고 실패 시 에러 메세지를 출력합니다.

결제결과 DB 처리는 웹훅(Webhook)을 연동하여 수신되는 데이터를 기준으로 처리하셔야 결제결과 누락없이 안정적인 결과처리를 완료하실 수 있습니다.

결제완료 처리하기

Iframe 방식으로 진행되는 대부분의 PC환경 결제인 경우 결제응답은 callback 함수로 받아볼 수 있으며 가맹점 서버에서 결제결과 처리가 최종적으로 완료되면 아래 예제처럼 결제 성공유무에 따른 분기를 통해 결과 메세지 처리를 진행 하실 수 있습니다.

PORT.requestPayment({
  /* ...중략... */
}, function (rsp) { // callback
  if (rsp.isSuccess) { // 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우
      // jQuery로 HTTP 요청
      const data = await fetch({
        /* ...중략... */
      })

  switch(data.status) {
    case: "vbankIssued":
      // 가상계좌 발급 시 로직
      break;
    case: "success":
      // 결제 성공 시 로직
      break;
    }
  } else {
    alert("결제에 실패하였습니다. 에러 내용: " +  rsp.message);
  }
});

새로운 페이지로 리디렉션되어 결제가 진행되는 대부분의 모바일환경에서의 결제는 redirectUrl 파라미터로 설정하신 가맹점 EndPoint URL 에서 최종 결제완료 메세지 처리를 진행해 주시면 됩니다.