[프로젝트] 간편결제 API(TossPayments)

Inung_92·2023년 4월 5일
4

프로젝트

목록 보기
8/9
post-thumbnail

개요

⚡️ 개발환경

  • Front - React.js
  • Back - SpringFramework(legacy) 4.3.30
  • DB - MySql5.7
  • Server - Tomcat 9
  • Tool - 전자정부프레임워크 3.0(수업목적), VScode
  • Build - maven

⚡️ 시나리오

  1. React에서 API서버로 UI출력 및 결제요청 호출
  2. API 서버로부터 Redirect 받은 정보 Spring 서버로 전달 및 요청
  3. 전달받은 정보이용 Spring에서 Toss API 서버로 결제 승인 요청
  4. 반환받은 승인 결과 DB 저장
  5. 저장된 DB를 React로 응답
  6. 응답된 결과를 React로 UI 출력

흐름도는 아래와 같다.

⚡️ 간편결제 선택 이유

TossPayments는 크게 가상계좌와 간편결제를 이용할 수 있었다. 하지만 간편결제를 사용할 경우 네이버 페이를 이용한 계좌이체도 지원하고 있었기에 가상계좌까지 나눌 필요성을 느끼지 못해 간편결제만 구현하기로 마음을 먹은 것이다. 또한 간편결제의 경우 다양한 은행(국민 등)과 간편결제를 지원하는 기업들과의 연동이 좋기 때문에 결제 수단을 사용자가 선택하기에 폭이 넓다고 생각한 부분도 있었다.


기능구현

⚡️ API 연동 준비

소셜 로그인과 마찬가지로 공식문서를 참고하는 것이 가장 좋지만 나도 절차를 다시 한번 확인할겸 차근차근 설명하겠다.

  1. 우선 공식홈페이지로 이동하자.
  2. 클라이언트키와 시크릿키를 복사해서 데이터로 선언해주자.

    나는 React와 Spring에서 필요했기 때문에 해당 키를 .env와 .properties 파일에 각각 선언해주었다. 여기서 주의할 점은 React의 경우에는 꼭 REACT_APP_xxxx 처럼 REACT_APP이 접두사로 붙어줘야한다는 것을 명심하자. 또한, 우리가 발급받은 키는 테스트 키이기 때문에 실제 결제는 이루어지지 않는다.
  3. 인증을 위한 인코딩된 데이터를 준비하자.

    여기서 헷갈리는 부분은 시크릿키의 :을 합친 값을 설명하는 것이다. 테스트키는 사진에 있는 키를 그대로 복사해서 사용하면 된다. 하지만 실제 결제를 이용할 경우 사용자의 시크릿 키를 base64로 인코딩한 값을 넣어줘야한다.

    base64란?
    8비트 이진 데이터(예를 들어 실행 파일이나, ZIP 파일 등)를 문자 코드에 영향을 받지 않는 공통 ASCII 영역의 문자들로만 이루어진 일련의 문자열로 바꾸는 인코딩 방식

    쉽게 말해서 지금은 그냥 사진에 있는 것으로 쓰면되고 차후 실제 연동을 할 경우에는 위의 형식을 따라야한다. 이때 중요한 부분을 아래 코드에서 잠시 보자.
    {SECRET_KEY}: 의 형식으로 되어있는 경우 제대로된 Base64 인코딩이 되지 않는다.
    {}을 제외하고 SECRET_KEY: 형태로만 인코딩을 해줘야한다.
    이렇게 인증키까지 준비가 되었다면 요청을 보낼 수 있다.

⚡️ 결제요청

요청은 React를 기준으로 설명하겠다.

🖥️ SDK 준비 및 요청

#설치 커맨드
npm install @tosspayments/payment-sdk --save

설치가 완료되었다면 컴포넌트에 아래와 같이 import를 해주고, loadTossPayments()를 통해 UI 및 결체요청을 진행하면된다.

import { loadTossPayments } from "@tosspayments/payment-sdk";

//페이호출 메소드
const handlePayment = (subject) => {
  const random = new Date().getTime() + Math.random(); //난수생성
  const randomId = btoa(random); //난수를 btoa(base64)로 인코딩한 orderID
  
  if (subject === "카드") { //간편결제 함수 실행
    loadTossPayments(client_id).then(tossPayments => {
      tossPayments.requestPayment(subject, {
        amount: reserv.amount, //주문가격
        orderId: `${randomId}`, //문자열 처리를 위한 ``사용
        orderName: orderName(), //결제 이름(여러건일 경우 복수처리)
        customerName: '테스트', //판매자, 판매처 이름
        successUrl: process.env.REACT_APP_TOSS_SUCCESS,
        failUrl: process.env.REACT_APP_TOSS_FAIL,
      })
    });
  }
}

