쇼핑몰 웹사이트 만들어보기 - 결제 기능(토스 api 활용)

Shiba·2024년 8월 14일
0

프로젝트 및 일기

목록 보기
17/29

이번 글에서는 토스 api를 적용하여 결제기능을 만들어보도록 하자.
적용중에 파라미터가 어디서 날라오는 것인지 헷갈려서 삽질을 좀 많이했으니 제대로 정리해두도록하자.

적용하면서 몸으로 느낀 흐름은 다음과 같다.

  1. 장바구니 페이지에서 주문번호, 상품의 정보, 결제 금액, 그외 사용자의 개인정보를 받아와서 결제하기 페이지를 구성
  2. 받아온 정보들을 이용하여 토스api에서 결제화면 구성
  3. 결제하기를 하게되면 실제 거래와 같이 인증을 통해 결제진행
  4. 카드사에서 해당 결제를 인증해서 성공하였다면 /success로 이동
    - 이때, api가 자동으로 파라미터로 orderId, paymentKey, amount를 붙여서 리다이렉트
  5. 리다이렉트된 화면에서 파라미터를 이용하여 결제성공과 해당 정보들을 표시해줌

이런식으로 결제가 진행되는 것 같았다.

그러면 적용하기위해 작성한 코드를 살펴보자.
대부분은 토스api 가이드에서 들고온 틀을 깨지않고 조정만 하여 실험하였다.

사전준비

장바구니 페이지에서 정보들을 가져와야한다. 사용자의 개인정보는 스프링 시큐리티를 적용하였으니 쉽게 가져올 수 있을 것 같지만, 구매하는 상품과 관련된 정보는 장바구니 페이지에서 가져와야한다.

파라미터를 사용하기에는 악용의 위험성이 너무 크기때문에 sessionStorage를 사용했다. 결제하기 버튼을 누르면 이동하기전에 미리 저장해두고 결제하기 페이지에서 이를 꺼내서 사용하도록 했다.

document.getElementById("buy").addEventListener("click", function () {
        let name = document.getElementById("name").innerText;
        const row = document.getElementById("table").rows.length;
        const total = document.getElementById("totalAmount").innerText.replace("원", "");
        if(row > 2)
            name = name + " 외" + (String)(row-2) + "건";
        sessionStorage.setItem("product_name",name);
        sessionStorage.setItem("total",total);
        window.location.href="/pay";
    })

작성한 코드

checkout.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <!-- SDK 추가 -->
    <script src="https://js.tosspayments.com/v2/standard"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/uuid/8.3.2/uuid.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.7.0.js"></script>
</head>
<body>
<div class="checkout-container">
    <!-- 장바구니 목록 -->
    <div class="cart-items">
        <div class="item">
            <span id="product_name"></span>
        </div>
        <!-- 다른 상품들도 반복 -->
        <div class="total">
            <h3>총 합계: <span id="totalAmount"></span></h3>
        </div>
    </div>

    <!-- 배송지 정보 -->
    <div class="shipping-info">
        <h2>배송지 정보</h2>
        <div class="existing-address">
            <h3>기존 주소</h3>
            <label id="user_name" th:text="${user.name}"></label>
            <label id="phone" th:text="${user.phone}"></label>
            <label id="place" th:text="${user.place}"></label>
        </div>
    </div>

    <!-- 주문서 영역 -->
    <div class="wrapper">
        <div class="box_section" style="padding: 40px 30px 50px 30px; margin-top: 30px; margin-bottom: 50px">
            <h1>일반 결제</h1>
            <!-- 결제 UI -->
            <div id="payment-method" style="display: flex">
                <button id="CARD" class="button2" onclick="selectPaymentMethod('CARD')">카드</button>
                <button id="TRANSFER" class="button2" onclick="selectPaymentMethod('TRANSFER')">계좌이체</button>
                <button id="VIRTUAL_ACCOUNT" class="button2" onclick="selectPaymentMethod('VIRTUAL_ACCOUNT')">가상계좌</button>
                <button id="MOBILE_PHONE" class="button2" onclick="selectPaymentMethod('MOBILE_PHONE')">휴대폰</button>
                <button id="CULTURE_GIFT_CERTIFICATE" class="button2" onclick="selectPaymentMethod('CULTURE_GIFT_CERTIFICATE')">문화상품권</button>
            </div>
            <!-- 결제하기 버튼 -->
            <button class="button" id="pay_button" style="margin-top: 30px" onclick="requestPayment()">결제하기</button>
        </div>
    </div>
