부트캠프의 마지막 프로젝트로 무료 취소 불가한 숙박 상품 거래 플랫폼을 만들게 되었고 그 중 결제부분을 맡게 되었다.
우리 서비스가 네고기능도 있고 여러가지의 상태값, 예를들어 상품상태, 네고상태, 주문상태...에 따라서 처리해야할 것들이 많아서 결제만 깔끔하게 딱 이루어진 코드는 없지만 각 상태에 따라서 결제를 어떻게 처리 했는지 기록해보려고 한다.
pg사 연동 코드를 찾는 이들에게 조금의 도움이 될까 싶어 끄적끄적 적어보았다
프로젝트의 전체적인 erd는 위와 같고 내가 집중해서 봐야하는 쪽은 아래 확대한 부분이다
상품 테이블: 등록된 상품에 대한 정보
주문 테이블: 결제 상세 페이지에 들어가게 되었을때 저장되는 주문 관련 정보
네고 테이블: 네고를 진행 했을시 저장되는 정보
결제 테이블: 결제 시도 후 포트원 결제 단건조회 결과를 저장
취소내역 테이블: 환불 시 저장되는 결제 관련 정보
build.gradle
repositories {
mavenCentral()
//iamport
maven {
url 'https://jitpack.io'
}
}
dependencies{
//iamport
implementation 'com.github.iamport:iamport-rest-client-java:0.2.23'
}
imp_key, imp_secret_key는 properties 파일에 올리고 숨겨서 사용하였다.
그리고 IamportClient를 빈으로 등록하여 사용할수 있도록 설정하였다
IamportApiProperty.java
@Getter
@Configuration
@PropertySource("classpath:/secret.properties")
public class IamportApiProperty {
@Value("${imp_key}")
private String impKey;
@Value("${imp_secret}")
private String impSecret;
}
IamportConfig.java
@Configuration
@RequiredArgsConstructor
public class IamportConfig {
private final IamportApiProperty iamportApiProperty;
@Bean
public IamportClient iamportClient() {
return new IamportClient(iamportApiProperty.getImpKey(), iamportApiProperty.getImpSecret());
}
}
전체적인 결제 진행 과정과 상태값은 다음과 같다.
주문 상세 페이지 -(결제 버튼 누름)→ 사전 검증 -(사전검증 성공시)→ 프론트 측에서 pg사 결제 진행 -(결제 완료 시 결제 결과 서버측으로 넘김)→ 사후 검증 -(사후검증 완료시)→ 결제 완료
우리의 서비스는 네고가 만약 성공했다면 그 사람에게 해당 상품이 20분간 예약이 되고 그 시간동안에 다른사람은 해당 상품을 구매할수도, 네고할 수도 없다.
그리고 만약 그 사람이 해당 상품을 20분 내에 구매하지 않았더라도 네고에 성공했기 때문에 그 사람은 네고를 성공한 가격에 해당 상품을 구매할 수 있도록 해야했다.
그래서 위와 같이 많은 상태값을 이용하게 되었다.
사용자가 주문하기 버튼을 눌렀을때 주문 상세 페이지로 들어오게 되고 이때 주문 테이블에 주문 정보를 저장한다.
(최대한 결제관련해서만 적어보려고 생략한 부분이 많습니다! 자세한 코드는 아래 깃허브 링크를 참고해주세요 :-) )
PaymentService.java
public PaymentDetailResponse getPaymentDetail(Long productId,
PrincipalDetails principalDetails) {
User user = userService.findById(principalDetails.getUserId());
//상품상태에 따른 처리 과정 생략
int price = product.getGoldenPrice();
Order order = Order.of(product.getId(), user.getId(), null, price);
//네고 확인하는 과정 생략
Order savedOrder = orderRepository.save(order);
return PaymentDetailResponse.of(savedOrder.getId(), user, product, price,
product.getYanoljaPrice());
}
사전검증시 백엔드에서는 포트원측에 주문 id , 결제 금액을 저장하는 작업을 진행한다.
해당 작업이 완료되면 프론트 측으로 결제 관련 정보들을 응답으로 넘겨 주게 되고 응답으로 넘어온 데이터를 이용하여 포트원 결제를 진행하도록 하였다.
이때, 포트원 측에 주문 id, 결제 금액을 저장하는 이유는 넘어온 데이터를 이용하여 포트원 결제를 진행할때 포트원 측에서 저장된 데이터와 결제를 진행하는 데이터가 같은지 검증하기 위하여 다음과 같은 처리를 한다고 한다!
PaymentController.java
@PostMapping("/{orderId}/prepare")
public ResponseEntity<CommonResponse<PaymentReadyResponse>> preparePayment(
@PathVariable(name = "orderId") final Long productId,
@AuthenticationPrincipal PrincipalDetails principalDetails) {
PaymentReadyResponse paymentReadyResponse = paymentService.preparePayment(productId, principalDetails);
return ResponseEntity.ok(CommonResponse.ok("Payment ready successfully", paymentReadyResponse));
}
PaymentService.java
public PaymentReadyResponse preparePayment(Long orderId, PrincipalDetails principalDetails) {
User user = userService.findById(principalDetails.getUserId());
Order order = orderRepository.findById(orderId).orElseThrow(
() -> new CustomException(ErrorCode.ORDER_NOT_FOUND)
);
Product product = productService.getProduct(order.getProductId());
if (product.isNotOnSale()) {
throw new CustomException(ErrorCode.PRODUCT_NOT_ON_SALE);
}
//상품상태 = 예약중, 해당 경우에 본인이 네고를 진행하였고 결제 대기중인지 확인
Optional<Nego> nego = negoRepository.findFirstByUser_IdAndProduct_IdOrderByCreatedAtDesc(
user.getId(), product.getId());
if (product.getProductStatus() == ProductStatus.RESERVED) {
if (nego.isEmpty()) {
throw new CustomException(ErrorCode.PRODUCT_NOT_ON_SALE);
}
if (nego.get().getStatus() != NegotiationStatus.PAYMENT_PENDING) {
throw new CustomException(ErrorCode.PRODUCT_NOT_ON_SALE);
}
}
//주문 상태 = 결제 대기중 으로 변경
order.requestPayment();
//포트원 측에 주문 id, 결제 금액을 저장
iamportRepository.prepare(order.getId(), BigDecimal.valueOf(order.getTotalPrice()));
return PaymentReadyResponse.create(user, product, order);
}
포트원 측에 저장된 실제 결제값과 우리쪽 주문 데이터베이스에 저장된 결제되어야 하는 값을 비교하는 작업을 진행한다.
이때 우리의 결과값은 SUCCESS ,TIME_OVER, FAILED 세가지였다.
SUCCESS = 정상적으로 결제 완료된 건
TIME_OVER = 네고 성공 후 시간 초과
FAILED = 결제가 되지 않은 경우, 더 이상 살 수 없는 상품일 경우
여기서 네고 성공한 사람이 주문 상세 페이지에 들어올때는 20분간 예약중인 시간 내에 들어왔지만 결제 완료시에 그 시간이 지났다면 해당 거래를 취소시키도록 서비스가 기획되었다.
이때 TIME_OVER인 경우와 FAILED에서 더 이상 살 수 없는 상품일 경우에는 이미 사용자의 카드 결제가 완료 되었지만 주문을 실패처리해야하기 때문에 결제된 것을 취소처리 하는 과정을 추가하였다.
PaymentController.java
@PostMapping("/result")
public ResponseEntity<CommonResponse<PaymentResponse>> savePayment(
@Valid @RequestBody PaymentRequest request,
@AuthenticationPrincipal PrincipalDetails principalDetails
) {
PaymentResponse paymentResponse = paymentService.savePayment(request, principalDetails);
return ResponseEntity.ok(CommonResponse.ok("Payment result", paymentResponse));
}
findPaymentByImpUid
를 이용하여 결제 내역을 조회할 수 있다.
PaymentService.java
public PaymentResponse savePayment(PaymentRequest request, PrincipalDetails principalDetails) {
Long userId = principalDetails.getUserId();
Payment payment = iamportRepository.findPaymentByImpUid(request.getImpUid())
.orElseThrow(() -> new CustomException(ErrorCode.PAYMENT_NOT_FOUND));
Order order = orderRepository.findById(request.getOrderId())
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
Product product = productService.getProduct(order.getProductId());
Payment savedPayment = paymentRepository.save(payment);
if (payment.isDifferentAmount(order.getTotalPrice())) {
throw new CustomException(ErrorCode.INVALID_PAYMENT_AMOUNT_ERROR);
}
이후 각 상황에 맞는 결과값을 내려준다.
//결제가 되지 않은 경우
if (savedPayment.isNotPaid()) {
order.paymentFailed();
return PaymentResponse.failed();
}
//상품상태 = 상품만료, 솔드아웃
if (product.isNotOnSale()) {
cancelPayment(request.getImpUid());
return PaymentResponse.failed();
}
Optional<Nego> nego = negoRepository.findFirstByUser_IdAndProduct_IdOrderByCreatedAtDesc(
userId, product.getId());
//상품상태 = 예약중
if (product.getProductStatus() == ProductStatus.RESERVED) {
if (nego.isEmpty()) {
cancelPayment(request.getImpUid());
return PaymentResponse.failed();
}
if (nego.get().getStatus() != order.getNegoStatus()) {
cancelPayment(request.getImpUid());
return PaymentResponse.failed();
}
if (nego.get().getStatus() != NegotiationStatus.PAYMENT_PENDING) {
cancelPayment(request.getImpUid());
return PaymentResponse.failed();
}
}
//상품상태 = 판매중
//네고 내역 존재할 때
if (nego.isPresent()) {
//네고 상태: 결제 대기중 -> 타임아웃 => 결제 결과 타임오버
if (nego.get().getStatus() != order.getNegoStatus()) {
cancelPayment(request.getImpUid());
return PaymentResponse.timeOver();
}
nego.get().transferPending();
}
order.waitTransfer();
product.setProductStatus(ProductStatus.RESERVED);
// 채팅방, 알림관련 생략
return PaymentResponse.success(chatRoomId);
}
여기까지가 내가 구현한 결제부분이었다. 사실 서비스를 이해하느라 더 오랜시간이 걸렸었고 이를 정리하고 수정하고 일어날 수 있는 모든 케이스를 찾아내야만 했기 때문에 완벽한 결제 구현을 못한것이 아쉬웠다.
하지만 이번 기회로 포트원을 사용해 볼 수 있어서 좋았고 좀 더 최적화 하는 과정을 거칠 예정이다!