Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

토스 결제 코드 : 프론트가 없으면 만들어서 확인하기 본문

스프링

토스 결제 코드 : 프론트가 없으면 만들어서 확인하기

쿠마냥 2024. 9. 5. 23:48

프로젝트 중 토스 결제를 구현하게 되었다.

분명 이전에 구현한 적이 있고 블로그에 관련 글을 적은 적도 있는데, 낯설어서 한참을 헤맸다.

 

[데브 캠프] 6일차 토스 결제 API 이해하기

1. 토스 API는 어떻게 작동하는가? 카카오 로그인을 구현해 본 경험 덕분에 토스 결제도 비교적 수월하게 이해할 수 있었다. 토스 결제 API를 사용한 결제 시스템은 다음과 같은 순서로 작동한다: 1

kmcp.tistory.com

 

토스 결제 흐름은 이전에 적은 글과 같다. 

1) 사용자는 필요한 결제 정보를 입력하고 [결제하기] 버튼을 누른다. 클라이언트는 서버에게 '결제 생성'을 요청한다. 

2) 서버는 '결제 생성' dto를 검증하고 필요한 값들을 추가한다. 이후 데이터베이스에 '임시적으로' 결제 정보를 저장하고 그 결과를 프론트엔드에 반환한다. (이때 충분히 긴 길이의 orderId를 만들어 반환한다.)

3) 클라이언트는 백엔드의 응답을 확인한 후, '토스 페이먼츠 결제창'을 호출한다. (orderId를 함께 보낸다.)

4) 토스는 결제창을 띄워 보여주고 사용자는 결제 절차를 완료한다. 

5) 토스는 결제 성공 여부와 관련 파라미터 (우리 서버에서 받았던 orderId + 토스에서 만든 paymentKey(나중에 결제 취소나 조회를 할 때 사용된다.) + 결제 총 금액 amount)를 successUrl로 리다이렉트한다. 

6) 클라이언트는 해당 정보를 서버에게 전달하고 결제 승인을 요청한다. 

7) 서버는 한번 더 결제 정보를 확인한 뒤 토스에게 '최종 승인'을 요청한다. 

8) 200이 오면 결제가 성공되었다는 메시지를 보내고, 이상이 생기면 결제를 롤백시킨다. 

 

 

유레카에서 조금이나마 html과 java script코드에 대해 배웠으므로, 프론트를 만들어서 토스 결제를 확인해 보려 한다.

위에 적힌 글 순서대로 코드와 함께 살펴보자. https://docs.tosspayments.com/guides/v2/payment-widget/integration 

 

연동하기 | 토스페이먼츠 개발자센터

토스페이먼츠의 간편한 결제 연동 과정을 한눈에 볼 수 있습니다. 각 단계별 설명과 함께 달라지는 UI와 코드를 확인해보세요.

docs.tosspayments.com

 

 

토스 결제 이해하기

1) 사용자는 필요한 결제 정보를 입력하고 [결제하기] 버튼을 누른다. (단순화를 위해 가격은 30000원으로 통일한다.)

다소 민망한 html...

 

<!-- html 1 -->
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>결제 테스트</title>
    <!-- 토스페이먼츠 결제창 SDK 추가 -->
    <script src="https://js.tosspayments.com/v1/payment"></script>
</head>
<body>
<section>
    <!-- 구매하기 버튼 만들기 -->
    <div>
        <span>상품 가격: </span>
        <span>30,000원</span>
    </div>
    <button id="payment-button">결제하기</button>
</section>
  • '결제하기' 버튼을 누르면 토스 결제창이 열려야 하기 때문에 head에 아래와 같이 추가한다.  <script src="https://js.tosspayments.com/v1/payment"></script>
  • 결제하기 버튼의 id는 payment-button으로 설정한다. 

 

<!-- html 2. 위의 html 코드에서 계속 이어짐 -->
<script>
    var clientKey = 'test_ck_토스에서 받은 테스트키를 입력';
    var tossPayments = TossPayments(clientKey);

    var button = document.getElementById('payment-button');

    button.addEventListener('click', function () {
        // 서버에 주문 생성 요청을 보냄
        fetch('http://localhost:8080/members/orders', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                amount: "30000",
                userName: "이름",
                address: {
                    roadNameAddress: "서울시 강남구",
                    addressDetails: "강남대로",
                    zipCode: "12345"
                },
                brandsForOrderResponse: [
                    {
                        brandId: 1,
                        brandName: "브랜드명2",
                        itemResponse: [  
                            {
                                itemId: 1,
                                itemName: "Test Item",
                                itemPrice: 10000,
                                itemImage: "http://example.com/images/test_item.png",
                                itemOptionId: 2,
                                itemOptionName: "Option 2",
                                itemOptionPrice: 200,
                                count: 1
                            }
                        ]
                    }
                ]
            })
        })
  • 토스는 두 개의 키로 각각의 사용자(우리가 만들고 있는 웹사이트)를 식별한다. 바로 클라이언트키와 시크릿키. 이들은 테스트용과 라이브용으로 나뉘어져 있으며, 자세한 내용은 여기에서 확인할 수 있다. -> API 키 | 토스페이먼츠 개발자센터
 

