위챗페이 결제 연동의 마지막 단계 중 하나인 환불 인터페이스 구현은 비즈니스 로직에서 매우 중요한 비중을 차지합니다. 본 가이드에서는 Spring Boot 3.1.x 환경을 바탕으로 PC 웹 환경에서 위챗페이 V3 API를 사용하여 환불을 신청하고, 그 결과를 처리하는 전체 프로세스를 다룹니다.
1. 위챗페이 환불 프로세스 개요
환불은 결제 완료 후 일정 기간(보통 1년 이내) 내에 구매자 혹은 판매자의 요청에 의해 발생합니다. 위챗페이는 상거래의 안정성을 위해 다음과 같은 제한 사항을 두고 있습니다.
- 결제 후 1년이 경과한 주문은 환불이 불가합니다.
- 한 건의 결제에 대해 최대 50회까지 부분 환불이 가능합니다.
- 환불 요청 시 동일한 '상점 환불 번호(out_refund_no)'를 사용하면 중복 처리를 방지할 수 있습니다.
- 환불 API의 응답은 '접수 완료'를 의미하며, 실제 성공 여부는 콜백(Callback)이나 조회 API를 통해 확인해야 합니다.
2. 데이터베이스 설계 (환불 정보 기록)
환불 상태를 추적하고 관리하기 위해 별도의 환불 정보 테이블이 필요합니다. 아래는 환불 이력을 관리하기 위한 테이블 스키마 예시입니다.
CREATE TABLE `refund_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_id` varchar(64) NOT NULL COMMENT '상점 주문 번호',
`refund_id` varchar(64) NOT NULL COMMENT '상점 환불 번호',
`wx_refund_id` varchar(64) DEFAULT NULL COMMENT '위챗 측 환불 번호',
`total_amount` int(11) NOT NULL COMMENT '원 주문 금액(분 단위)',
`refund_amount` int(11) NOT NULL COMMENT '환불 요청 금액(분 단위)',
`refund_reason` varchar(255) DEFAULT NULL COMMENT '환불 사유',
`status` varchar(20) DEFAULT NULL COMMENT '환불 상태',
`raw_response` text COMMENT 'API 응답 전문',
`callback_data` text COMMENT '콜백 데이터 전문',
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3. 백엔드 구현: 환불 신청 인터페이스
사용자로부터 환불 요청을 받는 컨트롤러와 비즈니스 로직을 처리하는 서비스 계층을 구현합니다.
Controller 구현
@RestController
@RequestMapping("/api/payment/wechat")
@Slf4j
public class WeChatRefundController {
private final RefundService refundService;
public WeChatRefundController(RefundService refundService) {
this.refundService = refundService;
}
@PostMapping("/request-refund/{orderId}/{transactionLastFour}/{reason}")
public ResponseEntity<ResponseDTO> applyRefund(
@PathVariable String orderId,
@PathVariable String transactionLastFour,
@PathVariable String reason) {
log.info("환불 요청 시작 - 주문번호: {}", orderId);
refundService.processRefundRequest(orderId, reason, transactionLastFour);
return ResponseEntity.ok(ResponseDTO.success());
}
}
Service 구현 로직
환불 요청 시에는 먼저 해당 주문이 실제 결제 완료 상태인지 검증해야 합니다. 또한 보안을 위해 사용자가 결제 내역에서 확인할 수 있는 위챗 거래 번호의 마지막 4자리를 대조하는 등의 추가 검증을 수행할 수 있습니다.
@Service
@Slf4j
public class RefundServiceImpl implements RefundService {
private final PaymentInfoMapper paymentInfoMapper;
private final RefundRecordMapper refundRecordMapper;
private final WeChatPayClient wxPayClient;
@Transactional(rollbackFor = Exception.class)
public void processRefundRequest(String orderNo, String reason, String checkDigits) {
// 1. 주문 및 결제 상태 확인
PaymentInfo payment = paymentInfoMapper.selectByOrderNo(orderNo);
if (payment == null || !PaymentStatus.SUCCESS.equals(payment.getTradeState())) {
throw new BusinessException("환불 가능한 결제 내역이 없습니다.");
}
// 2. 거래 번호 검증 (보안 단계)
String fullTransactionId = payment.getTransactionId();
if (!fullTransactionId.endsWith(checkDigits)) {
throw new BusinessException("거래 정보가 일치하지 않습니다.");
}
// 3. 환불 기록 생성
RefundRecord record = initRefundRecord(payment, reason);
// 4. 위챗 V3 환불 API 호출
callWeChatRefundApi(record);
}
private void callWeChatRefundApi(RefundRecord record) {
String url = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds";
Map<String, Object> rootParams = new HashMap<>();
rootParams.put("out_trade_no", record.getOrderNo());
rootParams.put("out_refund_no", record.getRefundNo());
rootParams.put("reason", record.getRefundReason());
rootParams.put("notify_url", config.getRefundNotifyUrl());
Map<String, Object> amountMap = new HashMap<>();
amountMap.put("refund", record.getRefundAmount());
amountMap.put("total", record.getTotalAmount());
amountMap.put("currency", "CNY");
rootParams.put("amount", amountMap);
// API 호출 및 결과 업데이트 로직...
String response = wxPayClient.post(url, JSONUtil.toJsonStr(rootParams));
updateRefundStatus(response, record);
}
}
4. 환불 결과 통보(콜백) 처리
위챗페이는 환불 처리가 완료되면 상점에서 지정한 notify_url로 비동기 알림을 보냅니다. 이 알림은 암호화되어 있으므로 복호화 과정을 거쳐 데이터를 확인해야 합니다.
- 상점은 통지를 받은 후 서명 검증을 수행해야 합니다.
- 이미 처리된 통지인지 확인하여 중복 처리를 방지(Idempotency)해야 합니다.
- 정상 처리 시 200 OK와 함께 위챗에서 요구하는 특정 포맷의 응답을 반환합니다.
@PostMapping("/refund-callback")
public Map<String, String> handleRefundNotify(HttpServletRequest request) {
log.info("위챗 환불 콜백 수신");
// 1. 요청 본문 및 헤더 검증
// 2. 데이터 복호화 (AES-256-GCM)
// 3. 비즈니스 로직 처리 (주문 상태 환불 완료로 변경)
Map<String, String> response = new HashMap<>();
response.put("code", "SUCCESS");
response.put("message", "OK");
return response;
}
5. 테스트 시 주의사항
현지 결제 수단을 테스트할 때는 외부망에서 접근 가능한 도메인이 필요하므로 Ngrok이나 로컬 터널링 도구를 사용하여 콜백 URL을 테스트 환경에 노출시켜야 합니다. 환불 요청 후 위챗 페이 앱의 결제 알림을 통해 환불이 정상적으로 사용자에게 도달했는지 확인하십시오.