</div>
<script th:inline="javascript">
    document.addEventListener("DOMContentLoaded", function () {
        const name = sessionStorage.getItem("product_name");
        const total = sessionStorage.getItem("total");

        document.getElementById('totalAmount').innerText = total + '원';
        document.getElementById('pay_button').innerText = total + "원 결제하기";
        document.getElementById("product_name").innerText = name;
    });

    let selectedPaymentMethod = null;

    function selectPaymentMethod(method) {
        if (selectedPaymentMethod != null) {
            document.getElementById(selectedPaymentMethod).style.backgroundColor = "#ffffff";
        }

        selectedPaymentMethod = method;

        document.getElementById(selectedPaymentMethod).style.backgroundColor = "rgb(229 239 255)";
    }

        // ------  SDK 초기화 ------
        // @docs https://docs.tosspayments.com/sdk/v2/js#토스페이먼츠-초기화
        const clientKey = "토스 결제 개발 연동 클라이언트 키";
        const customerKey = generateRandomString();
        const tossPayments = TossPayments(clientKey);
        // 회원 결제
        // @docs https://docs.tosspayments.com/sdk/v2/js#tosspaymentspayment
        const payment = tossPayments.payment({ customerKey });
        // 비회원 결제
        // const payment = tossPayments.payment({customerKey: TossPayments.ANONYMOUS})

        // ------ '결제하기' 버튼 누르면 결제창 띄우기 ------
        // @docs https://docs.tosspayments.com/sdk/v2/js#paymentrequestpayment
        async function requestPayment() {
            const total = sessionStorage.getItem("total");
            const productName = sessionStorage.getItem("product_name");
            var phone = [[${user.phone}]];
            var email = [[${user.email}]];
            var userName = [[${user.name}]];
            // 결제를 요청하기 전에 orderId, amount를 서버에 저장하세요.
            // 결제 과정에서 악의적으로 결제 금액이 바뀌는 것을 확인하는 용도입니다.
            switch (selectedPaymentMethod) {
                case "CARD":
                    await payment.requestPayment({
                        method: "CARD", // 카드 결제
                        amount: {
                            currency: "KRW",
                            value: parseInt(total),
                        },
                        orderId: generateRandomString(), // 고유 주분번호
                        orderName: productName,
                        successUrl: window.location.origin + "/success", // 결제 요청이 성공하면 리다이렉트되는 URL
                        failUrl: window.location.origin + "/fail", // 결제 요청이 실패하면 리다이렉트되는 URL
                        customerEmail: email,
                        customerName: userName,
                        customerMobilePhone: phone,
                        // 카드 결제에 필요한 정보
                        card: {
                            useEscrow: false,
                            flowMode: "DEFAULT", // 통합결제창 여는 옵션
                            useCardPoint: false,
                            useAppCardOnly: false,
                        },
                    });
                case "TRANSFER":
                    await payment.requestPayment({
                        method: "TRANSFER", // 계좌이체 결제
                        amount: {
                            currency: "KRW",
                            value: parseInt(total),
                        },
                        orderId: generateRandomString(),
                        orderName: productName,
                        successUrl: window.location.origin + "/success", // 결제 요청이 성공하면 리다이렉트되는 URL
                        failUrl: window.location.origin + "/fail", // 결제 요청이 실패하면 리다이렉트되는 URL
                        customerEmail: email,
                        customerName: userName,
                        customerMobilePhone: phone,
                        transfer: {
                            cashReceipt: {
                                type: "소득공제",
                            },
                            useEscrow: false,
                        },
                    });
                case "VIRTUAL_ACCOUNT":
                    await payment.requestPayment({
                        method: "VIRTUAL_ACCOUNT", // 가상계좌 결제
                        amount: {
                            currency: "KRW",
                            value: parseInt(total),
                        },
                        orderId: generateRandomString(),
                        orderName: productName,
                        successUrl: window.location.origin + "/success", // 결제 요청이 성공하면 리다이렉트되는 URL
                        failUrl: window.location.origin + "/fail", // 결제 요청이 실패하면 리다이렉트되는 URL
                        customerEmail: email,
                        customerName: userName,
                        customerMobilePhone: phone,
                        virtualAccount: {
                            cashReceipt: {
                                type: "소득공제",
                            },
                            useEscrow: false,
                            validHours: 24,
                        },
                    });
                case "MOBILE_PHONE":
                    await payment.requestPayment({
                        method: "MOBILE_PHONE", // 휴대폰 결제
                        amount: {
                            currency: "KRW",
                            value: parseInt(total),
                        },
                        orderId: generateRandomString(),
                        orderName: productName,
                        successUrl: window.location.origin + "/success", // 결제 요청이 성공하면 리다이렉트되는 URL
                        failUrl: window.location.origin + "/fail", // 결제 요청이 실패하면 리다이렉트되는 URL
                        customerEmail: email,
                        customerName: userName,
                        customerMobilePhone: phone,
                    });
                case "CULTURE_GIFT_CERTIFICATE":
                    await payment.requestPayment({
                        method: "CULTURE_GIFT_CERTIFICATE", // 문화상품권 결제
                        amount: {
                            currency: "KRW",
                            value: parseInt(total),
                        },
                        orderId: generateRandomString(),
                        orderName: productName,
                        successUrl: window.location.origin + "/success", // 결제 요청이 성공하면 리다이렉트되는 URL
                        failUrl: window.location.origin + "/fail", // 결제 요청이 실패하면 리다이렉트되는 URL
                        customerEmail: email,
                        customerName: userName,
                        customerMobilePhone: phone,
                    });
            }

        }


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

