티스토리 뷰

이제 카드 결제는 완료하였다. 성공한 완료된 카드 결제들에 대해 이제 카드 결제취소를 구현해보자.

 

결제 취소를 위해서는 결제 승인 시 토스페이먼츠에게서 발급 받은 {paymentKey}와 최소 이유인 {cancelReason}이 필요하다. 이때 paymentKey는 요청 시 pathVariable로 추가하면 되고, cancelReason은 requestParameter로 추가해주면 된다.

 

물론 이 요청을 보낼 때도 이전에 결제 승인 요청 때 보낸것과 마찬가지로 HTTP 헤더에 시크릿키를 이용한 Basic Authorize를 설정해서 보내야 한다.


결제 취소 구현

Payment Controller

@PostMapping("/cancel")
@ApiOperation(value = "결제 취소", notes = "완료 된 결제 건에 대해서 결제취소를 요청합니다.")
public SingleResult<String> requestPaymentCancel(
    @ApiParam(value = "토스 측 주문 고유 번호", required = true) @RequestParam String paymentKey,
    @ApiParam(value = "결제 취소 사유", required = true) @RequestParam String cancelReason
) {
    try {
        return responseService.getSingleResult(paymentService.requestPaymentCancel(paymentKey, cancelReason));
    } catch (Exception e) {
        e.printStackTrace();
        throw new BussinessException(e.getMessage());
    }
}
  • 취소 요청을 받을 API를 생성해준다.
  • 현재는 테스트용이므로 String 문자열로 반환하도록 했다.

Payment Service

@Transactional
public String requestPaymentCancel(String paymentKey, String cancelReason) {
    RestTemplate rest = new RestTemplate();

    URI uri = URI.create(tossOriginUrl + paymentKey + "/cancel");

    HttpHeaders headers = new HttpHeaders();
    byte[] secretKeyByte = (testSecretApiKey + ":").getBytes(StandardCharsets.UTF_8);
    headers.setBasicAuth(new String(Base64.getEncoder().encode(secretKeyByte)));
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

    JSONObject param = new JSONObject();
    param.put("cancelReason", cancelReason);

    return rest.postForObject(
            uri,
            new HttpEntity<>(param, headers),
            String.class
    );
}
  • 요청을 보낼 URL / HTTP header / 전달할 파리미터 / 파라미터를 전달 받는 방식 등을 세팅해주고 요청을 보낸다.
  • 시크릿 키도 Basic64 방식으로 인코딩하여 헤더에 넣어주었다.
  • 파라미터는 JSONObject 방식으로 키:쌍 형태를 만들어서 보내주었다.

테스트

정상적으로 응답이오고 transactionKey도 들어있는것을 확인할 수 있다.

 

고민

이렇게 취소하는 것들을 결제 객체에 포함해야할지, 새로운 객체를 만들어서 결제에 OneToOne으로 갖게해야할지, 그게 아니라 회원과 OneToMany관계를 갖게해야될지 고민이 있었다.

 

하지만 결제 객체에 가지지 않을수도 있는 취소 데이터를 포함시켜서 NULL값이 있게 하는것은 좋지 않아보였고, 결제에 OneToOne관계를 갖게 하는건 결제를 조회할 때마다 fetchJoin을 써서 하던가 아니면 1+N쿼리 문제를 가지고 가야 하는 문제가 있었다.

 

따라서 최종적으론 회원과 결제취소를 OneToMany관계를 갖게 하도록 결정하였고 결제 취소 테이블을 새롭게 만들기로 하였다. 그럼 이제 취소 내역을 DB에 저장할 수 있도록 변경하고, 응답 결과도 이전에 만들어놓은 DTO를 이용해서 깔끔하게 보이도록 변경해보자.

결제 취소 구현 2

CancelPayment Controller

@Api(tags = "13. 결제 취소")
@RequestMapping("/v1/api/cancelPayment")
@RestController
@RequiredArgsConstructor
public class CancelPaymentController {

	private final ResponseService responseService;
	private final CancelPaymentService cancelPaymentService;
	private final int FAIL = -1;

	@PostMapping
	@ApiOperation(value = "결제 취소", notes = "완료 된 결제 건에 대해서 결제취소를 요청합니다.")
	public CommonResult requestPaymentCancel(
			@ApiParam(value = "토스 측 주문 고유 번호", required = true) @RequestParam String paymentKey,
			@ApiParam(value = "결제 취소 사유", required = true) @RequestParam String cancelReason
	) {
		boolean result = cancelPaymentService.requestPaymentCancel(paymentKey, cancelReason);
		if (result) {
			return responseService.getSuccessResult();
		} else return responseService.getFailResult(
				FAIL,
				ExMessage.PAYMENT_CANCEL_ERROR_FAIL.getMessage()
		);
	}
}
  • 일단 결제 객체와 결제 취소내역 객체를 구분하였으므로 Controller도 구분하였다.

CancelPayment Entity

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CancelPayment {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "seq", nullable = false)
	private Long seq;

	@Column(nullable = false)
	private String orderId;

	@Column(nullable = false)
	private String paymentKey;

	// 생략.. 취소 관련 데이터

	@Setter
	@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)
	private Member customer;
}
  • 결제취소를 담을 객체를 생성해주었다.

PaymentResHandleDto (추가)

