개요
동아리 프로젝트를 진행하던 중 결제 기능을 맡아 구현하기로 했다!
사업자 등록 없이 프로젝트를 위해 테스트 연동이 가능한 툴울 알아보던 중 iamport(현 포트원)이랑 토스 페이먼츠 두 가지를 놓고 고민하게 됐다. 전 프로젝트에서 팀원이 iamport를 사용해서 결제를 구현한 걸 본 적이 있어 사용해볼까 생각했지만! 프론트엔드 팀원이 토스 페이먼츠를 사용해보고 싶다는 의견을 내어 최종적으로 토스 페이먼츠를 선택했다.
이 과정에서 토스 페이먼츠를 사용해 결제 및 환불을 어떻게 구현했는지 작성해보겠다.
구현
Version 2 카드/간편결제 통합 결제창 연동하기
토스 페이먼츠 API 문서가 엄청 친절하고 상세히 나와있어 페이지를 읽고 손쉽게 진행할 수 있었다!

위 이미지도 토스 페이먼츠 API 문서에서 가져왔다.
먼저, 프론트엔드에서 토스 페이먼츠로 결제 요청을 넣고 결제창을 호출해 띄운다. 결제 수단을 인증한 뒤, 백엔드로 결제 승인 API를 요청한다. 백엔드는 토스 페이먼츠로 결제가 승인이 된 게 맞는지 확인하는 승인 API를 전송하고 프론트엔드로 응답을 전달해주는 플로우다.
내가 맡은 프로젝트에서의 결제 로직 플로우는 이러하다.

나름 토스 페이먼츠의 이미지를 참고해서 그려본 건데 쉽지가 않았다^^.. 어쨌든 글로 풀어 작성하자면
사용자가 장바구니에서 특정 상품/혹은 전체 선택해서 결제하기를 누르면 프론트엔드에서 백엔드로 결제 전 확인 API를 보낸다. → 백엔드에서 확인, 어떤 쿠폰, 얼마만큼의 포인트를 쓰겠다고 했는지 확인하고 얼마를 결제하라고 총액을 돌려준다.
프론트엔드에서 결제 창을 띄우고 결제 진행 후 백엔드로 결제 승인 API를 전송한다. → 백엔드에서 토스 페이먼츠로 API를 전송해 결제가 완료됐는지 확인한다. → 프론트엔드로 결제 완료 여부를 전송한다.
이렇게 된다!
1. 결제 전 확인 API


얼마를 결제해야 하는지 계산하기 위해 결제 선택한 상품들, 배송지 주소, 배송지 관련 추가 메시지, 사용할 쿠폰과 사용할 포인트의 양을 받는다.
백엔드는 로직에 필요한 이것저것 + 🌟결제할 금액(finalAmount)과 orderId🌟를 DB에 저장한 뒤 프론트엔드로 전송한다.
📁 OrderCommandServiceImpl
@Override
public OrderPrepareResponseDto prepareOrder(Long memberId, OrderPrepareRequestDto request) {
// 쿠폰, 포인트, 금액 등 검증 로직 (생략)
// OrderId와 OrderName 생성 로직
String orderId = memberId + "ORDER-" + UUID.randomUUID();
String orderName = cartProducts.get(0).getProduct().getName() + (cartProducts.size() > 1 ? " 외 " + (cartProducts.size() - 1) + "건" : "");
Order order = Order.builder()
// 필요 데이터를 넣어서 만들고 DB에 저장 (생략)
.build();
orderRepository.save(order);
log.info("[OrderCommandService] 결제 준비 완료 - 회원ID: {}, orderId: {}, 최종금액: {}", memberId, orderId, finalAmount);
return OrderPrepareResponseDto.of(
orderId,
orderName,
couponDiscount,
request.getPoint() != null ? request.getPoint() : 0,
shippingFee,
finalAmount
);
}쿠폰의 로직이 이것저것 많고 까다로워 금액 처리를 프론트엔드에 맡기기보다 백엔드에서 처리하는 게 적합해 보였으며 그리고 결제 전 포인트가 사용 가능한 만큼 존재하는지, 쿠폰 사용 가능 여부를 한 번 더 검증 할 필요가 있다 생각했기에 만들었다. 그리고 토스 페이먼츠에서도 결제를 보내기 전 orderId와 amount(=finalAmount)를 서버에 먼저 저장해두라는 언급이 존재한다.
2. 결제 승인 API


