티스토리 뷰

회사에서 결제모듈을 구현해야 된다고 했을땐

막연히 겁이 났던것 같다.

당연하지만 남의 돈을 다룬다고 하는 것에 작은 죄책감(?) 때문인지도 모르겠다.

 

PG 결제모듈은 여러 회사가 있지만 그 중에 토스페이먼츠를 지정해주었다.

 

https://developers.tosspayments.com/

 

토스페이먼츠 개발자센터

토스페이먼츠 결제 연동 문서, API, 키, 테스트 내역, 웹훅 등록 등 개발에 필요한 정보와 기능을 확인해 보세요. 결제 연동에 필요한 모든 개발자 도구를 제공해 드립니다.

developers.tosspayments.com

 

5분 연동 가이드만 보아도 굉장히 쉽게 구현되어있었고 api나 샘플 코드들이 참고하기 쉬웠다는게 마음에 들었다.

https://github.com/tosspayments/payment-samples/tree/main/payment-window

 

그리고 디스코드에서도 궁금한 점을 물어볼 수 있어서 별다른 어려움 없이 구현했던것 같다.

https://tosspayments.discourse.group/

 

토스페이먼츠 디스코드 Repo

토스페이먼츠 디스코드 채널에 남겨진 많은 질문을 손쉽게 검색 및 확인하실 수 있습니다.

tosspayments.discourse.group

 


결제 승인처리 flow

하나의 트랜잭션으로 이루어진것은 아니지만 각각의 흐름에 따라 정리해 보았다.

 

 

결제 상품 리스트 전달

 

먼저 서버에서 결제상품 테이블을 만들어두고 회원에게 전달해준다.

결제 상품 테이블은

id amount item_name
1 5000 상품1
2 10000 상품2
3 15000 상품3
4 100 테스트

 

위와 같이 만들어주었고 프론트는 위 상품리스트 api를 받아 사용자에게 보여주면 된다.

 

 

 

결제 상품, 사용자 정보  '임시 결제 테이블' 저장

 

선배 개발자의 말로는 임시결제테이블은 로그의 개념으로만 사용된다고 한다.

그래서 아무런 정보가 들어가도 상관없다.

하지만 필수적으로 결제 승인에 필요하고 동일한 상품인지 검증하는 'orderId' 를 만들어 줄 필요가 있다.

결제승인 요청시 필수값 orderId

 

어떤 값을 넣어야 하나 싶다가 UUID가 생각났다.

맨처음엔 UUID가 128자인 줄 알아서 고려하지 않았는데 36자 문자열에 128비트 값이라고 하더라ㅋ

 

무튼, 임시결제테이블엔 orderId를 시작으로 추후 결제테이블에서 검증할 회원 정보, 상품정보 등을 넣게 되었다.

 

 

 

카드사 모듈 호출, 결제 요청 후 결제 승인 요청

 

이후 과정이 결제의 꽃이지 않을까 싶다.

Front에선 결제모듈을 호출하고 결제를 진행하고(은행앱의 결제과정)

결제모듈에서 결제 성공 여부를 확인 후 성공 시 서버에 결제승인 요청을 보내게 된다.

 

<script>
    let tossPayments = TossPayments("test_ck_OEP59LybZ8Bdv6A1JxkV6GYo7pRe");

    function pay(method, requestJson) {
        console.log(requestJson);
        tossPayments.requestPayment(method, requestJson)
            .catch(function (error) {

                if (error.code === "USER_CANCEL") {
                    alert('유저가 취소했습니다.');
                } else {
                    alert(error.message);
                }

            });
    }

    let path = "/";
    let successUrl = window.location.origin + path + "success";
    let failUrl = window.location.origin + path + "fail";
    let callbackUrl = window.location.origin + path + "va_callback";
    let orderId = new Date().getTime();

    let jsons = {
        "card": {
            "amount": amount,
            "orderId": "sample-" + orderId,
            "orderName": "토스 티셔츠 외 2건",
            "successUrl": successUrl,
            "failUrl": failUrl,
            "cardCompany": null,
            "cardInstallmentPlan": null,
            "maxCardInstallmentPlan": null,
            "useCardPoint": false,
            "customerName": "박토스",
            "customerEmail": null,
            "customerMobilePhone": null,
            "taxFreeAmount": null,
            "useInternationalCardOnly": false,
            "flowMode": "DEFAULT",
            "discountCode": null,
            "appScheme": null
        }

    }
</script>

 

위의 javascript 코드가 sample 결제모듈 되겠다.

successUrl 을 서버의 결제승인 api로 연결시켜서 추가적인 결제 승인 작업을 진행하면 된다.

 

 

 

결제 승인 요청

 