API 키 | 토스페이먼츠 개발자센터

토스페이먼츠 클라이언트 키 및 시크릿 키를 발급받고 사용하는 방법을 알아봅니다. 클라이언트 키는 SDK를 초기화할 때 사용하고 시크릿 키는 API를 호출할 때 사용합니다.

docs.tosspayments.com

  • var clientKey = 'test_ck_토스에서 받은 테스트키를 입력';
    이 부분에 발급받은 토스키를 입력한다. 
  • var tossPayments = TossPayments(clientKey);
    TossPayments는 위의 토스 sdk에서 제공하는 함수다. clientKey를 인자로 받아 토스와 상호작용할 수 있는 객체인 (여기서는) tossPayments를 반환한다. 이 객체에서 제공하는 메서드인 .requestPayments()를 통해 결제 요청이 가능한 것! 해당 코드는 밑에 나온다.
  • var button = document.getElementById('payment-button');
    결제하기 버튼을 눌렀을 때 반환되는 객체. 해당 객체에 이벤트 리스너를 받아 'click'되면 함수를 수행하게 할 수 있다. 
  • fetch('http://localhost:8080/members/orders', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    // 서버에 보낼 json 데이터...
                })
    fetch를 통해 백엔드 서버 "/members/orders"에 요청을 보낸다. 결제 정보를 임시로 만들어야 하므로 body에 많은 값이 들어간다. 이때 서버에서 받는 변수명과 프론트에서 보내는 변수명을 일치시키는 것 잊지 말기!

 

2) 서버는 '결제 요청' dto를 검증하고 필요한 값들을 추가한다. 이후 데이터베이스에 '임시적으로' 결제 정보를 저장하고 그 결과를 프론트엔드에 반환한다. (이때 충분히 긴 길이의 orderId를 만들어 반환한다.)

[OrderController]

@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<CommonResponse<String>> createOrder(@RequestBody CreateOrderDto createOrderDto) throws Exception {
        String orderId = orderService.createOrder(createOrderDto);
        return ResponseEntity.ok().body(CommonResponse.success(orderId));
    }
}

 

 

[OrderService]

@Service
@Transactional
@RequiredArgsConstructor
public class OrderService {

    private final OrderManager orderManager;
    private final OrderItemOptionManager orderItemOptionManager;

    public String createOrder(CreateOrderDto createOrderDto) {
        Order order = orderManager.createOrder(createOrderDto.userName());
        orderItemOptionManager.createOrderItemOption(order, createOrderDto);
        String tossOrderId = UUID.randomUUID().toString();
        order.setTossOrderId(tossOrderId);
        return tossOrderId;
    }
}
  • 자세한 구현은 각각의 Manager 클래스에서 구현하였다. 
  • orderManager.createOrder()
    주어진 정보로 Order 객체를 만든 후 repository에 저장한다. Order에는 주문자, 주문일시 등 아이템을 제외한 주문 정보가 담긴다. 
  • orderItemOptionManager.createOrderItemOption()
    주문 아이템의 이름, 옵션, 수량 등 주문 아이템에 대한 정보를 저장한다. 
  • 이후 UUID를 만든 후 tossOrderId 필드에 set한 뒤, 해당 id를 반환한다. 

 

3) 프론트는 백엔드의 응답이 200임을 확인한 후, '토스 페이먼츠 결제창'을 호출한다. 이때 백엔드에서 반환한 orderId를 함께 보낸다. 

<!-- html 3. 위의 html 코드에서 계속 이어짐 -->
.then(response => {
                if (!response.ok) {
                    throw new Error('주문 생성 실패');
                }
                return response.json();
            })
            .then(data => {
                var orderId = data.data; // 서버로부터 생성된 orderId를 받음
                // 결제창 띄우기
                tossPayments.requestPayment('카드', {
                    amount: 30000,
                    orderId: orderId, 
                    orderName: '테스트 상품 구매',
                    customerName: '이름',
                    customerEmail: 'customer@example.com',
                    successUrl: 'http://localhost:8080/api/v1/payments/toss/success', // 결제 성공 시 리다이렉트될 URL
                    failUrl: 'http://localhost:8080/api/v1/payments/toss/fail' // 결제 실패 시 리다이렉트될 URL
                });
            })
            .catch(error => {
                console.error('Error:', error);
                alert('주문 생성에 실패했습니다. 다시 시도해 주세요.');
            });
    });