프론트엔드에서 보내줘야 하는 것은 paymentKey, orderId, amount이다. paymentKey는 프론트엔드에서 토스 페이먼츠 결제 요청이 성공하면 받을 수 있는 쿼리 파라미터이다.
결제 승인 API를 구현하기 위해 해야 할 일은 아래와 같다.
{secretApiKey}:콜론 오타 아니고 꼭 포함시켜서 Base64로 인코딩해 Basic 인증 헤더를 생성한다.토스 페이먼츠로 보낼 결제 승인 API 헤더에 인코딩한 시크릿 키 인증 헤더를 추가한다.
요청 본문 파라미터에는
paymentKey,orderId,amount를 넣는다.응답 확인하고 맞는 결과를 프론트로 보내준다.
결제가 승인되면
{
"mId": "tosspayments",
"version": "2022-11-16",
"paymentKey": "YYN8AAozq6HcXT_XfoFW2",
"status": "DONE",
"lastTransactionKey": "DDtAyogg5y_zVblAfml6H",
"orderId": "cQ5OsLuIVUCFjCOkbx7OA",
"orderName": "토스 티셔츠 외 2건",
"requestedAt": "2022-06-08T15:40:09+09:00",
"approvedAt": "2022-06-08T15:40:49+09:00",
"useEscrow": false,
"cultureExpense": false,
"card": {
"issuerCode": "61",
"acquirerCode": "31",
"number": "12345678****789*",
"installmentPlanMonths": 0,
"isInterestFree": false,
"interestPayer": null,
"approveNo": "00000000",
"useCardPoint": false,
"cardType": "신용",
"ownerType": "개인",
"acquireStatus": "READY",
"amount": 15000
},
"virtualAccount": null,
"transfer": null,
"mobilePhone": null,
"giftCertificate": null,
"cashReceipt": null,
"cashReceipts": null,
"discount": null,
"cancels": null,
"secret": null,
"type": "NORMAL",
"easyPay": null,
"country": "KR",
"failure": null,
"isPartialCancelable": true,
"receipt": {
"url": "https://dashboard.tosspayments.com/sales-slip?transactionId=KAgfjGxIqVVXDxOiSW1wUnRWBS1dszn3DKcuhpm7mQlKP0iOdgPCKmwEdYglIHX&ref=PX"
},
"checkout": {
"url": "https://api.tosspayments.com/v1/payments/YYN8AAozq6HcXT_XfoFW2/checkout"
},
"currency": "KRW",
"totalAmount": 15000,
"balanceAmount": 15000,
"suppliedAmount": 13636,
"vat": 1364,
"taxFreeAmount": 0,
"metadata": null,
"taxExemptionAmount": 0,
"method": "카드"
}이런 객체가 온다.
이 데이터를 받으면 status로 상태를 확인하고 DONE인 경우 totalAmount로 결제 금액 맞는지, paymentKey, orderId가 동일한지 검증하면 될 것 같다.
올 수 있는 status의 종류는
READY: 인증 전IN_PROGRESS: 인증 완료됐고 결제 승인 API를 호출하면 완료된다.WAITING_FOR_DEPOSIT: 가상계좌 결제 시, 가상계좌에 구매자가 아직 입금하지 않은 상태 (프로젝트에는 가상 계좌 결제가 없어 고려하지 않았다.)DONE: 결제 승인!!CANCELED: 승인된 결제가 취소된 상태PARTIAL_CANCELED: 승인된 결제가 부분 취소된 상태ABORTED: 결제 승인 실패EXPIRED: 결제 유효기간이 끝난 상태
= DONE 빼고는 전부 결제가 완료되지 않은 상태다.
승인에 실패하면
{
"code": "NOT_FOUND_PAYMENT_SESSION",
"message": "결제 시간이 만료되어 결제 진행 데이터가 존재하지 않습니다."
}이렇게 온다고 한다. 올 수 있는 code의 종류는 4개이다.
NOT_FOUND_PAYMENT_SESSION: 결제 시간 만료REJECT_CARD_COMPANY: 카드 거절 (비밀번호 오류, 한도 초과, 포인트 부족 등등)FORBIDDEN_REQUEST: 내 잘못 (body 객체에 뭐 잘못 넣음)UNAUTHORIZED_KEY: 내 잘못 (API 키가 틀림)
에러 처리에 활용하면 될 것 같다.
구현해보자~
일단 토스 페이먼츠 웹 사이트에서 로그인 후 받은 API 연동 키를 환경 변수로 저장한다. 환경 변수로 저장한 뒤 애플리케이션에서 편히 꺼내 쓰기 위해 Config 파일로 만들었다.

