Tosspayment 1탄

이주현·2024년 12월 26일

toss

목록 보기
1/2
post-thumbnail

- 결제요청

payment.requestPayment() 메서드를 호출하면 결제 요청이 시작되고, 결제창이 열려요. 메서드의 파라미터로 결제수단(CARD), 주문번호, 결제금액, successUrl, failUrl 등 필요한 정보를 설정하세요.

결제하기 버튼을 클릭하면 주문창의 orderId, amount 등의 정보가 넘어온다.
우선 결제 승인 요청을 위해서 필수 정보인 orderId와 amount의 값만 session에 저장한다.

- 결제 요청 성공

사용자가 결제 방법을 선택한 후, 결제에 성공하면 결제 요청에 성공한거다. 그럼 미리 설정해둔 successUrl에 쿼리 파라미터로 paymentKey, orderId, amount가 입력되어서 돌아온다.
/success?orderId={ORDER_ID}&paymentKey={PAYMENT_KEY}&amount={AMOUNT}

- 결제 승인 요청

쿼리 파라미터의 값을 아까 session에 저장해둔 orderId와 amount의 값을 비교해 중간에 값이 바뀌진 않았는지 검증한다.
검증에 성공했다면 toss에게 결제 승인 요청을 보낸다.

- 결제 완료

모든 과정이 완료되면 결제가 완료된 것.
DB에 값을 저장한다.

[Code]

결제 요청

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>pay test</title>
  <script src="https://js.tosspayments.com/v2/standard"></script>
</head>
<body>
<div id="order_id">
  <ul>
    <li>
      <div class="order_item">
        <span>상품아이디 : </span>
        <span id="item_no1">1</span>
        <span>상품명 : </span>
        <span id="item_name1">티셔츠</span>
        <span>가격 : </span>
        <span id="item_amount1">5000</span>
        <span>수량 : </span>
        <span id="item_quantity1">3</span>
      </div>
    </li>
    <li>
      <div class="order_item">
          <span>상품아이디 : </span>
          <span id="item_no2">2</span>
          <span>상품명 : </span>
          <span id="item_name2">볼펜</span>
          <span>가격 : </span>
          <span id="item_amount2">500</span>
          <span>수량 : </span>
          <span id="item_quantity2">5</span>
      </div>
    </li>
  </ul>
</div>

<button class="button" style="margin-top: 30px" onclick="requestPayment()">결제하기</button>

<script>
  const clientKey = "test_ck_....";
  const customerKey = "....";
  const tossPayments = TossPayments(clientKey);

  async function requestPayment() {
    const items = [];
      const itemCount = document.querySelectorAll('#order_id .order_item').length;
      let orderId = generateRandomString();

      for (let i = 1; i <= itemCount; i++) {
      const itemId = document.getElementById(`item_no${i}`).innerText;
      const amount = parseInt(document.getElementById(`item_amount${i}`).innerText);
      const quantity = parseInt(document.getElementById(`item_quantity${i}`).innerText);
      const itemName = document.getElementById(`item_name${i}`).innerText;

      items.push({itemId, amount, quantity, itemName});
    }

    let totalAmount = 0;
    let totalQuantity = 0;
    let orderName = '';
    if (items.length === 1) {
        orderName = `${items[0].itemName}`;
    } else {
        orderName = `${items[0].itemName}${items.length - 1}`;
    }

    items.forEach((item, index) => {
      totalAmount += item.amount * item.quantity;
      totalQuantity += item.quantity;
    });

    console.log("상품 종류 개수: ", itemCount);
    console.log("총 상품 개수: ", totalQuantity);
    console.log("총 결제 금액: ", totalAmount);
    console.log("주문 이름: ", orderName);

    try {
      const response = await fetch('/api/payment/initiate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ orderId, totalAmount })
      });

      if (!response.ok) {
        throw new Error('서버에 결제 정보 저장 실패');
      }
    } catch (error) {
      console.error('결제 정보 저장 중 오류 발생:', error);
      return;
    }
    
    // 결제를 요청하기 전에 orderId, amount를 서버에 저장하세요.
    // 결제 과정에서 악의적으로 결제 금액이 바뀌는 것을 확인하는 용도입니다.
    // 결제 요청
    const payment = tossPayments.payment({customerKey});
    try {
      await payment.requestPayment({
        method: "CARD", // 카드 결제
        amount: {
          currency: "KRW",
          value: totalAmount,
        },
        orderId: orderId, // 고유 주문번호
        orderName: orderName,
        successUrl: 'http://localhost:8080/success', // 결제 요청이 성공하면 리다이렉트되는 URL
        failUrl: 'http://localhost:8080/fail', // 결제 요청이 실패하면 리다이렉트되는 URL
        customerEmail: "customer123@gmail.com",
        customerName: "김토스",
        customerMobilePhone: "01012341234",
        // 카드 결제에 필요한 정보
        card: {
          useEscrow: false,
          flowMode: "DEFAULT", // 통합결제창 여는 옵션
          useCardPoint: false,
          useAppCardOnly: false,
        },
      });
    } catch (error) {
      console.error('결제 요청 중 오류 발생:', error);
    }
  }

  function generateRandomString() {
    return window.btoa(Math.random()).slice(0, 20);
  }
</script>
</body>
</html>

우선 1차 코드다. totalAmount, quantity 등은 html 등에서 값 수정이 일어날 수 있기 때문에 보안상 나중에 fetch 등을 사용해서 백에서 구현할 예정이다.
그리고 orderId도 order Table에서 가져와야하는데 아직 없어서 우선 generateRandomString()으로 구현해뒀다.