</script>
</body>
</html>
  • requestPayment()를 호출하면서 파라미터로 결제에 필요한 정보들을 넣는다. 여기서는 결제가 되는지만 확인하는 용도기 때문에, 하드코딩으로 지정해 버렸다. 
  • 토스에서는 requestPayment()를 호출하기 전 orderId와 amount를 서버에 임시로 저장해 두기를 권장한다. 

 

4) 토스는 결제창을 띄워 보여주고 사용자는 결제 절차를 완료한다. 

 

 

 

민망한 html 2... 결제 성공시 나오는 html이다.

 

<!-- success.html 1 -->
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>결제 성공</title>
</head>
<body>
<h1>결제가 성공적으로 처리되었습니다.</h1>
<p>결제 확인 중입니다...</p>
<script>

 

 

5) 토스는 결제 성공 여부와 관련 파라미터 (우리 서버에서 받았던 orderId + 토스에서 만든 paymentKey(나중에 결제 취소나 조회를 할 때 사용된다.) + 결제 총 금액 amount)를 successUrl로 리다이렉트한다. 

@RestController
@RequestMapping("/api/v1/payments/toss")
public class PaymentResultController {
    @GetMapping("/success")
    public ResponseEntity<Void> handlePaymentSuccess(HttpServletResponse response, @RequestParam Integer amount, @RequestParam String paymentKey, @RequestParam String orderId) throws IOException {

        response.sendRedirect("/success.html?amount="+ amount + "&paymentKey=" + paymentKey + "&orderId=" + orderId); // 클라이언트의 success.html 파일로 리디렉트
        return ResponseEntity.ok().build();
    }
}
  • successUrl로 리다이렉트 됐을 시, success.html을 호출하도록 한다. 
  • 이때 토스에게 받은 정보를 프론트로 다시 전달하는데, 사실 이 부분은 불필요한 부분이지만.... 토스에서는 클라이언트가 구현해야 할 html을 총 세 개 (결제 html + 성공 시 보여줄 html + 실패 시 보여줄 html) 로 구성하고 있기 때문에 이렇게 구성해 보았다. 

 

6) 프론트엔드는 해당 정보를 서버에게 전달하고 결제 승인을 요청한다. 

<!-- success.html 2 -->
<script>
    // URL에서 쿼리 파라미터를 읽어오기
    const urlParams = new URLSearchParams(window.location.search);
    const paymentKey = urlParams.get('paymentKey');
    const orderId = urlParams.get('orderId');
    const amount = urlParams.get('amount');

    console.log('Current URL:', window.location.href);
    console.log('Extracted paymentKey:', paymentKey);
    console.log('Extracted orderId:', orderId);
    console.log('Extracted amount:', amount);

    if (paymentKey && orderId && amount) {
        // 추출한 파라미터로 dto를 만들어서 handlePaymentSuccess에 넘겨준다
        handlePaymentSuccess(paymentKey, orderId, amount);
    } else {
        alert('결제 정보가 부족합니다. 결제에 실패했을 수 있습니다.');
    }
    function handlePaymentSuccess(paymentKey, orderId, amount) {
        fetch('http://localhost:8080/members/orders/confirm', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({
                paymentKey: paymentKey,
                orderId: orderId,
                amount: amount
            })
        })
            .then(response => response.json())
            .then(data => {
                console.log(data)
                if (data['isPaymentSuccess']) {
                    alert('결제가 성공적으로 완료되었습니다.');
                } else {
                    alert('결제 승인 중 오류가 발생했습니다.');
                }
            })
            .catch(error => {
                console.error('Error:', error);
                alert('결제 승인 요청에 실패했습니다.');
            });
    }

</script>
</body>
</html>

 

7) 백엔드는 한번 더 결제 정보를 확인한 뒤 토스에게 '최종 승인'을 요청한다. 

[PaymentController]

@RestController
@RequiredArgsConstructor
@RequestMapping("/members/orders")
public class PaymentController {

    private final PaymentService paymentService;

    @Value("${payment.toss.test_secret_api_key}")
    private String widgetSecretKey;

