Elegent-Pay를 활용한 위챗페이·알리페이 연동 및 API 요청 중복 방지 구현 가이드

결제 게이트웨이 통합 아키텍처 개요

위챗페이와 알리페이 연동은 각사별 SDK 구조가 상이하여 유지보수 비용이 높은 편입니다. 이를 해결하기 위해 elegent-pay 라이브러리는 다단계 프로토콜을 추상화한 단일 진입점을 제공하며, 네이티브, 모바일 웹, 앱, 미니프로그램 등 다양한 결제 채널을 통일된 파라미터 형식으로 처리합니다. 또한 자동화된 메시지 서명 검증, 상태 전환 추적, 그리고 결제/환불 콜백에 대한 기본 중복 보호 메커니즘을 사전에 포함하고 있어 개발자는 비즈니스 로직에 집중할 수 있습니다.

Maven 의존성 등록

프로젝트 루트의 pom.xml에 아래 의존성을 추가합니다.

<!-- 위챗페이 모듈 -->
<dependency>
    <groupId>cn.elegent.pay</groupId>
    <artifactId>elegent-pay-wxpay</artifactId>
    <version>1.0.0</version>
</dependency>

<!-- 알리페이 모듈 -->
<dependency>
    <groupId>cn.elegent.pay</groupId>
    <artifactId>elegent-pay-alipay</artifactId>
    <version>1.0.0</version>
</dependency>

환경 설정 및 보안 자원 관리

결제 프로세스에서 필요한 인증 정보는 프로젝트 설정 파일과 리소스 경로에 분리하여 관리해야 합니다.

YAML 환경 변수 구성

elegent:
  pay:
    wxpay:
      mchId: 1561414331
      appId: wx6592a2db3f85ed25
      appSecret: d9a9ff00a633cd7353a8925119063b01
      mchSerialNo: 25FBDE3EFD31B03A4377EB9A4A47C517969E6620
      apiV3Key: CZBK51236435wxpay435434323FFDuv3
    alipay:
      appId: 2021003141676135
    callback:
      domain: https://your-domain.com/api
      watch: true
      cycle: 10

설정 옵션 설명:

  • mchId / appId / appSecret: 각각 상점 식별자, 애플리케이션 ID, 내부 통신 암호화 키입니다.
  • mchSerialNo: 위챗페이 V3 API 요구사항인 상점证书 시리얼 넘버입니다.
  • apiV3Key: API v3 버전 데이터 복호화 전용 키입니다.
  • callback.domain: 실시간 알림 수신용 외부 도메인입니다. 로컬 테스트 시 Ngrok 또는 Cpolar 같은 터널링 툴로 노출된 HTTPS 주소를 권장합니다.
  • callback.watch & cycle: 비정상적인 네트워크 차단 상황에서 백엔드에서 주기적으로 거래 상태를 폴링하는 폴백 매커니즘입니다. 프로덕션에서는 주로 false 상태로 유지합니다.

암호화 키 파일 배치

서명 검증 및 개인키 사용을 위해 src/main/resources 디렉토리에 다음 파일을 생성해야 합니다.

/resources/wxpay_private.key
/resources/alipay_private.key
/resources/alipay_public.key

거래 생성 컨트롤러 재구성

원본 구조를 개선하여 파라미터 바인딩을 명시화하고, 응답 객체를 표준화하였습니다.

@RestController
@RequestMapping("/api/v1/transaction")
public class TransactionController {

    @Autowired
    private UnifiedPaymentGateway paymentGateway;

    @PostMapping("/init/{channelType}/{payPlatform}")
    public TransactionResponse initTransaction(
            @RequestBody PaymentContext ctx,
            @PathVariable String channelType,
            @PathVariable String payPlatform) {
        
        log.info("결제 초기화 요청 상세: {}", ctx);
        
        // 1. 커머스 레이어에서 영수증 생성
        OrderEntity orderRecord = commerceService.generateOrder(ctx, payPlatform);
        
        // 2. 게이트웨이 추상화 계층으로 전달
        GatewayRequest req = new GatewayRequest();
        req.setProductName(orderRecord.getItemName());
        req.setAmountInCents(orderRecord.getTotalPrice().longValue());
        req.setReferenceId(orderRecord.getTransactionCode());
        req.setUserOpenId(orderRecord.getOpenIdentifier());
        
        return paymentGateway.constructPaymentSession(req, channelType, payPlatform);
    }
}