📁 TossPaymentsConfig
package com.jajaja.global.config;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "toss")
@Getter @Setter
public class TossPaymentsConfig {
private String approveUrl;
private String refundUrl;
private String secretApiKey;
private String secureKey;
}기존 결제 URL 끝단에 /{paymentKey}/cancel을 붙인 게 환불 URL이므로 굳이 이렇게 나눠 저장할 필요 없이 애플리케이션 단에서 조작해도 된다.
그리고 RestTemplate Config 파일도 만들었다. API를 보낼 때 동기 로직으로 구현할 거라 RestTemplate을 사용할 건데... 결제 승인 로직에서만이 아니라 환불 로직에서도 RestTemplate을 사용한다. 결제 승인, 환불 메서드에서 각각 RestTemplate 객체를 생성하고 싶지 않았기에 Config 파일을 생성했다.
📁 RestTemplateConfig
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}📁 OrderCommandServiceImpl
public OrderApproveResponseDto approveOrder(Long memberId, OrderApproveRequestDto request) {
if (!request.getPaidAmount().equals(order.getFinalAmount())) {
throw new GeneralException(ErrorStatus.PAYMENT_AMOUNT_MISMATCH);
}
if (!request.getPaymentKey().equals(order.getPaymentKey())) {
throw new BadRequestException(ErrorStatus.PAYMENT_KEY_MISMATCH);
}
if (!request.getOrderId().equals(order.getOrderId())) {
throw new BadRequestException(ErrorStatus.ORDER_ID_MISMATCH);
}필요한 금액만큼 결제됐는지, paymentKey와 orderId를 차례로 검증하고 난 뒤, 결제 승인 API를 보낸다.
try {
// 결제 승인 확인
// request body에 필요한 값을 넣는다
Map<String, Object> body = new HashMap<>();
body.put("paymentKey", request.getPaymentKey());
body.put("orderId", request.getOrderId());
body.put("amount", request.getPaidAmount());
// 헤더를 가져오고 데이터를 넣는다
HttpEntity<Map<String, Objec>> entity = new HttpEntity<>(body, getHeaders());
// restTemplate을 사용하여 API 전송
ResponseEntity<PaymentResponseDto> responseEntity = restTemplateConfig.restTemplate().postForEntity(tossPaymentsConfig.getApproveURL(), entity, PaymentResponseDto.class);
// 데이터를 받아온 뒤 미리 정의해둔 DTO로 자동 변환
PaymentResponseDto responseDto = getPaymentResponseDto(responseEntity);
// 백엔드 DB 업데이트
order.updatePaymentInfo(request.getOrderId(), PaymentMethod.valueOf(responseDto.type()), OrderStatus.DONE);
// 포인트 사용 및 쿠폰 사용 처리 (생략)
// 판매 완료 시 판매 카운트 증가 (생략)
log.info("[OrderCommandService] 주문 생성 완료 - 주문ID: {}", order.getId());
return OrderApproveResponseDto.of(order);
} catch (HttpClientErrorException e) { // 400번대 에러
log.error("토스 환불 4xx 서버 에러: {}", e.getResponseBodyAsString());
throw new TossPaymentException(ErrorStatus.TOSS_PAYMENT_BAD_REQUEST);
} catch (HttpServerErrorException e) { // 500번대 에러
log.error("토스 환불 5xx 서버 에러: {}", e.getResponseBodyAsString());
throw new TossPaymentException(ErrorStatus.TOSS_PAYMENT_SERVER_ERROR);
} catch (Exception e) {
log.error("[OrderCommandService] 주문 생성 실패 - 회원ID: {}, 에러: {}", memberId, e.getMessage(), e);
order.updateStatus(OrderStatus.ABORTED);
throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR);
}private HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setBasicAuth(tossPaymentsConfig.getSecretApiKey(), "");
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
return headers;
}HttpHeaders 헤더를 만들고 type을 설정하고 Secret API Key를 Base64로 인코딩해 헤더에 넣는다. 이후 response Body에 데이터를 넣고 RestTemplate을 사용하여 데이터를 전송한다.
3. 환불 API (전체 환불)
환불은 결제 승인과 매우 비슷하다. 기존 결제 승인 URL 끝단에 /{paymentKey}/cancel을 붙인 게 환불 URL이다. URL에 paymentKey를 추가하고 response body에는 취소(환불)하는 이유를 넣어 보낸다.
그리고 추가적으로 멱등키 헤더를 넣어 반복적인 취소를 방지할 수 있다.

