스프링부트에서 카카오 페이 API 연동하기

ggujunhee·2022년 2월 27일
3

파이널프로젝트

목록 보기
3/3

자꾸 미루고 있었는데 면접준비도 할겸 오늘은 포스팅을 하기로 마음먹었습니다...ㅎㅎ
왜, 어떻게 구현하는지 상세하게 적었으니 빠르게 구현하실분은 그냥 코드부분만 보시면 됩니다.

1. 카카오페이 API 사전설명.

먼저 카카오페이 연동은 request를 2번 보내서 결제요청과 결제승인후 결재내역정보를 받아와야합니다.
그리고 카카오페이와 카카오로그인은 연동하는 방법이 좀 다른데요.
카카오로그인은 ajax로 그냥 클라이언트 js에서 연동시키면 됐는데,
카카오페이는 controller로 값을 보내 클라이언트가 아닌 서버단에서 통신을 해야합니다.
그 이유는 바로 cors정책때문인데요.

cors(Cross-Origin Resource Sharing)

외부서버에 ajax요청을 했을 때 cors라는 Cross-Origin Resource Sharing 정책 위반 이슈가 발생한다.
웹페이지에서 ajax를 동일서버에 요청하는 건 괜찮지만 외부서버에 요청하는 순간
동일 origin이 아니기때문에 이 요청을 거부하는 보안관련 이슈가 생깁니다.

⇒ Q. 카카오로그인은 웹페이지(jsp)에서 외부서버로 ajax요청을 해도 왜 cors 이슈(ajax요청거부)가 나지 않는 걸까요?
cors는 열어놓을 수도 있어서 카카오로그인 api는 웹페이지에서 바로 요청해도 오류가 발생하지 않습니다.
다만, 돈과 관련된 api는 보안이슈로 인해 cors를 닫아놓기때문에 우회해서 요청해야합니다.

그럼 cors를 어떻게 우회할까?

웹페이지에서 ajax요청을 우리 서버(동일서버)에 보내고 그 안에있는 contrller 혹은 service에서 외부서버에 요청을 보내서 우회하면 cors에 걸리지 않습니다. 이럴 때 사용하는 것이 HttpUrlConnection객체 / spring을 사용한다면 RestTemplate 객체입니다.

둘 중에 RestTemplate이 좀더 구현하기 편한데 응답으로 받은 json데이터를 바로 자바객체로 변환해주기때문입니다. (응답을 바로 object로 맵핑 시켜줌 ⇒ postForObject)

2. 카카오 페이 결제 구현과정

자, 카카오페이연동을 어떤 식으로 접근해야할지 알아봤으니 구현해보도록 하겠습니다.

결제과정 3가지

  1. 결제요청준비,
  2. 결제 요청
  3. 결제 승인

아래는 클라이언트-서버-카카오(외부서버) 이 3개가 값을 주고 받는 과정입니다.

1. 클라이언트에서 서버로 주문관련정보를 Post로 던진다.
2. 서버는 controller에서 그 값을 받아 카카오 승인요청url로 던져준다.
3. 카카오는 승인과 동시에 다음 결제과정의 url과 tid(결제코드)을 서버로 던져주고 서버는 클라이언트에 url을 던진다.
4. 클라언트는 그 url로 qr코드 결제 화면에 접근한다.
5. 결제처리가 되면 카카오에서 우리 서버 controller의 결제 url로 token값을 보내준다.
6. 서버는 그 토큰과 처음에 받았던 tid로 결제승인을 요청하고 결재승인 정보를 받아온다.
7. 주문성공페이지 url을 클라이언트에 보내고 클라이언트는 그 페이지를 서버에 요청한다.
8. 서버가 주문완료 페이지를 클라이언트에 보내준다.

자세하게 보면 총 이렇게 8개의 step으로 카카오페이를 구현할 수 있습니다.
주문완료페이지가 없다면 바로 ajax에서 결재내역페이지로 보내셔도 됩니다.

처음엔 이 모든 과정을 controller에서 구현했는데 controller가 너무 복잡해져서 service로 빼내어 리팩토링을 했습니다.

3. ajax, vo, service, controller코드

