[Spring Boot] iamport 결제 및 환불 기능 구현하기

이동엽·2023년 6월 13일
4

spring

목록 보기
5/16

졸업 작품에 올인한지도 벌써 5달쯤 되었고, 1학기가 끝나기까지 약 한달이 남았다.


비동기 작업 성능을 높여보고.. Swagger로 API 문서를 자동화하고.. Redis를 도입하는 등의 작업을 했지만..
사실상 이메일 발송 을 제외한 대부분의 기능들이 CRUD다!


따라서 새로운 기능이 도입되었으면 했고, 생각해 본 바로는 아래와 같다.

  1. Spring Batch를 이용해 대용량 데이터를 일괄처리하기
    → 휴먼 계정이 있다고 할 때, 일괄로 이메일을 보내 알려주는건 어떨까?


  2. 결제 시스템 만들어보기
    → 아보카도의 경우 예약 기능이 있는데, 소액의 예약금을 받는 식으로 넣어볼까?

  3. Slack 챗봇 연동하기

우선, 2번의 결제 시스템을 먼저 도입해보기로 결정!


결제 시스템 설계 과정을 설명하기에 앞서 제대로 된 결제 시스템을 구현하려면, 사업자 등록을 우선시 해야된다.
하지만 심사가 통과될 때까지 시간이 오래 걸린다는 여러 의견들도 있었고,
'프로젝트 하나에 사업자 등록까지 해야 할까?'라는 의문이 함께 따라왔던 것 같다.

따라서 이것저것 찾아보다 iamport 라는 플랫폼을 발견할 수 있었고, 모의 테스트 결제 API를 제공한다.
게다가 개발 가이드까지 정리가 잘 되어있어 이를 사용하기로 결정!

추가로, 모든 전체 코드는 깃헙 링크를 통해 확인할 수 있습니다.



💡 결제 기능 구현하기

💡 결제 진행 흐름

  1. 클라이언트가 결제 요청시 프론트에서 결제창 호출
  2. Iamport 서비스에서 결제 진행
  3. 결제 완료시 응답을 백엔드 서버로 POST
  4. 서버는 해당 결제 내역을 저장하고, 해당 예약의 상태를 “결제 완료”로 바꿈


결제 완료 응답을 받는 Controller

@PostMapping("/{id}")
public ResponseEntity<?> savePayment(
    @RequestBody final Map<String, Object> model,
	@PathVariable(value = "id") final Long appointmentId) throws JSONException, IOException {

		//응답 header 생성
        final HttpHeaders responseHeaders = makeHttpHeader();

        final String impUid = (String) model.get("imp_uid");
        final String merchantUid = (String) model.get("merchant_uid");
        final boolean success = (boolean) model.get("success");
        final String errorMsg = (String) model.get("error_msg");

        if (!success) {
		    log.error(errorMsg);
	        return new ResponseEntity<>(errorMsg, responseHeaders, HttpStatus.OK);
		}

	    try {
			//해당 예약이 이미 결제 상태인지는 아닌지 확인
		    validateAppointmentPayStatus(appointmentId);

          final var iamportClient = new IamportClient(API_KEY, API_SECRET);
          final Payment payment = extractPayment(impUid, iamportClient);

          return new ResponseEntity<>(paymentService.save(appointmentId, payment), responseHeaders, HttpStatus.OK);

		} catch (IamportResponseException | IOException e) {

		    log.error("{}", e);

        	//예외 발생시 결제를 취소
			//뒤에서 구현할 예정
	        return ResponseEntity.badRequest().build();
		}
}

private Payment extractPayment(final String imp_uid, final IamportClient iamportClient) throws IamportResponseException, IOException {
		return iamportClient.paymentByImpUid(imp_uid).getResponse();
}

