0+ 스프링

[스프링 + 포트원] 스프링으로 포트원 사용해서 결제 구현 하는 방법(Spring Boot, JPA, PortOne)

힘들면힘을내는쿼카 2023. 6. 3. 15:31
728x90
반응형

[스프링 + 포트원] 스프링으로 포트원 사용해서 결제 구현 하는 방법(Java, Spring Boot, JPA, PortOne)

 

 

포스팅 동기

쇼핑몰을 개발하면 결제 부분에서 어떻게 해야할지 막막한 개발자들이 있을 것 입니다.
저 또한 그랬습니다. 😭

 

결제를 구현하기 위해서는 각 PG사에서 제공하는 API를 사용하여 개발을 진행해야하는데,
만만한 작업이 아니라고 생각합니다…

 

이러한 문제를 해결하고자 포트원이라는 업체가 등장했습니다.
그런데…. 이마저도 막상 개발하다보면 어려움을 느낍니다…

 

그리고 구글링을 하다보면 대부분 javascript를 사용하여 구현한 예제들만 있고,
Spring을 사용한 예제는 별로 없는 것 같더군요.. ㅎㅎ

 

저는 이러한 답답함에 조금이라도 도움을 드리고 싶어 글을 작성하게 되었습니다.^^

 

대한민국 결제 흐름

먼저 우리나라의 결제 흐름에 대해서 이해하셔야 개발하는데 수월 합니다.!!

우리나라의 경우 해외와 다르게 일부 PG사를 제외하고는 카드정보를 저장할 수 없도록 규정되어 있습니다.
그래서 카드정보가 가맹점 서버, PG사 서버를 직접 거쳐가지 않도록 구성하고 있습니다.

 

  1. 구매자 브라우저에서 카드사 서버로 카드 정보 전달
  2. 카드사 서버가 구매자 브라우저로 카드 인증 결과 회신
  3. 구매자 브라우저가 가맹점 서버로 결제 요청
  4. 가맹점 서버가 계약한 PG사 서버로 결제 요청
  5. PG사 서버가 카드사 서버로 결제 승인 요청
  6. 카드사 서버가 PG사 서버로 결제 승인 응답
  7. PG사 서버가 가맹점 서버로 결제 결과 응답
  8. 가맹점 서버가 구매자 브라우저로 결제 요청 결과 응답

 

참고: 대한민국 결제 흐름

 

포트원 결제 흐름

 

가맹점 서버가 해야할 일은 결제 내역을 검증하는 역할을 하면 됩니다.
결제를 처리하는 곳은 포트원과 PG사라고 이해하시면 될것 같습니다.

 

프로젝트 설명

여러분은 💵 1달러샵이라는 쇼핑몰에 접속하였습니다.!
1달러샵은 1달러를 주문하고 결제할 수 있는 쇼핑몰(?) 입니다.
1달러샵의 흐름은 아래와 같습니다.

https://youtube.com/shorts/QBkIJi62zrs?feature=share

 

흐름

1. 1달러샵에 접속
2. 주문하기 클릭
 2-1. 이때 자동으로 회원가입이 됩니다.
 2-2. 2-1에서 가입한 회원이 자동으로 상품을 주문합니다.
3. 결제하기 클릭
4. PG사의 결제창 진입
5. 결제 완료

 

참고
결제 모듈에 집중할 수 있도록 프로젝트는 최대한 간단하게 구성하였습니다. 😃

 

프로젝트 생성

프로젝트 설정

 

라이브러리 설정

 

Gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.0'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'seaung'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
    // 아임포트 관련
    maven {url 'https://jitpack.io'}
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // 아임포트 관련 //
    // https://mvnrepository.com/artifact/com.github.iamport/iamport-rest-client-java
    implementation group: 'com.github.iamport', name: 'iamport-rest-client-java', version: '0.2.22'
    // https://mvnrepository.com/artifact/com.squareup.retrofit2/adapter-rxjava2
    implementation group: 'com.squareup.retrofit2', name: 'adapter-rxjava2', version: '2.9.0'
    // https://mvnrepository.com/artifact/com.google.code.gson/gson
    implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.5'
    // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
    implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.9.3'
    // https://mvnrepository.com/artifact/com.squareup.retrofit2/converter-gson
    implementation group: 'com.squareup.retrofit2', name: 'converter-gson', version: '2.3.0'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

