실버케어 플랫폼 프로젝트

[Spring] 결제(포트원) 연동[결제 전/후 검증까지]

gotopm 2025. 4. 10. 09:34
포트원 이란?

 

결제 시스템을 구현하려면 은행API나 카드사 결제 시스템을 직접 붙이면 될 것이지만,

각 결제 수단마다 연동방식도 다르며 무엇보다 보안 인증이 복잡하여 쉽사리 할 수 없는 것이 현실이다.

그래서 PG사(Payment Gateway)를 사용한다.

(PG: 온라인 스토어와 카드사, 은행 사이에서 결제 승인을 중계해주는 시스템)

 

그런데 우리 입장에서 이 PG사의 결제시스템을 붙이는 것도 쉽지 않고,

결제가 올바르게 이루어진 것인지에 대한 사전,사후 검증에 대한 API까지 직접 구현하기가 쉽지 않다.

그래서  구현하기가 쉬우면서, 사전/사후 검증에 대한 API까지 제공하는 포트원을 결제시스템으로 많은 개발자들이 이용한다. 

 

이 포스트는 포트원 세팅-백엔드 세팅(결제 전 검증, 결제 후 검증)-프론트 세팅 순으로 작성했는데,

중간에 이해가 잘 안될 시에, 실제 진행 순서인 

포트원 세팅-백엔드 세팅(결제 전 검증) - 프론트 세팅 - 백엔드 세팅(결제 후 검증) 순으로 포스트를 읽으면 더 도움이 될 수 있을 것 같다.


포트원 세팅

 

1.포트원에 회원가입 하여 사용할 채널을 선택한다.

1-1.포트원 결제 연동 화면에 들어가 결제대행사에 KG이니시스, 결제모듈에 일반/정기결제 v1을 선택한다. 

(실연동은 실제 사업자 등록번호와 정산할 실제 계좌가 있어야하므로, 프로젝트를 위해선 테스트 연동을 택한다)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

1-2. [채널이름]에 원하는 이름을 넣고, [채널 속성]-결제,[PG상점아이디]-이니시스 결제창 일반결제(INIpayTest)을 택하고 채널추가를 완료한다.

 

1-3. 그런 다음 다시 결제연동-연동정보-식별코드에 들어가보면 식별코드와 API key, API Secret 값이 나와있는 걸 확인 할 수 있다.(이 값들은 프론트와 백엔드에서 다 쓰인다)

 

 

 


 

스프링(백엔드) 세팅

 

사실 결제만 구현하는 것은 아래 공식 홈페이지를 참고해서 프론트에 코드 붙이면 쉽게 구현된다.

그러나 단순히 프론트에서 결제만 띄우는 것은 결제 금액의 위조 및 조작이 쉽기 때문에 백엔드 단에서도 결제가 이루어지기 전과 후에 검증이 필수적으로 이루어져야한다.

https://developers.portone.io/opi/ko/integration/start/v1/auth?v=v1

 

인증 결제 연동하기

PG 결제창을 이용하는 인증 결제를 연동합니다.

developers.portone.io

 

사전 검증 절차는 이러하다.

사용자가 결제버튼을 누르면 먼저 백엔드에서 주문번호,상품이름,상품금액을 포트원 서버에 전송해주고,(이 주문번호에는 이 금액이 결제되야한다고 미리 알려주는 것)그 다음 프론트에서 결제가 진행되는데 이 때 주문번호나 금액 등 정보가 방금 포트원 서버에 전송된 것과 다르다면 포트원에서 결제가 이루어지지 않게 하는 것이다.

그리고 주문번호,상품이름,상품금액과 같은 정보를 포트원 서버에 전송해 줄 때 동시에, 주문번호와 상품금액을 레디스에 저장해놓는다.(사후검증 때 사용하기 위해서) 

 

사후 검증 절차는 결제가 이루어진 뒤, 백엔드에서 포트원 서버로부터 방금 이루어진 결제 정보를 가지고온다. 그리고 결제 금액과 상태를 한 번 더 확인한다. 이 과정에서 주문번호를 가지고 레디스에 저장해놓았던 해당 주문번호에 결제해야할 금액과 방금 이루어진 금액을 비교하는 것이다.

만약 여기서 문제가 있다면 수동으로 결제를 취소하는 식의 조치를 취한다거나, API를 활용하여 자동으로 취소하는 시스템을 구축해놓을 수도 있다(여기에서는 수동으로 결제를 취소한다는 가정으로 진행했다) 

 

 

