카카오페이 api를 이용해서 결제 기능 구현하기
프로젝트 진행 중 우리 서비스에 포인트 결제 기능이 있는데 이걸 구현하기 위해 카카오페이 api를 사용하게되었다.
실제 결제를 구현하려면 사업자 등록에 여러 거쳐야되는 절차가 필요하여 우선 테스트 결제라도 구현하는 방향으로 결정했다.
먼저 아래 링크에 들어가 카카오페이 디벨로퍼스에 가입하자.
https://developers.kakaopay.com/
카카오페이 | 개발자센터
새로운 기회와 가치를 함께 만들어봐요
developers.kakaopay.com
가입한 다음, 위와 같이 애플리케이션을 생성해준다.
애플리케이션 정보에 들어오면 위와같은 화면이 뜨게되는데, 여기서 우리가 테스트 결제용으로 사용할 secret key는 빨간색으로 칠해진 Secret key(dev) 이다. 이걸 따로 저장해두자.
다음은 우리가 해야할 단건 결제 api의 공식 문서를 살펴보자.
https://developers.kakaopay.com/docs/payment/online/single-payment
카카오페이 | 개발자센터
새로운 기회와 가치를 함께 만들어봐요
developers.kakaopay.com
단건 결제는 크게 결제 준비 -> QR코드로 결제창 진입 -> 결제 완료 이렇게 3단계로 이루어진다고 볼 수 있다.
차례대로 코드에 적용시켜보자.
결제 준비
https://open-api.kakaopay.com/online/v1/payment/ready
위의 주소로 헤더에 넣어줄 Authorization 부분에 아까 처음에 저장해뒀던 Secret key가 필요하다.
Authorization : SECRET_KEY Secret key(dev)
헤더는 이와같은 방식으로 설정해두면된다.
바디에는 들어갈 정보가 꽤나 많이 나와있는데 현재는 문서에서 Required가 O로 되어있는 것들만 써도 충분하고
추가로 우리 서비스는 카드 결제만 허용할 생각이기 때문에 payment_method_type 을 CARD로 넣어서 보내주면된다.
public @NotNull HashMap<String, String> createPayReadyBody(int point, Long userId, String productName) {
var body = new HashMap<String, String>();
body.put("cid", "TC0ONETIME"); //테스트 결제를 위한 cid
body.put("partner_order_id", "partner_order_id");
body.put("partner_user_id", String.valueOf(userId)); //유저id 를 받아와서 넣어줌
body.put("item_name", productName); //상품명
body.put("quantity", String.valueOf(point)); // 수량
body.put("total_amount", String.valueOf(point)); // 총 수량
body.put("tax_free_amount", "0"); // 상품 비과세 금액 일단 0으로 설정
body.put("approval_url", kakaoPayProperties.approveRedirectUrl()); //성공 시 리다이렉트 url
body.put("fail_url", kakaoPayProperties.failRedirectUrl()); //실패 시 리다이렉트 url
body.put("cancel_url", kakaoPayProperties.cancelRedirectUrl());//취소 시 리다이렉트 url
body.put("payment_method_type", "CARD"); //지불 수단 카드로 고정
return body;
}
이렇게 POST 요청을 보낼 body를 생성해주었다.
현재 프로젝트에서 approval_url로 쓸 url은 /api/point/purchase/approve 이다.
이제 헤더 설정하고 body를 넣어서 POST요청만 보내주면 결제 준비가 완료된다.
성공적으로 결제 준비가 완료되면 카카오페이 api 서버로 부터 다음과 같은 응답이 오게 된다.
위에서 우리가 실제로 사용할 요소는 tid 와 next_redircet_pc_url 두개 뿐이라 DTO에는 이 두개의 필드만 생성해준다.
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record PayReadyInfoResponse(
String tid,
String nextRedirectPcUrl
) {
}
서비스 단에서 쓸 PayReadyInfoResponse DTO를 만들어주고
PayApiCaller.java
public PayReadyInfoResponse payReady(int point, Long userId, String productName) {
String url = kakaoPayProperties.readyUrl(); //결제준비를 보낼 url
Map<String, String> body = createPayReadyBody(point, userId, productName); //body 생성
try {
String jsonBody = objectMapper.writeValueAsString(body); //body를 json 형식으로 변경
return restClient.post()
.uri(URI.create(url))
.contentType(MediaType.APPLICATION_JSON) // json 타입으로 보내기위한 설정
.header("Authorization", "SECRET_KEY "+ kakaoPayProperties.secretKey()) // 시크릿 키 주입
.body(jsonBody) // 바디 설정
.exchange((request, response) -> { // 에러 처리
if (response.getStatusCode().is4xxClientError()) {
System.out.println(objectMapper.readValue(response.getBody(), Map.class));
}
if (response.getStatusCode().isSameCodeAs(HttpStatus.OK)) {
return objectMapper.readValue(response.getBody(), PayReadyInfoResponse.class); // DTO에 매핑
}
throw new FileTypeMismatchException("응답받은 형식과 요청 형식이 다릅니다.");
});
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 변환 오류", e);
}
}
이렇게 RestClient를 이용해서 api 통신할 준비 완료.
이제 서비스 단에서 우리가 구현해놓은 PayApiCaller를 사용해보자.
@Transactional
public PointRecordModel.ReadyInfo readyPurchasePoint(Long userId, int point){
//결제 준비 api 호출
PayReadyInfoResponse payReadyInfoResponse = payApiCaller.payReady(point, userId, PointConstants.PRODUCT_NAME_POINT);
//redis에 응답으로 받은 tid 저장
redisPayService.saveTid(userId, payReadyInfoResponse.tid());
return PointRecordModel.ReadyInfo.from(payReadyInfoResponse);
}
위 코드를 보면 Redis에 tid를 저장하는 코드가 추가되어있는걸 볼 수 있는데, 그 이유는 먼저 결제한 건에 대해 결제 승인으로 넘어가려면 카카오페이 api에 결제 준비 응답으로 받은 tid를 전달해주어야한다.
그래서 선택한 방법은 tid를 Redis에 저장한뒤 필요할때 쓰고 제거해주는 방식이다.
로직 흐름은 다음과 같다.
tid를 Redis db에 임시로 저장 -> 결제 승인할때 저장해둔 tid를 사용해서 승인 요청 -> 승인 완료후 Redis db에서 제거
@PostMapping("/purchase")
public ResponseEntity<Void> purchasePoint(
@Authenticate Long userId,
@RequestBody @Valid PointRecordRequest.Purchase request // 결제할 포인트의 양을 담은 DTO
) {
PointRecordModel.ReadyInfo readyInfo = pointRecordService.readyPurchasePoint(userId, request.point());
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.header("location", readyInfo.nextRedirectPcUrl()) // 결제승인으로 리다이렉트
.build();
}
이렇게 결제 준비 컨트롤러 코드까지 작성하였다.
이제 직접 웹에서 테스트를해보자.
웹에서 테스트를하기위해 잠시 위의 코드를 Get으로 변경하고 파라미터를 주석 처리한 뒤 userId와 point의 값을 매직 넘버로 변경해주자.
@GetMapping("/purchase")
public ResponseEntity<Void> purchasePoint(
// @Authenticate Long userId,
// @RequestBody @Valid PointRecordRequest.Purchase request
) {
PointRecordModel.ReadyInfo readyInfo = pointRecordService.readyPurchasePoint(2L, 1000);
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.header("location", readyInfo.nextRedirectPcUrl())
.build();
}
이렇게하고 /api/point/purchase 를 웹에서 접속해보자
위와 같은 화면이 뜨면 성공적으로 결제 준비 구현이 완료된 것이다.
모바일로 qr코드를 찍어보면 다음과 같이 결제창을 볼 수 있게 된다.
이제 결제 준비는 됐으니, 결제를 한 뒤 결제 승인으로 넘어가는 로직을 작성해보자
결제 승인
문서를 읽어보면 결제창에서 결제를 완료하면 우리가 결제 준비 단계에서 설정해준 approval_url에 pg_token을 쿼리파라미터로 붙혀 redirect 시켜준다고 한다.
이제 결제승인으로 넘어가보자.
결제승인 문서를 보면 인증완료시 응답받은 pg_token과 tid로 최종 승인요청합니다.라고 표기되어있다.
pg_token 은 @RequestParam 으로 승인 url에 보내고, tid는 아까 결제 준비단계에서 Redis에 저장해놓은 tid를 쓰면 된다.
https://open-api.kakaopay.com/online/v1/payment/approve
위 url로 아까 결제 준비와같이
Authorization : SECRET_KEY Secret key(dev) 로 헤더를 설정해준다.
위의 명세에 따라 body를 작성해보자.
public @NotNull HashMap<String, String> createPayApproveBody(String tid, Long userId, String pgToken) {
var body = new HashMap<String, String>();
body.put("cid", "TC0ONETIME"); // 테스트 결제 cid
body.put("tid", tid); // 결제 준비 응답으로 받은 tid
body.put("partner_order_id", "partner_order_id");
body.put("partner_user_id", String.valueOf(userId));
body.put("pg_token", pgToken); // 리다이렉트 시 받은 pgToken
return body;
}
tid는 Redis에서 꺼내서 주입해줄 것이고, pgToken은 @RequestParam으로 redirect할때 전송된 pgToken을 넣어줄것이다.
public PayApproveInfoResponse payApprove(String tid, Long userId, String pgToken) {
String url = kakaoPayProperties.approveUrl(); //결제 승인 요청을 할 url
Map<String, String> body = createPayApproveBody(tid, userId, pgToken); // body 생성
try {
String jsonBody = objectMapper.writeValueAsString(body);
return restClient.post()
.uri(URI.create(url))
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "SECRET_KEY "+ kakaoPayProperties.secretKey()) //시크릿 키 주입
.body(jsonBody)
.exchange((request, response) -> {//에러 처리
if (response.getStatusCode().isSameCodeAs(HttpStatus.OK)) {
return objectMapper.readValue(response.getBody(), PayApproveInfoResponse.class);
}
throw new FileTypeMismatchException("응답받은 형식과 요청 형식이 다릅니다.");
});
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 변환 오류", e);
}
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record PayApproveInfoResponse(
String aid,
String tid,
String cid,
String partnerOrderId,
String partnerUserId,
String itemName,
int quantity,
Amount amount,
String paymentMethodType,
String createdAt,
String approvedAt
) {
public record Amount(
int total,
int taxFree,
int vat,
int point,
int discount,
int greenDeposit
) {}
}
이렇게 RestClient를 이용한 api 통신 준비는 완료.
이제 서비스 코드를 작성해보자.
@Transactional
public void approvePurchasePoint(Long userId, String pgToken){
Users user = userReaderService.getUserById(userId);
// 레디스db에서 tid를 읽어오기
String tid = redisPayService.getTid(userId);
PayApproveInfoResponse payApproveInfoResponse = payApiCaller.payApprove(tid, userId, pgToken);
int purchasedPoint = payApproveInfoResponse.amount().total(); // 결제한 포인트의 양을 받아옴
user.increasePoint(purchasedPoint); // 결제한 포인트만큼 유저의 포인트 증가
//포인트 로그 기록용 비동기 이벤트
var event = PointRecordEventDto.Earn.toDto(userId, purchasedPoint, purchasedPoint,
PointRecordOption.CHARGED,
PointConstants.POINT_PURCHASE_MESSAGE);
eventPublisher.publishEvent(event);
// 레디스 DB에서 tid 삭제
redisPayService.deleteByUserId(userId);
}
로직 흐름은 다음과 같다.
Redis에서 userId에 맞는 tid를 읽어옴 -> 결제 승인 api 통신 -> 응답으로 받은 PayApproveInfoResponse에서 유저가 결제한 포인트의 수량을 가져옴 -> 포인트의 수량만큼 유저의 포인트 증가 -> (비동기)포인트 로그 기록 -> Redis에서 가져온 tid 삭제
이제 컨트롤러 코드를 작성해보자.
@GetMapping("/purchase/approve")
public GlobalResponse payApproved(
@Authenticate Long userId,
@RequestParam("pg_token") String pgToken
) {
pointRecordService.approvePurchasePoint(userId, pgToken);
return GlobalResponse.builder().message("포인트 결제가 완료되었습니다.").build();
}
서비스 메서드에 @RequestParam으로 받은 pg_token 을 주입해주면 완성.
이제 결제를 완료해보자.
이렇게 성공적으로 테스트 결제가 완료되었다!
'KakaoTechCampus' 카테고리의 다른 글
[Spring] 페이징 처리 시 JPA N + 1 문제를 @EntityGraph로 해결해보자 (0) | 2024.11.09 |
---|---|
[카카오테크캠퍼스] STEP2 3주차 회고 (1) | 2024.07.21 |
[카카오테크캠퍼스] STEP2 2주차 회고 (0) | 2024.07.08 |