Django와 Vue.js를 활용한 알리페이(Alipay) 결제 시스템 구축 및 연동 가이드

1. 결제 게이트웨이 모듈 추상화 및 설정

알리페이 SDK를 프로젝트에 직접 노출하지 않고, 재사용 가능하도록 별도의 모듈로 추상화합니다. 이를 통해 키 관리와 클라이언트 초기화 로직을 분리할 수 있습니다.

# payment_gateway/config.py
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent

# 애플리케이션 설정
APP_ID = '2021000000000000'
SIGN_TYPE = 'RSA2'
IS_SANDBOX = True

# 키 파일 로드 (pathlib를 사용하여 경로 처리)
PRIVATE_KEY = (BASE_DIR / 'keys' / 'app_private_key.pem').read_text()
ALIPAY_PUB_KEY = (BASE_DIR / 'keys' / 'alipay_public_key.pem').read_text()

# 게이트웨이 URL 설정
GATEWAY_URL = 'https://openapi.alipaydev.com/gateway.do' if IS_SANDBOX else 'https://openapi.alipay.com/gateway.do'
# payment_gateway/client.py
from alipay import AliPay
from . import config

# 알리페이 클라이언트 인스턴스 생성
alipay_client = AliPay(
    appid=config.APP_ID,
    app_notify_url=None,
    app_private_key_string=config.PRIVATE_KEY,
    alipay_public_key_string=config.ALIPAY_PUB_KEY,
    sign_type=config.SIGN_TYPE,
    debug=config.IS_SANDBOX
)
# payment_gateway/__init__.py
from .client import alipay_client
from .config import GATEWAY_URL

2. 백엔드 주문 및 결제 모델 설계

결제 상태를 추적하고 주문 상세 정보를 저장하기 위한 데이터베이스 모델을 정의합니다. 주문 번호는 중복 방지를 위해 UUID 또는 Snowflake 알고리즘을 활용하여 생성합니다.

# orders/models.py
from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()

class PaymentOrder(models.Model):
    STATUS_CHOICES = [
        (0, '결제 대기'),
        (1, '결제 완료'),
        (2, '취소됨'),
        (3, '만료됨'),
    ]
    PAY_METHODS = [
        (1, '알리페이'),
        (2, '위챗페이'),
    ]

    merchant_order_id = models.CharField(max_length=64, unique=True, verbose_name="상점 주문 번호")
    provider_transaction_id = models.CharField(max_length=64, null=True, blank=True, verbose_name="PG사 거래 번호")
    title = models.CharField(max_length=150, verbose_name="주문 제목")
    total_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="총 결제 금액")
    status = models.SmallIntegerField(choices=STATUS_CHOICES, default=0, verbose_name="주문 상태")
    pay_method = models.SmallIntegerField(choices=PAY_METHODS, default=1, verbose_name="결제 수단")
    paid_at = models.DateTimeField(null=True, blank=True, verbose_name="결제 완료 시간")
    
    user = models.ForeignKey(User, on_delete=models.DO_NOTHING, db_constraint=False, related_name='payment_orders', verbose_name="결제자")
    created_at = models.DateTimeField(auto_now_add=True, verbose_name='생성 시간')

    class Meta:
        db_table = "service_payment_order"
        verbose_name = "결제 주문"

class OrderItem(models.Model):
    order = models.ForeignKey(PaymentOrder, on_delete=models.CASCADE, db_constraint=False, related_name='items', verbose_name="주문")
    product_id = models.IntegerField(verbose_name="상품 ID")
    original_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="정가")
    final_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="결제 금액")

    class Meta:
        db_table = "service_order_item"
        verbose_name = "주문 상세"

3. 결제 초기화 API 구현 (DRF)

프론트엔드에서 전달받은 데이터를 검증하고, 주문을 생성한 후 알리페이 결제 페이지 URL을 반환하는 API를 구현합니다.

# orders/serializers.py
import uuid
from django.utils import timezone
from rest_framework import serializers
from .models import PaymentOrder, OrderItem
from payment_gateway import alipay_client, GATEWAY_URL
from django.conf import settings

class PaymentInitSerializer(serializers.ModelSerializer):
    product_ids = serializers.ListField(child=serializers.IntegerField(), write_only=True)

    class Meta:
        model = PaymentOrder
        fields = ['title', 'total_amount', 'pay_method', 'product_ids']

    def validate(self, attrs):
        # 1. 금액 위변조 검증 (실제 환경에서는 DB 조회를 통해 정확한 금액을 계산해야 함)
        products = attrs.get('product_ids')
        calculated_total = sum([99.00 for _ in products]) # 예시 계산 로직
        if attrs['total_amount'] != calculated_total:
            raise serializers.ValidationError("결제 금액이 일치하지 않습니다.")

        # 2. 고유 주문 번호 생성
        order_id = uuid.uuid4().hex
        
        # 3. 알리페이 결제 URL 생성
        query_params = alipay_client.api_alipay_trade_page_pay(
            out_trade_no=order_id,
            total_amount=str(attrs['total_amount']),
            subject=attrs['title'],
            return_url=settings.FRONTEND_RETURN_URL,
            notify_url=settings.BACKEND_NOTIFY_URL
        )

        self.context['payment_url'] = f"{GATEWAY_URL}?{query_params}"
        attrs['user'] = self.context['request'].user
        attrs['merchant_order_id'] = order_id
        return attrs

    def create(self, validated_data):
        product_ids = validated_data.pop('product_ids')
        order = PaymentOrder.objects.create(**validated_data)
        
        # 주문 상세 정보 일괄 생성
        items_to_create = [
            OrderItem(
                order=order,
                product_id=pid,
                original_price=99.00,
                final_price=99.00
            ) for pid in product_ids
        ]
        OrderItem.objects.bulk_create(items_to_create)
        return order
