프로젝트/싹쓰리

[프로젝트] 카카오페이 api 스프링에서 사용하는 법

라임온조 2023. 5. 5. 12:52

0. 왜 카카오페이 api를 사용하게 되었나?

현재 진행하고 있는 프로젝트에서 상품을 주문한 후 결제하는 기능이 필요했다. 실제로 결제가 되는 과정을 거치면서, 사용자들이 편하게 결제할 수 있는 방법에는 뭐가 있을까 생각하다 api를 가져다 쓰면 좋겠다는 생각이 들었고, 주위에서 사용빈도가 높은 카카오페이 api를 사용하면 사용자들이 편하게 결제를 진행할 수 있겠다는 생각을 하게 되었다. 그래서 카카오페이 api를 프로젝트에 적용해보고자 하였다.

 

아래는 카카오페이 api를 스프링 및 스프링부트에서 사용하기 위한 방법이다.

 

흐름 정리

프론트엔드가 백엔드에게 결제 정보를 담은 후 api1를 요청한다. 그러면 백엔드는 api1에 대한 처리로 카카오페이 서버에게 결제를 하고 싶다는 요청을 보내고 응답으로 결제를 할 수 있는 화면으로 갈 수 있는 url을 받는다. 그럼 백엔드는 이 내용을 프론트엔드에게 전달한다.

프론트엔드는 api1의 요청으로 받은 응답을 토대로 결제할 수 있는 url로 이동시킨다. 이 화면에서 사용자는 결제를 완료한다. 정상적으로 완료되면 백엔드가 설정해놓은 결제 완료 후 가게 되는 api2로 자동으로 넘어가게 된다. 그러면 백엔드는 api2에 대한 처리로 카카오페이 서버에게 이런 내용으로 결제를 진행했다는 요청을 보내고 응답으로 결제 승인이 되었다는 내용을 받는다. 그럼 백엔드는 이 내용을 프론트엔드에게 전달한다.

 

1. Kakao Developers

이 사이트에서 회원가입을 한 후 애플리케이션을 하나 만든다. 프론트가 리액트네이티브이긴 한데, 일단 웹으로만 등록을 해줬다.

그리고 내 애플리케이션-내가 만든 앱 클릭-앱키에 가 보면 Admin키가 있다. 그걸 복사한다.

 

2. application.properties

## kakaopay
kakao.pay.admin-key=위에서 복사한 adminkey
kakao.pay.ready-url=https://kapi.kakao.com/v1/payment/ready
kakao.pay.approve-url=https://kapi.kakao.com/v1/payment/approve
kakao.pay.cid=TC0ONETIME -> 실제 결제가 아니라 테스트용으로 결제를 하기 위한 설정

 

3. Controller

1) 결제를 요청하기 위한 컨트롤러(readyPayment)

프론트엔드에서 카카오페이로 결제하기 버튼을 눌러서 결제 과정을 진행할 수 있게 할 api를 위한 컨트롤러를 만든다. 예를 들어 readyPayment라고 하자.

프론트엔드는 이 api에 요청을 보낼 때 Order 테이블에 저장할 정보를 담은 내용을 dto에 담아서 보내야 한다. 결제 진행되고 있는 메뉴, 결제를 진행하고 있는 사용자, 상품 이름, 상품 가격 등.

 

2) 결제 성공 후 가게 되는 컨트롤러(afterPayRequest)

readyPayment의 결과로 프론트엔드는 KakaoPayReadyResponseDto를 응답받게 된다. 여기에는 결제를 할 수 있는 곳으로 갈 수 있는 url이 담겨 있고 이 url에서 카카오페이를 이용해 결제를 진행한다. 

결제 진행을 정상적으로 완료한 후 가야 될 url을 스프링 서버가 명시해야 하는데 그 url이 이 컨트롤러이다.