2. 스프링에 포트원 설정 추가

2-1. 포트원 라이브러리는 maven 기반으로만 의존성이 추가된다. 그래서 gradle환경에서도 의존성을 추가하기 위해서 아래와 같이 코드를 작성하면 된다.

repositories {
	mavenCentral()
	//포트원은 maven 기반으로 의존성을 추가한다(여기에 jitpack.io를 추가하면 gradle에서도 iamport라이브러리를 추가할 수 있음
	maven { url 'https://jitpack.io' }
}
.
.
.
dependencies{

//	포트원 라이브러리
	implementation 'com.github.iamport:iamport-rest-client-java:0.2.23'

}

 

2-2.포트원 라이브러리를 추가하면 포트원 서버와 소통할 수 있는 IamportClient 객체를 사용할 수 있는데,

이 객체가 내 포트원 계정과 연결된 프로젝트와 연동하기 위해서는 우리가 프로젝트 생성시에 받았던 apiKey와 secretKey를 넣어주면된다(위,포트원 1-3참고)

따라서, 먼저 yml파일에 아래와 같이 입력해주면 된다.

imp:
  code: 본인 고객사 식별코드
  access: 본인 API Key
  secret: 본인 API Secret

그런 다음, yml파일에 있는 이 키와 시크릿 값을 Value어노테이션으로 가지고 온 다음 IamportClient객체를 초기화 시켜준다. 

@Configuration
public class PortOneConfig {

    @Value("${imp.access}")
    private String apiKey;
    @Value("${imp.secret}")
    private String secretKey;

    //포트원 객체에다 내 액세스,시크릿 키를 넣어 내 가맹정 점보로 초기화 시킨 것
    @Bean
    public IamportClient iamportClient(){
        return new IamportClient(apiKey,secretKey);
    }


}

 

3. 결제 전 검증 로직

3-1. 사전검증에 관한 서비스단 로직이다.

우리 프로젝트에서는 결제할 아이템이 하나 밖에 없고, 그마저도 가격이 동일하다.

따라서 여기서 여러분들이 참고할 부분은 try~catch 블록의 바로 윗 줄 String merchantUid 줄 부터이다.

merchantUid는 결제건에 대한 고유의 번호이다. 이 번호에 해당하는 결제건에 지불해야 할 결제금액을  포트원 서버로 보내는 것이다. 그리고 다시 백엔드는 이 결제번호를 프론트에 넘기고 프론트에서 이 결제번호를 가지고 결제를 진행하게 되는데 이 때 프론트에서 결제되는 금액이 포트원 서버에서 받은 금액과 일치하지 않는다면 포트원에서 결제를 차단시킴으로써 프론트단에서 조작 및 위조를 방지하는 것이다.

그리고 merchantUid와 결제해야할 금액을 레디스에도 캐시형태로 저장해놓는다.

아래 코드에서 리턴하는 부분을 보면  CashItemPrepareResDto가 있는데 나는 이 dto에다 결제번호, 결제금액,수량,상품이름(상품이 하나밖에 없어 초기값 세팅이 되어있기에 아래 builder패턴에는 포함하지 않았다.)을 담아 프론트로 보내주었다.

 

일반적인 프로젝트라면, 프론트에서 사용자가 상품과 갯수, 결제 금액을 입력하면 백엔드 사전 검증 로직에서 해당 상품의 재고도 확인하는 등의 로직을 추가한 뒤에 포트원 서버로 관련 정보를 보낼 수도 있다.

