프로젝트/유레카_미니프로젝트

[결제] 포트원(구 아임포트) API 연동 및 구현 - 1. 포트원 API 연동하여 결제기능 구현 (React, Spring)

위시리 2025. 5. 22. 10:43

포트원에 개발자 콘솔에서 test 환경을 제공한다. test 환경을 이용하여 포트원 API 연동을 진행해보았다.

 

01 포트원 회원 가입

https://portone.io/korea/ko

 

포트원 | 온라인 비즈니스 성장을 돕는 기업

포트원이 제공하는 단 한 줄의 코드로 세상의 모든 결제를 손쉽게 연동해보세요. PG사 통합결제 연동, 해외결제, 파트너 정산 관리, 결제 애널리틱스, 수수료 혜택까지, 포트원의 맞춤 컨설팅을

portone.io

 

1. 결제 연동 - 연동 정보 - 테스트

 

2. 결제 연동 - 연동 정보 - 채널 추가

  • 결제 대행사 : KG 이니시스
  • 결제 모듈 : 일반결제 (html5_inicis)

 

 

API 연동 및 사전 검증

1. 프론트 - React

사용자가 이름, 사용자 이메일, 게시글 제목, 결제 요청 금액을 입력하면 포트원 결제 창에 값 전달

 

localhost:3000

 

PortOnePayment.jsx

import React, { useEffect, useState } from "react";
import axios from "axios";
import { useNavigate, useParams } from "react-router-dom";

const PaymentPage = () => {
  const [userNickname, setUserNickname] = useState("");
  const [userEmail, setUserEmail] = useState("");
  const [taskTitle, setTaskTitle] = useState("");
  const [requesterSelectedAmount, setRequesterSelectedAmount] = useState("");
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();
  const { taskId } = useParams();

  // 포트원 스크립트 로딩
  useEffect(() => {
    const script = document.createElement("script");
    script.src = "https://cdn.iamport.kr/js/iamport.payment-1.2.0.js";
    script.async = true;
    document.body.appendChild(script);
    return () => {
      document.body.removeChild(script);
    };
  }, []);

  const onClickPayment = async () => {
    if (!window.IMP) {
      alert("결제 모듈 로딩 실패");
      return;
    }

    const { IMP } = window;
    IMP.init("imp--"); // 포트원 API : 고객사 식별코드

    // 이메일 입력은 선택
    // if (!userNickname || !userEmail || !taskTitle || !requesterSelectedAmount) {
    // alert("모든 항목을 입력해주세요");
    // return;
    // }

    setLoading(true);

    IMP.request_pay(
      {
        pg: "html5_inicis",
        pay_method: "card",
        merchant_uid: `mid_${new Date().getTime()}`,
        name: taskTitle,
        amount: Number(requesterSelectedAmount),
        buyer_name: userNickname,
        buyer_email: userEmail,
      },
      async (response) => {
        if (response.success) {
          console.log(response);
          try { // 서버에 post로 전송하여 검증 요청
            await axios.post("/api/payments/validation", {
              impUid: response.imp_uid,
              merchantUid: response.merchant_uid,
              amount: response.paid_amount,
              taskId: taskId,
            });
            alert("결제 성공 및 서버 검증 완료");
            navigate("/detail");
          } catch (err) {
            console.error("서버 에러 응답:", err.response);
            alert("서버 결제 검증 실패");
            navigate("/");
          }
        } else {
          alert(`결제 실패: ${response.error_msg}`);
        }
        setLoading(false);
      }
    );
  };

  return (
    <div style={{ padding: "20px" }}>
      <h1>심부름 상세 - 결제 요청 페이지</h1>

      {/* <div style={{ marginBottom: "10px" }}>
        <label>사용자 이름:</label>
        <br />
        <input
          type="text"
          value={userNickname}
          onChange={(e) => setUserNickname(e.target.value)}
          placeholder="닉네임"
        />
      </div> */}

      <div style={{ marginBottom: "10px" }}>
        <label>사용자 이름:</label>
        <br />
        <input type="text" value={userNickname} readOnly />
      </div>

      <div style={{ marginBottom: "10px" }}>
        <label>사용자 이메일:</label>
        <br />
        <input
          type="text"
          value={userEmail}
          onChange={(e) => setUserEmail(e.target.value)}
          placeholder="email"
        />
      </div>

      <div style={{ marginBottom: "10px" }}>
        <label>게시글 제목:</label>
        <br />
        <input type="text" value={taskTitle} readOnly />
      </div>

      <div style={{ marginBottom: "10px" }}>
        <label>결제 요청 금액 (원):</label>
        <br />
        <input type="number" value={requesterSelectedAmount} readOnly />
      </div>

      {loading ? (
        <div>결제요청중입니다... 잠시만 기다려주세요 🙏</div>
      ) : (
        <button onClick={onClickPayment}>결제 요청</button>
      )}
    </div>
  );
};

