아임포트(iamport)

무료로 PG 결제를 연동해주는 서비스

PG 결제

결제 요청을 카드사, 은행 등과 같은 결제 기관으로 보내서 승인을 받고, 그 결과를 상점에게 전달하는 것
확실히 금액이 지불된 것을 확인한 상점은 물건을 고객센터에 전달함
고객은 물건값을 결제기관에 지불하고, 결제기관은 PG사에게 정산해주고, PG사는 상점에게 정산해줌

인증 결제

  • 결제 시 PG사로부터 결제에 대한 인증 결과 수신 이후 해당 인증키로 결제를 요청하는 결제 방식
  • 국내에서 가장 많이 볼 수 있음
  1. 결제 주문 페이지에서 결제가 요청됨
  2. 각 PG사의 결제창이 활성화됨
  3. 고객이 선택한 카드사에 따른 카드사 전용 결제 모듈에서 인증이 완료됨
  4. 해당 인증값을 통해 결제를 요청

아임포트 연동하기

1. https://admin.portone.io/에서 회원가입
2. 포트원 SDK 설치(결제창 연동을 진행할 주문 페이지에 JS 라이브러리 추가

<script src="https://cdn.iamport.kr/v1/iamport.js"></script>

3. 의존성 추가
pom.xml에 의존성 추가

<!-- iamport -->
    <dependency>
      <groupId>com.github.iamport</groupId>
      <artifactId>iamport-rest-client-java</artifactId>
      <version>0.2.0</version>
    </dependency>
  </dependencies>

  <repositories>
    <repository>
      <id>jitpack.io</id>
      <url>https://jitpack.io</url>
    </repository>
  </repositories>

단순히 dependency만 추가해서는 안되기 때문에 jitpack.io 저장소(repositories에 추가)를 같이 추가해 주어야 함

3. API Keys 확인(결제 취소를 할 경우에만 사용)
관리자 콘솔의 결제 연동 페이지에서 고객사 식별코드 확인(결제 취소 시 사용됨)

여기서의 'REST API Key'가 imp.key이고, 'REST API Secret'이 imp.secret이다. 이 값은 git에 올라가면 안되는 값이므로, git ignore를 걸어둔 db.properties에 넣어주거나, 인텔리제이 상에서 환경 변수 추가를 해서 넣어주도록 한다.

결제 구현

https://developers.portone.io/api/rest-v1/payment#post%20%2Fpayments%2Fcancel
위 링크에 들어가면 결제를 구현하는 데 필요한 response, request값들이 나와 있다. 이걸 보며 개발을 진행하면 된다.

단순히 결제만 할 경우에는 Access Token이 없어도 된다(이 토큰은 아임포트 측의 api를 사용할 경우에만 필요함)

// 결제
    var IMP = window.IMP;
    IMP.init("imp72336673");

    function requestPay() {
        var today = new Date();
        var hours = today.getHours(); // 시
        var minutes = today.getMinutes();  // 분
        var seconds = today.getSeconds();  // 초
        var milliseconds = today.getMilliseconds();
        var makeMerchantUid = hours + minutes + seconds + milliseconds;

        console.log("Payment requested");

        IMP.request_pay({
            pg: 'uplus', // 토스페이
            pay_method: 'card',
            merchant_uid: "IMP" + makeMerchantUid,
            name: '축하해요 카드 결제',
            amount: 1000,
            buyer_email: 'Iamport@chai.finance',
            buyer_name: '아임포트',
            buyer_tel: '010-1234-5678',
            buyer_addr: '서울특별시 강남구 삼성동',
            buyer_postcode: '123-456',
            display: {
                card_quota: [3]  // 할부개월 3개월까지 활성화
            }
        }, function (rsp) {
            if (rsp.success) {
                console.log("결제 성공", rsp);
                console.log("응답 객체 구조:", JSON.stringify(rsp, null, 2));
                $.ajax({
                    url: '/payments/process',
                    type: 'POST',
                    contentType: 'application/json; charset=UTF-8',
                    data: JSON.stringify({
                        applyNum: rsp.apply_num,
                        buyer_email: rsp.buyer_email,
                        payNo: rsp.imp_uid,
                        merchantUid: rsp.merchant_uid,
                        payAmount: rsp.paid_amount,
                        paidAt: rsp.paid_at,
                        status: rsp.status,
                        receiptURL: rsp.receipt_url
                    }),
                    success: function (response) {
                        console.log("response" + response)
                        if (response > -1) {
                            saveHiddenData();
                            $('#cardIsPaid').val('true');
                            $(window).off('beforeunload');
                            $('#payID').val(response);
                            $('#cart-submit-button').click();
                        }
                    },
                    error: function (xhr, status, error) {
                        console.log("결제 후 DB 저장 실패", error);
                    }
                });
            } else {
                console.log("결제 실패", rsp);
                alert('Payment failed: ' + rsp.error_msg);
            }
        });
    }

    document.getElementById('edit-pay-button').addEventListener('click', function () {
        if (checkRequires()) {
            requestPay();
        }
    });

구매 영수증

영수증 url에 들어가면 아래 사진과 같이 정보를 입력하라고 나온다.

정보를 입력하면 발급된 영수증을 확인할 수 있다.

결제 취소 구현

3~5일은 아임포트에서 자동 취소를 지원한다. 자동 취소는 관리자를 거치지 않고 아임 포트 내에서 취소를 지원해 주는 것이다. 우리 프로젝트에서는 자동 취소만 지원하고, 나머지는 환불 불가로 하기로 하였다.

  1. 인증 토큰 발급받기
  2. 받아온 토큰을 Header에 담아 결제 취소 api로 요청
  3. 취소 response 값들을 받아와 DB에 저장

1. 인증 토큰 발급받기

포트원 개발자센터에 들어가 보면, 다음과 같이 쓰여져 있다.

우리는 인증 토큰을 발급받기 위하여 다음 두 가지 로직만 수행하면 된다.

  1. body에 json 형식으로 imp_key와 imp_secret를 담아 https://api.iamport.kr/users/getToken 으로 요청 보내기
  2. 받아온 json 형식의 응답에서 "access_token"을 추출하여 return하기

포트원에서는 기존 액세스 토큰의 만료 시간이 1분 미만으로 남았을 경우에는 기존 액세스 토큰의 만료 기한을 5분 연장해주고, 만료된 다음 access_token을 새로 호출해주면 새로운 액세스 토큰이 반환된다. 이 때, 발급된 새로운 액세스 토큰의 유효시간은 30분이다.

하지만, 여기서 토큰의 만료 기한의 존재 이유에 대한 의문점이 생겼다. 과거 해커톤에서 토큰을 사용할 때에는 만료가 된 토큰인지 아닌지를 구분하여 판단하였는데, 이번에는 그럴 필요가 없을 것 같았기 때문이다. 왜냐하면 취소 요청을 할 때 토큰을 발급받아와 취소 api를 호출할 시 한번만 사용하고, 이 토큰을 재사용을 하지 않을 것이기 때문이다. 그래서 토큰의 만료 여부를 판단하는 로직이 필요가 없을 것이라 생각했기 때문이었다.

그래서 토큰을 다시 공부해 보았다. '토큰'은 한마디로 '입장권'이라고 할 수 있다. 입장권을 보유한 사람만 들어갈 수 있는 것처럼 토큰을 보유한 사용자만 무언가를 할 수 있는 자격이 주어지는 것이다. '토큰 기반 인증'은 사용자가 자신의 아이덴티티를 확인하고 고유한 엑세스 토큰을 받는 것이다. 토큰이 유효하다면 사용자는 계속해서 액세스 할 수 있는 것이다.

내가 해커톤 때 써본, 헤더에 넣어 만료 시간 전까지 토큰을 재사용하는 것은 JSON Web Token(JWT)라는 특별한 토큰을 사용한 것이었다. 이 토큰은 access_token을 발급받아 사용자를 '인증'하고, 발급받은 이 토큰을 헤더에 넣어 인증 절차를 거친 사용자임을 '인가'하는 것이다.

쇼핑몰 프로젝트의 결제 취소 로직에서는 인가 과정이 필요가 없으므로 토큰을 재사용 할 필요가 없고, 그렇기 때문에 api 호출이 일어날 때 마다 토큰을 재발급받으면 된다.

아래는 그 과정을 구현한 코드이다.

PaymentService의 getToken()

 public String getToken() {
        System.out.println("key : " + key);
        System.out.println("secret Key : " + secretKey);

        try {
            String apiUrl = "https://api.iamport.kr/users/getToken"; // 요청을 보낼 api 주소

            RestTemplate restTemplate = new RestTemplate(); // body 설정

            HttpHeaders headers = new HttpHeaders(); // header 설정
            headers.setContentType(MediaType.APPLICATION_JSON); // 콘텐츠 타입을 JSON으로 설정

            Map<String, String> keyMap = new HashMap<>();
            keyMap.put("imp_key", key);
            keyMap.put("imp_secret", secretKey);
            ObjectMapper objectMapper = new ObjectMapper();
            String keyJson = objectMapper.writeValueAsString(keyMap);
            System.out.println("keyJson : " + keyJson);
            HttpEntity<String> requestEntity = new HttpEntity<>(keyJson, headers); // HttpEntity 객체 생성
            ResponseEntity<String> responseEntity = restTemplate.exchange(apiUrl, HttpMethod.POST, requestEntity, String.class);

            if (responseEntity.getStatusCode() == HttpStatus.OK) {
                String responseBody = responseEntity.getBody(); //응답값의 body를 가져옴
                JSONParser jsonParser = new JSONParser();
                JSONObject jsonObject = (JSONObject) jsonParser.parse(responseBody);
                JSONObject resultObject = (JSONObject) jsonObject.get("response");
                String accessToken = (String) resultObject.get("access_token");
                return accessToken;
            } else {
                throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "ACCESS_TOKEN을 가져오는 데 실패했습니다.");
            }
        } catch (ParseException e) {
            throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "JSON 응답 파싱에 실패했습니다.");
        } catch (Exception e) {
            throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, ErrorCode.INTERNAL_SERVER_ERROR.getMessage());
        }
    }

