결제환불 연동하기


이 문서는 아임포트 결제 환불 기능을 구현하는 방법을 설명합니다.

환불 요청부터 응답까지의 과정은 다음과 같습니다.
STEP1환불 요청하기
client-side

필요한 환불 정보로 서버에 환불 요청을 합니다. 가상계좌 환불의 경우, 환불수령 계좌 정보를 추가 파라미터로 전달해야 합니다.

환불하기 버튼 클릭 시 환불 요청이 되는 예제입니다.
<button onclick="cancelPay()">환불하기</button>
<script
  src="https://code.jquery.com/jquery-3.3.1.min.js"
  integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
  crossorigin="anonymous"></script><!-- jQuery CDN --->
<script>
  function cancelPay() {
    jQuery.ajax({
      "url": "{환불요청을 받을 서비스 URL}", // 예: http://www.myservice.com/payments/cancel
      "type": "POST",
      "contentType": "application/json",
      "data": JSON.stringify({
        "merchant_uid": "{결제건의 주문번호}", // 예: ORD20180131-0000011
        "cancel_request_amount": 2000, // 환불금액
        "reason": "테스트 결제 환불" // 환불사유
        "refund_holder": "홍길동", // [가상계좌 환불시 필수입력] 환불 수령계좌 예금주
        "refund_bank": "88" // [가상계좌 환불시 필수입력] 환불 수령계좌 은행코드(예: KG이니시스의 경우 신한은행은 88번)
        "refund_account": "56211105948400" // [가상계좌 환불시 필수입력] 환불 수령계좌 번호
      }),
      "dataType": "json"
    });
  }
</script>
STEP2결제정보 조회하기
server-side

아래와 같이 결제정보를 저장하는 Payments라는 테이블을 생성했다고 가정합니다.
  /* ... model/payments.js ... */
  var mongoose = require('mongoose');
  var Schema = mongoose.Schema;
  ...
  var PaymentsSchema = new Schema({
    imp_uid: String, // 아임포트 `unique key`(환불 요청시 `unique key`로 사용)
    merchant_uid: String, // 주문번호(결제정보 조회시 사용)
    amount: { type: Number, default: 0 }, // 결제 금액(환불 가능 금액 계산시 사용)
    cancel_amount: { type: Number, default: 0 }, // 환불 된 총 금액(환불 가능 금액 계산시 사용)
    ...
  });
  ...
  module.exports = mongoose.model('Payments', PaymentsSchema);
클라이언트에서 받은 주문번호(merchant_uid)를 사용해서 해당 주문의 결제정보를 Payments 테이블에서 조회합니다.
  /* ... 중략 ... */
  var Payments = require('./models/payments');
  app.post('/payments/cancel', async (req, res, next) => {
    try {
      /* 액세스 토큰(access token) 발급 */
      /* ... 중략 ... */
      /* 결제정보 조회 */
      const { body } = req;
      const { merchant_uid } = body; // 클라이언트로부터 전달받은 주문번호
      Payments.find({ merchant_uid }, async function(err, payment) { 
        if (err) {
          return res.json(err);
        }
        const paymentData = payment[0]; // 조회된 결제정보
        /* 아임포트 REST API로 결제환불 요청 */
        ...
      });
    } catch (error) {
      res.status(400).send(error);
    }
  });
STEP3아임포트 서버에 환불 요청하기
server-side

환불 요청을 하기 위해서 먼저 REST API access token을 발급받습니다.

