위챗페이 V3 API 연동
의존성 구성
위챗페이 V3 SDK와 JSON 처리 라이브러리를 추가합니다.
<!-- 위챗페이 V3 SDK -->
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.4.9</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
결제 설정 클래스
위챗페이 연동에 필요한 상수값을 관리하는 설정 클래스입니다.
package com.example.payment.wx;
import lombok.Data;
@Data
public class WxPayProperties {
// 인증서 일련번호
public static String certSerialNo = "6B89237B3DD11A5D18960E08EBD41D";
// 애플리케이션 ID
public static String appId = "wxe7534820";
// 가맹점 ID
public static String merchantId = "165005";
// API V3 키
public static String apiKey = "rw44t35hu36u6u64665u6j3";
// 개인키 파일 경로
// 로컬: C:/Users/D/Desktop/apiclient_key.pem
// 서버: /usr/apiclient_key.pem
public static String privateKeyPath = "C:/Users/D/Desktop/apiclient_key.pem";
}
서명 및 인증 유틸리티
플랫폼 인증서 검증과 결제 서명 생성을 담당하는 핵심 클래스입니다.
package com.example.payment.wx;
import com.wechat.pay.contrib.apache.httpclient.WechatPayHttpClientBuilder;
import com.wechat.pay.contrib.apache.httpclient.auth.*;
import org.apache.http.impl.client.CloseableHttpClient;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
public class WxSignatureUtil {
private static final ConcurrentHashMap<String, Verifier> verifierCache = new ConcurrentHashMap<>();
/**
* HTTP 클라이언트 생성 (서명 검증 포함)
*/
public static CloseableHttpClient createSecureClient() throws IOException {
PrivateKey merchantKey = loadPrivateKey();
return WechatPayHttpClientBuilder.create()
.withMerchant(WxPayProperties.merchantId, WxPayProperties.certSerialNo, merchantKey)
.withValidator(new WechatPay2Validator(getCachedVerifier(WxPayProperties.certSerialNo)))
.build();
}
/**
* 플랫폼 인증서 조회 (자동 갱신)
*/
public static Verifier getCachedVerifier(String serialNo) {
if (!verifierCache.containsKey(serialNo)) {
verifierCache.clear();
try {
PrivateKey key = loadPrivateKey();
PrivateKeySigner signer = new PrivateKeySigner(serialNo, key);
WechatPay2Credentials credentials = new WechatPay2Credentials(WxPayProperties.merchantId, signer);
Verifier verifier = new AutoUpdateCertificatesVerifier(
credentials,
WxPayProperties.apiKey.getBytes("utf-8")
);
verifierCache.put(verifier.getValidCertificate().getSerialNumber() + "", verifier);
return verifier;
} catch (Exception e) {
throw new RuntimeException("인증서 초기화 실패", e);
}
}
return verifierCache.get(serialNo);
}
/**
* APP 결제 서명 생성
*/
public static String generateAppSignature(String timestamp, String nonce, String prepayId) throws Exception {
PrivateKey key = loadPrivateKey();
String payload = String.join("\n", WxPayProperties.appId, timestamp, nonce, prepayId) + "\n";
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(key);
signer.update(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signer.sign());
}
/**
* JSAPI/미니프로그램 결제 서명 생성
*/
public static String generateJsApiSignature(String timestamp, String nonce, String prepayId) throws Exception {
PrivateKey key = loadPrivateKey();
String payload = String.join("\n", WxPayProperties.appId, timestamp, nonce, "prepay_id=" + prepayId) + "\n";
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(key);
signer.update(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signer.sign());
}
/**
* PEM 형식 개인키 로드
*/
public static PrivateKey loadPrivateKey() throws IOException {
String pem = new String(Files.readAllBytes(Paths.get(WxPayProperties.privateKeyPath)), "utf-8");
String base64Key = pem.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s+", "");
try {
KeyFactory factory = KeyFactory.getInstance("RSA");
return factory.generatePrivate(
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(base64Key))
);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("RSA 알고리즘 지원 불가", e);
} catch (InvalidKeySpecException e) {
throw new RuntimeException("잘못된 키 형식", e);
}
}
}
API 엔드포인트 상수
package com.example.payment.wx;
public class WxApiEndpoints {
public static final String BASE_URL = "https://api.mch.weixin.qq.com/v3";
// 결제 유형별 엔드포인트
public static final String APP_PAY = "/pay/transactions/app";
public static final String JSAPI_PAY = "/pay/transactions/jsapi";
public static final String NATIVE_PAY = "/pay/transactions/native";
public static final String H5_PAY = "/pay/transactions/h5";
// 환불 및 주문 관리
public static final String REFUND = "/refund/domestic/refunds";
public static final String CLOSE_ORDER = "/pay/transactions/out-trade-no/{}/close";
// 콜백 URL (실제 도메인으로 변경 필요)
public static final String PAY_NOTIFY_URL = "https://your-domain.com/api/payment/wx-notify";
public static final String REFUND_NOTIFY_URL = "https://your-domain.com/api/payment/wx-refund-notify";
}
결제 서비스 인터페이스
package com.example.payment.wx;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Map;
public interface WxPaymentService {
/** APP 결제 주문 생성 */
Map<String, Object> createAppOrder();
/** 결제 콜백 처리 */
Map<String, Object> handlePayCallback(HttpServletRequest req, HttpServletResponse resp);
/** 주문 취소 */
Map<String, Object> cancelOrder(String orderNo);
/** 환불 신청 */
Map<String, Object> requestRefund(String orderNo);
/** 환불 콜백 처리 */
Map<String, Object> handleRefundCallback(HttpServletRequest req);
}
결제 서비스 구현
package com.example.payment.wx;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.wechat.pay.contrib.apache.httpclient.notification.Notification;
import com.wechat.pay.contrib.apache.httpclient.notification.NotificationHandler;
import com.wechat.pay.contrib.apache.httpclient.notification.NotificationRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@Service
public class WxPaymentServiceImpl implements WxPaymentService {
@Override
public Map<String, Object> createAppOrder() {
Map<String, Object> result = new HashMap<>();
// 결제 금액 설정 (예: 0.01원)
BigDecimal amount = new BigDecimal("0.01");
int totalFee = amount.movePointRight(2).intValue();
try {
CloseableHttpClient client = WxSignatureUtil.createSecureClient();
HttpPost request = new HttpPost(WxApiEndpoints.BASE_URL + WxApiEndpoints.APP_PAY);
request.addHeader("Accept", "application/json");
request.addHeader("Content-type", "application/json; charset=utf-8");
// 요청 본문 구성
ObjectMapper mapper = new ObjectMapper();
ObjectNode body = mapper.createObjectNode();
body.put("appid", WxPayProperties.appId)
.put("mchid", WxPayProperties.merchantId)
.put("description", "상품 결제")
.put("out_trade_no", generateOrderNo())
.put("notify_url", WxApiEndpoints.PAY_NOTIFY_URL);
body.putObject("amount")
.put("total", totalFee)
.put("currency", "CNY");
request.setEntity(new StringEntity(body.toString(), "UTF-8"));
CloseableHttpResponse response = client.execute(request);
int status = response.getStatusLine().getStatusCode();
if (status == 200) {
String respBody = EntityUtils.toString(response.getEntity(), "UTF-8");
JSONObject json = JSONObject.parseObject(respBody);
String prepayId = json.getString("prepay_id");
// 클라이언트 결제 파라미터 생성
long ts = System.currentTimeMillis() / 1000;
String nonce = RandomStringUtils.randomAlphanumeric(32);
String sign = WxSignatureUtil.generateAppSignature(String.valueOf(ts), nonce, prepayId);
Map<String, String> payParams = new HashMap<>();
payParams.put("appid", WxPayProperties.appId);
payParams.put("partnerid", WxPayProperties.merchantId);
payParams.put("prepayid", prepayId);
payParams.put("package", "Sign=WXPay");
payParams.put("noncestr", nonce);
payParams.put("timestamp", String.valueOf(ts));
payParams.put("sign", sign);
result.put("code", 200);
result.put("data", payParams);
return result;
}
result.put("code", status);
result.put("message", "주문 생성 실패");
} catch (Exception e) {
result.put("code", 500);
result.put("message", e.getMessage());
}
return result;
}
@Override
public Map<String, Object> handlePayCallback(HttpServletRequest req, HttpServletResponse resp) {
Map<String, Object> result = new HashMap<>();
try {
StringBuilder body = new StringBuilder();
BufferedReader reader = req.getReader();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
NotificationRequest notifyReq = new NotificationRequest.Builder()
.withSerialNumber(req.getHeader("Wechatpay-Serial"))
.withNonce(req.getHeader("Wechatpay-Nonce"))
.withTimestamp(req.getHeader("Wechatpay-Timestamp"))
.withSignature(req.getHeader("Wechatpay-Signature"))
.withBody(body.toString())
.build();
NotificationHandler handler = new NotificationHandler(
WxSignatureUtil.getCachedVerifier(WxPayProperties.certSerialNo),
WxPayProperties.apiKey.getBytes(StandardCharsets.UTF_8)
);
Notification notification = handler.parse(notifyReq);
JSONObject data = JSONObject.parseObject(notification.getDecryptData());
if ("SUCCESS".equals(data.getString("trade_state"))) {
String orderNo = data.getString("out_trade_no");
String transId = data.getString("transaction_id");
// 비즈니스 로직 처리
result.put("code", "SUCCESS");
result.put("message", "처리 완료");
return result;
}
} catch (Exception e) {
result.put("code", "FAIL");
result.put("message", e.getMessage());
}
return result;
}
@Override
public Map<String, Object> cancelOrder(String orderNo) {
Map<String, Object> result = new HashMap<>();
try {
CloseableHttpClient client = WxSignatureUtil.createSecureClient();
String url = WxApiEndpoints.BASE_URL +
WxApiEndpoints.CLOSE_ORDER.replace("{}", orderNo);
HttpPost request = new HttpPost(url);
request.addHeader("Accept", "application/json");
request.addHeader("Content-type", "application/json; charset=utf-8");
ObjectMapper mapper = new ObjectMapper();
ObjectNode body = mapper.createObjectNode();
body.put("mchid", WxPayProperties.merchantId);
request.setEntity(new StringEntity(body.toString(), "UTF-8"));
CloseableHttpResponse response = client.execute(request);
if (response.getStatusLine().getStatusCode() == 204) {
result.put("code", 200);
result.put("message", "취소 성공");
}
} catch (Exception e) {
result.put("code", 500);
result.put("message", e.getMessage());
}
return result;
}
@Override
public Map<String, Object> requestRefund(String orderNo) {
Map<String, Object> result = new HashMap<>();
try {
CloseableHttpClient client = WxSignatureUtil.createSecureClient();
HttpPost request = new HttpPost(WxApiEndpoints.BASE_URL + WxApiEndpoints.REFUND);
request.addHeader("Accept", "application/json");
request.addHeader("Content-type", "application/json; charset=utf-8");
ObjectMapper mapper = new ObjectMapper();
ObjectNode body = mapper.createObjectNode();
body.put("transaction_id", "위챗페이 거래번호")
.put("out_refund_no", generateRefundNo())
.put("notify_url", WxApiEndpoints.REFUND_NOTIFY_URL);
ObjectNode amount = body.putObject("amount");
amount.put("refund", 100) // 환불 금액(단위: 분)
.put("total", 100) // 원 주문 금액
.put("currency", "CNY");
request.setEntity(new StringEntity(body.toString(), "UTF-8"));
CloseableHttpResponse response = client.execute(request);
String respBody = EntityUtils.toString(response.getEntity());
JSONObject json = JSONObject.parseObject(respBody);
String status = json.getString("status");
if ("SUCCESS".equals(status) || "PROCESSING".equals(status)) {
result.put("code", 200);
result.put("message", "환불 처리 중");
}
} catch (Exception e) {
result.put("code", 500);
result.put("message", e.getMessage());
}
return result;
}
@Override
public Map<String, Object> handleRefundCallback(HttpServletRequest req) {
// handlePayCallback과 유사한 로직
return handlePayCallback(req, null);
}
private String generateOrderNo() {
return "ORDER" + System.currentTimeMillis();
}
private String generateRefundNo() {
return "REFUND" + System.currentTimeMillis();
}
}
다른 결제 방식 전환
APP 결제를 다른 방식으로 변경하려면 엔드포인트만 교체하면 됩니다.
// QR 코드 결제 (Native)
HttpPost request = new HttpPost(WxApiEndpoints.BASE_URL + WxApiEndpoints.NATIVE_PAY);
// JSAPI 결제 (공식계정/미니프로그램)
HttpPost request = new HttpPost(WxApiEndpoints.BASE_URL + WxApiEndpoints.JSAPI_PAY);
// H5 결제
HttpPost request = new HttpPost(WxApiEndpoints.BASE_URL + WxApiEndpoints.H5_PAY);
응답 처리는 동일하게 200 상태 코드 확인 후 데이터 파싱:
if (status == 200) {
String result = EntityUtils.toString(response.getEntity(), "UTF-8");
JSONObject data = JSONObject.parseObject(result);
// QR 코드 결제의 경우: data.getString("code_url")
// JSAPI 결제의 경우: prepay_id로 서명 생성
}
공식 문서: https://pay.weixin.qq.com/doc/v3/merchant/4013070347
알리페이 연동
알리페이 V3 API 공식 문서: https://opendocs.alipay.com/open-v3/429e4d75_alipay.trade.app.pay
IJPay 통합 라이브러리
위챗페이, 알리페이, 유니페이 등을 통합 지원하는 오픈소스 라이브러리입니다.
공식 문서: https://ijpay.dreamlu.net/ijpay/guide/
IJPay를 사용하면 위챗페이 V2/V3, 알리페이의 복잡한 서명 로직을 추상화하여 더 간결한 코드로 통합 결제를 구현할 수 있습니다.