외부 라이브러리를 확인하면 아래와 같습니다.

 

참고: GitHub - iamport/iamport-rest-client-java: JAVA사용자를 위한 아임포트 REST API 연동 모듈입니다

 

yml

spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:onedoller-shop
    username: sa
    password:
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 1000
    open-in-view: false
  #        show_sql: true
  h2:
    console:
      enabled: true
      path: /h2-console

 

DB

 

PortOne 설정

PG사 선택

PG사는 KG 이니시스를 사용했습니다.

 

 

환경변수

구현에 필요한 포트원 환경변수 입니다.

 

 

Config

@Configuration
public class AppConfig {

    String apiKey = "REST API Key를 입력합니다.";
    String secretKey = "REST API Secret를 입력합니다.";

    @Bean
    public IamportClient iamportClient() {
        return new IamportClient(apiKey, secretKey);
    }
}

 

Entity

Member

@Entity
@Getter
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String email;
    private String address;

    @Builder
    public Member(String username, String email, String address) {
        this.username = username;
        this.email = email;
        this.address = address;
    }
}

 

Order

@Entity
@Getter
@Table(name = "orders")
@NoArgsConstructor
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long price;
    private String itemName;
    private String orderUid; // 주문 번호
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "payment_id")
    private Payment payment;

    @Builder
    public Order(Long price, String itemName, String orderUid, Member member, Payment payment) {
        this.price = price;
        this.itemName = itemName;
        this.orderUid = orderUid;
        this.member = member;
        this.payment = payment;
    }
}

 

Payment

@Entity
@Getter
@NoArgsConstructor
public class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long price;
    private PaymentStatus status;
    private String paymentUid; // 결제 고유 번호

    @Builder
    public Payment(Long price, PaymentStatus status) {
        this.price = price;
        this.status = status;
    }

    public void changePaymentBySuccess(PaymentStatus status, String paymentUid) {
        this.status = status;
        this.paymentUid = paymentUid;
    }
}

 

PaymentStatus

public enum PaymentStatus {
    OK,
    READY,
    CANCEL
}

 

Repository

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {
}

 

OrderRepository

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("select o from Order o" +
            " left join fetch o.payment p" +
            " left join fetch o.member m" +
            " where o.orderUid = :orderUid")
    Optional<Order> findOrderAndPaymentAndMember(String orderUid);

    @Query("select o from Order o" +
            " left join fetch o.payment p" +
            " where o.orderUid = :orderUid")
    Optional<Order> findOrderAndPayment(String orderUid);
}

 

PaymentRepository

public interface PaymentRepository extends JpaRepository<Payment, Long> {
}

 

Service

MemberService
주문하기 버튼을 누르면 자동으로 회원을 가입시키는 로직

public interface MemberService {
    Member autoRegister(); // 자동 회원 가입
}

 

MemberServiceImpl

@Service
@Transactional
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    // 회원 자동 생성
    @Override
    public Member autoRegister() {
        Member member = Member.builder()
                .username(UUID.randomUUID().toString())
                .email("example@example.com")
                .address("서울특별시 서초구 역삼동")
                .build();

        return memberRepository.save(member);
    }
}

 

OrderService
주문하기 버튼을 클릭하면 자동으로 상품을 주문한다.

public interface OrderService {
    Order autoOrder(Member member); // 자동 주문
}

 

OrderServiceImpl

@Service
@Transactional
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    @Override
    public Order autoOrder(Member member) {

        // 임시 결제내역 생성
        Payment payment = Payment.builder()
                .price(1000L)
                .status(PaymentStatus.READY)
                .build();

        paymentRepository.save(payment);

        // 주문 생성
        Order order = Order.builder()
                .member(member)
                .price(1000L)
                .itemName("1달러샵 상품")
                .orderUid(UUID.randomUUID().toString())
                .payment(payment)
                .build();

        return orderRepository.save(order);
    }
}

 