카카오페이 서버는 결제가 정상적으로 완료된 후 가야 될 url에 ptToken을 파라미터로 담아서 보낸다. 그런데 우리는 이 컨트롤러에서 실행할 서비스단에서 해당 결제의 주문 id도 필요해서 해당 id를 payReady 서비스에서 결제가 정상적으로 완료된 후 가야 될 url에 파라미터로 함께 넘겼다.

 

@PostMapping("/kakaopay/ready")
public KakaoPayReadyResponseDto readyPayment(@RequestBody KakaoPayRequestDto kakaoPayRequestDto) {
    return  kakaoPayService.payReady(kakaoPayRequestDto);
}

@GetMapping("/kakaopay/success")
public KakaoPayApproveResponseDto afterPayRequest(@RequestParam("pg_token") String pgToken, @RequestParam("orderId") String orderId){
    return kakaoPayService.payApprove(pgToken, orderId);
}

 

4. Service

1) 결제를 요청하기 위한 서비스(payReady)

프론트엔드가 readyPayment에 요청을 보내면 컨트롤러는 이 처리를 결제를 요청하기 위한 서비스로 넘긴다.

먼저 payReady는 컨트롤러부터 넘겨받은 dto 정보를 Order 테이블에 저장한다. 이때 중요한 것! 무작정 넘겨받은 order를 저장하다가는 A 고객이 메뉴1에 결제 요청해서 결제를 막 진행하고 있는 와중에 B 고객이 메뉴1에 결제를 요청하면 order가 2개가 생기게 된다. 따라서 이를 막기 위해 parReady에 왔을 때 해당 메뉴 id가 order 테이블에 이미 있는지, 즉 해당 메뉴가 주문이 진행중인지를 확인해야 한다. 나는 일단 

if(orderRepository.findByMenuEntityId(kakaoPayRequestDto.getMenuId()).isPresent()){
    return null;
}

이런 형태로 확인해 주었는데.... 이렇게 데이터베이스 ACID를 적절히 관리하기 위한 효과적인 방법이 무엇인지 더 알아봐야 할 것 같다.

그럼 이 서비스단에서는 스프링 서버가 카카오페이 서버에 결제를 진행하고 싶어요라는 요청을 보내게 되고, 그럼 카카오페이 서버는 결제를 진행할 수 있도록 스프링 서버에게 결제id, 결제 진행으로 넘어갈 수 있는 url등을 보내게 되고, 스프링 서버는 프론트엔드에게 이 내용을 전달한다.

payReady는 카카오페이 서버로부터 받은 tid를 위에서 생성한 Order테이블 행에 저장한다. 이는 나중에 결제 진행 과정을 거치고 tid를 사용할 일이 있어서 필요하다.

 

2) 결제 성공 후 가게 되는 서비스(payApprove)

afterPayRequest 컨트롤러에서 비즈니스 로직 처리를 위해 실행하는 서비스. 카카오페이 서버로 완료된 결제에 대한 내용을 담아 요청을 보내고 결제가 완료되었다는 승인에 대한 응답을 받는다. 그리고 이 응답을 컨트롤러에게 보내서 프론트엔드에게 전달한다.

 

public KakaoPayReadyResponseDto payReady(KakaoPayRequestDto kakaoPayRequestDto){
    // orderEntity 생성
    OrderEntity order = saveOrderEntity(kakaoPayRequestDto);

    // 카카오페이 서버로 보내기 위한 준비
    MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>();
    parameters.add("cid", cid);
    parameters.add("partner_order_id", String.valueOf(order.getId()));
    parameters.add("partner_user_id", String.valueOf(kakaoPayRequestDto.getUserId()));
    parameters.add("item_name", kakaoPayRequestDto.getName());
    parameters.add("quantity", "1");
    parameters.add("total_amount", String.valueOf(kakaoPayRequestDto.getPrice()));
    parameters.add("tax_free_amount", "0");
    parameters.add("approval_url", "http://localhost:8080/api/payment/kakaopay/success"+"?orderId="+String.valueOf(order.getId())); // 결제승인시 넘어갈 url
    parameters.add("cancel_url", "http://localhost:8080/api/payment/kakaopay/cancel"); // 결제취소시 넘어갈 url
    parameters.add("fail_url", "http://localhost:8080/api/payment/kakaopay/fail"); // 결제 실패시 넘어갈 url
    HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(parameters, this.getHeaders());

    // 카카오페이 서버로 요청 보내기
    RestTemplate template = new RestTemplate();
    KakaoPayReadyResponseDto readyResponse = template.postForObject(readyUrl, requestEntity, KakaoPayReadyResponseDto.class);

    // tid 저장
    saveTid(order.getId(), readyResponse);


    return readyResponse;
}

