본문 바로가기

금융/결제

아임포트 일반결제 연동하기

2021년 12월 21일에 작성된 글입니다.

PG(Payment Gateway)?

온라인 상에서 결제를 할 때 주로 신용카드를 이용한다. 그런데 카드사는 한 곳만 있는 것이 아니고, 각각의 카드사마다 API가 다를 것이기 때문에 모든 카드사와 직접 연동을 하는 것은 매우 비효율적이다.
따라서 PG 사에서는 각 카드사와 직접 연동을 하고 하나의 API로 통합하여 가맹점에 제공해준다.

오프라인에서는 VAN 사가 비슷한 역할을 한다.

또한, 결제 수단이 신용카드뿐만 아니라, 실시간 계좌이체, 가상계좌, 휴대폰 소액결제 등이 있는데, 이 역시 PG사에서 한 번에 연동할 수 있도록 해준다.

PG 연동 솔루션?

여러 카드사와의 연동을 PG 사가 해결해줬지만, 아직 문제가 남아있다.
신용카드로 직접 결제하는 경우도 있지만, 요즘은 간편결제 서비스를 이용하는 경우가 많다.
그러면 이제 PG 사 + 여러 간편결제 서비스를 연동해야 한다.
여러 PG 사와 여러 간편결제 서비스와 직접 연동을 하고 하나의 API로 통합하여 가맹점에 제공하는 솔루션이 생겼다.
그 중 하나가 아임포트다.

🇰🇷 K-전자결제 이해하기

결제에 필요한 카드 정보가 가맹점 서버, PG사 서버를 거쳐 카드사 서버로 전달되는 해외와 달리, 국내에서는 카드사와 일부 PG사를 제외하고는 카드 정보를 저장할 수 없다.

따라서, 카드 정보가 가맹점 서버, PG사 서버를 거치지 않고 카드사 서버로 직접 전달되도록 구성해야 한다.

개발환경

여기서는 다음과 같이 개발환경을 구성한다.

Front-end

  • TypeScript
  • React
  • axios

Back-end

  • Java
  • Spring
  • REST API

전체적인 흐름

  1. 프론트엔드 ➡ 백엔드
    주문 정보 생성 요청
  2. 백엔드 ➡ 프론트엔드
    주문 정보(주문 번호, 가격 등) 생성하고 반환
  3. 프론트엔드 ➡ 아임포트
    생성된 주문 정보로 결제 요청
  4. 아임포트 ➡ PG
    해당 PG 사에 결제 요청
  5. 사용자 ➡ PG
    결제 정보 확인 및 카드사 선택
  6. PG ➡ 카드사
    해당 카드사에 결제 요청
  7. 사용자 ➡ 카드사
    카드 정보 입력하고 결제
  8. 카드사 ➡ PG
    결제 결과 반환
  9. PG ➡ 아임포트
    결제 결과 반환
  10. 아임포트 ➡ 프론트엔드
    결제 결과 반환
  11. 프론트엔드 ➡ 백엔드
    결제 정보와 주문 정보를 이용하여 검증 요청
  12. 백엔드 ➡ 프론트엔드
    결제 정보와 주문 정보가 일치하는지 확인하여 검증하고 결과를 반환

Back-end 개발

Payment 엔티티

Payment.java

import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@AllArgsConstructor
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
@Getter
@NoArgsConstructor
@Setter
@Table(name = "payments", indexes = @Index(name = "index_payments_order_id", columnList = "orderId"))
@ToString
public class Payment {
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @JoinColumn(name = "buyer_id")
    @ManyToOne
    private User buyer; // 구매자

    @Column(nullable = false, unique = true)
    private String receiptId; // PG 사에서 생성한 주문 번호

    @Column(nullable = false, unique = true)
    private String orderId; // 우리가 생성한 주문 번호

    private PaymentMethod method; // 결제 수단

    private String name; // 결제 이름

    @Column(nullable = false)
    private BigDecimal amount; // 결제 금액

    @Builder.Default
    @Column(nullable = false)
    private PaymentStatus status = PaymentStatus.READY; // 상태

    @CreatedDate
    private LocalDateTime createAt; // 결제 요청 일시

    private LocalDateTime paidAt; // 결제 완료 일시

    private LocalDateTime failedAt; // 결제 실패 일시

    @Builder.Default
    private BigDecimal cancelledAmount = BigDecimal.ZERO; // 취소된 금액

    private LocalDateTime cancelledAt; // 결제 취소 일시
}

결제 정보를 저장하는데 필요한 필드를 작성했다.
String으로된 주문 번호를 이용해 쿼리를 하기 때문에 orderId에 대해서 인덱스 처리를 했다.