// 생략.. 기존과 동일
public CancelPayment toCancelPayment() {
    return CancelPayment.builder()
            .orderId(orderId)
            .orderName(orderName)
            .paymentKey(paymentKey)
            .requestedAt(requestedAt)
            .approvedAt(approvedAt)
            .cardCompany(card.getCompany())
            .cardNumber(card.getNumber())
            .receiptUrl(card.getReceiptUrl())
            .cancelAmount(cancels[0].getCancelAmount())
            .cancelDate(cancels[0].getCanceledAt())
            .cancelReason(cancels[0].getCancelReason())
            .build();
}
  • 결제취소시 반환받은 값을 PaymentResHandleDto 객체로 변환하고, 이를 다시 CancelPayment로 변환하기위해 메소드를 추가해주었다.

CancelPayment Repository

@Repository
public interface CancelPaymentRepository extends JpaRepository<CancelPayment, Long> {
	Optional<CancelPayment> findByPaymentKey(String orderId);
}

CancelPayment Service

@Slf4j
@Service
@RequiredArgsConstructor
public class CancelPaymentService {

	private final PaymentRepository paymentRepository;

	@Value("${payments.toss.test_client_api_key}")
	private String testClientApiKey;

	@Value("${payments.toss.test_secret_api_key}")
	private String testSecretApiKey;

	@Value("${payments.toss.origin_url}")
	private String tossOriginUrl;

	@Transactional
	public boolean requestPaymentCancel(String paymentKey, String cancelReason) {
		RestTemplate rest = new RestTemplate();

		URI uri = URI.create(tossOriginUrl + paymentKey + "/cancel");

		HttpHeaders headers = new HttpHeaders();
		byte[] secretKeyByte = (testSecretApiKey + ":").getBytes(StandardCharsets.UTF_8);
		headers.setBasicAuth(new String(Base64.getEncoder().encode(secretKeyByte)));
		headers.setContentType(MediaType.APPLICATION_JSON);
		headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

		JSONObject param = new JSONObject();
		param.put("cancelReason", cancelReason);

		PaymentResHandleDto paymentCancelResDto;

		try {
			paymentCancelResDto = rest.postForObject(
					uri,
					new HttpEntity<>(param, headers),
					PaymentResHandleDto.class
			);
		} catch (Exception e) {
			throw new BussinessException(e.getMessage().split(": ")[1]);
		}

		if (paymentCancelResDto == null) return false;

		Long cancelAmount = paymentCancelResDto.getCancels()[0].getCancelAmount();
		try {
			paymentRepository
					.findByPaymentKey(paymentKey)
					.filter(P -> P.getAmount().equals(cancelAmount))
					.orElseThrow(() -> new BussinessException(ExMessage.PAYMENT_ERROR_ORDER_NOTFOUND))
					.getCustomer()
					.addCancelPayment(paymentCancelResDto.toCancelPayment());
			return true;
		} catch (Exception e) {
			throw new BussinessException(ExMessage.DB_ERROR_SAVE);
		}
	}
}
  • 새롭게 결제취소를 요청하고, 성공적이라면 --> 결제내역을 갖고 있는 고객에게 취소내역을 추가해주었다.
    CascadeType이 PERSIST 이므로 자동적으로 엔티티매니저에 추가가 되어 트랜잭션 종료시 같이 flush되어 DB에 적용이 될 것이다.
  • 요청은 이전 [최종결제승인요청]과 비슷하다.

❗️참고사항❗️

PaymentResHandleDto 이 객체는 결제요청 / 결제취소 시 반환받는 문자열을 처리하는 DTO였다. 이때 card정보는 단일객체값으로 전달되었으므로 변수를 통해 바로 받으면 됐다.

하지만, 결제취소 관련 정보를 갖는 cancels 필드는 Array(배열)이다.
따라서 "PaymentResHandleCancelDto[] cancels; // : 결제 취소 이력 관련 객체"
이런식으로 배열로 받아줘야 한다.

참고로 필자는 부분취소 개념은 없으므로 취소이력이 여러개 쌓일 일이 없어서 인덱스0으로 항상 접근하도록 해놓았다. 이부분은 상황에 맞게 수정하도록 하자.

 

결과

요청 및 응답 확인

DB 취소 내역 저장 확인

[
  {
    "seq": 1,
    "approved_at": "2022-03-18T22:52:16+09:00",
    "cancel_amount": 3000,
    "cancel_date": "2022-03-20T21:20:22+09:00",
    "cancel_reason": "단순 변심",
    "card_company": "국민",
    "card_number": "5570************",
    "order_id": "36a7a916-5aa3-41d0-8321-888cf542fc10",
    "order_name": --상품명-
    "payment_key": --페이먼트키--
    "receipt_url": "https://dashboard.tosspayments.com/sales-slip?transactionId=1XKHbawlO%2B9xTnsg7kf0Sfe2k4Y9Nm0LoJyxdBU9H7OtF8yB5gnXKTrhRssJxhTo&ref=PX",
    "requested_at": "2022-03-18T22:51:57+09:00",
    "customer_seq": 7
  }
]

 

 

이렇게 결제취소까지 완료하였다. 다양한 추가적인 API들이 존재하지만 일단 토스페이먼츠를 이용한 결제신청/결제취소는 여기까지로 마무리하고 다음 포스트에서는 금융결제원을 통한 계좌번호 검증을 구현하겠다.

 

 


Reference

https://docs.tosspayments.com/guides/apis/cancel-payment

 

 

 

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday