[SpringBoot] 결제 로직(1) - 전체적인 흐름

포테이토웅·2024년 7월 19일
0

springboot-결제로직

목록 보기
1/6

프로젝트 Github 주소

✏️ 개요

회사 프로젝트 중 헥토파이낸셜 결제 모듈을 연동한 경험이 있었습니다. 그러나 기획상 강좌 신청과 결제가 분리되어 약간의 아쉬움이 있었습니다. 이번 프로젝트에서는 토스페이먼츠 결제 모듈을 이용해 일반적인 이커머스 사이트처럼 상품 구매와 동시에 결제가 이루어지는 시스템을 구현했습니다.

주요 구현 및 학습 내용

  • 단일/다중 상품 결제 기능
  • 장바구니에 담은 상품 결제 기능
  • 상품 재고량 동시성 제어
  • 트랜잭션 관리
  • HikariCP 설정

🧾 Flow Chart

1. 결제하기 버튼 클릭

결제하기 버튼을 클릭하면 선택한 상품의 재고량이 있는 지 확인을 합니다.

@Transactional
public void checkAvailablePay(UserPayReqDto.CheckRequest dto) {
	// 상품 ID 파싱
    List<Long> productIds = dto.products().stream()
        .map(CheckProduct::productId)
        .toList();

    // 상품 정보 조회
    Map<Long, Product> savedProductMap = productRepository.findByProductIdIn(productIds).stream()
            .collect(Collectors.toMap(Product::getProductId, Function.identity()));
    if (savedProductMap.size() != productIds.size()) {
        throw new CustomException(ErrorCode.PRODUCT_NOT_FOUND);
    }

    // 결제 금액 계산
    final int totalAmount = getTotalAmount(dto, savedProductMap);

    // 결제 금액 검증
    if (totalAmount != dto.amount()) {
        throw new CustomException(ErrorCode.PAY_AMOUNT_NOT_MATCH);
    }

    // 장바구니 정보 조회
    Map<Long, ShoppingCart> shoppingCartMap = getShoppingCartMap(dto);

    // 결제 정보 - 결제 트랜잭션 정보 연결 테이블 저장
    TossPaymentPayTransaction tossPaymentPayTransaction = TossPaymentPayTransaction.builder()
            .orderId(dto.orderId())
            .build();
    tossPaymentPayTransactionRepository.save(tossPaymentPayTransaction);

    // 결제 트랜잭션 저장
    List<PayTransaction> payTransactions = new ArrayList<>();
    for (UserPayReqDto.CheckProduct checkProduct : dto.products()) {
        Product product = savedProductMap.get(checkProduct.productId());
        ShoppingCart shoppingCart = shoppingCartMap.get(checkProduct.shoppingCartId());

        PayTransaction payTransaction = PayTransaction.builder()
            .tossPaymentPayTransaction(tossPaymentPayTransaction)
            .product(product)
            .shoppingCart(shoppingCart)
            .quantity(checkProduct.quantity())
            .price(product.getPrice())
            .build();
        payTransactions.add(payTransaction);
    }
    payTransactionRepository.saveAll(payTransactions);
}

/**
 * 총 결제 금액 계산
 */
private int getTotalAmount(UserPayReqDto.CheckRequest dto, Map<Long, Product> savedProductMap) {
    return dto.products().stream()
        .mapToInt(product -> {
            Product savedProduct = savedProductMap.get(product.productId());

            // 재고량 검증
            if (product.quantity() > savedProduct.getStockQuantity()) {
                throw new CustomException(ErrorCode.PRODUCT_STOCK_NOT_ENOUGH);
            }

            return savedProduct.getPrice() * product.quantity();
        })
        .sum();
    }

상품ID들을 이용해 상품을 조회해 재고량 및 결제 금액이 일치하는지 검증합니다. 문제가 없다면 결제 트랜잭션 데이터를 저장해 추후 결제 로직에서 검증할 수 있도록 합니다. 만약, 장바구니를 통해 결제를 진행하는 경우 결제 완료 후 장바구니 데이터 삭제를 위해 장바구니 정보를 같이 저장합니다.

2. 결제 후 파라미터 검증

결제가 가능하다고 응답을 받으면 클라이언트에서 1차적으로 결제를 진행합니다. 카드, 휴대폰 등으로 결제를 진행하면 토스페이먼츠에서 응답 값을 줍니다. 해당 응답 값을 이용해 중간에 위/변조된 내용이 없는지 서버 측에서 검증을 진행해야 합니다.

@Transactional(readOnly = true)
public void checkAfterPay(UserPayReqDto.VerifyPayment dto) {
    // 결제 트랜잭션 조회
    TossPaymentPayTransaction tossPaymentPayTransaction = tossPaymentPayTransactionRepository.findTossPaymentPayTransactionAndPayTransactionByOrderId(dto.orderId())
            .orElseThrow(() -> new CustomException(ErrorCode.PAY_TRANSACTION_NOT_FOUND));

    // 총 결제 금액 계산
    final int totalAmount = tossPaymentPayTransaction.getPayTransactions().stream()
            .mapToInt(transaction -> transaction.getPrice() * transaction.getQuantity())
            .sum();

    // 결제 금액 검증
    if (totalAmount != dto.amount()) {
        throw new CustomException(ErrorCode.PAY_AMOUNT_NOT_MATCH);
    }
}