User 엔티티는 알아서 작성하도록 하자.

PaymentMethod.java

public enum PaymentMethod {
    CARD,
    TRANS,
    VBANK,
    PHONE
}

PaymentStatus.java

public enum PaymentStatus {
    READY,
    PAID,
    FAILED,
    CANCELLED
}

API Key

API Key는 Java 코드 내에 직접 삽입하지 않고, application.properties에 저장한다.

# pgmodule
pgmodule.app-id=your-app-id
pgmodule.secret-key=your-secret-key

서비스와 레포지토리

PaymentRepository.java

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface PaymentRepository extends JpaRepository<Payment, Long> {
    List<Payment> findAllByBuyer(User buyer);

    Optional<Payment> findByOrderIdAndBuyer(String orderId, User buyer);
}

PaymentService.java

import com.siot.IamportRestClient.IamportClient;
import com.siot.IamportRestClient.exception.IamportResponseException;
import com.siot.IamportRestClient.response.IamportResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.math.BigDecimal;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import static com.google.common.base.Preconditions.checkNotNull;

@ConfigurationProperties(prefix = "pgmodule")
@RequiredArgsConstructor
@Service
public class PaymentService {
    private final PaymentRepository paymentRepository;

    @Value("${pgmodule.app-id}")
    private String apiKey;
    @Value("${pgmodule.secret-key}")
    private String apiSecret;

    @Transactional
    public Payment requestPayment(User buyer, String name, BigDecimal amount) {
        Payment payment = new Payment();
        payment.setBuyer(buyer);
        payment.setOrderId(buyer.getName() + "_" + Objects.hash(buyer, name, amount, System.currentTimeMillis()));
        payment.setName(name);
        payment.setAmount(amount);
        return paymentRepository.save(payment);
    }