export default PaymentPage;

 

 

1. 사용자가 입력한 정보를 바탕으로 포트원에 결제 요청
2. 결제 완료 후, 포트원은 imp_uid (결제 건의 고유 ID)를 프론트에 전송

try { // 서버에 post로 전송하여 검증 요청
    await axios.post("/api/payments/completion", {
      impUid: response.imp_uid,
      merchantUid: response.merchant_uid,
      amount: response.paid_amount,
      taskId: taskId,
    });

 

3. 사용자가 입력한 값과 실제 포트원에서 이루어진 결제 금액이 같은지 검증 (spring)
4. 서버에서 검증 완료하면 목록 페이지 ("/detail") 로 이동
5. 서버에서 검증 실패하면 메인 페이지 ("/") 로 이동

 

2. 서버- Spring

resource - application.yml 에 포트원에서 제공하는 REST API key, REST API Secret 을 지정해줘야 한다.

portone:
  imp_key: // REST API Key
  imp_secret: // REST API Secret

 

entity - PaymentGateway.java

포트원에서 결제 후 전달하는 정보 저장 entity

package com.project.pg.entity;

import static jakarta.persistence.EnumType.*;
import static lombok.AccessLevel.*;

import java.math.BigDecimal;
import java.time.LocalDateTime;

import org.hibernate.annotations.Check;

import com.dangsim.common.entity.BaseEntity;
import com.dangsim.payment.entity.Payment;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "payment_gateway")
@Check(constraints = "amount >= 1 AND amount <= 1000000")
@Getter
@NoArgsConstructor(access = PROTECTED)
public class PaymentGateway extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "payment_gateway_id")
    private Long id;

    @Size(max = 255)
    // @Nullable
    @Column(name = "imp_uid", length = 255)
    private String impUid;

    @Size(max = 40)
    // @Nullable
    @Column(name = "merchant_uid", length = 40, unique = true)
    private String merchantUid;

    @Size(max = 30)
    // @NotNull
    // nullable
    @Column(name = "pay_method", length = 30)
    private String payMethod;

    @Size(max = 30)
    // @NotNull
    @Column(name = "pg_provider", length = 30)
    private String pgProvider;

    @Size(max = 50)
    // nullable
    @Column(name = "pg_tid", length = 30)
    private String pgTid;

    @Size(max = 30)
    @Column(name = "pg_id", length = 30)
    private String pgId;

    @Column(name = "amount")
    private BigDecimal amount;

    @NotNull
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(
       name = "payment_id",
       nullable = false,
       foreignKey = @ForeignKey(name = "fk_pg_payment")
    )
    private Payment payment;

    @Size(max = 10)
    @Column(name = "currency", length = 10)
    private String currency;

    @Size(max = 30)
    // @NotNull
    @Column(name = "apply_num", length = 30)
    private String applyNum;

    @Size(max = 30)
    // @NotNull
    @Column(name = "buyer_name", length = 30)
    private String buyerName;

    @Size(max = 10)
    // @NotNull
    @Column(name = "card_code")
    private String cardCode;

    @Size(max = 50)
    @Column(name = "card_name", length = 50)
    private String cardName;

    @Column(name = "card_quota")
    private String cardQuota;

    @Column(name = "card_number")
    private String cardNumber;

    @Enumerated(STRING)
    @Column(name = "status")
    private PaymentGatewayStatus status;

    @Column(name = "card_type")
    private String cardType;

    @Column(name = "start_at")
    private LocalDateTime startedAt; // 결제 시작시점

    @Column(name = "paid_at")
    private LocalDateTime paidAt; // 결제 완료 시점

    @Column(name = "canceled_at")
    private LocalDateTime canceledAt;

    @Column(name = "failed_at")
    private LocalDateTime failedAt;

    @Builder(access = PRIVATE)
    private PaymentGateway(String impUid, String merchantUid, String payMethod, String pgProvider,
       String pgTid, String pgId, BigDecimal amount, String currency,
       String applyNum, String buyerName, String cardCode, String cardName,
       String cardQuota, String cardNumber, PaymentGatewayStatus status, String cardType,
       LocalDateTime startedAt, LocalDateTime paidAt, LocalDateTime canceledAt, LocalDateTime failedAt,
       Payment payment) {
       this.impUid = impUid;
       this.merchantUid = merchantUid;
       this.payMethod = payMethod;
       this.pgProvider = pgProvider;
       this.pgTid = pgTid;
       this.pgId = pgId;
       this.amount = amount;
       this.currency = currency;
       this.applyNum = applyNum;
       this.buyerName = buyerName;
       this.cardCode = cardCode;
       this.cardName = cardName;
       this.cardQuota = cardQuota;
       this.cardNumber = cardNumber;
       this.status = status;
       this.cardType = cardType;
       this.startedAt = startedAt;
       this.paidAt = paidAt;
       this.canceledAt = canceledAt;
       this.failedAt = failedAt;
       this.payment = payment;
    }

    public static PaymentGateway of(String impUid, String merchantUid, String payMethod, String pgProvider,
       String pgTid, String pgId, BigDecimal amount, String currency,
       String applyNum, String buyerName, String cardCode, String cardName,
       String cardQuota, String cardNumber, PaymentGatewayStatus status, String cardType,
       LocalDateTime startedAt, LocalDateTime paidAt, LocalDateTime canceledAt, LocalDateTime failedAt,
       Payment payment) {

       return PaymentGateway.builder()
          .impUid(impUid)
          .merchantUid(merchantUid)
          .payMethod(payMethod)
          .pgProvider(pgProvider)
          .pgTid(pgTid)
          .pgId(pgId)
          .amount(amount)
          .currency(currency)
          .applyNum(applyNum)
          .buyerName(buyerName)
          .cardCode(cardCode)
          .cardName(cardName)
          .cardQuota(cardQuota)
          .cardNumber(cardNumber)
          .status(status)
          .cardType(cardType)
          .startedAt(startedAt)
          .paidAt(paidAt)
          .canceledAt(canceledAt)
          .failedAt(failedAt)
          .payment(payment)
          .build();
    }
}