이벤트 푸시 및 상태 전이 처리

외부 결제 사슬에서 반환되는 상태 변경 이벤트를 수신하여 내부 DB 트랜잭션을 동기화합니다.

@Service
@Transactional
@Slf4j
public class PaymentEventDispatcher implements EventListenerInterface {

    @Autowired
    private OrderRepository orderRepo;

    @Override
    public void handlePaymentConfirmation(String referenceId) {
        OrderEntity record = orderRepo.findByReferenceId(referenceId);
        if (record != null && record.getCurrentState() == OrderState.CREATED) {
            record.transitionTo(OrderState.PAID);
            record.updatePaymentStatus(PaymentResult.SUCCESS);
            orderRepo.save(record);
            logisticsService.dispatchShipment(record.getId());
        }
    }

    @Override
    public void handlePaymentDecline(String referenceId) {
        log.warn("결제 거절 이벤트 수신: {}", referenceId);
    }

    @Override
    public void handleRefundProcessed(String referenceId) {
        OrderEntity record = orderRepo.findByReferenceId(referenceId);
        if (record != null) {
            record.updatePaymentStatus(PaymentResult.REFUNDED);
            orderRepo.save(record);
        }
    }

    @Override
    public void handleRefundPending(String referenceId) {
        OrderEntity record = orderRepo.findByReferenceId(referenceId);
        if (record != null) {
            record.updatePaymentStatus(PaymentResult.REFUNDING);
            orderRepo.save(record);
        }
    }
}

API 중복 호출 방지 (Idempotency) 설계

네트워크 지연, 브라우저 리프레시, 또는 클라이언트 측 자동 재시도로 인해 동일한 결제 요청이 서버에 여러 번 도달할 수 있습니다. 이때 서버가 중복으로 차감을 수행하면 자금 과다 청구 및 장부 불일치가 발생합니다. 이를 막기 위해 요청 단위로 고유 스냅샷(SN) 또는 토큰을 검증하는 관점이 필요합니다.

elegant-idm 연동을 통한 중복 방어 구현

elegent-idem 컴포넌트는 Redis 기반 분산 락과 키 매핑 전략을 활용하여 HTTP 헤더 또는 쿼리 파라미터에 담긴 고유값을 일회성으로 유효화합니다.

추가 의존성 및 저장소 설정

<dependency>
    <groupId>cn.elegent.idem</groupId>
    <artifactId>elegent-idem-core</artifactId>
    <version>1.1.0</version>
</dependency>
elegent:
  idem:
    redis:
      host: 192.168.200.128
      port: 6379
      password: your_secure_password
      lockTimeoutSeconds: 30

중복 검사 어노테이션 적용

트랜잭션 생성 메소드에 @DuplicatePrevention을 붙이면 프레임워크가 내부적으로 고유값 격리 로직을 실행합니다. 동일 값이 유입되면 즉시 409 Conflict 응답을 반환하여 백그라운드 중복 처리를 차단합니다.

@RestController
@RequestMapping("/api/v1/transaction")
public class TransactionController {

    @Autowired
    private UnifiedPaymentGateway paymentGateway;

    @DuplicatePrevention(keyExpression = "#ctx.uniqueTransactionToken")
    @PostMapping("/init/{channelType}/{payPlatform}")
    public TransactionResponse initTransaction(
            @RequestBody PaymentContext ctx,
            @PathVariable String channelType,
            @PathVariable String payPlatform) {
        
        log.debug("중복 검사를 통과한 결제 세션 생성: {}", ctx.uniqueTransactionToken);
        
        OrderEntity safeOrder = commerceService.reserveInventory(ctx, payPlatform);
        
        GatewayRequest secureReq = new GatewayRequest();
        secureReq.setProductTitle(safeOrder.getSkuDescription());
        secureReq.setChargeAmount(safeOrder.getValueInCents());
        secureReq.setLedgerId(safeOrder.getTxnHash());
        secureReq.setClientToken(safeOrder.getClientSignature());
        
        return paymentGateway.prepareCheckoutFlow(secureReq, channelType, payPlatform);
    }
}

태그: elegant-pay elegant-idem spring-boot api-idempotency wechat-pay

10월 1일 17:03에 게시됨