ajax

	// 카카오결제
	$(function(){
		$("#btn-kakao-pay").click(function(){
			
			// 필수입력값을 확인.
			var name = $("#form-payment input[name='pay-name']").val();
			var tel = $("#form-payment input[name='pay-tel']").val();
			var email = $("#form-payment input[name='pay-email']").val();
			
			if(name == ""){
				$("#form-payment input[name='pay-name']").focus()
			}
			if(tel == ""){
				$("#form-payment input[name='pay-tel']").focus()
			}
			if(email == ""){
				$("#form-payment input[name='pay-email']").focus()
			}
			
			// 결제 정보를 form에 저장한다.
			let totalPayPrice = parseInt($("#total-pay-price").text().replace(/,/g,''))
			let totalPrice = parseInt($("#total-price").text().replace(/,/g,''))
			let discountPrice = totalPrice - totalPayPrice 
			let usePoint = $("#point-use").val()
			let useUserCouponNo = $(":radio[name='userCoupon']:checked").val()
			
			// 카카오페이 결제전송
			$.ajax({
				type:'get'
				,url:'/order/pay'
				,data:{
					total_amount: totalPayPrice
					,payUserName: name
					,sumPrice:totalPrice
					,discountPrice:discountPrice
					,totalPrice:totalPayPrice
					,tel:tel
					,email:email
					,usePoint:usePoint
					,useCouponNo:useUserCouponNo	
					
				},
				success:function(response){
					location.href = response.next_redirect_pc_url			
				}
			})
		})
	})

VO

vo는 총 3개를 준비해야합니다.
1. 결재요청 vo -> 결재요청할때 사용

package com.hta.lecture.kakaopay;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class ReadyResponse {

	private String tid;
	private String next_redirect_pc_url;
	private String partner_order_id;
}

2. 결재승인요청vo -> 결재승인요청할 때 담아서 보냄.

package com.hta.lecture.kakaopay;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class ApproveResponse {
	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 Amount amount;
	
	
}

3. 결재내역 vo -> 결재내역을 가져올 때 사용.

package com.hta.lecture.kakaopay;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class Amount {

	private int total;
	private int tax_free;
	private int vat;
	private int point;
	private int discount;
}

Service


@Slf4j
@Service
public class KakaoPayService {
	
	@Autowired
	private CartMapper cartMapper;
	
	public ReadyResponse payReady(int totalAmount) {
		
		User user =  (User)SessionUtils.getAttribute("LOGIN_USER");
		List<CartDto> carts = cartMapper.getCartByUserNo(user.getNo());
		
		String[] cartNames = new String[carts.size()];
		for(CartDto cart: carts) {
			for(int i=0; i< carts.size(); i++) {
				cartNames[i] = cart.getClassTitle();
			}
		}	
		String itemName = cartNames[0] + " 그외" + (carts.size()-1);
		log.info("강좌이름들:"+itemName);
		String order_id = user.getNo() + itemName;
		
        // 카카오가 요구한 결제요청request값을 담아줍니다. 
		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>();
		parameters.add("cid", "TC0ONETIME");
		parameters.add("partner_order_id", order_id);
		parameters.add("partner_user_id", "inflearn");
		parameters.add("item_name", itemName);
		parameters.add("quantity", String.valueOf(carts.size()));
		parameters.add("total_amount", String.valueOf(totalAmount));
		parameters.add("tax_free_amount", "0");
		parameters.add("approval_url", "http://localhost/order/pay/completed"); // 결제승인시 넘어갈 url
		parameters.add("cancel_url", "http://localhost/order/pay/cancel"); // 결제취소시 넘어갈 url
		parameters.add("fail_url", "http://localhost/order/pay/fail"); // 결제 실패시 넘어갈 url
		
		log.info("파트너주문아이디:"+ parameters.get("partner_order_id")) ;
		HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(parameters, this.getHeaders());
		// 외부url요청 통로 열기.
		RestTemplate template = new RestTemplate();
		String url = "https://kapi.kakao.com/v1/payment/ready";
        // template으로 값을 보내고 받아온 ReadyResponse값 readyResponse에 저장.
		ReadyResponse readyResponse = template.postForObject(url, requestEntity, ReadyResponse.class);
		log.info("결재준비 응답객체: " + readyResponse);
        // 받아온 값 return
		return readyResponse;
	}
	
    // 결제 승인요청 메서드
	public ApproveResponse payApprove(String tid, String pgToken) {
		User user =  (User)SessionUtils.getAttribute("LOGIN_USER");
		List<CartDto> carts = cartMapper.getCartByUserNo(user.getNo());
		// 주문명 만들기.
		String[] cartNames = new String[carts.size()];
		for(CartDto cart: carts) {
			for(int i=0; i< carts.size(); i++) {
				cartNames[i] = cart.getClassTitle();
			}
		}	
		String itemName = cartNames[0] + " 그외" + (carts.size()-1);
		
		String order_id = user.getNo() + itemName;
		
		// request값 담기.
		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<String, String>();
		parameters.add("cid", "TC0ONETIME");
		parameters.add("tid", tid);
		parameters.add("partner_order_id", order_id); // 주문명
		parameters.add("partner_user_id", "회사명");
		parameters.add("pg_token", pgToken);
		
        // 하나의 map안에 header와 parameter값을 담아줌.
		HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(parameters, this.getHeaders());
		
        // 외부url 통신
		RestTemplate template = new RestTemplate();
		String url = "https://kapi.kakao.com/v1/payment/approve";
        // 보낼 외부 url, 요청 메시지(header,parameter), 처리후 값을 받아올 클래스. 
		ApproveResponse approveResponse = template.postForObject(url, requestEntity, ApproveResponse.class);
		log.info("결재승인 응답객체: " + approveResponse);
		
		return approveResponse;
	}
	// header() 셋팅
	private HttpHeaders getHeaders() {
		HttpHeaders headers = new HttpHeaders();
		headers.set("Authorization", "KakaoAK 어드민키");
		headers.set("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
		
		return headers;
	}
}

controller

@Autowired는 많아서 생략함.

@Slf4j
@Controller
// 세션에 저장된 겂을 사용할때 쓰는 어노테이션, session에서 없으면 model까지 훑어서 찾아냄.
@SessionAttributes({"tid","order"}) 
public class OrderController {
	