결제하기 페이지이다. 페이지가 로드되면 장바구니 페이지에서 저장해둔 정보를 이용하여 페이지에서 보이도록 설정하였다. api코드들은 domloaded안에 넣으면 오류가 발생하여 밖으로 빼두었다. 밖으로 빼두어서 사용해야했기 때문에 타임리프와 sessionStorage를 js에서 직접 사용하여 정보를 전달하도록 했다.

success.html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
</head>
<body>
<h2>결제 성공</h2>
<p id="paymentKey"></p>
<p id="orderId"></p>
<p id="amount"></p>

<script>
  // 쿼리 파라미터 값이 결제 요청할 때 보낸 데이터와 동일한지 반드시 확인하세요.
  // 클라이언트에서 결제 금액을 조작하는 행위를 방지할 수 있습니다.
  const urlParams = new URLSearchParams(window.location.search);
  const paymentKey = urlParams.get("paymentKey");
  const orderId = urlParams.get("orderId");
  const amount = urlParams.get("amount");

  async function confirm() {
    const requestData = {
      paymentKey: paymentKey,
      orderId: orderId,
      amount: amount,
    };

    const response = await fetch("/confirm", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(requestData),
    });

    const json = await response.json();

    if (!response.ok) {
      // 결제 실패 비즈니스 로직을 구현하세요.
      console.log(json);
      window.location.href = `/fail?message=${json.message}&code=${json.code}`;
    }

    // 결제 성공 비즈니스 로직을 구현하세요.
    console.log(json);
  }
  confirm();

  const paymentKeyElement = document.getElementById("paymentKey");
  const orderIdElement = document.getElementById("orderId");
  const amountElement = document.getElementById("amount");

  orderIdElement.textContent = "주문번호: " + orderId;
  amountElement.textContent = "결제 금액: " + amount;
  paymentKeyElement.textContent = "paymentKey: " + paymentKey;
</script>
</body>
</html>

결제 성공 시, 이동하는 페이지이다. url파라미터를 통해 텍스트를 구성하는 것을 확인할 수 있다.

fail.html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
</head>

<body>
<h2> 결제 실패 </h2>
<p id="code"></p>
<p id="message"></p>
</body>
</html>

