위챗페이·알리페이 V3 네이티브 연동 및 IJPay 활용 가이드

위챗페이 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, 알리페이의 복잡한 서명 로직을 추상화하여 더 간결한 코드로 통합 결제를 구현할 수 있습니다.

태그: wechatpay Alipay IJPay PaymentGateway SpringBoot

7월 26일 08:40에 게시됨