이렇게 요청을 하면 아래와 같은 화면 또는 동의 화면이 출력된다. 혹시라도 위의 파라미터들에 대한 정보가 필요하다면 여기를 참고하자.

여기서 중요한 부분은 successURl과 failUrl이다. 반드시 다음 요청이 이루어지는 Url을 지정하여 Redirect를 받아야만 정상적인 결제요청 데이터를 서버(Spring)에 전송할 수 있다.

🖥️ RedirectUrl

https://{ORIGIN}/success?paymentKey={PAYMENT_KEY}&orderId={ORDER_ID}&amount={AMOUNT}

RedirectUrl 형식이다. 여기서 paymentKey, orderId, amount는 API 서버에서 RedirectUrl의 파라미터로 전달해준다. 이때 해당 정보를 포함하는 객체를 생성하여 Spring 서버로 요청을하는 것이다.
나는 아래와 같이 Callback 컴포넌트를 생성하여 RedirectUrl 응답과 동시에 Spring으로 요청을 보냈다.

function TossCallback() {
  const navigate = useNavigate();
  useEffect(() => {
    const paymentKey = new URL(window.location.href).searchParams.get("paymentKey");
    const orderId = new URL(window.location.href).searchParams.get("orderId");
    const amount = new URL(window.location.href).searchParams.get("amount");

    //spring 서버로 인증키를 통해 유저정보를 획득하고 로그인 처리 요청
    accessClient.post(`${process.env.REACT_APP_REQUEST_URL}/api/client/token/payment`, {
      //spring 서버로 전달할 요청 params
      paymentKey: paymentKey,
      orderId: orderId,
      amount: amount,
    }).then((res) => {
      //spring에서 처리된 정보 전달
      const reserv = res.data;
      //예약내역 페이지로 전환 및 임시저장 정보 삭제
      
      localStorage.removeItem("rsvIdx");
      navigate("/shop/reservation/detail", {state: reserv});
    }).catch((err) => {
      //에러발생 시 경고처리 후 login 페이지로 전환
      alert(err.response.data.detail);
      window.history.back();
    })
  }, []);

해당 컴포넌트는 RedirectUrl을 받자마자 자동으로 Spring 서버로 post 요청을 보내게 되는 것이다. 여기까지하면 결제요청에 대한 부분은 마무리가 된다. 하지만 결제요청이 되었다고 해서 결제가 된 것은 아니다. 요청된 정보를 Spring에서 다시 Toss API 서버로 승인요청을 보내야한다.

⚡️ 결제승인

결제 승인에서 주의할 점은 표시해놓은 두가지 일 것이다. 아래 사진을 참고하여 기능을 구현해보자.

🖥️ 결제승인 요청

public Payment getConfirmPayment(PaymentParams paymentParams) {
	//인증키 base64 인코딩
	String authorization = Base64.getEncoder().encodeToString(toss_secret.getBytes());
	log.debug("------ API 전송 요청 -------" + authorization);
    
    RestTemplate rt = new RestTemplate();
    
    //헤더 구성
    HttpHeaders headers = new HttpHeaders();
    headers.add("Authorization", "Basic " + authorization);
    headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
    
    //요청 객체 생성
    HttpEntity<PaymentParams> paymentRequest = new HttpEntity<>(paymentParams, headers);
    
    //Post 요청과 동시에 데이터 매핑
    TossPayment tossPayment = rt.postForObject(toss_url, paymentRequest, TossPayment.class);
    
    log.debug("------ API 응답 반환 -------");
    return getPaymentInfo(tossPayment, paymentParams);
}

요청에 대한 로직은 위의 사진과 같다. 다만 RestTemplate를 사용했을 뿐이다. 승인을 보내면 결제승인에 대한 응답 객체가 아래와 같은 형태로 반환된다.

{
  "mId": "tosspayments",
  "version": "2022-11-16",
  "paymentKey": "437cqAK3STB6934xB2MZ2",
  "lastTransactionKey": "dvlxwdQuXByXEjdY6gDzJ",
  "orderId": "Nz_LpeYO8G2cHt1MznJnt",
  "orderName": "토스 티셔츠 외 2건",
  "status": "DONE",
  "requestedAt":"2022-08-17T15:38:47+09:00",
  "approvedAt":"2022-08-17T15:39:14+09:00",
  ...중략
  "type": "NORMAL",
  "easyPay": {
    "provider": "토스페이",
    "amount": 15000,
    "discountAmount": 0
  },
  "country": "KR",
  "failure": null,
  "isPartialCancelable": true,
  "receipt": {
    "url": "https://dashboard.tosspayments.com/sales-slip?transactionId=cno3Idq53AKHoXP%2BJnAWt70lTLYJHVytjcCu%2FhEIUd56LAMEmBlJ9FWaQinp0uZ1&ref=PX"
  },
  "checkout": {
    "url": "https://api.tosspayments.com/v1/payments/437cqAK3STB6934xB2MZ2/checkout"
  },
  "currency": "KRW",
  "totalAmount": 15000,
  "balanceAmount": 15000,
  "suppliedAmount": 13636,
  "vat": 1364,
  "taxFreeAmount": 0,
  "taxExemptionAmount": 0,
  "method": "간편결제"
}

여기서 필요한 정보들을 획득하기 위해 아래와 같이 객체를 별도로 선언해주었다.

🖥️ 응답객체

// API 응답값을 전달할 DTO
@Getter
@JsonIgnoreProperties(ignoreUnknown = true)
public class TossPayment {
	private String orderName;
	private String method;
	private String totalAmount;
	private Card card;
	private VirtualAccount virtualAccount;
	
	@Getter
	@JsonIgnoreProperties(ignoreUnknown = true)
	public class Card{
		private String approveNo;
	}
}

응답 객체를 통해 결제승인 결과에 대한 데이터를 매핑하고, 실제 DB에 저장할 객체에게 해당 정보를 다시 한번 매핑해준다.

🖥️ DB저장 DTO 데이터 매핑

//DTO
@Data
public class Payment {
	private int payIdx;
	private int rsvIdx;
	private String payMethod;
	private String payName;
	private String payAmount;
	private String payApproval;
	private String payDate;
}

//매핑 메소드
public Payment getPaymentInfo(TossPayment tossPayment, PaymentParams params) {
	// Empty 객체 생성 및 데이터 세팅
    Payment payment = new Payment();
    payment.setRsvIdx(params.getRsvIdx());
    payment.setPayMethod(tossPayment.getMethod());
    payment.setPayName(tossPayment.getOrderName());
    payment.setPayApproval(tossPayment.getCard().getApproveNo());
    payment.setPayAmount(tossPayment.getTotalAmount());
    
    return payment;
}

이렇게 데이터 매핑까지 완료된 Payment는 Service와 DAO를 통해 DB로 저장된다. DB저장 로직은 개발자마다 상이한 부분이 있으니 굳이 작성하지 않겠다.

⚡️ 오류

React와 Spring을 연동하여 Toss API를 사용하다보니 자잘한 오류들이 많았기 때문에 이런 부분들을 간단히 공유하려한다.

🚨 Bad Request - 1

원인 : base64로 인코딩 할 때 형식이 제대로 작성되지 않아 발생
해결 : SECRET_KEY: 형태로 인코딩이 되어야함.
예를 들어 secret_key가 "test.toss"라고 가정할 때 아래와 같이 작성해야함.

String authorization = Base64.getEncoder().encodeToString("test.toss:");

여기서 :이 빠지거나 "{test.toss}:" 형태로 작성하면 오류가 발생한다. 참고로 테스트는 아까도 이야기했지만 그냥 토스 홈페이지에 나온 것을 복사해서 사용하면 된다.

🚨 Bad Request - 2

원인 : post 전송 시 amount의 자료형이 불일치
해결 : 전송 시 amount의 자료형을 동일하게 작성

amount는 필수 파라미터로 number형이다. 따라서 String형으로 작성되면 정상적인 데이터를 읽지 못하는 것 같다. 나는 DTO의 형식과 동일한 객체를 구성하여 넘겨줌으로써 해당 오류를 해결하였다.

🚨 RedirectUrl

어떻게보면 배포단계에서 가장 많이 발생하는 오류 일 수도 있다. 해당 오류는 개발단계에서는 크게 무리가 되지 않지만 배포를 하게되면 Origin이 바뀌게 되어 정상적으로 작동하지 않는다.
예를 들어 개발단계에서는 localhost.3000/toss/callback을 RedirectUrl로 지정했는데 AWS에 배포하니 xx.xx.xxx.323:3000/toss/callback이 되었다면 해당 Origin으로 지정한 데이터를 변경해주어야 한다는 것이다. 이 부분은 배포를 진행할 경우에만 적용될 수 있으니 참고하도록 하자.


마무리

프로젝트를 진행하면서 벌써 4번째 Open API를 연동했다. 대부분 비슷한 원리로 흘러가고 특정 파라미터가 다르다거나 요청 절차가 상이한 것 외에는 비슷하게 느껴진다. 아주 기초적인 부분을 연동한 것이기는 하지만 조금 더 익숙해진다면 OpenAPI를 다루는 부분에 있어서 효율적으로 코드를 작성하고 여러가지 부분을 응용할 수 있을 것이라는 생각을 한다.

참고로 API를 연동하면서 발생하는 오류들은 기존에 개발하면서 발생한 오류들과는 성질이 다르다보니 여기서 얻어가는 경험적인 부분들이 상당히 많은 것 같다.

그럼 이만.👊🏽

profile
서핑하는 개발자🏄🏽

0개의 댓글