취소가 완료되면 결제 승인과 구조가 동일한 데이터에 cancels 배열이 채워져 반환된다.
{
// 생략
"status": "CANCELED",
// 생략
"cancels": [
{
"cancelReason": "구매자가 취소를 원함",
"canceledAt": "2022-01-01T11:32:04+09:00",
"cancelAmount": 10000,
"taxFreeAmount": 0,
"taxExemptionAmount": 0,
"refundableAmount": 0,
"transferDiscountAmount": 0,
"easyPayDiscountAmount": 0,
"transactionKey": "8B4F646A829571D870A3011A4E13D640",
"receiptKey": "V4AJ6AhSWsGN0RocizZQlagPLN8s2IahJLXpfSHzQBTKoDG7",
"cancelStatus": "DONE",
"cancelRequestId": null
}
],
// 생략
}status는 CANCELED, 구체적인 사항은 cancels 배열 내에 들어오게 된다.
status가 CANCELED인지 확인하고, cancelAmount가 paidAmount와 동일한지, cancelStatus가 DONE인지 검증하면 될 것 같다.
public OrderRefundResponseDto refundOrder(Long memberId, OrderRefundRequestDto request) {
// 환불이 가능한지 확인하는 로직 (생략)
try {
order.updateStatus(OrderStatus.REFUND_REQUESTED);
Map<String, Object> body = new HashMap<>();
body.put("cancelReason", request.getRefundReason());
// 멱등성 설정
HttpHeaders headers = getHeaders();
headers.set("Idempotency-Key", UUID.randomUUID().toString());
// 환불 시도
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
// URL paymentKey를 내 paymentKey로 변경
ResponseEntity<PaymentResponseDto> responseEntity = restTemplateConfig.restTemplate().postForEntity(
tossPaymentsConfig.getRefundUrl().replace("paymentKey", request.getPaymentKey()), entity, PaymentResponseDto.class);
PaymentResponseDto responseDto = responseEntity.getBody();
if (responseDto.balanceAmount() != 0) {
log.warn("환불 후 잔액이 0이 아닙니다. 결제 키: {}, 잔액: {}", request.getPaymentKey(), responseDto.balanceAmount());
throw new GeneralException(ErrorStatus.REFUND_FAILED);
}
cancelAmount
if ("CANCELED".equals(responseDto.status())) {
if (responseDto.cancels() != null
&& !responseDto.cancels().isEmpty()
&& "CANCELED".equals(responseDto.status())
&& "DONE".equals(responseDto.cancels().get(0).cancelStatus())
&& responseDto.cancels().get(0).cancelAmount == request.getPaidAmount()) {
log.info("환불 성공. 결제 키: {}, 환불 금액: {}", request.getPaymentKey(), order.getPaidAmount());
PaymentResponseDto.CancelDto cancelInfo = responseDto.cancels().get(0);
} else {
// 2xx 응답을 받았지만 CANCEL이 없는 상황
throw new GeneralException(ErrorStatus.PAYMENT_UNSPECIFIED_ERROR);
}
// 백엔드 DB 업데이트
order.updateStatus(OrderStatus.REFUNDED);
// 환불 성공 시 구매 통계에서 판매 개수만큼 수량 줄이기 (생략)
return OrderRefundResponseDto.of(order, order.getPointUsedAmount(), request.getRefundReason());
} catch (HttpClientErrorException e) { // 400번대 에러
log.error("토스 환불 4xx 서버 에러: {}", e.getResponseBodyAsString());
throw new TossPaymentException(ErrorStatus.TOSS_PAYMENT_BAD_REQUEST);
} catch (HttpServerErrorException e) { // 500번대 에러
log.error("토스 환불 5xx 서버 에러: {}", e.getResponseBodyAsString());
throw new TossPaymentException(ErrorStatus.TOSS_PAYMENT_SERVER_ERROR);
} catch (Exception e) {
order.updateStatus(OrderStatus.REFUND_FAILED);
log.error("[OrderCommandService] 환불 처리 실패 - 주문ID: {}, 에러: {}", request.getOrderId(), e.getMessage(), e);
throw new GeneralException(ErrorStatus.REFUND_FAILED);
}
}결과

연동 성공 &&_*!
깔끔한 API 문서라는 건 이토록 중요하구나