0. 들어가며
외주가 들어왔다.
전 회사에서 새로운 개발자 채용 또는 내부에서 처리하겠다고 한건이 퇴사한 나에게 넘어왔다.
웹앱 출시 후 실제 결제 API로 변경과 결제취소 구현이 남아있었는데.. 결국 돌고돌아 내가 다시 맡게되었다.
다른 유지보수 건들도 있었는데 그건 다 끝나고.. 카카오페이 API는 몇번 만져봤으니 후딱 해치워보자.
물론, 실제 비지니스에 직결되어있으며, 금액 관련 기능이라 꼼꼼히 살펴봐야한다.
1. 공식문서 살펴보기
https://developers.kakaopay.com/docs/payment/online/cancellation
카카오페이 | 개발자센터
새로운 기회와 가치를 함께 만들어봐요
developers.kakaopay.com
Request
Response
다른 응답값들에 대한 변수들도 있었으나 내가 작업중인 서비스에는 필요없는 내용들이라 패스
요청에 대한 예시는 아래와 같다
cancel_amount 로 취소 금액을 보내고 세금관련 부가 정보들을 담아 보내면 된다. 당연히 tid, client_id 값도 함께!
취소도 마찬가지로 결제를 구현했을때 처럼
프론트엔드 '주문취소'버튼 -> 백엔드 주문취소 api 호출
-> 백엔드 order repository에서 orderStatus 변경 등 서비스 로직
-> 서비스 로직 성공 시 카카오 API exchange -> 성공 시 프론트로 200 응답
의 순서로 구현하면 될 것 같다.
물론 뇌코딩이 쉽지 구현하다보면 뭐가 계속 문제가 터지겠지..?
2. Spring Boot API 개발
Controller
// 회원 주문 캔슬
@GetMapping("/order/{orderId}/cancel")
public ResponseEntity<BaseResponse<CanceledPaymentResponseDto>> cancelUserOrder(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long orderId) {
CanceledPaymentResponseDto responseDto = userService.cancelUserOrder(userDetails.getUser(), orderId);
return ResponseEntity.ok(new BaseResponse<>(BaseResponseStatus.SUCCESS, responseDto));
}
여기서 주문 캔슬, 즉 결제 취소를 하는데 Get 이라니?
사실, Patch, Post 둘다 써봤는데 무슨 이유인지는 모르겠으나 자꾸 405 method not allowed 가 걸린다.
security config, web config 에서 다 cors 잘 설정되어있고,
apache 서버 내의 cors 설정도 잘 되어있다.
아니, 애초에 다른 api들은 잘 작동한다.
한참을 구글링해봐도 답이 없어서 우선 개발자도구에서 알려주는.. Allow = GET 을 보고
Get만 허용되었기 때문에 GetMapping 으로 바꿔주고, 나중에 헷갈리는걸 방지하기 위해서 엔드포인트에 /cancel을 붙여줬다.
의심가는게 있다면 카카오페이 API 연동부분이 따로 controller를 통한 호출을 하지않고
해당 Controller api의 서비스 코드 내에서 연계되어 작동되게끔 코드를 짜서
restTemplate exchange 하는 과정에서 GET 이 무언가 강제되지 않았을까 싶긴하다.
하지만 GPT에도 물어보고 구글링해도 이에대한 정보가 나오지않고, 문제가 없다고하길래
일단 GetMapping 으로 ..
Service
UserServiceImpl.java
// 회원 주문 취소
@Override
@Transactional
public CanceledPaymentResponseDto cancelUserOrder(User user, Long orderId) {
// userId로 사용자 유효성 조회
userRepository.findById(user.getId())
.orElseThrow(() -> new BaseException(BaseResponseStatus.USER_NOT_FOUND));
// 특정 사용자의 주문 정보를 조회
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new BaseException(BaseResponseStatus.ORDER_NOT_FOUND));
// 이미 배송완료인지 확인
if (order.getOrderStatus() != OrderStatus.POST_COMPLETE) {
order.setOrderStatus(OrderStatus.ORDER_CANCEL);
}
// 결제 방식에 따라서 환불
CanceledPaymentResponseDto canceledPaymentResponseDto = new CanceledPaymentResponseDto();
// 1. 무통장일때
if (order.getPaymentType() == PaymentType.DEPOSIT) {
// 총 환불 금액
ApprovePaymentResponseDto.Amount amount = new ApprovePaymentResponseDto.Amount();
amount.setTotal(order.getTotalPrice()); // 총 환불 금액 설정
canceledPaymentResponseDto.setAmount(amount); // 환불 정보에 금액 설정
canceledPaymentResponseDto.setOrderNumber(order.getOrderNumber()); // 주문번호
// 주문상태 -> 주문취소
order.setOrderStatus(OrderStatus.ORDER_CANCEL);
// 포인트 복구
// 1. 사용 포인트 복구
if (order.getUsePoints() != 0) {
user.setPoints(user.getPoints() + order.getUsePoints());
PointHistory pointHistory = PointHistory.builder()
.user(user)
.points(order.getUsePoints())
.usePoints(0)
.description(order.getOrderNumber() + " 환불 건으로 인한 결제포인트 재지급")
.build();
pointHistoryRepository.save(pointHistory);
}
// 2. 적립 포인트 복구
if (order.getSavePoints() != 0) {
user.setPoints(user.getPoints() - order.getSavePoints());
PointHistory pointHistory = PointHistory.builder()
.user(user)
.points(0)
.usePoints(order.getSavePoints())
.description(order.getOrderNumber() + " 환불 건으로 인한 포인트 재차감")
.build();
pointHistoryRepository.save(pointHistory);
}
} else if (order.getPaymentType() == PaymentType.KAKAO_PAY) {
// 2. 카카오페이일때
// 카카오 결제 취소 API
canceledPaymentResponseDto = kakaoPayService.cancelKakaoPayment(user, order);
// 결제상태 -> 결제취소
order.setPaymentStatus(PaymentStatus.PAYMENT_CANCEL);
// 주문상태 -> 주문취소
order.setOrderStatus(OrderStatus.ORDER_CANCEL);
// 포인트 복구
// 1. 사용 포인트 복구
if (order.getUsePoints() != 0) {
user.setPoints(user.getPoints() + order.getUsePoints());
PointHistory pointHistory = PointHistory.builder()
.user(user)
.points(order.getUsePoints())
.usePoints(0)
.description(order.getOrderNumber() + " 환불 건으로 인한 결제포인트 재지급")
.build();
pointHistoryRepository.save(pointHistory);
}
// 2. 적립 포인트 복구
if (order.getSavePoints() != 0) {
user.setPoints(user.getPoints() - order.getSavePoints());
PointHistory pointHistory = PointHistory.builder()
.user(user)
.points(0)
.usePoints(order.getSavePoints())
.description(order.getOrderNumber() + " 환불 건으로 인한 포인트 재차감")
.build();
pointHistoryRepository.save(pointHistory);
}
}
return canceledPaymentResponseDto;
}
---
KakaoPayService.java
// 결제 취소 요청
public CanceledPaymentResponseDto cancelKakaoPayment(User user, Order order) {
// 취소 가능 금액 조회
URI infoUri = buildUri("online/v1/payment/order");
HttpHeaders headers = buildHeaders();
Map<String, Object> infoBody = buildInfoRequestBody(order.getTid());
RequestEntity<Map<String, Object>> infoRequestEntity = RequestEntity
.post(infoUri)
.headers(headers)
.body(infoBody);
InfoPaymentRequestDto infoPaymentRequestDto;
try {
// 결제정보 조회 요청 전송
ResponseEntity<InfoPaymentRequestDto> responseEntity =
restTemplate.exchange(infoRequestEntity, InfoPaymentRequestDto.class);
infoPaymentRequestDto = responseEntity.getBody();
if (infoPaymentRequestDto == null) {
log.error("infoPaymentRequestDto is null");
} else {
log.info(String.valueOf(infoPaymentRequestDto));
}
} catch (Exception e) {
// 오류 발생 시 예외 던지기
throw new RuntimeException("카카오페이 결제 정보 조회 중 오류 발생", e);
}
// 취소 요청
URI uri = buildUri("/online/v1/payment/cancel");
// 취소 가능 금액 검증
Integer cancelAvailableAmount = infoPaymentRequestDto.getCancel_available_amount().getTotal();
Integer cancelDbAmount = order.getPaymentPrice();
Integer cancelAmount = infoPaymentRequestDto.getAmount().getTotal();
log.info("취소 가능 금액 :" + cancelAvailableAmount + ", DB저장된 결제금액 : " + cancelDbAmount + ", 카카오서버 저장된 결제금액 :" + cancelAmount);
Map<String, Object> body = buildCancelRequestBody(order.getTid(), cancelAmount, cancelAvailableAmount);
// 결제 취소 요청 엔티티 생성
RequestEntity<Map<String, Object>> requestEntity = RequestEntity
.post(uri)
.headers(headers)
.body(body);
try {
// 결제 취소 요청 전송
ResponseEntity<CanceledPaymentResponseDto> responseEntity =
restTemplate.exchange(requestEntity, CanceledPaymentResponseDto.class);
CanceledPaymentResponseDto canceledPaymentResponseDto = responseEntity.getBody();
return canceledPaymentResponseDto;
} catch (Exception e) {
// 오류 발생 시 예외 던지기
throw new RuntimeException("카카오페이 결제 취소 중 오류 발생", e);
}
}
/**
* 결제 승인 요청 본문을 생성합니다.
*
* @param tid 결제 고유 번호
* @param pgToken 결제 승인 토큰
* @return 요청 본문
*/
private Map<String, Object> buildApproveRequestBody(String tid, String pgToken) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("cid", cid);
body.put("tid", tid);
body.put("partner_order_id", "bloomsocial_order");
body.put("partner_user_id", "partner_user_id");
body.put("pg_token", pgToken);
return body;
}
private Map<String, Object> buildCancelRequestBody(String tid, Integer paymentPrice, Integer cancelAvailableAmount) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("cid", cid);
body.put("tid", tid);
body.put("partner_order_id", "bloomsocial_order");
body.put("partner_user_id", "partner_user_id");
body.put("cancel_amount", paymentPrice);
body.put("cancel_available_amount", cancelAvailableAmount);
body.put("cancel_tax_free_amount", 0);
// body.put("cancel_vat_amount", 0);
return body;
}
private Map<String, Object> buildInfoRequestBody(String tid) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("cid", cid);
body.put("tid", tid);
return body;
}
일단 제대로 작동하는거 확인하자마자 블로그에 옮기는 코드이기 때문에
중복사용되어 길어진 코드가 있다. 그 부분은 따로 메서드로 빼서 정리하면 이뻐짐..
서비스쪽 코드를 짧게 설명하면 이렇다.
orderId 받아와서 해당 order를 찾고,
무통장결제일때, 카카오페이 결제일때를 나눠서 결제 취소 로직을 나누었다.
무통장일때는 그냥 일반적으로 order의 속성들 수정하고 연관 객체 속성들도 수정하면서 상태변경시켰고
카카오페이는 공식문서에 나와있는 결제 취소 API를 restTemplate exchange를 이용해서 구현했다.
그리고 추가로 정상적으로 결제 취소가 되면 결제 때 사용한 포인트에 대해서 복구 후 내역에 남기기까지
이다.
+ 추가내용 (25.01.22)
테스트 cid 에서 실제 결제 cid로 교체하고나니 주문취소에서 문제가 생겼다.
카카오페이 결제시 세금 관련 문제로 db에 저장하던 결제금액과 카카오 서버에 저장된 결제금액과의 차이가 발생한 것.
다행히, 카카오페이에서 제공하는 주문정보 대한 조회 API를 사용해서
결제 금액 정보와 결제 가능 금액 정보를 가져와 취소 요청을 보내니 잘 작동하는 것을 확인할 수 있었다.
3. 프론트와 연동
템플릿 쪽
...
<div class="other_info">
<button @click="openModal(order.order_id)">주문 상세 ></button>
<button role="button" @click="cancelOrder(order.order_id)">주문취소</button>
<button role="button" class="delivery_view" @click="deliveryView">배송조회</button>
</div>
...
스크립트 쪽
...
async cancelOrder(orderId) {
try {
const response = await apiClient.get(`/api/user/order/${orderId}/cancel`);
if (response.data.isSuccess) {
alert('주문이 성공적으로 취소되었습니다.');
this.fetchOrders(true); // 주문 목록 갱신
} else {
alert('주문 취소에 실패했습니다. 이미 배송이 시작된 경우 취소할 수 없습니다. 자세한 사항은 고객센터로 문의바랍니다.');
}
} catch (error) {
console.error('주문 취소 중 오류 발생:', error);
alert('오류가 발생했습니다. 다시 시도해 주세요.');
}
},
...
axios로 restAPI 연동해준다.
근데 cancel 이라고 엔드포인트에 넣어줬어도, get이 자꾸 신경쓰임.. ㅜㅜ 뭐가 문제였을까
4. 결과
성공!
25-01-22 추가 )
실제 결제, 취소도 성공!
'Backend > Spring' 카테고리의 다른 글
헥사고날 아키텍처에 대해 알아보았다. (3) | 2025.06.06 |
---|---|
Maven 설치 및 환경변수 설정 (With Intel Mac) (0) | 2025.03.05 |
[스프링 핵심 원리 - 기본편] 객체 지향 설계와 스프링 (0) | 2024.03.21 |
SSE를 사용하여 실시간 알림 기능 구현해보았다. (0) | 2024.03.01 |
JPA의 더티 체킹(Dirty checking)이란 무엇인가요? (0) | 2024.02.20 |