(현재는 관리되지 않는) 공식 문서에 cardQuota(할부) 가 Integer로 반환된다고 하는데 실제로는 String으로 반환된다.

 

프론트에서 넘겨주는 데이터를 처리할 DTO

dto - PortOneResponse.java

package com.project.pg.dto;

import lombok.Getter;

@Getter
public class PortOneResponse {
    private String impUid;        // 아임포트 결제 고유번호
    private String merchantUid;   // 주문 고유번호
    private Long taskId;
    private String buyer_name;
}

 

controller - PaymentGatewayContoller.java

package com.project.pg.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.dangsim.pg.dto.PaymentResponse;
import com.dangsim.pg.dto.PortOneResponse;
import com.dangsim.pg.service.PaymentGatewayService;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class PaymentGatewayController {

    private final PaymentGatewayService paymentGatewayService;

    @PostMapping("/api/payments/validation")
    public ResponseEntity<PaymentResponse> completePayment(@RequestBody PortOneResponse portOneResponseDto) {

       paymentGatewayService.verifyPaymentDetail(portOneResponseDto.getImpUid(), portOneResponseDto.getMerchantUid());

       // 결제 성공 시 결제 및 테스크 상태 업데이트
       paymentGatewayService.updatePaymentAndTaskStatus(portOneResponseDto.getMerchantUid());

       return ResponseEntity.ok()
          .body(new PaymentResponse(true, "결제 및 검증 성공", portOneResponseDto.getTaskId()));
    }
}