    @PostMapping("/confirm")
    public ResponseEntity<PaymentResultDto> confirmPayment(@RequestBody PaymentConfirmDto paymentConfirmDto, @AuthenticationPrincipal Member member) throws Exception {
        String paymentKey = paymentConfirmDto.paymentKey();
        String tossOrderId = paymentConfirmDto.orderId();
        Integer amount = paymentConfirmDto.amount();

        Order order = paymentService.getOrderByTossOrderId(tossOrderId);
        Long orderId = order.getId();

        Integer totalPrice = paymentService.getTotalPrice(orderId);

        paymentService.prepareOrder(orderId, tossOrderId, paymentKey);

        JSONObject obj = new JSONObject();
        obj.put("orderId", tossOrderId);
        obj.put("amount", amount);
        obj.put("paymentKey", paymentKey);

        Base64.Encoder encoder = Base64.getEncoder();
        byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes("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);

        JSONParser parser = new JSONParser () ;
        JSONObject jsonObject = (JSONObject) parser.parse(reader);
        responseStream.close();

        String status = (String) jsonObject.get("status");
        // 결제가 성공한 경우
        if (isSuccess && status.equalsIgnoreCase("DONE")) {
            try {
                paymentService.completeOrder(orderId);
            } catch (Exception e) {
                String cancelUrlString = "https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel";
                URL cancelUrl = new URL(cancelUrlString);
                HttpURLConnection cancelConnection = (HttpURLConnection) cancelUrl.openConnection();
                cancelConnection.setRequestProperty("Authorization", authorizations);
                cancelConnection.setRequestProperty("Content-Type", "application/json");
                cancelConnection.setRequestMethod("POST");
                cancelConnection.setDoOutput(true);

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

                PaymentResultDto result = PaymentResultDto.withdraw(false, "결제 완료 처리중 에러 발생하여 취소 api 호출에 완료했습니다.");

                return ResponseEntity.ok(result);
            }
            // 토스 결제 승인 api 호출 성공 && 우리쪽 결제 완료처리 성공
            PaymentResultDto result = PaymentResultDto.success(true, "결제 성공!");
            return ResponseEntity.ok(result);
        }

        // 결제가 성공하지 못한경우 주문 상태를 다시 대기로 되돌리기
        paymentService.undoOrder(orderId);
        PaymentResultDto result = PaymentResultDto.fail(false, "결제 승인 처리가 정상적으로 완료되지 않았습니다. \nStatus : " + (String) jsonObject.get("status"));
        return ResponseEntity.status(code).body(result);
    }
}
 

연동하기 | 토스페이먼츠 개발자센터

토스페이먼츠의 간편한 결제 연동 과정을 한눈에 볼 수 있습니다. 각 단계별 설명과 함께 달라지는 UI와 코드를 확인해보세요.

docs.tosspayments.com

 

 

8) 200이 오면 결제가 성공되었다는 메시지를, 이상이 생기면 결제를 롤백시킨다. 

 

  • url을 확인해 보면 amount와 paymentKey, orderId가 제대로 넘어왔으며, 백엔드 로직 수행 후 200 응답을 받은 후 수행되는 alert("결제가 성공적으로 완료되었습니다.") 메시지가 뜬 것을 확인할 수 있다. 

 

 

서버에서만 토스를 구현할 수 있을 텐데, 감이 오지 않기도 하고... 포스트맨으로 테스트할 방법이 도저히 떠오르지 않았다. 구글링해 봐도 적당한 예시가 없어 일단 서버사이드 렌더링을 구현했는데, 이후 포스트맨으로 테스트가 가능하도록 수정할 예정이다. 이 과정에서 프론트 코드를 만든 것이 많은 도움이 되었다.

 

또한 결제위젯과 브랜드페이, api, sdk 와 같은 용어들이 매우 헷갈렸기 때문에, 아래에 간략히 정리하려 한다. 

  1. 결제위젯 : 카드, 간편 결제(네이버 페이, 카카오 페이, 토스 페이 등), 은행 계좌이체 등 다양한 옵션을 제공하는 UI 컴포넌트. html의 <script> 태그에 sdk를 설치하면 손쉽게 사용이 가능하다. 
  2. 브랜드페이 : 토스에서 제공하는 페이. 당연히 토스 페이만 가능하다. 
  3. api : 토스 결제 api를 쓴다는 말은 주로 서버쪽, 백엔드에서 사용한다. Application Programming Interface의 준말로, 어플리케이션들이 서로 연결될 수 있도록 하는 프로토콜과 도구 세트라고 보면 된다. 
  4. sdk: Software Development Kit 또한 개발을 위한 도구인데, 주로 클라이언트쪽에서 사용한다. 얼굴이라고 봐도 되려나!