이후 test code를 작성하여 의도한 대로 로직이 수행되는지를 확인하였다. test code 작성이 익숙하지 않다는 핑계를 대며 postman으로만 테스트 해 왔었는데, 이번에 처음으로 도전해 보았다. 창 이동 없이 에러메세지를 바로 볼 수 있어서 매우 편했다.

    @Test
    public void tokenTest() {
        String token = service.getToken();
        log.debug(token);
    }

2. 결제 취소 api 호출하기
아래의 로직대로 코드를 작성하면 된다.

  1. HttpHeaders를 이용하여 헤더에 아임포트에서 요청하는 Authorization값을 넣어줌
  2. HashMap에 key, value 형식으로 데이터를 넣고 ObjectMapper를 활용하여 json 객체로 변환해줌(writeValueAsString 메소드 활용)
  3. HttpEntity를 이용하여 HTTP 요청을 보낼 객체를 생성하고, HttpHeader와 HttpBody에 1,2번에서 만든 값을 넣어 HTTP 요청에 해당하는 값을 생성함
  4. RestTemplate의 exchange 메소드 안에 각각의 값들을 파라미터로 넣어 URI를 생성함
  5. 응답값을 파싱하여 DB에 저장

PaymentController

  @PostMapping("/cancel")
    public ResponseEntity<String> cancelPayment(@RequestParam String payNo) {

        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Type", "application/json;charset=UTF-8");
        try {
            paymentService.cancelPayment(payNo);
            return new ResponseEntity<>(SuccessCode.CANCEL_SUCCESS.getMessage(), headers, SuccessCode.CANCEL_SUCCESS.getHttpStatus());
        } catch (Exception e) {
            return new ResponseEntity<>(ErrorCode.INTERNAL_SERVER_ERROR.getMessage(), headers, ErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus());
        }
    }

