본문 바로가기
Spring

[Spring] Spring boot Java 카카오 페이 단건 결제 구현하기

by Bhinney 2022. 12. 8.

메인 프로젝트를 하면서 카카오 API를 이용하여 카카오 페이를 구현해보았다.

해당 부분을 기억하기 위해 이 글을 작성하였다.

이 포스팅은 프로젝트 과정에서 흐름을 기억하기 위해 작성한 것으로, 기본 흐름의 틀정도라고 생각하면 좋을 듯 하다.

해당 포스팅의 코드는 아래의 깃헙 주소에서도 확인 가능.

 

GitHub - Bhinney/Study: ✨ 공부하면서 기록하는 공간 ✨

✨ 공부하면서 기록하는 공간 ✨. Contribute to Bhinney/Study development by creating an account on GitHub.

github.com


✨ 들어가기 전에, 메인 프로젝트에서 사용한 카카오 페이의 흐름

: 아래의 흐름을 바탕으로 프로젝트의 코드에 약간의 변형을 주어 구현할 예정

📚자료 참조 : https://developers.kakao.com/docs/latest/ko/kakaopay/common


 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

📎 구현 전에 준비

  • ⭐️ Kakao Developers에서 애플리케이션 설정하기 ⭐️
  • 위의 사이트로 들어가 애플리케이션을 생성해준다.
  • 요약 정보에서 앱 키를 확인할 수 있다.
  • 앱 설정 👉🏻 플랫폼 👉🏻Web 사이트 도메인 등록 


1️⃣ application.yml 생성

  • application.properties 파일을 yml 파일로 변경
  • 어드민 키 환경변수 설정

 

spring:
  h2:
    console:
      enabled: true
      path: /h2
  datasource:
    url: jdbc:h2:mem:test
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  kakao:
    adminKey: ${KAKAO_ADMIN_KEY}​

2️⃣ 필요한 응답 객체 3가지 생성

  • 결제 내역에 사용할 Amount
  • 결제 요청에 사용할 ReadyResponse 
  • 결제 승인에 사용할 ApproveResponse
@Getter
@Setter
@ToString
public class Amount {
   private int total; /* 전체 금액 */
   private int tax_free; /* 비과세 금액 */
   private int vat; /* 부과세 금액 */
   private int point; /* 사용한 포인트 */
   private int discount; /* 할인 금액 */
}

 

/* 결제 요청할 때 사용 */
@Getter
@Setter
@ToString
public class ReadyResponse {
   private String tid; /* 결제 고유 번호 */
   private String next_redirect_pc_url; /* 카카오 결제 창이 나오는 url */
   private String partner_order_id; /* 가맹점 주문 번호 (최대 100자) */
}

 

/* 결제 승인 시 담아 보냄 */
@Getter
@Setter
@ToString
public class ApproveResponse {
   private String aid; /* 요청 고유 번호 */
   private String tid; /* 결제 고유 번호 */
   private String cid; /* 가맹점 코드 */
   private String sid; /* 정기결제용 ID, 정기결제 CID 로 단건결제 요청 시 발급 */
   private String partner_order_id; /* 가맹점 주문 번호 (최대 100자) */
   private String partner_user_id; /* 가맹점 회원 아이디 (최대 100자) */
   private String payment_method_type; /* 결제 수단 (카드 혹은 현금) */
   private String item_name; /* 상품 이름 (최대 100자) */
   private String item_code; /* 상품 코드 (최대 100자) */
   private int quantity; /* 주문 수량 */
   private String created_at; /* 결제 준비 요청 시간 */
   private String approved_at; /* 결제 승인 시간 */
   private String payload; /* 결제 승인 요청에 대해 저장한 값, 요청 시 전달된 내용 */
   private Amount amount; /* 결제 금액 정보 */
}

3️⃣ KakaoPayService 구현

  • 결제 준비 요청
  • 결제 승인 요청
  • 결제 실패 혹은 취소

⭐️ 헤더에 어드민 키를 넣을 때, 앞에 "KakaoAk " 필수 ⭐️

: 해당 부분 넣지 않고 에러를 만난 경험이 있음.

❓주문 DB에 tid를 저장하는 이유 : 그러지 않으면 이후에 tid를 불러올 수 가 없음.

❓게시판의 정보를 수정하는 이유 : (나의 프로젝트의 경우임) 주문 등록 시 빠지는 재고를 취소가 되면 되돌리기 위해.

❓주문 상태의 변경 이유 : (나의 프로젝트의 경우임) 결제 완료가 된 소비자만 리뷰 작성 가능.

❓url에 주문 번호를 추가하는 이유 : 주문 식별을 위해, 그리고 여러 결제가 동시에 이루어질 때 꼬이지 않기 위해.

 

@Slf4j
@Service
@RequiredArgsConstructor
public class KakaoPayService {

   private final OrdRepository ordRepository;
   private final BoardRepository boardRepository;
   @Value("${spring.kakao.adminKey}")
   private String adminKey;