//  1.사전검증
    public CashItemPrepareResDto preparePayment(String loginId, CashItemPrepareReqDto dto){
        User user = userRepository.findByLoginIdAndDelYN(loginId, DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 회원입니다"));
        //힐링포션 구매 수량
        int quantity = dto.getQuantity();

        if(quantity<=0){
            throw new IllegalArgumentException("유효하지 않은 수량입니다");
        }
        int totalAmount = quantity * 1000;//총 가격 =힐링포션 개수 * 1000원
        String merchantUid = String.valueOf(UUID.randomUUID()); // 결제 건을 고유하게 식별하기 위한 고유 주문번호
       try {
           //포트원 라이브러리에서 제공하는 사전검증요청객체(PrepareData)는 결제건 고유의 ID와 BigDecimal타입의 결제금액을 인자로 요구한다.
           PrepareData prepareData = new PrepareData(merchantUid, BigDecimal.valueOf(totalAmount));
           //포트원의 사전검증API를 호출. 이걸 호출하면 포트원 서버에 merchant_uid,amount가 등록되고, 이후 프론트에서 결제 실행시 금액이 다르면 결제를 막아준다.
           iamportClient.postPrepare(prepareData);
           //레디스에도 결제번호와 금액을 저장. TTL설정을 걸어 10분뒤 삭제되는 캐시형태로 저장(결제 후 비교를 위해)
           redisTemplate.opsForValue().set(merchantUid,String.valueOf(totalAmount),10, TimeUnit.MINUTES);

       } catch (IOException | IamportResponseException e){
           throw new RuntimeException("포트원 사전검증 등록 실패",e);
       }
        return CashItemPrepareResDto.builder().merchant_uid(merchantUid).amount(totalAmount).quantity(quantity).build();
    }

 

4. 결제 후 검증 로직

4-1.백엔드의 사전검증을 거친 뒤 프론트에서 결제가 성공했다면 진행되는 로직이다.

프론트에서 결제가 성공되면 imp_uid 라는 값을 받게된다. imp_uid는 결제가 성공된 건에 대해서 포트원 측에서 부여하는 고유의 번호다. 이 번호를 백엔드로 가지고 와서 이 번호를 인자값으로 하여 포트원 서버에 결제정보를 받는 것이다.

이 결제정보에 문제가 있다면 에러가 터지도록 설계했다. 문제가 없다면 정상적인 결제건이므로 결제 엔티티에 저장하는 것이다.

//   2. 사후검증
    public void afterSuccessPayment(String loginId, CashItemVerifyRequest dto) {
        User user = userRepository.findByLoginIdAndDelYN(loginId,DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 회원입니다"));

        try {
            //1.포트원 서버에서 결제 정보 조회
            //paymentByImpUid는 imp_uid로 포트원에 등록된 결제정보를 요청하는 메서드
            //imp_uid는 포트원 서버가 생성하는 것으로 실제 결제된 건을 고유하게 식별하기위한 번호다
            IamportResponse<Payment> response = iamportClient.paymentByImpUid(dto.getImp_uid());
            Payment iamportPayment = response.getResponse(); //결제정보
            //2. 결제 상태 확인
            if (!"paid".equals(iamportPayment.getStatus())) {
                throw new IllegalArgumentException("결제가 완료되지 않았습니다");
            }
            //3. 결제 금액 확인
            int paidAmount = iamportPayment.getAmount().intValue(); //포트원 서버에서 받아온 결제금액
            String redisKey = dto.getMerchant_uid();
            String expectedAmountStr = redisTemplate.opsForValue().get(redisKey); //사전검증 때 레디스에 저장해놓았던 결제금액
            if(expectedAmountStr == null){
                throw new IllegalArgumentException("사전검증 정보가 만료되거나 없습니다");
            }

            if(paidAmount !=  Integer.parseInt(expectedAmountStr)){
                throw new IllegalArgumentException("결제 금액 불일치 - 위변조 의심");
            }

            //4. 저장
            CashItem cashItem = CashItem.builder()
                    .imp_uid(dto.getImp_uid())
                    .merchant_uid(dto.getMerchant_uid())
                    .user(user)
                    .amount(paidAmount)
                    .status(iamportPayment.getStatus())
                    .build();
            cashItemRepository.save(cashItem);

        } catch(IamportResponseException | IOException e){
            throw new RuntimeException("결제 검증 중 오류 발생",e);
        }
    }

 


 

 

프론트(Vue)세팅

 

5. 프론트 코드

5-1. 먼저 뷰에서 포트원 API를 호출해놓을 수 있도록 아래 스크립트를  index.html에 작성한다.

<script src="https://cdn.iamport.kr/v1/iamport.js"></script>

 

5-2. 결제버튼을 누르면 우리가 구현해놓은 백엔드의 사전검증  API를 먼저 호출한 뒤 포트원API를 호출하여 실결제를 진행하게 한다. 그리고 실결제가 성공하면 백엔드의 사후검증 API를 호출하도록 설계했다.

이 때 사전검증을 위해 여기서는 quantity 개수만 넘겨주었지만

(우리가 구현한 서비스는 유료아이템이 같은 가격이라 개수만 받았다. 또한 아직 테스트용으로 프론트화면을 만든거라 로그인아이디도 하드코딩으로 넣어주었으니 참고)

보통의 프로젝트라면 화면에서 사용자가 선택한 결제품목,개수,가격 등의 정보를 넘겨주고 백엔드에서 받도록 해야할 것이다.

 

포트원 API호출에 넣을 값인 고객사 식별코드는 이 포스트 가장 상단부분에 안내를 해놓았다.

채널키는 포트원 콘솔의 결제 연동->연동정보->채널관리에 들어가면 확인할 수 있다.

 

<script>
import axios from 'axios';
export default{
    data(){
        return{
            quantity:10,
            loginId: "user"
        };

    },
    
    methods:{
     async requestPay(){ //결제버튼을 누르면 호출되는 함수
        try{
        const requestData = {quantity:this.quantity}; //사전검증을 위해 백엔드에 넘겨주어야할 값
        const prepareRes = await axios.post("http://localhost:8080/user-service/silverpotion/payment/prepare",requestData,{headers :{"X-User-LoginId" : this.loginId}})
        console.log(prepareRes)
        const {merchant_uid,amount,name} = prepareRes.data.result; 
        console.log("prepare에서 받은 merchant_uuid 값:", merchant_uid);
        
           //실 결제 진행
            const IMP = window.IMP;
            IMP.init("본인의 고객사 식별코드");

            IMP.request_pay(
                    {
                        channelKey: "본인의 채널키",
                        pay_method: "card",
                        merchant_uid: merchant_uid, // 주문 고유 번호
                        name: name,
                        amount: amount,
                        buyer_email: "gildong@gmail.com",
                        buyer_name: "홍길동",
                        buyer_tel: "010-4242-4242",
                        buyer_addr: "서울특별시 강남구 신사동",
                        buyer_postcode: "01181",
                    },
                    async (rsp) => {
                        if(rsp.success){ //결제가 성공하면 백엔드에 사후검증  API진행
                            const verifyData ={
                                imp_uid: rsp.imp_uid,
                                merchant_uid: rsp.merchant_uid
                            };
                        console.log(verifyData)
                        const verifyRes = await axios.post("http://localhost:8080/user-service/silverpotion/payment/afterPayment",verifyData,{headers :{"X-User-LoginId" : this.loginId}})        
                        const result =verifyRes.data.result;
                        alert(result.amount)

                        } else{
                            alert("결제실패"+rsp.error_msg)
                        }
                    },
                    );

        }catch(error){
            console.log(error)
        }
     }
    }
}
</script>

 


결제내역은 포트원 콘솔에서 결제내역-필터에 테스트 결제 체크하면- 결제 내역을 확인할 수 있다.
콘솔에서도 결제를 취소할 수도 있다.


+ 결제취소(환불 API)

결제취소 API를 사용하기 위해선 impUid(포트원 서버에서 만드는 결제에 대한 고유번호)값이 필요하다.

따라서 나 같은 경우엔 사용자 주문내역을 조회할 시 결제번호를 볼 수 있게 하고, 해당 주문에 대해 취소를 누르면

백엔드에 결제번호와 사유를 넘기도록 했다.

그러면 백엔드에서 이 결제번호를 가지고 포트원서버 측에 보내 결제취소를 요청하는 것이다.

3. 환불
    public void refundHealingPotion(String loginId, CashItemRefundDto dto){
        User user = userRepository.findByLoginIdAndDelYN(loginId,DelYN.N).orElseThrow(()->new EntityNotFoundException("없는 회원입니다"));
        System.out.println(dto.getImpUid());
       try {
           //CancelData 포트원에 결제 취소요청을 보낼때 필요한 데이터 객체로 결제번호를 인자값으로 가진다 true는 전액환불여부
           CancelData cancelData = new CancelData(dto.getImpUid(), true);
           cancelData.setReason(dto.getReason());//취소사유
           IamportResponse<Payment> cancelResponse = iamportClient.cancelPaymentByImpUid(cancelData);

           if(!"cancelled".equals(cancelResponse.getResponse().getStatus())){
               throw new IllegalArgumentException("결제 취소 실패");
           }

        CashItem cashItem =  cashItemRepository.findByImpUid(dto.getImpUid()).orElseThrow(()->new EntityNotFoundException("없는 결제건입니다"));
           System.out.println(cashItem.getImpUid());
        cashItem.changePaymentStatus(); //취소상태로 변경

       } catch (IamportResponseException | IOException e){
           throw new RuntimeException("결제 취소 중 오류 발생",e);
       }
       }
728x90