여기서 가 버튼을 클릭하면 requestPayment가 동작한다는 코드다 -> 결제 요청

fetch를 사용해 orderId와 amount를 Controller로 보내면 그 값을 session에 임시 저장한다.

@PostMapping("/payment")
    public ResponseEntity<?> initiatePayment(@RequestBody RequestOrderValueDto orderValue, HttpServletRequest request) {
        HttpSession session = request.getSession(true);

        session.setAttribute("orderId", orderValue.getOrderId()); // orderValue 필드명 - javascript fetch에서 보내는 데이터의 이름이랑 같아야 함 (json 역직렬화 규칙)
        session.setAttribute("amount", orderValue.getTotalAmount());

        return ResponseEntity.ok("Order information saved in session");
    }

session에 임시 저장한 후, 결제 버튼에 대한 로직이 진행되는데 티셔츠 외 2건, 총 결제 금액을 javascript에 구현해둬서 동적으로 동작한다.

결제 요청 성공

사용자가 결제 방법 및 결제까지 완료하면 미리 설정해둔 successUrl에 쿼리 파라미터로 paymentKey를 덧붙여서 Toss가 보내준다.

@GetMapping("/success")
    public ResponseEntity<Success> paymentSuccess(@RequestParam String paymentKey,
                                                     @RequestParam String orderId,
                                                     @RequestParam Double amount,
                                                     HttpServletRequest request) {
        Success successDto = new Success(paymentKey, orderId, amount);
        HttpSession session = request.getSession();

        // 임시 저장값과 같은지 검증
        if (Objects.equals(orderId, (String) session.getAttribute("orderId"))) {
            if (Objects.equals(amount, (Double) session.getAttribute("amount"))) {
                // 같으면 세션 삭제
                session.removeAttribute(orderId);
                try {
                    rest.confirm(successDto);
                } catch (URISyntaxException e) {
                    failUrl("INVALID_REQUEST", "잘못된 요청입니다.", orderId);
                }
            }
        }
        return ResponseEntity.ok().build();
    }

session에 저장되어있는 값과 파라미터로 들어온 값이 일치해 검증에 성공했다.
-> session의 임시 저장 값을 삭제한다.
-> 그 후, toss에게 RestTemplate으로 결제 승인 요청을 보낸다.

결제 승인 요청

public String confirm(@RequestBody Success success) throws URISyntaxException {
        HttpHeaders headers = new HttpHeaders();
        RestTemplate restTemplate = new RestTemplate();

        URI uri = new URI("https://api.tosspayments.com/v1/payments/confirm");

        headers.setBasicAuth("Basic base64("{API_SECRET_KEY}:")"... encoding한 값...);
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

        Map<String, String > map = new HashMap<>();
        map.put("paymentKey", success.getPaymentKey());
        map.put("orderId", success.getOrderId());
        map.put("amount", String.valueOf(success.getAmount()));

        ObjectMapper objectMapper = new ObjectMapper();

        HttpEntity<String> request = null;
        try {
            request = new HttpEntity<>(objectMapper.writeValueAsString(map), headers);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        ResponseEntity<String> responseEntity = restTemplate.exchange(
                uri,
                HttpMethod.POST,
                request,
                String.class
        );
        return responseEntity.getBody();
    }

curl --request POST \
--url https://api.tosspayments.com/v1/payments/confirm \
--header 'Authorization: Basic dGVzdF9za196WExrS0V5cE5BcldtbzUwblgzbG1lYXhZRzVSOg==' \
--header 'Content-Type: application/json' \
--data '{"paymentKey":"{PAYMENT_KEY}","amount":{amount},"orderId":"{orderId}"'

이 형식에 맞춰서 restTemplate을 작성하면 된다.

그럼 최종적으로 이런 형태로 응답이 온다.

{
"mId": "tvivarepublica",
"lastTransactionKey": "txrd_a01jfvst07khb2fr75mn4vyn82p",
"paymentKey": "tviva20241224164056dL1N9",
"orderId": "MC44NDgyMDA5ODYxMzI2",
"orderName": "티셔츠 외 2건",
"taxExemptionAmount": 0,
"status": "DONE",
"requestedAt": "2024-12-24T16:40:56+09:00",
"approvedAt": "2024-12-24T16:41:21+09:00",
"useEscrow": false,
"cultureExpense": false,
"card": null,
"virtualAccount": null,
"transfer": null,
"mobilePhone": null,
"giftCertificate": null,
"cashReceipt": null,
"cashReceipts": null,
"discount": null,
"cancels": null,
"secret": "ps_XZYkKL4Mrjq9jp0n5a5oV0zJwlEW",
"type": "NORMAL",
"easyPay": {
"provider": "토스페이",
"amount": 19500,
"discountAmount": 0
},
"country": "KR",
"failure": null,
"isPartialCancelable": true,
"receipt": {
"url": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20241224164056dL1N9&ref=PX"
},
"checkout": {
"url": "https://api.tosspayments.com/v1/payments/tviva20241224164056dL1N9/checkout"
},
"currency": "KRW",
"totalAmount": 19500,
"balanceAmount": 19500,
"suppliedAmount": 17727,
"vat": 1773,
"taxFreeAmount": 0,
"method": "간편결제",
"version": "2022-11-16",
"metadata": null
}

그리고 앱으로 결제했다면 이런 알림이 오고 결제가 완료된다.

profile
쫄면?! 만두

0개의 댓글