   /* 결제 준비 */
   public ReadyResponse payReady(long ordId) {
      Ord ord = findVerifiedOrd(ordId);

      /* 카카오가 요구한 결제 요청 */
      MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
      param.add("cid", "TC0ONETIME");  /* 테스트 용 가맹점 코드 */
      param.add("partner_order_id", String.valueOf(ord.getOrdId())); /* 가맹점 주문번호 -> String 형 */
      param.add("partner_user_id", "17farm");
      param.add("item_name", ord.getProduct().getBoard().getTitle());
      param.add("quantity", String.valueOf(ord.getQuantity())); /* 개수는 상수형 */
      param.add("total_amount", String.valueOf(ord.getTotalPrice())); /* 상품 총액 */
      param.add("tax_free_amount", "0"); /* 상품 비과세 금액 */

      param.add("approval_url", "http://localhost:8080/order/pay/completed" + ord.getOrdId()); /* 결제 승인 url */
      param.add("cancel_url", "http://localhost:8080/order/pay/cancel" + ord.getOrdId()); /* 결제 취소 url */
      param.add("fail_url", "http://localhost:8080/order/pay/fail" + ord.getOrdId()); /* 결제 실패 url */

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

      RestTemplate template = new RestTemplate();
      String url = "https://kapi.kakao.com/v1/payment/ready";

      ReadyResponse readyResponse = template.postForObject(url, requestEntity, ReadyResponse.class);
      log.info("결제 준비 응답 객체 확인 : " + readyResponse);

      ord.setTid(readyResponse.getTid());
      ordRepository.save(ord);

      return readyResponse;
   }

   /* 승인 요청 메서드 */
   public OrdResponseDto payApprove(Long ordId, String pgToken) {

      Ord findOrd = findVerifiedOrd(ordId);

      /* 요청 값 */
      MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
      param.add("cid", "TC0ONETIME");
      param.add("tid", findOrd.getTid()); /* 결제 고유번호, 결제 준비 API 응답에 포함 */
      param.add("partner_order_id", String.valueOf(findOrd.getOrdId())); /* 가맹점 주문번호, 결제 준비 API 요청과 일치해야 함 */
      param.add("partner_user_id", "17farm"); /* 가맹점 회원 id, 결제 준비 API 요청과 일치해야 함 */
      param.add("pg_token", pgToken); /* 결제승인 요청을 인증하는 토큰 */

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

      /* 외부 url 통신 */
      RestTemplate template = new RestTemplate();

      String url = "https://kapi.kakao.com/v1/payment/approve";

      ApproveResponse approveResponse = template.postForObject(url, requestEntity, ApproveResponse.class);
      log.info("결제 승인 응답 객체 확인: " + approveResponse);

      /* 주문 상태 변경 */
      findOrd.setStatus(Ord.OrdStatus.PAY_COMPLETE);
      ordRepository.save(findOrd);

      return OrdResponseDto.builder()
         .ordId(ordId)
         .build();
   }

   /* 결제 취소 혹은 삭제 */
   public void cancelOrFailPayment(Long ordId) {

      /* 결제 취소 or 결제 실패 -> 재고 수정 */
      Ord findOrd = findVerifiedOrd(ordId);
      Board findBoard = findOrd.getProduct().getBoard();

      findBoard.getProduct().setLeftStock(findBoard.getProduct().getLeftStock() + findOrd.getQuantity());
      boardRepository.save(findBoard);
      ordRepository.delete(findOrd);
   }

   /* 여기에는 OrdService 를 안만들었으므로 여기서 확인 */
   public Ord findVerifiedOrd(long ordId) {
      Ord findOrd = ordRepository.findById(ordId)
         .orElseThrow(() -> new RuntimeException("존재하지 않는 주문입니다."));

      return findOrd;
   }

   /* 서버에 요청할 헤더 */
   private HttpHeaders getHeaders() {
      HttpHeaders headers = new HttpHeaders();
      headers.set("Authorization", "KakaoAK " + adminKey);
      headers.set("Content-type", "application/x-www-form-urlencoded;charset=utf-8");

      return headers;
   }
}

4️⃣ KakaoPayController 구현

  • 결제 준비 요청
  • 결제 승인 요청
  • 결제 실패
  • 결제 취소
@Slf4j
@Controller
@RequiredArgsConstructor
@Validated
public class KakaoPayController {

   private final KakaoPayService kakaopayService;

   /* 카카오 페이 결제 요청 */
   @GetMapping("/order/pay/{ord_id}")
   public ResponseEntity payReady(@PathVariable("ord_id") long ordId) {
      ReadyResponse response = kakaopayService.payReady(ordId);

      return ResponseEntity.ok(response);
   }

   /* 결제 승인 요청 */
   @GetMapping("/order/pay/completed/{ord_id}")
   public String payComplete(@RequestParam("pg_token") String pgToken, @PathVariable long ordId) {

      /* 카카오 결제 요청 */
      OrdResponseDto response = kakaopayService.payApprove(ordId, pgToken);

      URI uri = UriComponentsBuilder.fromUri(URI.create("http://localhost:3000/order/pay/completed"))
         .queryParam("ordId", ordId)
         .build().toUri();

      /* 프런트 페이지로 리다이렉트 */
      return "redirect:" + uri;
   }

   /* 결제 취소 요청 -> 주문 내역 삭제 */
   @GetMapping("/order/pay/cancel/{ord_id}")
   public String payCancel(@PathVariable("ord_id") long ordId) {
      kakaopayService.cancelOrFailPayment(ordId);
      log.info("결제 취소");

      /* 프런트 페이지로 리다이렉트 */
      return "redirect:http://localhost:3000/order/pay/cancel";
   }

   /* 결제 실패 */
   @GetMapping("/order/pay/fail/{ord_id}")
   public String payFail(@PathVariable("ord_id") long ordId) {
      kakaopayService.cancelOrFailPayment(ordId);
      log.info("결제 실패");

      /* 프런트 페이지로 리다이렉트 */
      return "redirect:http://localhost:3000/order/pay/fail";
   }
}

댓글