발급받은 액세스 토큰(access token)과 환불 및 결제 정보로 REST API(POST https://api.iamport.kr/payments/cancel)를 호출하여 결제 환불을 요청합니다.

환불요청 시 유의해야 하는 파라미터들입니다.
  • 환불 unique key(imp_uid 또는 merchant_id)

    환불 대상 거래를 특정하기 위해서 imp_uid 또는 merchant_uid를 환불 unique key로 설정합니다. imp_uid의 값이 우선순위를 갖게되며 유효하지 않는 imp_uid값을 입력하면 merchant_uid값과 무관하게 환불요청이 실패합니다.
  • 환불 금액(amount)

    요청한 환불금액을 입력합니다. 미입력시 전액이 환불됩니다.

    휴대폰 소액결제 환불 시 유의사항

    • 휴대폰 소액결제는 통신사 정책상 부분환불이 불가능하며 전액환불만 가능합니다.
    • 또한 결제가 이루어진 월과 환불을 요청하는 월이 다를 경우, 전액환불도 불가능합니다. 예를 들어, 1월 31일 결제건은 2월 1일에 환불할 수 없습니다.
  • 환불 가능 금액(checksum)

    환불이 가능한 금액을 입력합니다. 예를 들어, 10000원짜리 제품의 checksum은 10000입니다. 만약 10000원짜리 제품이 과거 1000원 부분환불 되었다면, 이후 환불시 checksum은 9000입니다.

    입력된 checksum을 사용해서 서버와 아임포트 서버간에 환불 가능 금액이 일치하는지 확인합니다. 만약 일치하지 않으면 환불 요청은 실패하며 미 입력시 검증은 실행되지 않습니다.

    checksum을 입력해야 하는 이유

    checksum은 필수입력은 아니지만 서버와 아임포트 서버간에 환불 가능 금액을 검증하기 위해서 입력을 권장합니다. 예를 들어, 10000원짜리 제품에 대한 1000원 부분환불 요청이 아임포트 서버에서 완료하였으나 가맹점이 서버 혹은 DB오류로 이를 반영하지 못했다면? 아임포트 서버의 checksum은 9000이 되고, 가맹점 서버의 checksum은 그대로 10000이 됩니다. 이후 남은 금액을 환불하려고 할때 checksum(10000)을 입력하면, 해당 값이 아임포트 서버의 checksum(9000)과 일치하지 않으므로 요청은 실패합니다.
환불 요청을 하는 예제입니다.
  /* ... 중략 ... */
  app.post('/payments/cancel', async (req, res, next) => {
    try {
      /* 액세스 토큰(access token) 발급 */
      /* ... 중략 ... */
      /* 결제정보 조회 */
      const { body } = req;
      const { merchant_uid, reason, cancel_request_amount } = body; // 클라이언트로부터 전달받은 주문번호, 환불사유, 환불금액
      Payments.find({ merchant_uid }, async function(err, payment) { 
        /* ... 중략 ... */
        const paymentData = payment[0]; // 조회된 결제정보
        const { imp_uid, amount, cancel_amount } = paymentData; // 조회한 결제정보로부터 imp_uid, amount(결제금액), cancel_amount(환불된 총 금액) 추출
        const cancelableAmount = amount - cancel_amount; // 환불 가능 금액(= 결제금액 - 환불 된 총 금액) 계산
        if (cancelableAmount <= 0) { // 이미 전액 환불된 경우
          return res.status(400).json({ message: "이미 전액환불된 주문입니다." });
        }
        ...
        /* 아임포트 REST API로 결제환불 요청 */
        const getCancelData = await axios({
          url: "https://api.iamport.kr/payments/cancel",
          method: "post",
          headers: {
            "Content-Type": "application/json",
            "Authorization": access_token // 아임포트 서버로부터 발급받은 엑세스 토큰
          },
          data: {
            reason, // 가맹점 클라이언트로부터 받은 환불사유
            imp_uid, // imp_uid를 환불 `unique key`로 입력
            amount: cancel_request_amount, // 가맹점 클라이언트로부터 받은 환불금액
            checksum: cancelableAmount // [권장] 환불 가능 금액 입력
          }
        });
        const { response } = getCancelData.data; // 환불 결과
        /* 환불 결과 동기화 */
        ...
      });
    } catch (error) {
      res.status(400).send(error);
    }
  });

가상계좌 환불 시 필요한 추가 파라미터

가상계좌 환불을 위해서는, PG사의 가상계좌 특약서비스에 가입되어 있어야 합니다.

가상계좌 특약서비스에 가입해야 하는 이유

신용카드와는 달리 가상계좌의 경우, 결제/환불에 대한 수수료는 환불대상에서 제외됩니다.

예를 들어 10000원 결제건에 대해서 가맹점은
  • 결제 시, 9700원(10000원 - 가상계좌 발행 수수료 300원)을 PG사로부터 정산받습니다.
  • 환불 시, 10300원(환불되어야할 10000원 + 환불 계좌로의 송금 수수료 300원)을 PG사로 지불합니다.

PG사는 이런 과정에서 발생할 수 있는 혼란을 미연에 방지하고자 가상계좌 특약서비스에 가입한 가맹점에 한해서만 가상계좌 환불을 제공하고 있습니다.
가상계좌의 경우 단방향 결제수단이여서 환불 대상을 알 수 없으므로, 환불 금액 외에 다음의 환불 수령계좌 정보를 입력해야 합니다.
  • refund_holder: 환불 수령계좌 예금주
  • refund_account: 활불 수령계좌 번호
  • refund_bank: 환불 수령계좌 은행코드

가상계좌 은행코드는 PG사에 따라 다릅니다

가상계좌 은행코드는 같은 은행이더라도 PG사에 따라 다르므로 아임포트 API 문서 하단 은행코드표(가상계좌 환불 시 필요)에서 은행코드를 확인해 주세요.

현재 가상계좌 은행코드를 금융결제원이 제공하는 3자리 표준코드로 통일하기 위한 작업이 진행중이며 추후 업데이트될 예정입니다.
가상계좌 환불을 요청하는 예제입니다.
  /* ... 중략 ... */
  app.post('/payments/cancel', async (req, res, next) => {
    try {
      /* 액세스 토큰(access token) 발급 */
      /* ... 중략 ... */
      /* 결제정보 조회 */
      const { body } = req;
      const { merchant_uid, reason, cancel_request_amount, refund_holder, refund_bank, refund_account } = body; // 클라이언트로부터 전달받은 주문번호, 환불사유, 환불금액, 가상계좌 정보(예금주, 계좌번호, 은행코드)
      Payments.find({ merchant_uid }, async function(err, payment) { 
        /* ... 중략 ... */
        const paymentData = payment[0]; // 조회된 결제정보
        const { imp_uid, amount, cancel_amount } = paymentData; // 조회한 결제정보로부터 imp_uid, amount(결제금액), cancel_amount(환불된 총 금액) 추출
        const cancelableAmount = amount - cancel_amount; // 환불 가능 금액(= 결제금액 - 환불된 총 금액) 계산
        if (cancelableAmount <= 0) { // 이미 전액 환불된 경우
          return res.status(400).json({ message: "이미 전액환불된 주문입니다." });
        }
        ...
        /* 아임포트 REST API로 결제환불 요청 */
        const getCancelData = await axios({
          url: "https://api.iamport.kr/payments/cancel",
          method: "post",
          headers: {
            "Content-Type": "application/json",
            "Authorization": access_token // 아임포트 서버로부터 발급받은 엑세스 토큰
          },
          data: {
            reason, // 가맹점 클라이언트로부터 받은 환불사유
            imp_uid, // imp_uid를 환불 `unique key`로 입력
            amount: cancel_request_amount, // 가맹점 클라이언트로부터 받은 환불금액
            checksum: cancelableAmount, // [권장] 환불 가능 금액 입력
            refund_holder, // [가상계좌 환불시 필수입력] 환불 수령계좌 예금주
            refund_bank, // [가상계좌 환불시 필수입력] 환불 수령계좌 은행코드(ex. KG이니시스의 경우 신한은행은 88번)
            refund_account // [가상계좌 환불시 필수입력] 환불 수령계좌 번호
          }
        });
        const { response } = getCancelData.data; // 환불 결과
        /* 환불 결과 동기화 */
        ...
      });
    } catch (error) {
      res.status(400).send(error);
    }
  });
다음과 같이 가상계좌 환불을 요청하여 성공하면 PG사 담당자가 다음날 해당 계좌로 환불 금액을 입금합니다. 이는 통상적으로 영엽일 기준 하루 정도 소요됩니다.
STEP4환불 결과 저장하기
server-side

결제 환불이 완료되면, 그 결과를 데이터베이스에 다음과 같이 저장합니다.

환불 시 유의할 점

REST API(POST https://api.iamport.kr/payments/cancel) 요청에 대한 응답 코드가 200이라도 응답 body의 code가 0이 아니면 환불에 실패했다는 의미입니다. 실패 사유는 body의 message를 통해 확인하실 수 있습니다.
  /* ... 중략 ... */
  app.post('/payments/cancel', async (req, res, next) => {
    try {
      /* 액세스 토큰(access token) 발급 */
      /* ... 중략 ... */
      /* 결제정보 조회 */
      Payments.find({ merchant_uid }, async function(err, payment) { 
        /* ... 중략 ... */
        /* 아임포트 REST API로 결제환불 요청 */
        /* ... 중략 ... */
        const { response } = getCancelData.data; // 환불 결과
        /* 환불 결과 동기화 */
        const { merchant_uid } = response; // 환불 결과에서 주문정보 추출
        Payments.findOneAndUpdate({ merchant_uid }, response, { new: true }, function(err, payment) { // 주문정보가 일치하는 결제정보를 추출해 동기화
          if (err) {
            return res.json(err);
          }
          res.json(payment); // 가맹점 클라이언트로 환불 결과 반환
        });
      });
    } catch (error) {
      res.status(400).send(error);
    }
  });
STEP5환불 응답 처리하기
client-side

페이지에 요청에 대한 응답를 처리하는 로직을 다음과 같이 작성합니다.
<button onclick="cancelPay()">환불하기</button>
<script
  src="https://code.jquery.com/jquery-3.3.1.min.js"
  integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
  crossorigin="anonymous"></script><!-- jQuery CDN --->
<script>
  function cancelPay() {
    jQuery.ajax({
      /* ... 중략 ... */
    }).done(function(result) { // 환불 성공시 로직 
      alert("환불 성공");
    }).fail(function(error) { // 환불 실패시 로직
      alert("환불 실패");
    });
  }
</script>