※ 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
전체적인 흐름
- 프론트엔드 ➡ 백엔드
주문 정보 생성 요청 - 백엔드 ➡ 프론트엔드
주문 정보(주문 번호, 가격 등) 생성하고 반환 - 프론트엔드 ➡ 아임포트
생성된 주문 정보로 결제 요청 - 아임포트 ➡ PG
해당 PG 사에 결제 요청 - 사용자 ➡ PG
결제 정보 확인 및 카드사 선택 - PG ➡ 카드사
해당 카드사에 결제 요청 - 사용자 ➡ 카드사
카드 정보 입력하고 결제 - 카드사 ➡ PG
결제 결과 반환 - PG ➡ 아임포트
결제 결과 반환 - 아임포트 ➡ 프론트엔드
결제 결과 반환 - 프론트엔드 ➡ 백엔드
결제 정보와 주문 정보를 이용하여 검증 요청 - 백엔드 ➡ 프론트엔드
결제 정보와 주문 정보가 일치하는지 확인하여 검증하고 결과를 반환
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);
}
}