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>