CancelDTO

@Data
public class CancelDTO {
    private long canceledAt;
    private String failReason;
    private String receiptUrl;

    public static PaymentVO of(CancelDTO cancelDTO) {
        PaymentVO paymentVO = new PaymentVO();
        paymentVO.setCanceledAt(cancelDTO.getCanceledAt());
        paymentVO.setFailReason(cancelDTO.getFailReason());
        paymentVO.setReceiptURL(cancelDTO.getReceiptUrl());

        return paymentVO;
    }
}

PaymentService의 cancelPayment 메소드

  public void cancelPayment(String payNo) {
        try {
            String apiUrl = "https://api.iamport.kr/payments/cancel";

            HttpHeaders headers = new HttpHeaders();
            String accessToken = getToken();
            System.out.println("accessToken : " + accessToken);

            headers.set("Authorization", "Bearer " + accessToken);
            headers.setContentType(MediaType.APPLICATION_JSON);

            RestTemplate restTemplate = new RestTemplate();

            Map<String, String> keyMap = new HashMap<>();
            keyMap.put("imp_uid", payNo);
            keyMap.put("reason", "단순 고객 변심");
            ObjectMapper objectMapper = new ObjectMapper();
            String keyJson = objectMapper.writeValueAsString(keyMap);
            System.out.println("keyJson : " + keyJson);

            HttpEntity<String> requestEntity = new HttpEntity<>(keyJson, headers);
            ResponseEntity<String> responseEntity = restTemplate.exchange(apiUrl, HttpMethod.POST, requestEntity, String.class);

            if (responseEntity.getStatusCode() == HttpStatus.OK) {
                System.out.println("결제 취소 성공");
                System.out.println("responseEntity : " + responseEntity);
                System.out.println("응답 코드 : " + responseEntity.getStatusCode());
                String responseBody = responseEntity.getBody();
                JSONParser jsonParser = new JSONParser();
                JSONObject jsonObject = (JSONObject) jsonParser.parse(responseBody);
                JSONObject resultObject = (JSONObject) jsonObject.get("response"); //response값만 추출

                System.out.println("resultObject : " + resultObject);

                long canceledAt = (Long) resultObject.get("cancelled_at"); //취소 시각. 결제 취소가 아니면 0
                String failReason = (String) resultObject.get("fail_reason"); //결제 실패 사유. 결제 성공 시 null값.
                String receiptUrl = (String) resultObject.get("receipt_url"); //결제건의 매출전표

                CancelDTO cancelDTO = new CancelDTO();
                cancelDTO.setCanceledAt(canceledAt);
                cancelDTO.setFailReason(failReason);
                cancelDTO.setReceiptUrl(receiptUrl);

                PaymentVO paymentVO = cancelDTO.of(cancelDTO);

                //이 위치에 mapper에 paymentVO를 insert하는 부분 추가

                System.out.println("DB 저장 성공");
            } else {
                throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR, "결제 취소에 실패하였습니다.");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

jsonObject : 서버로부터 받은 전체 JSON 응답
resultObject : JSON 문자열 전체를 파싱한 결과. JSON문자열을 JSONObject로 변환한 것

결제 취소 응답값이 반환되면, 아임포트 쪽에서는 한글 깨짐을 방지하기 위하여 유니코드로 값을 변환해서 전송해준다. 그럼 한글이 깨지기 때문에 받은 값을 내가 다시 디코딩 해 주어야 한다. 디코딩 하는 어노테이션이 따로 없기 때문에, 서버에서 디코딩 하는 로직을 구현해 주어야 한다.

처음에는 응답 값을 받아오는 PaymentService 안에 메소드를 따로 빼서 구현해 주려고 하였다. 하나의 메소드가 하나의 기능을 수행하도록 하는 것이 결합도를 낮추는 방법이기 때문이다. 하지만, 여기에서 의문점이 생겼다. 우리의 로직 중, 유니코드를 한글로 디코딩하는 부분은 결제 취소 실패 사유를 받아오는 부분밖에 없다. 즉, cancelPayment()에서밖에 사용되지 않는 것인데 굳이 메소드를 따로 쓸 필요가 있는가에 대한 의문점이었다.

그래서 강사님께 여쭈어 보았더니, 다음과 같은 답변이 돌아왔다.
(질문 : 디코딩 메소드는 한번만 사용하고 재사용하지 않을 것인데 따로 작성해야 하는지 + cancelPayment()가 현재는 취소 요청 보내기와 응답 받기의 두 개의 로직을 수행하는데 이것도 분리해야 하는지)

즉, 좋은 코드를 위하여 지켜야 할 것은 다음과 같은 것이다.
1. 하나의 메소드는 하나의 일만 수행한다.
2. 다만, 두 개의 로직을 동시에 수행하고, 이게 구별될 일이 없는 경우에는 하나의 메소드가 두 개의 일을 하도록 한다.
3. 추후라도 공통적으로 사용될 수 있는 부분은 따로 뺀다.

답변을 듣고 확장성을 생각하여 디코딩 하는 메소드를 새로운 패키지의 새로운 클래스로 빼서 작성하기로 하였다. 다른 api를 추가했을 때, 거기서 받아오는 값을 디코딩 해 주는 경우에도 필요할 수 있기 때문이다.

디코딩 로직은 다른 벨로그에 작성해 두었다.
https://velog.io/@y_bin/Unicode-한글-변환

CancelTest

import com.choikang.chukahaeyo.payment.PaymentService;
import lombok.extern.log4j.Log4j;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;

@Log4j
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {config.MvcConfig.class})
@WebAppConfiguration
@Slf4j
public class CancelTest {
    @Autowired
    PaymentService service;

    @Test
    public void cancelTest() {
        service.cancelPayment("imp_933567985768");
    }

    @Test
    public void tokenTest() {
        String token = service.getToken();
        log.debug(token);
    }
}

취소 영수증

취소도 결제 영수증과 마찬가지로 정보를 입력하면 영수증을 확인할 수 있다.

테스트 결제 시 자동취소 되는 상황

테스트 결제 시, 카카오페이나 토스페이먼츠는 실 결제가 이루어지지 않는다. 그래서 영수증도 발행되지 않는다. 하지만, PG 이니시스는 실결제가 이루어지고 영수증도 발급이 된다. 테스트 결제이기 때문에 별다른 행동을 하지 않아도 당일 저녁 11시쯤 결제 취소가 된다. 이 때, 결제 취소 된 값의 응답값이 있다면 이것을 받아와 자동으로 관리자 페이지에서 확인할 수 있도록 하고 싶었다.

그래서 문의를 해 본 결과, 다음과 같은 답변이 돌아왔다.. 😭

테스트 결제가 자동으로 취소되는 로직은 내가 직접 취소된 결제인지 아닌지를 조회하지 않는 이상 알 수 없다고 한다.. ㅠㅠ

profile
컴퓨터가 이해하는 코드는 바보도 작성할 수 있다. 사람이 이해하도록 작성하는 프로그래머가 진정한 실력자다. -마틴 파울러

0개의 댓글