imp_uid로 포트원에 결제 정보를 확인할 수 있다.
-> 화면에 입력된 값 (Task Entity에 저장) 과  imp_uid로 조회한 결제 금액이 일치하면 (사전 검증)
-> DB 저장 후 프론트에 true 및 결제 성공 전달 (controller)

 

세부 동작 구현

service - PaymentGatewayService.java

package com.project.pg.service;

import static com.dangsim.common.util.DateTimeFormatUtils.*;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

import com.dangsim.payment.entity.PaymentStatus;
import com.dangsim.task.entity.TaskStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;

import com.dangsim.common.exception.runtime.BaseException;
import com.dangsim.payment.entity.Payment;
import com.dangsim.payment.entity.PaymentStatus;
import com.dangsim.payment.exception.PaymentErrorCode;
import com.dangsim.payment.repository.PaymentRepository;
import com.dangsim.pg.dto.InicisResponse;
import com.dangsim.pg.dto.PortOneTokenResponse;
import com.dangsim.pg.entity.PaymentGateway;
import com.dangsim.pg.entity.PaymentGatewayStatus;
import com.dangsim.pg.exception.PaymentGatewayErrorCode;
import com.dangsim.pg.repository.PaymentGatewayRepository;
import com.dangsim.task.entity.Task;
import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class PaymentGatewayService {

    @Value("${portone.imp_key}")
    private String apiKey;

    @Value("${portone.imp_secret}")
    private String apiSecret;

    private final RestTemplate restTemplate;

    private final PaymentRepository paymentRepository;
    private final PaymentGatewayRepository paymentGatewayRepository;

    public String getAccessToken() {
       String url = "https://api.iamport.kr/users/getToken"; // 포트원 토큰 발급 API URL : 여기에 post 요청

       Map<String, String> body = new HashMap<>(); // 서버에 보낼 데이터를 담을 Map
       body.put("imp_key", apiKey);
       body.put("imp_secret", apiSecret);

       // http 요청 body에 json을 보내기 위해 Java 객체를 JSON 문자열로 변환
       // portone api는 json 형태를 요구하기 때문에 body를 json 형태로 변환
       ObjectMapper objectMapper = new ObjectMapper();
       String requestBody;
       try {
          requestBody = objectMapper.writeValueAsString(body);
       } catch (Exception e) {
          throw new BaseException(PaymentGatewayErrorCode.JSON_CONVERT_FAILED);
       }

       HttpHeaders headers = new HttpHeaders();
       headers.setContentType(MediaType.APPLICATION_JSON);
       HttpEntity<String> entity = new HttpEntity<>(requestBody, headers);

       ResponseEntity<PortOneTokenResponse> response = restTemplate.postForEntity(url, entity,
          PortOneTokenResponse.class);

       PortOneTokenResponse responseBody = response.getBody();
       if (responseBody == null || responseBody.getCode() != 0) {
          throw new BaseException(PaymentGatewayErrorCode.PAYMENT_VERIFICATION_FAILED);
       }

       return response.getBody().getResponse().getAccess_token();
    }

    @Transactional
    public void verifyPaymentDetail(String impUid, String merchantUid) {
       String token = getAccessToken();

       String PORTONE_PAYMENT_LOOKUP_URL = "https://api.iamport.kr/payments/";
       String url = PORTONE_PAYMENT_LOOKUP_URL + impUid;

       HttpHeaders headers = new HttpHeaders();
       headers.setBearerAuth(token);
       HttpEntity<Void> entity = new HttpEntity<>(headers);

       ResponseEntity<InicisResponse> response = restTemplate.exchange(url, HttpMethod.GET, entity,
          InicisResponse.class);

       InicisResponse.Response paymentData = response.getBody().getResponse();

       BigDecimal portOneAmount = BigDecimal.valueOf(paymentData.getAmount());

       Payment payment = paymentRepository.findByMerchantUid(merchantUid)
          //        Payment payment = paymentRepository.findByMerchantUid(paymentData.getMerchant_uid())
          .orElseThrow(() -> new BaseException(PaymentGatewayErrorCode.PAYMENT_NOT_FOUND));

       PaymentGateway paymentGateway = PaymentGateway.of(
          paymentData.getImp_uid(),
          //                paymentData.getMerchant_uid(),
          merchantUid,
          paymentData.getPay_method(),
          paymentData.getPg_provider(),
          paymentData.getPg_tid(),
          paymentData.getPg_id(),
          BigDecimal.valueOf(paymentData.getAmount()),
          paymentData.getCurrency(),
          paymentData.getApply_num(),
          paymentData.getBuyer_name(),
          paymentData.getCard_code(),
          paymentData.getCard_name(),
          paymentData.getCard_quota(),
          paymentData.getCard_number(),
          PaymentGatewayStatus.valueOf(paymentData.getStatus().toUpperCase()),
          paymentData.getCard_type(),
          parseDateTimePG(paymentData.getStarted_at()),
          parseDateTimePG(paymentData.getPaid_at()),
          paymentData.getCanceled_at() == null ? null : parseDateTimePG(paymentData.getCanceled_at()),
          parseDateTimePG(paymentData.getFailed_at()),
          payment
       );

       validatePaymentAmount(paymentGateway, portOneAmount);
       paymentGatewayRepository.save(paymentGateway);
    }

    public void validatePaymentAmount(PaymentGateway paymentGateway, BigDecimal portOneAmount) {
       if (paymentGateway == null) {
          throw new IllegalArgumentException("PaymentGateway 정보가 없습니다.");
       }

       Payment payment = paymentGateway.getPayment();
       if (payment == null) {
          throw new IllegalArgumentException("PaymentGateway에 연결된 Payment 정보가 없습니다.");
       }

       Task task = payment.getTask();
       if (task == null) {
          throw new IllegalArgumentException("Payment에 연결된 Task 정보가 없습니다.");
       }

       BigDecimal taskReward = task.getReward();
       if (taskReward == null || portOneAmount == null) {
          throw new IllegalArgumentException("Task 리워드 값 또는 결제 금액이 null입니다.");
       }

       if (taskReward.compareTo(portOneAmount) != 0) {
          throw new IllegalArgumentException("Task 리워드 금액과 결제 금액이 일치하지 않습니다.");
       }
    }

    @Transactional
    public void updatePaymentAndTaskStatus(String merchantUid) {
       Payment payment = paymentRepository.findByMerchantUid(merchantUid)
          .orElseThrow(() -> new BaseException(PaymentErrorCode.NOT_FOUND_PAYMENT));

       // 상태 업데이트
       payment.updatePaymentSuccessStatus(PaymentStatus.PAYMENT_SUCCESSES);
    }
}

 

 

