yataProject에서 포인트 충전 방식을 도입하기로 결정하였고,
토스페이를 연동하기로 하였다.
토스페이 동작 과정은 다음과 같다.
https://develoyummer.tistory.com/93
연동에 앞서 , 토스페이먼츠에 가입 후, 개발자용 테스트상점에서 API 키를 받을 수 있다.
📌설정
appliaction.yml에 정보들을 적어준 후,
TossPaymentConfig 클래스에 @Value 애너테이션을 통 API 키와 URL들을 변수로 사용할 수 있도록 함.
프로젝트에서 구현한 로직을 controller의 메서드를 통해 미리 살펴보자면 크게 다음과 같다
1. 결제 요청,
public ResponseEntity requestTossPayment()
2. 결제 성공시 로직,
public ResponseEntity tossPaymentSuccess()
3. 결제 실패시 로직,
public ResponseEntity tossPaymentFail()
4. 결제 취소,
public ResponseEntity tossPaymentCancelPoint()
5. 결제내역 조회
public ResponseEntity getChargingHistory()
1-3은 이번 포스팅에 4-5는 다음 포스팅에 나누어 포스팅하였다.
📌구현해야 할 것
Dto
요청 값들을 담아줌
Entity
결제 요청 객체, 결제에 필요한 정보들을 담고있으며 DB에 저장
Controller
서버에서 결제에 필요한 정보(DTO)를 입력받을 수 있도록 함
Service
전달받은 정보를 가지고 검증 / 필요한 값들을 생성
Repository
저장 장소
mapper
mapper클래스는 따로 만들지 않고, Dto, Entity에 메서드를 추가하는 방식으로 구현하였다.
📌결제 요청 구현
Dto
요청 값들을 담아줌
처음 프론트에서 입력받을 PaymentDto,
payType(카드,현금,포인트)
결제 금액
주문 이름
성공 url,실패 url 이 필드로 존재하고 있으며
Dto를 Payment Entity로 바꿔주기 위한 매서드도 추가해 주었다.(mapper)
@Setter
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PaymentDto {
@NonNull
private PayType payType;
@NonNull
private Long amount;
@NonNull
private String orderName;
private String yourSuccessUrl;
private String yourFailUrl;
public Payment toEntity() {
return Payment.builder()
.payType(payType)
.amount(amount)
.orderName(orderName)
.orderId(UUID.randomUUID().toString())
.paySuccessYN(false)
.build();
}
}
PayType은 카드,현금,포인트로 나누어 따로 enum으로 빼줌
public enum PayType {
CARD("카드"),
CASH("현금"),
POINT("포인트");
private String description;
PayType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
PaymentDto로 받은 정보들을 검증 후, 실제 토스페이먼츠에서 결제 요청을 하기 위해 필요한 값들을 포함하여 PaymentResDto로 반환
@Setter
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PaymentResDto {
private String payType;
private Long amount;
private String orderName;
private String orderId;
private String customerEmail;
private String customerName;
private String successUrl;
private String failUrl;
private String failReason;
private boolean cancelYN;
private String cancelReason;
private String createdAt;
}
Entity
결제 요청 객체, 결제에 필요한 정보들을 담고있으며 DB에 저장
추가로 Entity를 응답Dto로 변환해주는 메서드를 포함하였다.(mapper)
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Table(indexes = {
@Index(name = "idx_payment_member", columnList = "customer"),
@Index(name = "idx_payment_paymentKey", columnList = "paymentKey"),
})
public class Payment extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "payment_id", nullable = false, unique = true)
private Long paymentId;
@Column(nullable = false, name = "pay_type")
@Enumerated(EnumType.STRING)
private PayType payType;
@Column(nullable = false, name = "pay_amount")
private Long amount;
@Column(nullable = false, name = "pay_name")
private String orderName;
@Column(nullable = false, name = "order_id")
private String orderId;
private boolean paySuccessYN;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "customer")
private Member customer;
@Column
private String paymentKey;
@Column
private String failReason;
@Column
private boolean cancelYN;
@Column
private String cancelReason;
public PaymentResDto toPaymentResDto() {
return PaymentResDto.builder()
.payType(payType.getDescription())
.amount(amount)
.orderName(orderName)
.orderId(orderId)
.customerEmail(customer.getEmail())
.customerName(customer.getName())
.createdAt(String.valueOf(getCreatedAt()))
.cancelYN(cancelYN)
.failReason(failReason)
.build();
}
}
Repository
저장 장소
주문 Id를 찾는 메서드,
paymentKey와 고객 email로 Payment 객체를 찾아오는 메서드,
내 주문 전체 조회를 위한 findAllByCustomer_Email메서드를 추가해주었다.
public interface JpaPaymentRepository extends JpaRepository<Payment, Long> {
Optional<Payment> findByOrderId(String orderId);
Optional<Payment> findByPaymentKeyAndCustomer_Email(String paymentKey, String email);
Slice<Payment> findAllByCustomer_Email(String email, Pageable pageable);
}
controller
@RestController
@Validated
@RequestMapping("/api/v1/payments")
public class PaymentController {
private final PaymentServiceImpl paymentService;
private final TossPaymentConfig tossPaymentConfig;
private final PaymentMapper mapper;
public PaymentController(PaymentServiceImpl paymentService, TossPaymentConfig tossPaymentConfig, PaymentMapper mapper) {
this.paymentService = paymentService;
this.tossPaymentConfig = tossPaymentConfig;
this.mapper = mapper;
}
@PostMapping("/toss")
public ResponseEntity requestTossPayment(@AuthenticationPrincipal User principal, @RequestBody @Valid PaymentDto paymentReqDto) {
PaymentResDto paymentResDto = paymentService.requestTossPayment(paymentReqDto.toEntity(), principal.getUsername()).toPaymentResDto();
paymentResDto.setSuccessUrl(paymentReqDto.getYourSuccessUrl() == null ? tossPaymentConfig.getSuccessUrl() : paymentReqDto.getYourSuccessUrl());
paymentResDto.setFailUrl(paymentReqDto.getYourFailUrl() == null ? tossPaymentConfig.getFailUrl() : paymentReqDto.getYourFailUrl());
return ResponseEntity.ok().body(new SingleResponse<>(paymentResDto));
}
requestTossPayment() 프론트에서 결제를 요청하기 위해 1차적으로 요청하는 API
Service 로직을 타고 들어가 검증 과정을 마치고 정상적으로 진행이 된다면 토스페이먼츠에 실제 결제 요청을 보냄
응답 Dto에 성공/ 실패 url을 담아줌
service
전달받은 정보를 가지고 검증 /저장
@Service
@Transactional
public class PaymentServiceImpl implements PaymentService {
.
.
.
public Payment requestTossPayment(Payment payment, String userEmail) {
Member member = memberService.findMember(userEmail);
if (payment.getAmount() < 1000) {
throw new CustomLogicException(ExceptionCode.INVALID_PAYMENT_AMOUNT);
}
payment.setCustomer(member);
return paymentRepository.save(payment);
}
결제요청 화면구성
<!DOCTYPE html>
<html lang="ha">
<head>
<meta charset="utf-8" />
<title>결제하기</title>
<!-- 토스페이먼츠 결제창 SDK 추가 -->
<script src="https://js.tosspayments.com/v1/payment"></script>
</head>
<body>
<section>
<!-- 충전하기 버튼 만들기 -->
<span>총 포인트 충전 금액 :</span>
<span>58500원</span>
<button id="payment-button">58500원 충전하기</button>
</section>
<script>
// ------ 클라이언트 키로 객체 초기화 ------
var clientKey = '테스트_클라이언트_키'
var tossPayments = TossPayments(clientKey)
⠀
// ------ 충전하기 버튼에 기능 추가 ------
var button = document.getElementById('payment-button') // 충전하기 버튼
button.addEventListener('click', function () {
⠀
// ------ 결제창 띄우기 ------
tossPayments.requestPayment('CARD', {
amount: 58500,
orderId: 'bec1d544-2a34-4f44-ada0-c5213d8fd8dd',
orderName: '포인트 충전',
customerName: '첫번째',
customerEmail: 'test1@gmail.com',
successUrl: 'http://localhost:8081/api/v1/payments/toss/success',
failUrl: 'http://localhost:8081/api/v1/payments/toss/fail'
})
})
</script>
</body>
</html>
클라이언트 키를 이용하여 TossPayments 객체를 생성
이 객체를 가지고 requestPayment() 메서드 호출
requestPayment(결제수단,{결제정보}) - 결제창을 호출하는 메서드
이렇게까지 하면 다음 과정까지 마친 것이다.
=>
결제 금액 / 상품명이 요청한 내용과 동일한 결제창 호출됨
결제창을 이용하여 정상적으로 결제가 완료되면,
성공 시 콜백 URL로 orderId, paymentKey, amount 3개의 파라미터 값이 넘어옴.
이제 이 값들을 처리해줌
📌결제 성공 시 로직
controller
@GetMapping("/toss/success")
public ResponseEntity tossPaymentSuccess(
@RequestParam String paymentKey,
@RequestParam String orderId,
@RequestParam Long amount
) {
return ResponseEntity.ok().body(new SingleResponse<>(paymentService.tossPaymentSuccess(paymentKey, orderId, amount)));
}
service
@Transactional
public PaymentSuccessDto tossPaymentSuccess(String paymentKey, String orderId, Long amount) {
Payment payment = verifyPayment(orderId, amount);
PaymentSuccessDto result = requestPaymentAccept(paymentKey, orderId, amount);
payment.setPaymentKey(paymentKey);//추후 결제 취소 / 결제 조회
payment.setPaySuccessYN(true);
payment.getCustomer().setPoint(payment.getCustomer().getPoint() + amount);
memberService.updateMemberCache(payment.getCustomer());
return result;
}
public Payment verifyPayment(String orderId, Long amount) {
Payment payment = paymentRepository.findByOrderId(orderId).orElseThrow(() -> {
throw new CustomLogicException(ExceptionCode.PAYMENT_NOT_FOUND);
});
if (!payment.getAmount().equals(amount)) {
throw new CustomLogicException(ExceptionCode.PAYMENT_AMOUNT_EXP);
}
return payment;
}
@Transactional
public PaymentSuccessDto requestPaymentAccept(String paymentKey, String orderId, Long amount) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = getHeaders();
JSONObject params = new JSONObject();//키/값 쌍을 문자열이 아닌 오브젝트로 보낼 수 있음
params.put("orderId", orderId);
params.put("amount", amount);
PaymentSuccessDto result = null;
try { //post요청 (url , HTTP객체 ,응답 Dto)
result = restTemplate.postForObject(TossPaymentConfig.URL + paymentKey,
new HttpEntity<>(params, headers),
PaymentSuccessDto.class);
} catch (Exception e) {
throw new CustomLogicException(ExceptionCode.ALREADY_APPROVED);
}
return result;
}
private HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
String encodedAuthKey = new String(
Base64.getEncoder().encode((tossPaymentConfig.getTestSecretKey() + ":").getBytes(StandardCharsets.UTF_8)));
headers.setBasicAuth(encodedAuthKey);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
return headers;
}
}
Dto
@Data
public class PaymentSuccessDto {
String mid;
String version;
String paymentKey;
String orderId;
String orderName;
String currency;
String method;
String totalAmount;
String balanceAmount;
String suppliedAmount;
String vat;
String status;
String requestedAt;
String approvedAt;
String useEscrow;
String cultureExpense;
PaymentSuccessCardDto card;
String type;
}
@Data
public class PaymentSuccessCardDto {
String company; // 회사명
String number; // 카드번호
String installmentPlanMonths; // 할부 개월
String isInterestFree;
String approveNo; // 승인번호
String useCardPoint; // 카드 포인트 사용 여부
String cardType; // 카드 타입
String ownerType; // 소유자 타입
String acquireStatus; // 승인 상태
String receiptUrl; // 영수증 URL
}
=> 테스트 시 결과값에 .페이번트 키랑, 페이 successYN 설정했는데 왜 false랑 null로 되어있는지?
📌결제 실패 시 로직
controller
@GetMapping("/toss/fail")
public ResponseEntity tossPaymentFail(
@RequestParam String code,
@RequestParam String message,
@RequestParam String orderId
) {
paymentService.tossPaymentFail(code, message, orderId);
return ResponseEntity.ok().body(new SingleResponse<>(
PaymentFailDto.builder()
.errorCode(code)
.errorMessage(message)
.orderId(orderId)
.build()
));
}
service
@Transactional
public void tossPaymentFail(String code, String message, String orderId) {
Payment payment = paymentRepository.findByOrderId(orderId).orElseThrow(() -> {
throw new CustomLogicException(ExceptionCode.PAYMENT_NOT_FOUND);
});
payment.setPaySuccessYN(false);
payment.setFailReason(message);
}
Dto
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PaymentFailDto {
String errorCode;
String errorMessage;
String orderId;
}
여기까지 완성!
다음 포스팅에서는 결제 취소와, 결제 조회를 구현할 것이다.
https://develoyummer.tistory.com/96