2025.07.30 - [분류 전체보기] - [SpringBoot] TOSS/토스 결제 API 연동하기
[SpringBoot] TOSS/토스 결제 API 연동하기
프로젝트를 진행하면서, 토스 결제 연동을 맡게 되어서, 내가 할 수 있을까 불안했다.내가 먼저 토스 결제 부분을 하고 싶다고 했었기 때문에, 무조건 해내야 하는,,, 압박감이랄까 혹시나 구현
jyitdevelopment.tistory.com
이전 글에서는 토스 결제가 성공하면 주문 정보만 DB에 저장하는 수준까지 구현했다.
이번에는 프로젝트 요구사항에 맞춰, 결제 성공 시 금액권까지 DB에 저장되도록 개선하였다.
요구사항 요약
- 상세 화면에서 선택한 금액(단가)과 개수(수량)만큼 금액권이 생성되어야 함
- 예: 1만원권 3개 결제 → 내 금액권 목록에 1만원 금액권 3개 표시
- Request DTO에 총 결제금액 외에 금액권 단가와 수량을 포함
- (현재는 서로 다른 금액의 금액권을 묶음 결제할 수 없으므로, 혼합 금액은 고려하지 않음)

상세화면은 위와 같다.
전체 흐름
- 결제 사전 등록
- 주문명, 총 결제금액과 함께 금액권 단가/수량을 Redis에 저장
- orderId 미지정 시 신규 생성
- 결제 성공(확인)
- Redis에서 사전 정보를 조회
- 요청 금액과 사전 금액 무결성 검증
- 토스 결제 승인 API 호출
- 결제 엔티티 저장
- 금액권 분할 발급 호출
- 금액권 발급
- 단가와 수량 기준으로 금액권 생성
- 각 금액권에는 다음 정보가 포함됨:
- 고유 QR UUID
- 주문명 (orderName)
- value / remainingValue = unitPrice
- 유효기간 1년
- 상태: 미사용
- 생성된 금액권은 DB에 저장
보통 QR 코드를 만들거나 스캔할 때는 ZXing 라이브러리를 많이 사용한다. 이 라이브러리를 활용해서 기능을 구현하였다.
implementation group: 'com.google.zxing', name: 'javase', version: '3.5.0'
implementation group: 'com.google.zxing', name: 'core', version: '3.5.0'
아래와 같이 QR 코드 관련 유틸리티 메서드를 구현하였다.
QrCodeUtil 클래스는 ZXing 라이브러리를 기반으로 문자열을 QR 코드 이미지로 변환하고, 이를 Base64 형태로 반환하는 기능을 제공한다. 또한 금액권, 쿠폰, 스탬프와 같이 서로 다른 QR 코드 종류를 구분하기 위해 QrCodeType을 Enum으로 정의하여 접두어(prefix)를 관리하였다.
public class QrCodeUtil {
// QR 종류 : 금액권, 쿠폰, 스탬프
static final int QR_WIDTH = 200;
static final int QR_HEIGHT = 200;
static final String QR_FILETYPE = "png";
/**
* 문자열을 QR 코드 바이트로 변환합니다.
*
* @param qrSource 은 변환할 QR코드의 source 문자열
* @return 은 QR 코드에 대한 byte[] 형태로 반환합니다.
*/
// 문자열 -> byte[]
private static byte[] makeQrCodeByte(String qrSource) {
try {
// QR코드 생성 옵션 설정
Map<EncodeHintType, Object> hintMap = new HashMap<>();
hintMap.put(EncodeHintType.MARGIN, 0);
hintMap.put(EncodeHintType.CHARACTER_SET, "UTF-8");
// QR 코드 생성
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix =
qrCodeWriter.encode(
qrSource, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT, hintMap);
// QR 코드 이미지 생성
BufferedImage qrCodeImage = MatrixToImageWriter.toBufferedImage(bitMatrix);
// QR 코드 이미지를 바이트 배열로 변환, byteArrayOutputStream 에 저장
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ImageIO.write(qrCodeImage, QR_FILETYPE, byteArrayOutputStream);
byteArrayOutputStream.flush();
byte[] qrCodeBytes = byteArrayOutputStream.toByteArray();
byteArrayOutputStream.close();
return qrCodeBytes;
} catch (Exception e) {
log.warn("QR 코드 생성 실패: " + e.getMessage());
}
return null;
}
/**
* 문자열을 받아서 Base64로 인코딩된 QR 코드 문자열을 반환하는 메서드입니다. 문자열을 makeQrCode() 메서드를 통해 byte[] 형태로 받고 -> 본
* 메서드에서 Base64로 인코딩합니다.
*
* @param qrSource 은 QR 코드로 변환할 문자열입니다.
* @return 인코딩된 QR 정보
*/
// byte[] -> String ( Base64 인코딩 )
public static String qrCodeToBase64(String qrSource) {
return Base64.getEncoder().encodeToString(makeQrCodeByte(qrSource));
}
/** 접두어와 함께 QR UUID 생성 */
public static String generatePrefixedUuid(QrCodeType type) {
return type.getQrPrefix() + UUID.randomUUID();
}
/** QrCodeType Enum 기반으로 접두사 제거 */
public static String removePrefix(String uuid, QrCodeType type) {
if (uuid != null && uuid.startsWith(type.getQrPrefix())) {
return uuid.substring(type.getQrPrefix().length());
}
return uuid;
}
}
결제 사전 등록 메소드
@Transactional
public PaymentPrepareResponseDto prepare(PaymentRequestDto.PaymentPrepareRequestDto request) {
log.info(
"[결제 사전 등록 요청] orderName={}, amount={}, unitPrice={}, quantity={}",
request.getOrderName(),
request.getAmount(),
request.getVoucherUnitPrice(),
request.getVoucherQuantity());
// orderId가 비어있으면 새로 생성
String orderId =
request.getOrderId() != null
? request.getOrderId()
: UUID.randomUUID().toString().replace("-", "").substring(0, 20);
PaymentRequestDto.PaymentPrepareRequestDto saveDto =
PaymentRequestDto.PaymentPrepareRequestDto.of(
orderId,
request.getOrderName(),
request.getAmount(),
request.getVoucherUnitPrice(), //추가된 금액권 액수
request.getVoucherQuantity()); //추가된 금액권 수량
redisRepository.save(saveDto);
return new PaymentPrepareResponseDto(
saveDto.getOrderId(), saveDto.getOrderName(), saveDto.getAmount());
}
결제 성공 메소드
@Transactional
public Payment confirm(PaymentRequestDto.PaymentConfirmRequestDto req) {
User user = authService.getCurrentUser();
return internalConfirm(req, user);
}
private Payment internalConfirm(PaymentRequestDto.PaymentConfirmRequestDto req, User user) {
log.info("[결제 확인] 요청 orderId: {}, amount: {}", req.getOrderId(), req.getAmount());
// 1. Redis에서 사전 저장된 결제 정보 조회
PaymentRequestDto.PaymentPrepareRequestDto prepare =
redisRepository
.findByOrderId(req.getOrderId())
.orElseThrow(
() -> {
log.error(
"[결제 확인 실패] Redis에서 orderId={} 정보 없음",
req.getOrderId());
return new PaymentHandler(ErrorStatus.PAYMENT_INFO_NOT_FOUND);
});
// 2. 금액 무결성 검증
if (req.getAmount() != prepare.getAmount()) {
log.error("[결제 금액 불일치] 요청: {}, 사전 저장: {}", req.getAmount(), prepare.getAmount());
throw new PaymentHandler(ErrorStatus.PAYMENT_AMOUNT_MISMATCH);
}
// 3. Toss 결제 승인 요청
log.info(
"[Toss 요청] paymentKey={}, orderId={}, amount={}",
req.getPaymentKey(),
req.getOrderId(),
req.getAmount());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String encodedKey =
Base64.getEncoder()
.encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8));
headers.set("Authorization", "Basic " + encodedKey);
Map<String, Object> body =
Map.of(
"paymentKey", req.getPaymentKey(),
"orderId", req.getOrderId(),
"amount", req.getAmount());
HttpEntity<?> entity = new HttpEntity<>(body, headers);
ResponseEntity<TossPaymentConfirmResponseDto> response =
restTemplate.postForEntity(
"https://api.tosspayments.com/v1/payments/confirm",
entity,
TossPaymentConfirmResponseDto.class);
TossPaymentConfirmResponseDto res = response.getBody();
if (res == null) {
log.error("[Toss 응답] 응답 객체가 null");
throw new PaymentHandler(ErrorStatus.PAYMENT_CONFIRM_RESPONSE_NULL);
}
// 4. 결제 정보 저장
Payment payment = PaymentConverter.toEntity(prepare, user);
payment.updateFromTossResponse(res);
paymentRepository.save(payment);
// 5. 금액권 분할 발급
int unitPrice = prepare.getVoucherUnitPrice();
int quantity = prepare.getVoucherQuantity();
log.info(
"[금액권 발급 시도] orderId={}, unitPrice={}, quantity={}",
req.getOrderId(),
unitPrice,
quantity);
voucherCommandService.issueVouchersByQr(unitPrice, quantity, user, payment);
return payment;
}
confirm과 internalConfirm으로 굳이 나눈 이유는 가독성이 주된 이유이기도 하고, 임의 사용자로 테스트를 해보려고 이와 같이 나눈 이유가 있다.
금액권 발급 메소드
결제 시, 호출할 금액권 발급 메소드이다.
/**
* 결제 정보와 사용자 정보를 바탕으로 단가와 수량에 따라 금액권(QR 기반)을 분할 발급합니다.
*
* @param unitPrice 금액권 1장당 금액 (단가)
* @param quantity 발급할 금액권 수량
* @param user 결제한 사용자
* @param payment 결제 엔티티
* @return 생성된 Voucher 리스트
*/
@Transactional
public List<Voucher> issueVouchersByQr(
int unitPrice, int quantity, User user, Payment payment) {
log.info("[금액권 발급 시작] 수량: {}, 단가: {}, 사용자: {}", quantity, unitPrice, user.getEmail());
List<Voucher> vouchers = new ArrayList<>();
for (int i = 0; i < quantity; i++) {
String qrUuid = QrCodeUtil.generatePrefixedUuid(QrCodeType.VOUCHER);
Voucher voucher =
Voucher.builder()
.qrCodeUuid(qrUuid)
.voucherName(payment.getOrderName())
.value(unitPrice)
.remainingValue(unitPrice)
.validDays(LocalDate.now().plusYears(1).toString())
.payment(payment)
.status(CouponStatus.UNUSED)
.user(user)
.build();
vouchers.add(voucher);
}
List<Voucher> saved = voucherRepository.saveAll(vouchers);
return saved;
}
지금 코드를 올리면서 저 금액권 builder를 추후에 converter로 분리해야겠다는 생각이 들었다.
아무튼 이렇게 하면, 데이터에 2장이 쏙 들어가 있는 것을 확인할 수 있다.

'SpringBoot' 카테고리의 다른 글
| [SpringBoot] AOP 기반 로깅 시스템 구현하기 - 로그와 비즈니스 로직 트랜잭션은 분리되지만 예외는 공유된다. (0) | 2025.10.14 |
|---|---|
| [SpringBoot] JPA / Hibernate / Spring Data JPA (0) | 2025.10.13 |
| [SpringBoot] AOP 기반 로깅 시스템 구축하기 (0) | 2025.09.29 |
| [SpringBoot] TOSS/토스 결제 API 연동하기 (5) | 2025.07.30 |
| [트러블 슈팅]FastAPI+Spring Boot EC2+Docker+Nginx (3) | 2025.07.21 |