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

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

카테고리 없음

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

쿠마냥 2024. 3. 27. 14:38

1. 토스 API는 어떻게 작동하는가?
카카오 로그인을 구현해 본 경험 덕분에 토스 결제도 비교적 수월하게 이해할 수 있었다. 

 

출처: https://docs.tosspayments.com/guides/payment-widget/integration?backend=java


토스 결제 API를 사용한 결제 시스템은 다음과 같은 순서로 작동한다:

1) 사용자는 필요한 결제 정보를 입력하고, [결제하기] 버튼을 클릭하여 백엔드에 결제 요청을 한다.

 

2) 백엔드는 받은 요청 객체를 검증하고 필요한 값들을 추가한 후, 데이터베이스에 결제 정보를 저장하고 그 결과를 프론트엔드로 반환한다.

    @GetMapping("/checkout")
    public ResponseEntity<OrderInfoDto> getCheckoutData(@RequestParam Long orderId) throws Exception {
        OrderInfoDto orderInfo = paymentFacade.getOrderInfo(orderId);
        return ResponseEntity.ok(orderInfo);
    }

 

3) 프론트엔드는 백엔드로부터 받은 결제 정보를 바탕으로 tossPayments.requestPayment('카드', {결제 정보 파라미터}) 함수를 호출하여 토스페이먼츠 결제창을 사용자에게 보여준다.

 

4) 사용자는 토스페이먼츠 결제창에서 결제 절차를 완료한다.

 

5) 결제 완료 후, 토스페이먼츠는 결제 성공 여부와 관련 파라미터를 프론트엔드의 콜백 주소(successUrl)로 리다이렉트한다.

 

6) 프론트엔드는 토스에게 받은 결제정보를 서버에게 전달한다.

 

7) 백엔드는 토스페이먼츠에 최종 결제 승인 요청을 보낸다. 실패한 경우에는 실패 정보를 프론트엔드로 반환하여 사용자에게 알린다.

 

* 데브 캠프 예시 코드와 달리, 메서드의 역할을 분리하여 구현해 보았다. 

public ResponseEntity<PaymentResultDto> confirmPayment(@RequestBody String jsonBody, @AuthenticationPrincipal User user) throws Exception {
        PaymentResultDto result = new PaymentResultDto();

        // JSON 요청 파싱
        PaymentRequestDto requestDto = parsePaymentRequest(jsonBody);

        // 주문 정보 확인
        validateOrder(requestDto);

        // 결제 승인 API 호출
        PaymentConfirmationDto confirmationDto = callPaymentConfirmationApi(requestDto);

        // 결제 상태에 따라 처리
        if (confirmationDto.isSuccessful()) {
            // 결제 완료 처리
            completePayment(requestDto, user);

            result.setPaymentSuccess(true);
            result.setMessage("결제 성공!");
            return ResponseEntity.ok(result);
        } else {
            // 결제 실패 시 주문 상태 복구
            paymentFacade.undoOrder(Long.parseLong(requestDto.getOrderId()));
            result.setPaymentSuccess(false);
            result.setMessage("결제 승인 처리가 정상적으로 완료되지 않았습니다. \nStatus : " + confirmationDto.getStatus());
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
        }
    }

 

private PaymentRequestDto parsePaymentRequest(String jsonBody) {
        try {
            JSONParser parser = new JSONParser();
            JSONObject requestData = (JSONObject) parser.parse(jsonBody);
            String paymentKey = (String) requestData.get("paymentKey");
            String orderId = (String) requestData.get("orderId");
            String amount = (String) requestData.get("amount");

            return new PaymentRequestDto(paymentKey, orderId, amount);
        } catch (ParseException e) {
            throw new RuntimeException(e);
        }
    }

 

private void validateOrder(PaymentRequestDto requestDto) throws Exception {
        OrderInfoDto orderInfo = paymentFacade.getOrderInfo(Long.parseLong(requestDto.getOrderId()));
        if (Integer.parseInt(requestDto.getAmount()) != orderInfo.getTotalPrice()) {
            throw new Exception("주문 정보가 상이합니다.");
        }
        paymentFacade.prepareOrder(Long.parseLong(requestDto.getOrderId()));
    }

 

private PaymentConfirmationDto callPaymentConfirmationApi(PaymentRequestDto requestDto) throws Exception {
        // 토스에서 발급받은 위젯 시크릿 키
        String widgetSecretKey = "test_sk_4yKeq5bgrpPdRYpyg1zJrGX0lzW6";
        // 인코딩 후 string 타입 객체 authorizations에 담기
        Base64.Encoder encoder = Base64.getEncoder();
        byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes("UTF-8"));
        String authorizations = "Basic " + new String(encodedBytes, 0, encodedBytes.length);

        // Json으로 결제 정보 구성
        JSONObject obj = new JSONObject();
        obj.put("orderId", requestDto.getOrderId());
        obj.put("amount", requestDto.getAmount());
        obj.put("paymentKey", requestDto.getPaymentKey());

        // 토스 승인 url에 위에서 만든 authorizations 전송. http 메서드와 전송할 데이터 설정 후 전송
        URL url = new URL("https://api.tosspayments.com/v1/payments/confirm");
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        setConnectionProperties(connection, authorizations);
        writeToConnection(connection, obj.toString().getBytes("UTF-8"));

        return parsePaymentConfirmationResponse(connection);
    }

 

private void completePayment(PaymentRequestDto requestDto, User user) {
        try {
            paymentFacade.completeOrder(Long.parseLong(requestDto.getOrderId()), user);
        } catch (Exception e) {
            // 우리쪽 결제 완료처리에 에러가 발생한 경우 토스 취소 api를 호출.
            cancelPayment(requestDto.getPaymentKey());
            throw new RuntimeException("결제 완료 처리중 에러 발생", e);
        }
    }

 

 


 

 

2. @Temporal 어노테이션

- JPA에서 날짜와 시간을 다루는 필드에 적용되어, 데이터베이스에 어떻게 저장될지 형태를 정한다. 

 - java.util.Date나 java.util.Calendar 타입의 필드에 사용되며, DATE(날짜만), TIME(시간만), TIMESTAMP(둘 다, ex - 2023-03-25 11:30:00) 중 하나로 저장 타입을 지정한다.

- Java 8 이후, java.time 패키지가 도입됨. LocalDate, LocalTime, LocalDateTime, ZonedDateTime 같은 클래스가 생기면서 @Temporal 애노테이션의 필요성이 줄었으나, 이전 버전 호환성을 위해 여전히 사용됨.

- 날짜와 시간을 저장하는 컬럼 위에 달아 사용. (예시 참조)

 

@Entity
@Getter
public class IssuedCoupon extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;

    @OneToOne
    private Order usedOrder;

    @Column(columnDefinition = "boolean default false")
    private boolean isValid;

    @Column(nullable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private Date validFrom;

    @Column(nullable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private Date validUntil;

    @Column(columnDefinition = "boolean default false")
    @Setter
    private boolean isUsed;

    @Column(nullable = true)
    @Temporal(TemporalType.TIMESTAMP)
    private Date usedAt;

    public void use() {
        this.isUsed = true;
        this.isValid = false;
        this.usedAt = new Date();
    }

}