되자!백엔드개발자
[Spring] 카카오페이 연동 구현 본문
회사업무로 카카오페이 인증쪽 연동하는 일을 맡게되었다.
한번 복습해볼겸 집에서 정말 간단화해서 구현한걸 기록 겸 작성해본다.
정말 간단하게 해서 DB 아예 안썼다. 실무에서 이러면 큰일난다.
서버간통신 예제 겸 보면 좋겠다.
목표
- 카카오페이 인증창 띄우기
개발환경
- JAVA 17
- Spring 3.2.2
필요 Gradle
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'com.google.code.gson:gson:2.9.0'
결제 페이지 : kakaopay.html
<html>
<meta charset="UTF-8" />
<body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<div>
<h2>카카오페이연습</h2>
<form id="payInfo">
<input type="text" name="ordId" value="20240210">
<input type="text" name="userId" value="한장">
<input type="text" name="itemNm" value="휴지">
<input type="text" name="quantity" value="3">
<input type="text" name="itemAmt" value="1004">
<input type="text" name="freeAmt" value="0">
<button id="submitBtn" type="button">결제</button>
</form>
</div>
</body>
<script>
$(document).ready(function(){
$("#submitBtn").on("click", function () {
var formData = $("#payInfo").serialize();
$.ajax({
type: "POST",
url: "/pay/kakao",
data: formData,
dataType: "json",
success: function (result) {
window.open(result.next_redirect_pc_url, "kakaopay_pop", "width = 500, height = 500, top = 100, left = 200, location = no");
},
error: function (error) {
console.log("결제 실패");
console.log(error);
},
complete: function (xhr, status) {
console.log("Request completed with status: " + status);
}
});
});
})
- 결제 버튼을 누르면 ajax를 통해 /pay/kakao로 데이터를 전달한다.
- 데이터가 받아지면 popup창을 띄워 next_redirect_pc_url을 띄우게 된다.
ModelAttribute용 : KakaopayReq
@ToString
@Getter @Setter
@NoArgsConstructor
public class KakaopayReq {
private String ordId;
private String userId;
private String itemNm;
private int quantity;
private int itemAmt;
private int freeAmt;
@Builder
public KakaopayReq(String ordId, String userId, String itemNm, int quantity, int itemAmt, int freeAmt) {
this.ordId = ordId;
this.userId = userId;
this.itemNm = itemNm;
this.quantity = quantity;
this.itemAmt = itemAmt;
this.freeAmt = freeAmt;
}
}
- kakaopay.html 에서 전달한 데이터를 받기 위해 DTO 객체이다.
카카오 전달용 VO : KakaopayReqVo
@Getter
@ToString
public class KakaopayReqVo {
private String cid;
private String partner_order_id;
private String partner_user_id;
private String item_name;
private Integer quantity;
private Integer total_amount;
private Integer tax_free_amount;
private String approval_url;
private String cancel_url;
private String fail_url;
public KakaopayReqVo(String cid, String receiveUrl, KakaopayReq kakaopayReq) {
this.cid = cid;
this.partner_order_id = kakaopayReq.getOrdId();
this.partner_user_id = kakaopayReq.getUserId();
this.item_name = kakaopayReq.getItemNm();
this.quantity = kakaopayReq.getQuantity();
this.total_amount = kakaopayReq.getItemAmt();
this.tax_free_amount = kakaopayReq.getFreeAmt();
this.approval_url = receiveUrl;
this.cancel_url = receiveUrl;
this.fail_url = receiveUrl;
}
}
- 카카오에게 전달하기 위해서는 카카오가 사용하는 변수명으로 변경해야 하므로 사용한다..
- 또한 사용자에게 받는 데이터 외에도 개발자가 설정해야하는 데이터도 존재하는데 여기서 설정해준다.
. 카카오페이는 취소, 승인, 실패일 경우 호출할 URL을 줘야하는데 나는 "/pay/kakao/receive"로 통합해서 처리하도록 할 예정이기에 receiveURL하나만 받도록 구현했다.
PayService:toKakaoServer
@Slf4j
@Service
public class PayService {
@Autowired
Gson gson;
public <T> T toKakaoServer(String secretKey, String url, Object reqBody, Class<T> resp) {
URI uri = UriComponentsBuilder
.fromUriString("https://open-api.kakaopay.com")
.path(url)
.build()
.toUri();
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set("Authorization", "SECRET_KEY " + secretKey);
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Object> requestsMessage = new HttpEntity<>(reqBody, httpHeaders);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<T> tResponseEntity = restTemplate.postForEntity(uri, requestsMessage, resp);
if (!tResponseEntity.getStatusCode().equals(HttpStatus.OK)) {
throw new RuntimeException("서버간 통신 에러발생 = " + tResponseEntity);
}
return tResponseEntity.getBody();
}
}
- 카카오 서버에 서버간 통신을 하는 메서드이다.
- RestTemplate을 사용했고 JSON형태로 전달하고 매개변수인 resp의 클래스 형으로 데이터를 전달받도록 구현했다.
카카오서버가 전달하는 정보 받는 VO: KakaopayAuthResVo
@AllArgsConstructor
@NoArgsConstructor
@Setter @Getter
public class KakaopayAuthResVo {
private String tid;
private String next_redirect_mobile_url;
private String next_redirect_pc_url;
private LocalDateTime created_at;
}
- 카카오페이가 요청에 대한 답으로 보내는 정보를 담을 객체이다.
PayController: kakaopayAuth
@Slf4j
@Controller
public class PayController {
private final String CID = "TC0ONETIME";
private final String SECRETKEY = "{본인의 secretKey}";
private final String SERVER_DOMAIN = "http://127.0.0.1:8080";
@Autowired
PayService payService;
@ResponseBody
@PostMapping("/pay/kakao")
public ResponseEntity<KakaopayAuthResVo> kakaopayAuth(KakaopayReq kakaopayReq) {
KakaopayReqVo kakaopayReqVo = new KakaopayReqVo(CID, SERVER_DOMAIN + "/pay/kakao/receive", kakaopayReq);
KakaopayAuthResVo response = payService.toKakaoServer(SECRETKEY, "/online/v1/payment/ready", kakaopayReqVo, KakaopayAuthResVo.class);
return ResponseEntity.ok(response);
}
}
- CID는 카카오가 테스트용으로 제공해주는 CID를 사용했다. SECRETKEY는 본인의 것을 사용하면 된다.
- 실제 CID, SECRETKEY는 DB, 혹은 application.properties에 안전하게 보관을 해야겠지만 나는 귀찮...기 때문에 하드코딩했다.
여기까지 하면 KakaopayAuthResVo에는 이런 정보를 받를 담게 된다.
HTTP/1.1 200 OK
Content-type: application/json;charset=UTF-8
{
"tid": "T1234567890123456789",
"next_redirect_app_url": "https://mockup-pg-web.kakao.com/v1/xxxxxxxxxx/aInfo",
"next_redirect_mobile_url": "https://mockup-pg-web.kakao.com/v1/xxxxxxxxxx/mInfo",
"next_redirect_pc_url": "https://mockup-pg-web.kakao.com/v1/xxxxxxxxxx/info",
"android_app_scheme": "kakaotalk://kakaopay/pg?url=https://mockup-pg-web.kakao.com/v1/xxxxxxxxxx/order",
"ios_app_scheme": "kakaotalk://kakaopay/pg?url=https://mockup-pg-web.kakao.com/v1/xxxxxxxxxx/order",
"created_at": "2023-07-15T21:18:22"
}
나는 지금 PC로 결제창을 띄우는 것을 구현하고 있기에 next_redirect_pc_url을 Popup창으로 띄우게 된다.
여기까지만 하면 아래와 같이 화면이 띄워지는걸 확인할 수 있다.
이제 좀만 더 보충해보겠다.
보충1. TID 보관
- tid는 승인을 보낼 때 필요한 정보이므로 DB에 저장하여 보관할 필요가 있다.
- 실제로 구현했을 때는 DB에 보관했는데 지금은 DB를 사용하지 않으므로 프론트에 보관하도록 하겠다.
결제 페이지 : kakaopay.html에 tid저장 추가
$.ajax({
.... (생략)
success: function (result) {
window.open(result.next_redirect_pc_url, "kakaopay_pop", "width = 500, height = 500, top = 100, left = 200, location = no");
var newInput = $("<input>").attr({ // 추가
type: "hidden",
name: "tid",
value: result.tid
});
$("#payInfo").append(newInput);
},
.... (생략)
});
- 결제창을 띄우고 나면 tid가 저장된 것을 확인할 수 있다.
보충2: 취소를 누를 경우, QR을 진행할 경우
위에서 카카오에게 전달한 approval_url, cancel_url에 똑같이 /pay/kakao/receive를 담아서 전달했었다.
저 두 경우는 카카오가 pg_token을 전달했느냐로 구분할 수 있다.
PayController
// PayController
@GetMapping("/pay/kakao/receive")
public String kakaoReceive(@RequestParam(name = "pg_token", required = false)String token, Model model) {
model.addAttribute("resultCd", "9999");
model.addAttribute("token", "null");
if (!Objects.isNull(token)) {
model.addAttribute("token", token);
model.addAttribute("resultCd", "0000");
}
return "kakaoReceive";
}
- 사실 토큰도 화면단에 전달하면 안될 것 같은 정보고 실전에서는 DB에 저장해서 사용했지만 여기서는 DB를 전혀 사용하지 않기에 Mode에 저장하여 전달했다.
kakaoReceive.html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<meta charset="UTF-8" />
<body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
</body>
<script>
$(document).ready(function(){
let resultCd = "[[${resultCd}]]"
let token = "[[${token}]]";
let data = {"token": token, "resultCd" : resultCd};
window.opener.postMessage(data, "*");
window.close();
})
</script>
</html>
- 부모창인 kakaopay.html에 취소에 대한 정봅 혹은 승인(token)에 대한 정보를 전달한다.
kakaopay.html
...(생략)
window.addEventListener("message", function (result) {
if (result.data.resultCd == "0000") {
let _form = $("#payInfo");
let newInput = $("<input>").attr({
type: "hidden",
name: "token",
value: result.data.token
});
_form.append(newInput);
_form.attr({
method: "post",
action: "/pay/kakao/approve"
});
_form.submit();
} else {
$("#payInfo > input[name='tid']").remove();
}
}, false);
- 결제 결과가 성공코드이면 token을 저장하여 승인 로직으로 전송하도록 한다.
- 위 방식대로 하면 재시도 할 때 tid가 중복될 수 있으므로 취소일 경우 tid를 삭제해주는 코드도 넣어주었다.
PayController:kakaoApprove
@PostMapping("/pay/kakao/approve")
public String kakaoApprove(KakaopayApproveReq approveReq, Model model) {
KakaoApprovReqVO kakaoApprovReqVO = new KakaoApprovReqVO(CID, approveReq);
Map response = payService.toKakaoServer(SECRETKEY, "/online/v1/payment/approve", kakaoApprovReqVO, Map.class);
model.addAttribute("res", response);
return "kakaoResult";
}
- 카카오에 전송하는 메서드는 이전에 만들어 둔 것을 재사용할 수 있어서 Controller코드만 짜면 끝이다.
- 카드를 사용하느냐,카카오페이머니를 사용하느냐에 따라 결과값이 달라지므로 Map으로 받을 수 있도록 하였다.
- 결과 페이지는 ... 귀찮아서 안 만들었다.