Reference

https://developers.portone.io/opi/ko/support/flow?v=v1

 

포트원 결제 플로우

PG사 직연동과 포트원 연동시의 결제 플로우 차이점을 확인할 수 있습니다.

developers.portone.io

https://velog.io/@gangintheremark/SpringBoot-%ED%8F%AC%ED%8A%B8%EC%9B%90%EC%95%84%EC%9E%84%ED%8F%AC%ED%8A%B8-%EA%B2%B0%EC%A0%9C-API-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EC%83%81%ED%92%88-%EA%B2%B0%EC%A0%9C-%EC%84%9C%EB%B9%84%EC%8A%A4


포트원 연동 정보 문서

https://developers.portone.io/api/rest-v1?v=v1

 

PortOne REST API - V1

(주문형-네이버페이) 네이버페이 주문환불 API post/payments/{imp_uid}/naver/cancel (주문형-네이버페이) 구매자의 환불요청 승인처리 API post/payments/{imp_uid}/naver/approve-cancel (주문형-네이버페이) 상품주문

developers.portone.io

https://api.iamport.kr/

 

API-포트원

 

api.iamport.kr

https://portone.gitbook.io/docs/api/api

 

포트원 API 소개 | PortOne Docs

포트원 API 를 소개합니다.

portone.gitbook.io