    @Transactional
    public Payment verifyPayment(Payment payment, User buyer) {
        checkNotNull(payment, "payment must be provided.");
        checkNotNull(buyer, "buyer must be provided.");

        if (!payment.getBuyer().equals(buyer)) {
            throw new NotFoundException("Could not found payment for " + buyer.getName() + ".");
        }

        IamportClient iamportClient = new IamportClient(apiKey, apiSecret);
        try {
            IamportResponse<com.siot.IamportRestClient.response.Payment> paymentResponse = iamportClient.paymentByImpUid(payment.getReceiptId());
            if (Objects.nonNull(paymentResponse.getResponse())) {
                com.siot.IamportRestClient.response.Payment paymentData = paymentResponse.getResponse();
                if (payment.getReceiptId().equals(paymentData.getImpUid()) && payment.getOrderId().equals(paymentData.getMerchantUid()) && payment.getAmount().compareTo(paymentData.getAmount()) == 0) {
                    PaymentMethod method = PaymentMethod.valueOf(paymentData.getPayMethod().toUpperCase());
                    PaymentStatus status = PaymentStatus.valueOf(paymentData.getStatus().toUpperCase());
                    payment.setMethod(method);
                    payment.setStatus(status);
                    paymentRepository.save(payment);
                    if (status.equals(PaymentStatus.READY)) {
                        if (method.equals(PaymentMethod.VBANK)) {
                            throw new PaymentRequiredException(paymentData.getVbankNum() + " " + paymentData.getVbankDate() + " " + paymentData.getVbankName());
                        } else {
                            throw new PaymentRequiredException("Payment was not completed.");
                        }
                    } else if (status.equals(PaymentStatus.PAID)) {
                        payment.setPaidAt(paymentData.getPaidAt().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime());
                        paymentRepository.save(payment);
                    } else if (status.equals(PaymentStatus.FAILED)) {
                        throw new ForbiddenException("Payment failed.");
                    } else if (status.equals(PaymentStatus.CANCELLED)) {
                        throw new ForbiddenException("This is a cancelled payment.");
                    }
                } else {
                    throw new ForbiddenException("The amount paid and the amount to be paid do not match.");
                }
            } else {
                throw new NotFoundException("Could not found payment for " + payment.getReceiptId() + ".");
            }
        } catch (IamportResponseException e) {
            e.printStackTrace();
            switch (e.getHttpStatusCode()) {
                case 401 -> throw new InternalServerErrorException("Authentication token not passed or invalid.");
                case 404 -> throw new NotFoundException("Could not found payment for " + payment.getReceiptId() + ".");
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        return payment;
    }

    @Transactional
    public Payment verifyPayment(String receiptId, String orderId, User buyer) {
        checkNotNull(receiptId, "receiptId must be provided.");

        Optional<Payment> optionalPayment = findByOrderIdAndBuyer(orderId, buyer);
        if (optionalPayment.isPresent()) {
            Payment payment = optionalPayment.get();
            payment.setReceiptId(receiptId);
            return verifyPayment(payment, buyer);
        } else {
            throw new NotFoundException("Could not found payment for " + orderId + ".");
        }
    }
}

엔드포인트

PaymentController.java

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;
import java.util.stream.Collectors;

@RequestMapping("payments")
@RequiredArgsConstructor
@RestController
public class PaymentController {
    private final PaymentService paymentService;
    private final UserService userService;

    @PostMapping
    public ResponseEntity<?> requestPayment(
            @AuthenticationPrincipal JwtAuthentication authentication,
            @Valid @RequestBody PaymentRequest request
    ) {
        User buyer = developerService.findById(authentication.id)
                                        .orElseThrow(() -> new NotFoundException("Could not found developer for " + authentication.id + "."));
        BigDecimal amount = new BigDecimal(request.getAmount());
        Payment payment = paymentService.requestPayment(buyer, request.getName(), amount);
        return ResponseEntity.ok(payment);
    }

    @PutMapping("{orderId}")
    public ResponseEntity<?> verifyPayment(
          @AuthenticationPrincipal JwtAuthentication authentication,
          @PathVariable String orderId,
          @Valid @RequestBody String receiptId
    ) {
        User buyer = userService.findById(authentication.id)
                .orElseThrow(() -> new NotFoundException("Could not found user for " + authentication.id + "."));
        Payment payment = paymentService.verifyPayment(receiptId, orderId, buyer);
        return ResponseEntity.ok(payment);
    }
}

PaymentRequest.java

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;

import javax.validation.constraints.NotBlank;

@AllArgsConstructor
@Getter
@NoArgsConstructor
public class PaymentRequest {
    @NotBlank(message = "name must be provided.")
    private String name;

    @NotBlank(message = "amount must be provided.")
    private String amount;

    @Override
    public String toString() {
        return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
                .append("name", name)
                .append("amount", amount)
                .toString();
    }
}

Front-end 개발

아임포트 라이브러리

iamport.ts

export const userCode = 'your user code';

export function importIamport() {
    const jqueryScript = document.createElement('script');
    jqueryScript.src = 'https://code.jquery.com/jquery-3.6.0.js';
    document.head.appendChild(jqueryScript);

    const iamportScript = document.createElement('script');
    iamportScript.src = 'https://cdn.iamport.kr/js/iamport.payment-1.2.0.js';
    document.head.appendChild(iamportScript);
}

CheckoutPage.tsx

importIamport();

REST API 클라이언트

client.ts

import axios from "axios";

const client = axios.create({
    baseURL: process.env.NODE_ENV === 'development' ? '/' : 'https://example.com',
    withCredentials: true
});

export interface Payment {
    buyer: string;
    orderId: string;
    method: PaymentMethod;
    name: string;
    amount: number;
    status: PaymentStatus;
    createAt: string;
    paidAt: string;
    failedAt: string;
    cancelledAmount: number;
    cancelledAt: string;
}

export type PaymentMethod = 'card' | 'trans' | 'vbank' | 'phone';
export type PaymentStatus = 'ready' | 'paid' | 'failed' | 'cancelled';

export interface PaymentRequest {
    name: string;
    amount: string;
}

export const requestPayment = (request: PaymentRequest) => client.post('/payments', request);

export const verifyPayment = (receiptId: string) => client.put(`/payment/${orderId}`, request);

export default client;

주문 번호 생성

CheckoutPage.tsx

const requestPay = async () => {
    try {
        const response = await requestPayment({
            name: 'my payment',
            amount: '1000.0'
        });
        const payment = response.data as Payment;

          /* 결제 요청 */
    } catch (e) {
            console.log(e);
    }
};

결제 요청

CheckoutPage.tsx

const {IMP} = window;

if (payment && IMP) {
    const {orderId: merchant_uid, name} = payment;

    IMP.init(userCode);
    IMP.request_pay({
        pg,
        pay_method,
        merchant_uid,
        name,
        amount,
        buyer_email,
        buyer_name,
        buyer_tel,
    }, rsp: RequestPayResponse => {
        if (rsp.success) {
            /* 결제 성공 시 */
        } else {
            /* 결제 실패 시 */
        }
    });
}

결제 정보 전달

CheckoutPage.tsx

async (rsp: RequestPayResponse) => {
    if (rsp.success) {
        try {
            const data = await verifyPayment({
                receiptId: rsp.imp_uid!!,
                orderId: rsp.merchant_uid
            });

              /* 결제 검증 성공 시 */
              console.log(data);
        } catch (e) {
            /* 결제 검증 실패 시 */
            alert(e);
        }
    } else {
        alert(rsp.error_msg);
    }
}