	// 카카오페이결제 요청
	@GetMapping("/order/pay")
	public @ResponseBody ReadyResponse payReady(@RequestParam(name = "total_amount") int totalAmount, Order order, Model model) {
		
		log.info("주문정보:"+order);
		log.info("주문가격:"+totalAmount);
		// 카카오 결제 준비하기	- 결제요청 service 실행.
		ReadyResponse readyResponse = kakaopayService.payReady(totalAmount);
		// 요청처리후 받아온 결재고유 번호(tid)를 모델에 저장
		model.addAttribute("tid", readyResponse.getTid());
		log.info("결재고유 번호: " + readyResponse.getTid());		
		// Order정보를 모델에 저장
		model.addAttribute("order",order);
		
		return readyResponse; // 클라이언트에 보냄.(tid,next_redirect_pc_url이 담겨있음.)
	}
	
    // 결제승인요청
	@GetMapping("/order/pay/completed")
	public String payCompleted(@RequestParam("pg_token") String pgToken, @ModelAttribute("tid") String tid, @ModelAttribute("order") Order order,  Model model) {
		
		log.info("결제승인 요청을 인증하는 토큰: " + pgToken);
		log.info("주문정보: " + order);		
		log.info("결재고유 번호: " + tid);
		
		// 카카오 결재 요청하기
		ApproveResponse approveResponse = kakaopayService.payApprove(tid, pgToken);	
		
		// 5. payment 저장
		//	orderNo, payMathod, 주문명.
		// - 카카오 페이로 넘겨받은 결재정보값을 저장.
		Payment payment = Payment.builder() 
				.paymentClassName(approveResponse.getItem_name())
				.payMathod(approveResponse.getPayment_method_type())
				.payCode(tid)
				.build();
		
		orderService.saveOrder(order,payment);
		
		return "redirect:/orders";
	}
	// 결제 취소시 실행 url
	@GetMapping("/order/pay/cancel")
	public String payCancel() {
		return "redirect:/carts";
	}
    
	// 결제 실패시 실행 url    	
	@GetMapping("/order/pay/fail")
	public String payFail() {
		return "redirect:/carts";
	}
	
	
}

번외_

한번에 여러개를 결제하는 경우.

카카오페이에서 요청하는 데이터를 보면 item_name은 하나밖에 적을 수가 없어서 상품이 3개일 경우 상품명 그외 2 이런식으로 보내야합니다.

그러면 결제상품 데이터들을 조회하거나 부분 결제취소를 하려면 어떡해해야할까요?


그땐 item_code에 상품코드들을 담아서 보내면 됩니다.
(배열을 string.join()으로 문자열 처리해서 보내고 나중에 값을 받아 다시 배열화 시켜 사용하면 됨)

[배열 → 문자열 /문자열→ 배열 처리 포스팅 | https://tosuccess.tistory.com/198] )

필수요청값은 아니지만 여기에 상품코드들을 담아서 보낸다면, 상품코드들을 받아서 주문상세아이템 테이블에
차곡차곡 저장해주면 됩니다.

저는 장바구니에 저장된 것을 전부 결제하는 방식이라 카트에서 값을 빼왔지만 만일 부분적으로 결제를 하시는 분이라면 저런식으로 값을 보내고 결재정보를 전달받을때 같이 받아서 따로 저장하시면 될 것같습니다.

profile
꾸준히 배워가는 블로그입니다.

3개의 댓글

comment-user-thumbnail
2023년 2월 2일

개발자가 되기 위해 열심히 공부중인 학생입니다 혹시 조금 이해 안되는 부분이 있는데 html 자료나 추가 자료들 좀 개인적으로나 포스팅 좀 부탁드려도 될까요? 글 잘 봤습니다!

1개의 답글
comment-user-thumbnail
2023년 3월 26일

감사합니다 혹시 깃허브에 이프로젝트 올리셨으면 좀 실행하면서 볼수있을까요?

답글 달기