public OrderEntity saveOrderEntity(KakaoPayRequestDto kakaoPayRequestDto){
    OrderEntity order = OrderEntity.builder()
            .status("T")
            .menuEntity(menuRepository.findById(kakaoPayRequestDto.getMenuId()).get())
            .userEntity(userRepository.findById(kakaoPayRequestDto.getUserId()).get())
            .build();


    return orderRepository.save(order);
}

public void saveTid(long orderId, KakaoPayReadyResponseDto readyResponse){
    Optional<OrderEntity> order = orderRepository.findById(orderId);
    if(order.isPresent()){
        OrderEntity savedOrder = OrderEntity.builder()
                .id(order.get().getId())
                .tid(readyResponse.getTid())
                .status(order.get().getStatus())
                .menuEntity(order.get().getMenuEntity())
                .userEntity(order.get().getUserEntity())
                .build();

        orderRepository.save(savedOrder);
    }
}

public KakaoPayApproveResponseDto payApprove(String pgToken, String orderId){

    // tid를 찾아와야 함
    OrderEntity order = orderRepository.findById(Long.parseLong(orderId)).get();

    MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
    parameters.add("cid", cid);
    parameters.add("tid", order.getTid());
    parameters.add("partner_order_id", String.valueOf(order.getId()));
    parameters.add("partner_user_id", String.valueOf(order.getUserEntity().getId()));
    parameters.add("pg_token", pgToken);

    HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(parameters, this.getHeaders());

    RestTemplate restTemplate = new RestTemplate();

    KakaoPayApproveResponseDto approveResponse = restTemplate.postForObject(
            approveUrl,
            requestEntity,
            KakaoPayApproveResponseDto.class);

    return approveResponse;

}

// header() 셋팅
private HttpHeaders getHeaders() {
    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "KakaoAK " + admin);
    headers.set("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

    return headers;
}

 

5. Dto

1) 프론트엔드가 readyPayment에게 보내는 Dto

상품 가격, 상품 이름, 메뉴 id, 유저 id 등의 내용을 담고 있다.

 

public class KakaoPayRequestDto {
    private long userId;
    private long menuId;
    private String name;
    private int price;
}

 

2) 결제 요청 후 응답 받는 Dto(KakaoPayReadyResponseDto)

payReady에서 카카오 페이 서버로 결제 요청을 하면 카카오 페이 서버로부터 응답받는 내용을 담고 있는 dto.

 

public class KakaoPayReadyResponseDto {
    private String tid;
    private String next_redirect_mobile_url;
    private String next_redirect_pc_url;
    private String partner_order_id;
}

 

3) 결제 완료 후 응답 받는 Dto(KakaoPayApproceResponseDto)

payApprove에서 카카오페이 서버로 결제 승인 요청을 하면 카카오페이 서버로부터 응답받는 내용을 담고 이는 dto.

 

public class KakaoPayApproveResponseDto {
    private String aid;
    private String tid;
    private String cid;
    private String sid;
    private String partner_order_id;
    private String partner_user_id;
    private String payment_method_type;
    private String item_name;
    private String item_code;
    private int quantity;
    private String created_at;
    private String approved_at;
    private String payload;
    private KakaoPayHistoryResponseDto kakaoPayHistoryResponseDto;
}