# orders/views.py
from rest_framework.viewsets import GenericViewSet
from rest_framework.mixins import CreateModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .serializers import PaymentInitSerializer

class PaymentViewSet(GenericViewSet, CreateModelMixin):
    permission_classes = [IsAuthenticated]
    serializer_class = PaymentInitSerializer

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        return Response({'payment_url': serializer.context['payment_url']})

4. 프론트엔드 결제 요청 및 리디렉션

Vue.js를 사용하여 사용자가 결제 버튼을 클릭했을 때 백엔드에 요청을 보내고, 반환된 URL로 리디렉션하는 로직을 작성합니다.

<template>
  <button @click="initiatePurchase(course)" class="purchase-btn">결제하기</button>
</template>

<script>
export default {
  methods: {
    async initiatePurchase(course) {
      const token = this.$cookies.get('auth_token');
      if (!token) {
        this.$message.warning('결제를 진행하려면 로그인이 필요합니다.');
        return;
      }

      try {
        const response = await this.$axios.post('/api/v1/payments/initiate/', {
          title: course.title,
          total_amount: course.price,
          pay_method: 1,
          product_ids: [course.id]
        }, {
          headers: { Authorization: `Bearer ${token}` }
        });
        
        // 알리페이 결제 페이지로 이동
        window.location.href = response.data.payment_url;
      } catch (error) {
        this.$message.error('결제 초기화 중 오류가 발생했습니다.');
      }
    }
  }
}
</script>

5. 결제 완료 콜백 처리 (동기 및 비동기)

알리페이는 결제 완료 후 프론트엔드로 GET 요청(Return URL)을, 백엔드로 POST 요청(Notify URL)을 보냅니다. 보안상의 이유로 실제 주문 상태 변경은 백엔드 POST 콜백에서验签(서명 검증) 후 처리해야 합니다.

# orders/callback_views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from django.utils import timezone
from .models import PaymentOrder
from payment_gateway import alipay_client
import logging

logger = logging.getLogger(__name__)

class AlipayCallbackView(APIView):
    # 외부 PG사 콜백이므로 CSRF 및 인증 제외
    authentication_classes = []
    permission_classes = []

    def post(self, request):
        """알리페이 비동기 알림 (Notify URL)"""
        data = request.data.dict()
        signature = data.pop('sign', None)

        if not signature:
            return Response('failed')

        # 서명 검증
        is_valid = alipay_client.verify(data, signature)
        if is_valid and data.get('trade_status') in ['TRADE_SUCCESS', 'TRADE_FINISHED']:
            order_id = data.get('out_trade_no')
            transaction_id = data.get('trade_no')

            # 주문 상태 업데이트
            updated = PaymentOrder.objects.filter(merchant_order_id=order_id, status=0).update(
                status=1,
                provider_transaction_id=transaction_id,
                paid_at=timezone.now()
            )
            
            if updated:
                logger.info(f"Order {order_id} paid successfully.")
            return Response('success')

        logger.warning(f"Invalid callback signature or status for order: {data.get('out_trade_no')}")
        return Response('failed')

    def get(self, request):
        """프론트엔드 동기 검증 (Return URL 검증용)"""
        order_id = request.query_params.get('out_trade_no')
        is_paid = PaymentOrder.objects.filter(merchant_order_id=order_id, status=1).exists()
        return Response({'is_paid': is_paid})
// PaymentSuccess.vue (프론트엔드 콜백 페이지)
<script>
export default {
  data() {
    return {
      orderDetails: {},
      isVerified: false
    };
  },
  async created() {
    // URL 쿼리 파라미터 파싱
    const params = new URLSearchParams(window.location.search);
    this.orderDetails = Object.fromEntries(params.entries());
    
    // 백엔드에 결제 상태 최종 확인 요청
    try {
      const response = await this.$axios.get('/api/v1/payments/callback/', {
        params: { out_trade_no: this.orderDetails.out_trade_no }
      });
      this.isVerified = response.data.is_paid;
    } catch (error) {
      console.error('Payment verification failed');
    }
  }
}
</script>

태그: Django DjangoRESTFramework Vue.js Alipay python

10월 1일 15:28에 게시됨