
Toss에서 제공하는 Payment Gateway.
결제를 위한 관문을 이야기함. 다양한 결제 방식(카드 결제, 간편 결제, 계좌 이체, 가상 계좌(무통장입금), 상품권 결제, 휴대폰 결제)등을 사용할 수 있도록 연결해주는 역할을 함. 온라인 결제를 대신 해주기 때문에 PG사는 결제대행사, '전자결제 지급대행사'라고도 부름.

위 사진처럼 우리가 일상 생활에서 결제를 위해서 흔히 마주하는 위젯 화면이 바로 PG사에서 제공하는 서비스를 이용하는 과정임.

PG사를 포함한 온라인 결제 흐름은 위 사진과 같음. 고객이 상점에서 상품을 주문하면 상점에서 PG사로 결제 요청을 전송함. PG사는 고객이 선택한 결제 수단에 맞는 결제기관으로 요청을 전달하고 그 결과를 다시 상점에게 전달함. 금액 지불을 확인한 상점은 고객에게 상품을 전달하고 고객이 결제기관에게 지불한 비용은 결제기관→PG사→상점으로 정산이 이루어짐.

PG 결제를 위해서는 클라이언트와 서버의 역할을 구분해서 이해해야 함. 클라이언트 측에서는 구매자가 볼 화면을 구성하는 것과 동시에 PG사에게 결제 정보를 전달해야 함. 결제 위젯을 렌더링한 후에 구매자가 선택한 결제 수단에 맞는 결제 요청을 전송하여 결제창을 띄움. 구매자가 결제 정보를 입력하고 입력한 결제 정보에 문제가 없다면 미리 설정한 successUrl로 이동함. 이후에 서버가 클라이언트에서 결제 정보를 전달 받아서 결제 승인 API를 호출하면 결제가 완료됨.
처음 결제를 연동해야 하는 상황을 마주했을 때 꽤나 겁먹었던 것에 비해서 생각보다 간단하다고 생각함. Toss Payments에서 제공하는 결제 위젯 덕분에 프론트도 그렇게까지 어려워 보이진 않다고 느낌.(아니라면 ㅈㅅ;)
Toss Payments API Reference
위에 첨부한 링크를 접속하면 Toss Payments에서 제공하는 공식 API 문서를 확인할 수 있는데 특히 Payment 객체에 대한 설명을 확인할 수 있음.
PG 결제 API를 구현하려고 하는 초기 단계에 위 그림에서는 생략된 과정이 있었는데 바로 orderId를 발급하는 과정임.