private void validateAppointmentPayStatus(final Long appointmentId) {
    final Appointment appointment = appointmentService.findById(appointmentId);
		
	if (appointment.getPayStatus() == PayStatus.COMPLETED) {
		throw new IllegalValueException("이미 결제된 예약입니다.", ErrorCode.ILLEGAL_STATE);
    }	
}
		
private HttpHeaders makeHttpHeader() {
    final HttpHeaders responseHeaders = new HttpHeaders();
	responseHeaders.add("Content-Type", "application/json; charset=UTF-8");
    responseHeaders.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
    return responseHeaders;
}


결제 정보를 저장하고, 예약 상태를 변경하는 Service

@Transactional
public Long save(final Long appointmentId, final Payment payment) {
		//예약 정보 조회
    final Appointment appointment = appointmentService.findById(appointmentId);

    //해당 예약건 결제
    appointment.payment();

    //결제 정보 생성(매핑) 후 저장
    final PaymentEntity paymentEntity = paymentToPaymentEntity(payment);

    return paymentRepository.save(paymentEntity).getId();
}

private PaymentEntity paymentToPaymentEntity(final Payment payment) {
		return PaymentEntity.createPaymentEntity(
						payment.getPgProvider(),
				        payment.getPayMethod(),
				        "아보카도 병원 예약금",
				        payment.getBuyerEmail(),
				        payment.getBuyerName());
}


결과

  • kcp 결제 화면

  • 카카오페이 결제 화면


💡 결제 취소 구현하기

💡 결제 취소가 되는 경우의 수

  1. 고객이 결제 취소를 요청한 경우
  2. 결제 완료 후, 결제 정보 저장 및 예약 상태 변경 과정에서 예외가 발생한 경우


우선, Iamport의 결제 취소 API는 토큰을 요구하므로 토큰을 먼저 발급받자.

Token 발급

@GetMapping("/token")
public ResponseEntity<String> getToken() throws IOException, JSONException {

	final HttpURLConnection conn = getTokenConnection();

    final JSONObject obj = getJsonObject();

    sendRequest(conn, obj);

    final int responseCode = getResponseCode(conn);

    if (responseCode != 200) {
        return ResponseEntity.badRequest().build();
    }

    final BufferedReader br = new BufferedReader(
				new InputStreamReader(conn.getInputStream()));

    final String accessToken = getResponse(br);

    disconnect(conn, br);
    return ResponseEntity.ok(accessToken);
}


취소 요청 보내기

@PostMapping("/cancel/{id}")
public ResponseEntity<String> refund(
	final HttpServletRequest httpServletRequest,
    @RequestBody final String merchantUid,
    @PathVariable final Long appointmentId) {

        final String token = extractor.extract(httpServletRequest, "Bearer");

        final ResponseEntity<String> response 
                    = executePaymentCancel(token, merchantUid, appointmentId);

        return response;
}

결제는 완료되었지만, 서버에서 처리 과정에서 예외가 발생해 결제 취소를 해야하는 경우

try {
	validateAppointmentPayStatus(appointmentId);

    final var iamportClient = new IamportClient(API_KEY, API_SECRET);
	final Payment payment = extractPayment(impUid, iamportClient);

    return new ResponseEntity<>(paymentService.save(appointmentId, payment), responseHeaders, HttpStatus.OK);

} catch (IamportResponseException | IOException e) {

	log.error("{}", e);

    //예외 발생시 결제를 취소
    final String token = refundController.getToken().getBody();
    refundController.refundWithToken(token, merchantUid, appointmentId);

    return ResponseEntity.badRequest().build();
}

우선은 초안으로 결제 기능과 환불 기능을 완료하였지만,
갑작스럽게 도입한 기능이다보니 아직 팀원과 상세 요구사항이 명세되지 않아 이를 맞추어 가야 할 것 같다.

profile
백엔드 개발자로 등 따숩고 배 부르게 되는 그 날까지

1개의 댓글

comment-user-thumbnail
2023년 6월 14일

뭐요

답글 달기