1번 과정에서 저장한 결제 트랜잭션 정보를 조회해 결제 금액이 일치하는지 검증합니다.

3. 결제 승인 API 호출

중간에 위/변조된 값이 없으면 서버측에서 토스페이먼츠 결제 승인 API를 호출합니다.

@PostMapping("/confirm")
public ResponseEntity<ApiResponseEntity<String>> confirm(@Valid @RequestBody UserPayReqDto.VerifyPayment dto) {
    Map<Long, Integer> transactionMap = new HashMap<>();
    TossPaymentPayTransaction tossPaymentPayTransaction;

    try {
        // 재고량 확인 및 수정
        tossPaymentPayTransaction = payConfirmService.modifyProductStockQuantity(transactionMap, dto.orderId());
    } catch (Exception e) {
        log.error("재고량 확인 및 수정 실패 :: ", e);
        rollbackProductQuantity(transactionMap);
        throw new PayApiException();
    }

    try {
        // 결제 승인 API 호출
        payConfirmService.payConfirm(tossPaymentPayTransaction, dto);
    } catch (Exception e) {
        log.error("결제 승인 API 호출 실패 :: ", e);
        rollbackProductQuantity(transactionMap);
        throw new PayApiException();
    }

    // 장바구니 정보 삭제(비동기)
    payConfirmService.removeShoppingCart(dto.orderId());

    return ResponseEntity.ok(ApiResponseEntity.of(ResponseText.SUCCESS_PAY));
}

결제 승인 API를 호출하기 전 최종적으로 재고량을 확인하고 재고량을 수정합니다.

/**
 * 상품 재고량 검증 및 감소
 */
@Transactional
public TossPaymentPayTransaction modifyProductStockQuantity(Map<Long, Integer> transactionMap, final String orderId) {
    // 결제 트랜잭션 정보 조회
    TossPaymentPayTransaction tossPaymentPayTransaction = tossPaymentPayTransactionRepository.findTossPaymentPayTransactionAndPayTransactionAndProductByOrderId(orderId)
            .orElseThrow(() -> new CustomException(ErrorCode.PAY_TRANSACTION_NOT_FOUND));

    // 상품 재고량 수정
    List<PayTransaction> payTransactions = tossPaymentPayTransaction.getPayTransactions();
    for (PayTransaction payTransaction : payTransactions) {
        final long productId = payTransaction.getProduct().getProductId();
        userProductService.decreaseProductQuantityWithLock(productId, payTransaction.getQuantity());
        transactionMap.put(productId, payTransaction.getQuantity());
    }
    return tossPaymentPayTransaction;
}

재고량 검증 및 감소 작업이 끝나면 결제 승인 API를 호출합니다. API 호출 결과가 200이면 결제 정보, 주문 정보를 저장하고 로직이 종료됩니다. 만약 API 호출 결과가 200외의 것이면 실패라 판단하고, 오류 메시지를 DB에 기록합니다.

/**
 * 결제 승인 API 호출
 */
@Transactional
public void payConfirm(TossPaymentPayTransaction tossPaymentPayTransaction, UserPayReqDto.VerifyPayment dto) {
    String paymentKey = null;
    try {
        // 결제 승인 API 호출
        PaymentConfirmApiResDto paymentConfirmApiResDto = fetchConfirmApi(dto);
        JSONObject jsonObject = paymentConfirmApiResDto.jsonObject();
        paymentKey = (String) jsonObject.get("paymentKey");
        if (paymentConfirmApiResDto.isSuccess()) {
            // 결제 승인 성공
            processPayConfirmSuccess(jsonObject, tossPaymentPayTransaction, dto.amount());
        } else {
            // 결제 승인 실패
            processPayConfirmFail(jsonObject, dto.orderId());
        }
    } catch (Exception e) {
        // 망 취소
        if (StringUtils.isNotBlank(paymentKey)) {
            payCancelService.fetchNetCancelApi(tossPaymentPayTransaction, paymentKey);
        }
        throw new PayApiException();
    }
}

/**
 * 결제 승인 API 호출
 */
@SuppressWarnings("unchecked")
private PaymentConfirmApiResDto fetchConfirmApi(UserPayReqDto.VerifyPayment dto) {
    JSONObject obj = new JSONObject();
    obj.put("orderId", dto.orderId());
    obj.put("amount", dto.amount());
    obj.put("paymentKey", dto.paymentKey());

    HttpHeaders headers = TossPaymentUtils.getCommonApiHeaders(tossSecretKey);
    HttpEntity<String> request = new HttpEntity<>(obj.toString(), headers);

    final String tossConfirmApi = "https://api.tosspayments.com/v1/payments/confirm";
    ResponseEntity<JSONObject> response = restTemplate.exchange(tossConfirmApi, HttpMethod.POST, request, JSONObject.class);

    return PaymentConfirmApiResDto.builder()
        .isSuccess(response.getStatusCode().is2xxSuccessful())
        .jsonObject(response.getBody())
        .build();
}