<script>
  const urlParams = new URLSearchParams(window.location.search);

  const codeElement = document.getElementById("code");
  const messageElement = document.getElementById("message");

  codeElement.textContent = "에러코드: " + urlParams.get("code");
  messageElement.textContent = "실패 사유: " + urlParams.get("message");
</script>

결제 실패 페이지다. 원리는 결제 성공 페이지와 같다.

WidgetController

package com.shoppingmall.controller;

import com.shoppingmall.domain.Users;
import com.shoppingmall.service.UserService;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

@Controller
public class WidgetController {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    private final UserService userService;

    public WidgetController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/pay")
    public String payPage(@AuthenticationPrincipal UserDetails userDetail, Model model){
        Users users = userService.findById(userDetail.getUsername());
        model.addAttribute("user", users);
        return "/toss/checkout";
    }

    @GetMapping("/success")
    public String success() {
        return "/toss/success";
    }

    @GetMapping("/fail")
    public String fail() {
        return "/toss/fail";
    }

    @RequestMapping(value = "/confirm")
    public ResponseEntity<JSONObject> confirmPayment(@RequestBody String jsonBody) throws Exception {

        JSONParser parser = new JSONParser();
        String orderId;
        String amount;
        String paymentKey;
        try {
            // 클라이언트에서 받은 JSON 요청 바디입니다.
            JSONObject requestData = (JSONObject) parser.parse(jsonBody);
            paymentKey = (String) requestData.get("paymentKey");
            orderId = (String) requestData.get("orderId");
            amount = (String) requestData.get("amount");
        } catch (ParseException e) {
            throw new RuntimeException(e);
        }
        ;
        JSONObject obj = new JSONObject();
        obj.put("orderId", orderId);
        obj.put("amount", amount);
        obj.put("paymentKey", paymentKey);

        // 토스페이먼츠 API는 시크릿 키를 사용자 ID로 사용하고, 비밀번호는 사용하지 않습니다.
        // 비밀번호가 없다는 것을 알리기 위해 시크릿 키 뒤에 콜론을 추가합니다.
        String widgetSecretKey = "토스 결제 개발 연동 시크릿 키";
        Base64.Encoder encoder = Base64.getEncoder();
        byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes(StandardCharsets.UTF_8));
        String authorizations = "Basic " + new String(encodedBytes);

        // 결제를 승인하면 결제수단에서 금액이 차감돼요.
        URL url = new URL("https://api.tosspayments.com/v1/payments/confirm");
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestProperty("Authorization", authorizations);
        connection.setRequestProperty("Content-Type", "application/json");
        connection.setRequestMethod("POST");
        connection.setDoOutput(true);

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

        int code = connection.getResponseCode();
        boolean isSuccess = code == 200;

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

        // 결제 성공 및 실패 비즈니스 로직을 구현하세요.
        Reader reader = new InputStreamReader(responseStream, StandardCharsets.UTF_8);
        JSONObject jsonObject = (JSONObject) parser.parse(reader);
        responseStream.close();

        return ResponseEntity.status(code).body(jsonObject);
    }
}

컨트롤러 이름을 보면 알겠지만 결제위젯에서 사용하던 컨트롤러 코드를 거의 그대로 가져왔다고 보면 된다. 추가한 것은 api핸들코드 위에있는 사이트 이동 코드 정도이다. 결국 위젯이나 결제창이나 사용하는 api는 같기 때문에 가능했던 것 같다.

테스트 결과

성공적으로 결제 성공창으로 이동했음을 볼 수 있었다.


테스트 결제 내역에서도 성공적으로 결제가 완료되었음을 볼 수 있다.

api이지만 실제로 휴대폰에 알림도 오며, 이메일로 해당 결제정보들이 수신되서 놀랐다.

결제 기능까지 구현함으로서 이제 쇼핑몰이라는 웹사이트에 있어야할 핵심 기능들은 어느정도 다 만들어진 것 같다. 다음 글부터는 세부적인 기능을 추가해보도록 하자. 예를 들자면 찜하기, 리뷰 기능, 회원등급, 상품 추천, 검색 시 가격순, 리뷰좋은순, 할인율높은순으로 정렬하기 와 같은 기능들을 생각해볼 수 있을 것같다.

profile
모르는 것 정리하기

0개의 댓글