@GetMapping(value = "success")
    public String paymentResult(
            Model model,
            @RequestParam(value = "orderId") String orderId,
            @RequestParam(value = "amount") Integer amount,
            @RequestParam(value = "paymentKey") String paymentKey) throws Exception {

        String secretKey = "test_ak_ZORzdMaqN3wQd5k6ygr5AkYXQGwy:";

        Base64.Encoder encoder = Base64.getEncoder();
        byte[] encodedBytes = encoder.encode(secretKey.getBytes("UTF-8"));
        String authorizations = "Basic " + new String(encodedBytes, 0, encodedBytes.length);

        URL url = new URL("https://api.tosspayments.com/v1/payments/" + paymentKey);

        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestProperty("Authorization", authorizations);
        connection.setRequestProperty("Content-Type", "application/json");
        connection.setRequestMethod("POST");
        connection.setDoOutput(true);
        JSONObject obj = new JSONObject();
        obj.put("orderId", orderId);
        obj.put("amount", amount);

        OutputStream outputStream = connection.getOutputStream();
        outputStream.write(obj.toString().getBytes("UTF-8"));

        int code = connection.getResponseCode();
        boolean isSuccess = code == 200 ? true : false;
        model.addAttribute("isSuccess", isSuccess);

        InputStream responseStream = isSuccess ? connection.getInputStream() : connection.getErrorStream();

        Reader reader = new InputStreamReader(responseStream, StandardCharsets.UTF_8);
        JSONParser parser = new JSONParser();
        JSONObject jsonObject = (JSONObject) parser.parse(reader);
        responseStream.close();
        model.addAttribute("responseStr", jsonObject.toJSONString());
        System.out.println(jsonObject.toJSONString());

        model.addAttribute("method", (String) jsonObject.get("method"));
        model.addAttribute("orderName", (String) jsonObject.get("orderName"));

        if (((String) jsonObject.get("method")) != null) {
            if (((String) jsonObject.get("method")).equals("카드")) {
                model.addAttribute("cardNumber", (String) ((JSONObject) jsonObject.get("card")).get("number"));
            } else if (((String) jsonObject.get("method")).equals("가상계좌")) {
                model.addAttribute("accountNumber", (String) ((JSONObject) jsonObject.get("virtualAccount")).get("accountNumber"));
            } else if (((String) jsonObject.get("method")).equals("계좌이체")) {
                model.addAttribute("bank", (String) ((JSONObject) jsonObject.get("transfer")).get("bank"));
            } else if (((String) jsonObject.get("method")).equals("휴대폰")) {
                model.addAttribute("customerMobilePhone", (String) ((JSONObject) jsonObject.get("mobilePhone")).get("customerMobilePhone"));
            }
        } else {
            model.addAttribute("code", (String) jsonObject.get("code"));
            model.addAttribute("message", (String) jsonObject.get("message"));
        }

        return "success";
    }

 

적당히 분리해서 진행하면 되고 나의 경우엔 MVC 패턴이 아니다보니

직접 redirectView를 호출하여 결제 성공 페이지, 결제 실패 페이지로 연결해 주었다.

 

위의 코드는 결제 승인의 과정 뿐이고 실제로는 이 코드 안에

'임시 결제 테이블'에 저장한 orderId 와 비교해 실제로 같은 사람이 결제한 상품인지 검증하는 부분과

같은 상품인지. 같은 가격인지. 등등 여러 방어로직을 만들어 두었다.

 

카드사 모듈에서 결제된 금액이 결제승인 과정에서 실패된다면 어떻게 되는지 이해가 잘 가지 않아

디스코드에 물어보니 만약 위 결제승인 과정 도중 예외처리가 되어 승인 api가 실패되고 보내지지 않는다면 금액이 지불되지 않는다는 답변을 받았다.

 

그러니 혹시 이글을 보는 여러분들도 위의 코드를 Controller에서 동작하지 말고

Service 코드에서 @Transactional 어노테이션을 사용해서 예외처리시 rollback 해주시길 바란다.

 

 

 

마무리

 

결제에 복잡한 과정이 섞여있지 않고 단순한 결제 처리만 있다 보니

별다른 어려운 점 없이 토스페이먼츠 api 만을 분석해서 결제 과정을 구현해 보았다.

참. api 문서라는게 볼 때는 정말 귀찮다가도 구현하게 된다면 그만큼 후련해지는것 같다.

'Backend > SpringBoot' 카테고리의 다른 글

디자인패턴에 대해서  (1) 2023.12.11
네이버 문자(sms) 서비스 구현  (0) 2023.09.12
Swagger Opendoc API  (0) 2023.07.18
Jwt + SpringSecurity + Mybatis 구현  (0) 2023.07.06
SpringBoot 동적인(?) 파일 수정  (0) 2023.05.15
Comments