orderId에 대한 공식 문서에서의 설명은 위와 같음. 각 주문을 식별하는 주문번호의 역할을 하는 것이 바로 orderId인 것. 결제 요청에서 가맹점이 직접 생성한 영문 대소문자, 숫자, 특수문자로 이루어진 문자열이라고 설명함.
여기서 말하는 "가맹점" 이 결제 요청을 전송 받는 Toss가 아니라 Toss PG를 연동한 사이트를 이야기하는 것이기 때문에 직접 orderId를 발급하는 과정이 필요함.
이 과정은 클라이언트 측에서 설정하거나 또는 서버 측에서 클라이언트로 발급하는 2가지 방식 중에서 선택할 수 있음. 하지만 서버에서 클라이언트로 orderId, amount(결제 금액)를 발급해주는 것이 보안 상 더 안전하다고 하기에 후자의 방법으로 구현함. 결제 금액은 왜 발급해야 하는 것인지 의아할 수 있지만 결제 금액을 발급하지 않는 경우 실제 결제해야 하는 금액을 조작해서 처리할 수 있는 위험성이 있기 때문에 결제 금액도 함께 발급하는 방식을 선택함.
@Override
@Transactional(readOnly = true)
public OrderIdResponse generateOrderId(Long timeTableId) {
String orderId = BASE_ORDER_ID + UUID.randomUUID();
int amount = timeTableService.getSaleCostByTimeTableId(timeTableId);
redisService.saveOrderIdAndAmount(orderId, amount);
return new OrderIdResponse(timeTableId, orderId, amount);
}
orderId를 발급하는 api를 새롭게 하나 만듦. 첨부한 코드처럼 UUID를 생성하여 orderId로 반환했고, 이 과정에서 amount는 구매하려고 하는 상품의 실제 금액을 조회해서 반환하도록 함.
redisService.saveOrderIdAmount(orderId, amount);
이 코드는 Redis 캐시 메모리에 orderId와 amount를 저장하는 역할을 함. Redis에 해당 정보를 저장하는 것은 앞서 서술한 것처럼 실제 결제 금액과 결제 승인을 요청하는 금액이 일치하는지 서버에서 1차 검증을 위함. orderId를 key로 사용해서 조회한 value가 결제 승인 요청에서 받은 amount의 값과 다르다면 결제 데이터가 조작된 것을 의미함.
Toss PG는 결제 요청을 보내고 나서 10분 이내에 결제 승인 요청을 전송하지 않으면 자동으로 결제 취소가 이루어지고 이후에 orderId와 amount 정보는 불필요해지기 때문에 Redis에 데이터를 저장할 때 10분이 지나면 자동으로 삭제되도록 처리함.
orderId와 amount를 발급해준 이후로는 클라이언트 측에서 결제를 수행하고 마지막으로 서버에서 결제 승인 API를 호출하는 것으로 결제가 마무리됨. 결제 승인 API에 필요한 것은 paymentKey, orderId, amount. 3개의 값을 필드에 각각 담아 Toss PG로 결제 승인 요청을 전송하면 결제 결과를 알 수 있음.
// 예시 응답.
{
"mId": "tosspayments",
"lastTransactionKey": "9C62B18EEF0DE3EB7F4422EB6D14BC6E",
"paymentKey": "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1",
"orderId": "a4CWyWY5m89PNh7xJwhk1",
"orderName": "토스 티셔츠 외 2건",
"taxExemptionAmount": 0,
"status": "DONE",
"requestedAt": "2024-02-13T12:17:57+09:00",
"approvedAt": "2024-02-13T12:18:14+09:00",
"useEscrow": false,
"cultureExpense": false,
"card": {
"issuerCode": "71",
"acquirerCode": "71",
"number": "12345678****000*",
"installmentPlanMonths": 0,
"isInterestFree": false,
"interestPayer": null,
"approveNo": "00000000",
"useCardPoint": false,
"cardType": "신용",
"ownerType": "개인",
"acquireStatus": "READY",
"receiptUrl": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20240213121757MvuS8&ref=PX",
"amount": 1000
},
"virtualAccount": null,
"transfer": null,
"mobilePhone": null,
"giftCertificate": null,
"cashReceipt": null,
"cashReceipts": null,
"discount": null,
"cancels": null,
"secret": null,
"type": "NORMAL",
"easyPay": {
"provider": "토스페이",
"amount": 0,
"discountAmount": 0
},
"country": "KR",
"failure": null,
"isPartialCancelable": true,
"receipt": {
"url": "https://dashboard.tosspayments.com/receipt/redirection?transactionId=tviva20240213121757MvuS8&ref=PX"
},
"checkout": {
"url": "https://api.tosspayments.com/v1/payments/5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1/checkout"
},
"currency": "KRW",
"totalAmount": 1000,
"balanceAmount": 1000,
"suppliedAmount": 909,
"vat": 91,
"taxFreeAmount": 0,
"metadata": null,
"method": "카드",
"version": "2022-11-16"
}
이렇게 많은 정보를 받을 수 있지만 추후에 발생할 수 있는 결제 조회, 결제 취소 등의 상황을 위해서 필수로 받아야 하는 값은 orderId, paymentKey.
이외에도 결제 수단 같은 정보도 받을 수 있으므로 필요한 정보를 dto로 받아서 처리하면 됨.
public TossPaymentConfirmResponse confirmPayment(String paymentKey, String orderId, int amount) {
WebClient webClient = WebClient.builder().build();
String encodedSecretKey = Base64.getEncoder().encodeToString((tossSecretKey + ":").getBytes());
return webClient.post()
.uri(TOSS_BASE_URL + "/v1/payments/confirm")
.header("Authorization", "Basic " + encodedSecretKey)
.header("Content-Type", "application/json")
.bodyValue(buildRequestBody(paymentKey, orderId, amount))
.retrieve()
.onStatus(HttpStatusCode::isError, response -> response.bodyToMono(TossConfirmFailResponse.class)
.flatMap(failResponse -> {
log.info(failResponse.getCode());
log.info(failResponse.getMessage());
return Mono.error(new TossConfirmFailedException(failResponse.getMessage(), failResponse.getCode()));
}))
.bodyToMono(TossPaymentConfirmResponse.class)
.block();
}
위와 같은 코드로 결제 승인 api를 호출하고 필요한 필드만 dto로 받아서 처리할 수 있었음.
주의해야 할 점은 클라이언트에서 결제 위젯을 불러오기 위해서 사용하는 키(클라이언트 키)와 서버에서 결제 승인 요청을 전송하기 위해서 사용하는 키(시크릿 키)가 다르다는 것임.
사진 출처: Toss Payments 홈페이지
혹시 잘못된 부분이나 개선할 부분이 있다면 알려주세요
잘 모르는 제가 봐도 이해가 잘되네요!!