카카오페이 단건결제 시 결제 승인단계에서 tid값 null 문제

다음과 같이 작성할 때 오류가 생긴 이유/원인에 대한 것과 해결방안 문의드립니다.
package com.goldensnitch.qudditch.service;

import com.goldensnitch.qudditch.dto.CustomerOrder;
import com.goldensnitch.qudditch.dto.payment.PaymentResponse;
import com.goldensnitch.qudditch.mapper.CustomerOrderProductMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;

// 카카오계정
// 카카오 페이( Kakao pay) 기능구현
@Service
public class PaymentService {
private final RestTemplate restTemplate;

@Autowired
private CustomerOrderProductMapper customerOrderProductMapper;

// 카카오페이 결제 요청
@Value("${kakao.pay.ready.url}") private String kakaoPayReadyUrl;
// 결제 승인
@Value("${kakao.pay.approve.url}") private String kakaoPayApproveUrl;
// 결제 취소
@Value("${kakao.pay.cancel.url}") private String kakaoPayCancelUrl;
// 카카오페이 API 사용을 위한 인증 키
@Value("${kakao.pay.authorization}") private String kakaoPayAuthorization;
// 가맹점 코드
@Value("${kakao.pay.cid}") private String cid;

// RestTemplate 주입을 통한 HTTP 클라이언트 초기화
@Autowired
public PaymentService(RestTemplate restTemplate) {
    this.restTemplate = restTemplate;
}

// 결제 준비를 시작하고 사용자를 결제 페이지로 리디렉션하는 URL을 반환하는 메소드
public String initiatePayment(String cid, String partnerOrderId, String partnerUserId,
                              String itemName, Integer quantity, Integer totalAmount,
                              Integer taxFreeAmount, String approvalUrl, String cancelUrl,
                              String failUrl) {
    HttpHeaders headers = new HttpHeaders();
    // "Authorization" 헤더에 카카오페이 인증 키 추가
    headers.add("Authorization", "KakaoAK " + kakaoPayAuthorization);
    // 요청 본문의 "Content-Type"을 "application/x-www-form-urlencoded"로 설정

    // headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    // PaymentRequest 객체 대신 MultiValueMap을 사용하여 요청 파라미터를 설정
    MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
    map.add("cid", cid);
    map.add("partner_order_id", partnerOrderId);
    map.add("partner_user_id", partnerUserId);
    map.add("item_name", itemName);
    map.add("quantity", quantity.toString());
    map.add("total_amount", totalAmount.toString());
    map.add("tax_free_amount", taxFreeAmount.toString());
    map.add("approval_url", approvalUrl);
    map.add("cancel_url", cancelUrl);
    map.add("fail_url", failUrl);

    HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(map, headers);

    try {
        ResponseEntity<PaymentResponse> responseEntity = restTemplate.exchange(
                kakaoPayReadyUrl, HttpMethod.POST, entity, PaymentResponse.class);

        PaymentResponse paymentResponse = responseEntity.getBody();
        if (paymentResponse != null) {
            return paymentResponse.getNext_redirect_pc_url();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return "Error";
}

public String approvePayment(String pgToken, String tid, String partnerOrderId, String partnerUserId) {
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "KakaoAK " + kakaoPayAuthorization);
    headers.setContentType(MediaType.APPLICATION_JSON);

    // JSON 형식으로 요청 바디 구성
    Map<String, Object> requestBody = new HashMap<>();
    requestBody.put("cid", cid);
    requestBody.put("tid", tid);
    requestBody.put("pg_token", pgToken);
    requestBody.put("partner_order_id", partnerOrderId);
    requestBody.put("partner_user_id", partnerUserId);

    HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);

    try {
        ResponseEntity<PaymentResponse> responseEntity = restTemplate.exchange(
                kakaoPayApproveUrl, HttpMethod.POST, entity, PaymentResponse.class);

        PaymentResponse paymentResponse = responseEntity.getBody();
        if (paymentResponse != null) {
            return "Payment approved successfully.";
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return "Error during payment approval.";
}

private void updateOrderStatus(Integer orderId, String tid) {
    // Placeholder method. Implement the logic to update the order's status or save the transaction ID (`tid`) to the order in your database.
    CustomerOrder order = customerOrderProductMapper.findById(orderId);
    if (order != null) {
        order.setTid(tid);
        customerOrderProductMapper.update(order); // Assuming an `update` method exists to update the order
    }
}

// 결제 취소 메서드
public PaymentResponse cancelPayment(String tid, String cancelAmount, String cancelTaxFreeAmount) {
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "KakaoAK " + kakaoPayAuthorization);
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
    parameters.add("cid", cid); // 가맹점 코드
    parameters.add("tid", tid); // 결제 고유 번호
    parameters.add("cancel_amount", cancelAmount); // 취소 금액
    parameters.add("cancel_tax_free_amount", cancelTaxFreeAmount); // 비과세 금액

    HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(parameters, headers);

    ResponseEntity<PaymentResponse> response = restTemplate.exchange(
            kakaoPayCancelUrl, HttpMethod.POST, entity, PaymentResponse.class);

    return response.getBody();
}

}

package com.goldensnitch.qudditch.controller;

import com.goldensnitch.qudditch.dto.payment.PaymentRequest;
import com.goldensnitch.qudditch.dto.payment.PaymentResponse;
import com.goldensnitch.qudditch.service.PaymentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(“api/payment”)
public class PaymentController {

@Autowired
private PaymentService paymentService;

@Autowired
public PaymentController(PaymentService paymentService) {
    this.paymentService = paymentService;
}

@PostMapping("/initiate")
public ResponseEntity<?> initiatePayment(@RequestBody PaymentRequest paymentRequest) {
    try {
        // 사용자로부터 받은 정보와 주문 ID를 기반으로 결제 초기화
        paymentRequest.setCid("TC0ONETIME");

        String redirectUrl = paymentService.initiatePayment(
                paymentRequest.getCid(),
                paymentRequest.getPartner_order_id(),
                paymentRequest.getPartner_user_id(),
                paymentRequest.getItem_name(),
                paymentRequest.getQuantity(),
                paymentRequest.getTotal_amount(),
                paymentRequest.getTax_free_amount(),
                "http://localhost:8080/api/payment/approval", // Updated to include orderId in the approvalUrl
                paymentRequest.getCancel_url(),
                paymentRequest.getFail_url()
        );

        if (!"Error".equals(redirectUrl)) {
            return ResponseEntity.ok().body(redirectUrl);
        } else {
            return ResponseEntity.badRequest().body("Failed to initiate payment");
        }
    } catch (Exception e) {
        return ResponseEntity.internalServerError().body("Error initiating payment: " + e.getMessage());
    }
}

@GetMapping("/approval")
public ResponseEntity<?> approvePayment(@RequestParam("pg_token") String pgToken,
                                        @RequestBody String tid,
                                        @RequestBody String partnerOrderId,
                                        @RequestBody String partnerUserId) {
    try {
        String result = paymentService.approvePayment(pgToken, tid, partnerOrderId, partnerUserId);
        if ("Payment approved successfully.".equals(result)) {
            return ResponseEntity.ok().body("Payment approved successfully.");
        } else {
            return ResponseEntity.badRequest().body(result);
        }
    } catch (Exception e) {
        return ResponseEntity.badRequest().body("Payment approval failed: " + e.getMessage());
    }
}

@PostMapping("/cancel")
public ResponseEntity<?> cancelPayment(@RequestParam String tid,
                                       @RequestParam String cancelAmount,
                                       @RequestParam String cancelTaxFreeAmount) {
    try {
        PaymentResponse paymentResponse = paymentService.cancelPayment(tid, cancelAmount, cancelTaxFreeAmount);
        if (paymentResponse != null) {
            // 결제 취소가 성공적으로 이루어졌을 경우의 처리 로직
            return ResponseEntity.ok().body(paymentResponse);
        } else {
            // 결제 취소 요청이 실패했을 경우의 처리 로직
            return ResponseEntity.badRequest().body("Failed to cancel payment");
        }
    } catch (Exception e) {
        // 예외 처리 로직
        return ResponseEntity.internalServerError().body("Error canceling payment: " + e.getMessage());
    }
}

}

[오류]
GET “/api/payment/approval?pg_token=d5c100e2bc10d0ac23b4”, parameters={masked}
2024-03-19T11:34:27.531+09:00 DEBUG 18856 — [nio-8080-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.goldensnitch.qudditch.controller.PaymentController#approvePayment(String, String, String, String)
2024-03-19T11:34:27.562+09:00 DEBUG 18856 — [nio-8080-exec-3] m.m.a.RequestResponseBodyMethodProcessor : Read “application/octet-stream” to []
2024-03-19T11:34:27.566+09:00 WARN 18856 — [nio-8080-exec-3] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing: public org.springframework.http.ResponseEntity<?> com.goldensnitch.qudditch.controller.PaymentController.approvePayment(java.lang.String,java.lang.String,java.lang.String,java.lang.String)]
2024-03-19T11:34:27.567+09:00 DEBUG 18856 — [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet : Completed 400 BAD_REQUEST
2024-03-19T11:34:27.575+09:00 DEBUG 18856 — [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet : “ERROR” dispatch for GET “/error?pg_token=d5c100e2bc10d0ac23b4”, parameters={masked}
2024-03-19T11:34:27.575+09:00 DEBUG 18856 — [nio-8080-exec-3] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#errorHtml(HttpServletRequest, HttpServletResponse)
2024-03-19T11:34:27.607+09:00 DEBUG 18856 — [nio-8080-exec-3] o.s.w.s.v.ContentNegotiatingViewResolver : Selected ‘text/html’ given [text/html, text/html;q=0.8]
2024-03-19T11:34:27.608+09:00 DEBUG 18856 — [nio-8080-exec-3] o.s.web.servlet.DispatcherServlet : Exiting from “ERROR” dispatch, status 400

안녕하세요. 카카오페이 입니다.

전달주신 코드/에러만으로는 정확한 답변을 드리기는 어렵습니다.
다만 내용을 보았을때, 사용자 인증완료후 응답받는 redirect url인 approva_url에 대해 pg_token은 정상적으로 수신하는것으로 확인됩니다.
하지만 redirect url에는 tid, partner_order_id, partner_user_id 등은 수신되지 않습니다.
ready때 요청하여 응답받은 정보를 바탕으로 매핑하여 처리해주셔야합니다.

가장 많이 사용하시는 로직은,
Ready시 partner_order_id, partner_user_id 를 요청하게되고, tid를 응답받고 같이 저장하신기 바랍니다.

  • ready시 approval_url에 주문번호를 쿼리스트링으로 같이 넣어주시면, redirect 시에도 주문번호는 응답받게됩니다.
    이후 redirect 시 pg_token과 수신된 주문번호를 통해 tid를 확인하여 승인(approve)요청해주시면 됩니다.

감사합니다.