PaymentService
뷰로 전달할 결제 요청 데이터를 조회하는 메서드결제를 검증하는 메서드 정의했습니다.

public interface PaymentService {
    // 결제 요청 데이터 조회
    RequestPayDto findRequestDto(String orderUid);
    // 결제(콜백)
    IamportResponse<Payment> paymentByCallback(PaymentCallbackRequest request);
}

 

PaymentServiceImpl

@Service
@Transactional
@RequiredArgsConstructor
public class PaymentServiceImpl implements PaymentService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;
    private final IamportClient iamportClient;

    @Override
    public RequestPayDto findRequestDto(String orderUid) {

        Order order = orderRepository.findOrderAndPaymentAndMember(orderUid)
                .orElseThrow(() -> new IllegalArgumentException("주문이 없습니다."));

        return RequestPayDto.builder()
                .buyerName(order.getMember().getUsername())
                .buyerEmail(order.getMember().getEmail())
                .buyerAddress(order.getMember().getAddress())
                .paymentPrice(order.getPayment().getPrice())
                .itemName(order.getItemName())
                .orderUid(order.getOrderUid())
                .build();
    }

    @Override
    public IamportResponse<Payment> paymentByCallback(PaymentCallbackRequest request) {

        try {
            // 결제 단건 조회(아임포트)
            IamportResponse<Payment> iamportResponse = iamportClient.paymentByImpUid(request.getPaymentUid());
            // 주문내역 조회
            Order order = orderRepository.findOrderAndPayment(request.getOrderUid())
                    .orElseThrow(() -> new IllegalArgumentException("주문 내역이 없습니다."));

            // 결제 완료가 아니면
            if(!iamportResponse.getResponse().getStatus().equals("paid")) {
                // 주문, 결제 삭제
                orderRepository.delete(order);
                paymentRepository.delete(order.getPayment());

                throw new RuntimeException("결제 미완료");
            }

            // DB에 저장된 결제 금액
            Long price = order.getPayment().getPrice();
            // 실 결제 금액
            int iamportPrice = iamportResponse.getResponse().getAmount().intValue();

            // 결제 금액 검증
            if(iamportPrice != price) {
                // 주문, 결제 삭제
                orderRepository.delete(order);
                paymentRepository.delete(order.getPayment());

                // 결제금액 위변조로 의심되는 결제금액을 취소(아임포트)
                iamportClient.cancelPaymentByImpUid(new CancelData(iamportResponse.getResponse().getImpUid(), true, new BigDecimal(iamportPrice)));

                throw new RuntimeException("결제금액 위변조 의심");
            }

            // 결제 상태 변경
            order.getPayment().changePaymentBySuccess(PaymentStatus.OK, iamportResponse.getResponse().getImpUid());

            return iamportResponse;

        } catch (IamportResponseException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

 

Dto

RequestPayDto
View로 전달할 결제 관련 데이터 입니다.

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class RequestPayDto {
    private String orderUid;
    private String itemName;
    private String buyerName;
    private Long paymentPrice;
    private String buyerEmail;
    private String buyerAddress;

    @Builder
    public RequestPayDto(String orderUid, String itemName, String buyerName, Long paymentPrice, String buyerEmail, String buyerAddress) {
        this.orderUid = orderUid;
        this.itemName = itemName;
        this.buyerName = buyerName;
        this.paymentPrice = paymentPrice;
        this.buyerEmail = buyerEmail;
        this.buyerAddress = buyerAddress;
    }
}

 

PaymentCallbackRequest
결제가 이루어진 후 서버가 전달받는 데이터 입니다.

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PaymentCallbackRequest {
    private String paymentUid; // 결제 고유 번호
    private String orderUid; // 주문 고유 번호
}

 

MemberRequest

@Data
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class MemberRequest {
    private String username;
    private String email;
    private String address;
}

 

Controller

HomeController

@Controller
public class HomeController {

    @GetMapping("/")
    public String home() {
        return "home";
    }
}

 

OrderController

@Controller
@RequiredArgsConstructor
public class OrderController {

    private final MemberService memberService;
    private final OrderService orderService;

    @GetMapping("/order")
    public String order(@RequestParam(name = "message", required = false) String message,
                        @RequestParam(name = "orderUid", required = false) String id,
                        Model model) {

        model.addAttribute("message", message);
        model.addAttribute("orderUid", id);

        return "order";
    }

    @PostMapping("/order")
    public String autoOrder() {
        Member member = memberService.autoRegister();
        Order order = orderService.autoOrder(member);

        String message = "주문 실패";
        if(order != null) {
            message = "주문 성공";
        }

        String encode = URLEncoder.encode(message, StandardCharsets.UTF_8);

        return "redirect:/order?message="+encode+"&orderUid="+order.getOrderUid();
    }
}

 

PaymentController

@Slf4j
@Controller
@RequiredArgsConstructor
public class PaymentController {

    private final PaymentService paymentService;

    @GetMapping("/payment/{id}")
    public String paymentPage(@PathVariable(name = "id", required = false) Long id,
                              Model model) {

        RequestPayDto requestDto = paymentService.findRequestDto(id);
        model.addAttribute("requestDto", requestDto);
        return "payment";
    }

    @ResponseBody
    @PostMapping("/payment")
    public ResponseEntity<IamportResponse<Payment>> validationPayment(@RequestBody PaymentCallbackRequest request) {
        IamportResponse<Payment> iamportResponse = paymentService.paymentByCallback(request);

        log.info("결제 응답={}", iamportResponse.getResponse().toString());

        return new ResponseEntity<>(iamportResponse, HttpStatus.OK);
    }

    @GetMapping("/success-payment")
    public String successPaymentPage() {
        return "success-payment";
    }

    @GetMapping("/fail-payment")
    public String failPaymentPage() {
        return "fail-payment";
    }
}

 

View

home

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>1달러샵</title>
</head>
<body>
  <h1>1달러샵에 오신걸을 환영합니다. ^^</h1>
  <a href="/order">주문페이지로 이동</a>
</body>
</html>

 

order

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>1달러샵</title>
</head>
<body>
    <form method="post">
        <h1>주문 페이지</h1>
        <div>
            <input type="submit" value="주문하기">
        </div>
        <div style="margin-top: 20px">
            <a th:href="@{/payment/{orderUid} (orderUid=${orderUid})}">결제 페이지로 이동</a>
        </div>
        <p th:text="${message}"></p>
    </form>
</body>
</html>

 

payment

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>1달러샵</title>
    <script src="https://cdn.iamport.kr/v1/iamport.js"></script>
    <script type="text/javascript" src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
    <script>
        var IMP = window.IMP;
        IMP.init("가맹점식별코드를 입력합니다.");

        function requestPay() {

            var orderUid = '[[${requestDto.orderUid}]]';
            var itemName = '[[${requestDto.itemName}]]';
            var paymentPrice = [[${requestDto.paymentPrice}]];
            var buyerName = '[[${requestDto.buyerName}]]';
            var buyerEmail = '[[${requestDto.buyerEmail}]]';
            var buyerAddress = '[[${requestDto.buyerAddress}]]';

            IMP.request_pay({
                    pg : 'html5_inicis.INIpayTest',
                    pay_method : 'card',
                    merchant_uid: orderUid, // 주문 번호
                    name : itemName, // 상품 이름
                    amount : paymentPrice, // 상품 가격
                    buyer_email : buyerEmail, // 구매자 이메일
                    buyer_name : buyerName, // 구매자 이름
                    buyer_tel : '010-1234-5678', // 임의의 값
                    buyer_addr : buyerAddress, // 구매자 주소
                    buyer_postcode : '123-456', // 임의의 값
                },
                function(rsp) {
                    if (rsp.success) {
                        alert('call back!!: ' + JSON.stringify(rsp));
                        // 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우
                        // jQuery로 HTTP 요청
                        jQuery.ajax({
                            url: "/payment",
                            method: "POST",
                            headers: {"Content-Type": "application/json"},
                            data: JSON.stringify({
                                "payment_uid": rsp.imp_uid,      // 결제 고유번호
                                "order_uid": rsp.merchant_uid   // 주문번호
                            })
                        }).done(function (response) {
                            console.log(response);
                            // 가맹점 서버 결제 API 성공시 로직
                            //alert('Please, Check your payment result page!!' + rsp);
                            alert('결제 완료!' + rsp);
                            window.location.href = "/success-payment";
                        })
                    } else {
                        // alert("success? "+ rsp.success+ ", 결제에 실패하였습니다. 에러 내용: " + JSON.stringify(rsp));
                        alert('결제 실패!' + rsp);
                        window.location.href = "/fail-payment";
                    }
                });
        }
    </script>
</head>
<body>
    <h1>결제 페이지</h1>
    <button th:with="requestDto = ${requestDto}" onclick="requestPay()">
        결제하기
    </button>
</body>
</html>

 

fail-payment

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>1달러샵</title>
</head>
<body>
<h1>결제실패</h1>
<a href="/">홈으로 이동</a>
</body>
</html>

 

success-payment

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>1달러샵</title>
</head>
<body>
<h1>결제성공</h1>
<a href="/">홈으로 이동</a>
</body>
</html>

 

실행

 

주문페이지

주문페이지로 이동 합니다.

주문하기

 

서버를 보면 아래와 같은 쿼리가 발생한것을 확인할 수 있습니다.

Hibernate: 
    insert 
    into
        member
        (address,email,username,id) 
    values
        (?,?,?,default)
Hibernate: 
    insert 
    into
        payment
        (payment_uid,price,status,id) 
    values
        (?,?,?,default)
Hibernate: 
    insert 
    into
        orders
        (item_name,member_id,order_uid,payment_id,price,id) 
    values
        (?,?,?,?,?,default)

 

H2 데이터베이스를 확인하면 데이터가 잘 들어간 것을 확인할 수 있습니다.

 

결제 페이지

결제 페이지로 이동합니다.

 

결제하기

결제하기를 클릭합니다.
KG 이니시스 결제창이 생기는 것을 확인할 수 있습니다.

 

이용약관에 동의하고 결제를 원하는 카드를 선택하고 다음을 클릭합니다.

 

결제 버튼을 클릭합니다.

 

포트원에서 보내준 결제 관련 데이터 입니다.

 

 

결제가 완료되면 결제 성공 페이지로 이동 합니다.

 

서버에 아래와 같은 쿼리가 발생한 것을 확인할 수 있습니다.

Hibernate: 
    select
        o1_0.id,
        o1_0.item_name,
        m1_0.id,
        m1_0.address,
        m1_0.email,
        m1_0.username,
        o1_0.order_uid,
        p1_0.id,
        p1_0.payment_uid,
        p1_0.price,
        p1_0.status,
        o1_0.price 
    from
        orders o1_0 
    left join
        payment p1_0 
            on p1_0.id=o1_0.payment_id 
    left join
        member m1_0 
            on m1_0.id=o1_0.member_id 
    where
        o1_0.order_uid=?
Hibernate: 
    select
        o1_0.id,
        o1_0.item_name,
        o1_0.member_id,
        o1_0.order_uid,
        p1_0.id,
        p1_0.payment_uid,
        p1_0.price,
        p1_0.status,
        o1_0.price 
    from
        orders o1_0 
    left join
        payment p1_0 
            on p1_0.id=o1_0.payment_id 
    where
        o1_0.order_uid=?
Hibernate: 
    update
        payment 
    set
        payment_uid=?,
        price=?,
        status=? 
    where
        id=?

 

payment_uid에 결제 고유 번호가 저장된 것을 확인할 수 있습니다. (imp_406743040110)

 

포트원 확인

결제 > 상세 내역 조회에 들어가면 방금 결제한 내역을 확인할 수 있습니다.

먼저 필터에 테스트 결제를 추가합니다.

 

 

결제 상세 내역을 확인하면 결제 고유 번호가 일치하는것을 확인할 수 있습니다.!! 👍

 

기타

더 자세한 연동 방법은 아래 링크를 참고하셔서 개발하시면 됩니다.^^
포트원 결제 연동 Docs

 

포트원 결제 연동 Docs

 

developers.portone.io

 

 

 

 

728x90
반응형