/**
 * 결제 승인 성공 처리
 */
private void processPayConfirmSuccess(JSONObject jsonObject, TossPaymentPayTransaction tossPaymentPayTransaction, final int totalAmount) throws InvalidKeyException {
    // JSON -> DTO 변환
    Gson gson = new Gson();
    PaymentResDto paymentResDto = gson.fromJson(jsonObject.toJSONString(), PaymentResDto.class);

    // 주문자 정보 조회
    Member member = memberRepository.getReferenceById(SecurityUtils.getCurrentUserId());

    // 결제 정보 저장
    TossPayment tossPayment = TossPayment.of(tossPaymentPayTransaction, paymentResDto);
    tossPaymentRepository.save(tossPayment);

    // 주문 정보 저장
    OrderHistory orderHistory = OrderHistory.builder()
        .tossPayment(tossPayment)
        .member(member)
        .status(PurchaseHistoryStatus.SUCCESS)
        .totalAmount(totalAmount)
        .build();
    List<OrderProduct> orderProducts = tossPaymentPayTransaction.getPayTransactions().stream()
        .map(d -> OrderProduct.builder()
            .product(d.getProduct())
            .orderHistory(orderHistory)
            .quantity(d.getQuantity())
            .amount(d.getPrice() * d.getQuantity())
            .build())
        .toList();
    orderHistory.updateOrderProducts(orderProducts);
    orderHistoryRepository.save(orderHistory);
}

/**
 * 결제 승인 실패 처리
 */
private void processPayConfirmFail(JSONObject jsonObject, final String orderId) {
    final String errorCode = (String) jsonObject.get("code");
    final String errorMessage = (String) jsonObject.get("message");

    // 결제 실패 로그 저장
    PayErrorLog payErrorLog = PayErrorLog.builder()
        .orderId(orderId)
        .code(errorCode)
        .message(errorMessage)
        .build();
    payErrorLogRepository.save(payErrorLog);

    throw new PayApiException();
}

4. 망취소

payConfirm 메소드를 보시면 어떤 예외가 발생하든 망취소 서비스를 호출하고 있습니다.

망취소란?
결제 승인 API를 호출한 후 성공 시 결제 내역을 저장하고 있습니다. 만약, 결제 내역 저장 중 오류가 발생하면 롤백이 발생하여 DB에는 아무런 내용도 반영되지 않습니다. 그러나, 이미 토스페이먼츠에서는 결제가 완료된 상태입니다. 이때, 사용자의 실제 결제를 취소하는 절차가 필요합니다. 이를 망취소라고 합니다.

망취소는 결제 승인 API 호출 이후의 로직에서 오류가 발생했을 떄, 이미 완료된 결제를 취소하여 사용자가 실제로 금전적인 피해를 보지 않도록 하는 중요한 절차입니다. 이를 통해 결제 시스템의 신뢰성을 유지할 수 있습니다.

@Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = PayApiException.class)
public void fetchNetCancelApi(TossPaymentPayTransaction tossPaymentPayTransaction, final String paymentKey) {
    log.error("[{}] :: 망취소 API 호출", paymentKey);

    JSONObject obj = new JSONObject();
    obj.put("cancelReason", "망취소");

    HttpHeaders headers = TossPaymentUtils.getCommonApiHeaders(tossSecretKey);
    HttpEntity<String> request = new HttpEntity<>(obj.toString(), headers);

    final String tossCancelApi = "https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel";
    ResponseEntity<JSONObject> response = restTemplate.exchange(tossCancelApi, HttpMethod.POST, request, JSONObject.class);

    if (!response.getStatusCode().is2xxSuccessful()) {
        log.error("[{}] :: 망취소 API 호출 실패", paymentKey);
        return;
    }

    // 망취소 로그 저장
    PayNetCancelLog payNetCancelLog = PayNetCancelLog.builder()
        .tossPaymentPayTransaction(tossPaymentPayTransaction)
        .paymentKey(paymentKey)
        .build();
    payNetCancelLogRepository.save(payNetCancelLog);
    log.error("[{}] :: 망취소 API 호출 성공", paymentKey);
}

이미 승인된 결제를 취소하는 API를 호출합니다. 그 후, 망취소 로그를 별도로 저장하여 관리합니다. 만약, 망취소 자체가 실패하면, 관리자에게 알림을 보내 수동으로 처리하는 등의 추가 절차가 필요합니다. 이러한 방법으로 결제 과정의 신뢰성과 안전성을 확보할 수 있습니다.


📝 ERD

profile
주경야독

0개의 댓글