개발일기

[결제] KG이니시스 결제 구현하기 (feat. 포인트충전) 본문

Project Portfolio

[결제] KG이니시스 결제 구현하기 (feat. 포인트충전)

츄98 2023. 6. 9. 08:23

결제에서 제일 중요한 것은 사전검증과 사후 검증이다.

먼저 결제화면을 만들어보자~

 

- 결제화면 코드

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="static/css/pointcharge.css" />
    <title>ChocoTheCoo</title>
    <!-- Bootstrap -->
    <!-- jQuery -->
    <!-- iamport.payment.js -->
    <script type="text/javascript" src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js"></script>
</head>

<!-- header -->
<header></header>

<!-- body -->

<body>
    <div class="title-container">
        <div class="image-box"></div>
    </div>
    <div class="section">
        <div class="card text-center" id="card">
            <div class="card-header">
                포인트 충전하기
            </div>
            <img class="help-text" src="/static/images/helpbtn.png" data-bs-toggle="tooltip" data-bs-html="true"
                data-bs-placement="bottom" data-bs-title="포인트 충전은, 카카오페이로 할 시 24시간 내에 반드시 환불처리됩니다.<br/> 걱정말고 테스트해주세요~">
            <div class="card-body">
                <h5 class="card-title mb-4">😎😎포인트를 선택하세요😎😎</h5>
                <!--테스트목적-->
                <div><input value="100" class="btn btn-primary mb-2" id="100"></div>
                <!--아래는 실제 사용-->
                <div><input value="3000" class="btn btn-primary mb-2" id="3000"></div>
                <div><input value="5000" class="btn btn-primary mb-2" id="5000"></div>
                <div><input value="10000" class="btn btn-primary mb-2" id="10000"></div>
                <div><input value="30000" class="btn btn-primary mb-2" id="30000"></div>
                <div><input value="50000" class="btn btn-primary mb-2" id="50000"></div>
                <div><input value="100000" class="btn btn-primary mb-2" id="100000"></div>
            </div>
        </div>
    </div>
</body>
<!-- footer -->
<footer></footer>
<!-- script -->
</html>

100원을 만든 이유는 테스트를 하기 위함이다.. 테스트 결제이기 때문에 자정이면 환불이 된다.

 

 

- 결제 검증하기

결제금액의 위변조 검증 이유: 

  • 결제 요청은 클라이언트 환경에서 이루어지기 때문에 별도의 검증을 하지 않으면 클라이언트가 스크립트를 조작해 금액을 위 변조하여 결제를 요청할 수 있다.
  • 따라서 결제하고자 하는 상품의 금액과 실제로 결제된 금액을 반드시 검증해야 한다.
  • 예를 들어 420,000원짜리 상품을 결제할 때에는 amount: 420000으로 결제요청을 하게 되는데, 공격자가 스크립트를 조작하여 해당 속성을 실제 금액보다 낮은 값(예 amount: 420)으로 변조할 수 있다.
  • 클라이언트에서의 스크립트 조작은 원천적으로 막을 수 없는 기술적 특징이 있기 때문에 결제 전후로 서버에서 결제금액의 위변조 여부를 반드시 검증해야 한다.

 

- 결제 사전검증하기

결제정보 사전 검증은 클라이언트 변조를 원천적으로 차단하기 위한 필수 절차이다.

결제창을 띄우는 프론트엔드를 보여주기 전에 어떤 주문번호로 얼마만큼의 결제가 이루어져야 하는지를 아래의 API를 사용하여 사전에 등록할 수 있다.

https://api.iamport.kr/payments/prepare

 

내 경우 여기에 더해, 프로젝트 db에도 요청 온 결제정보를 저장하도록 했다.

이유는 사후 검증 때, 사전검증 때 저장한 정보와 비교하여 정상적인 결제인지 아닌지를 판단하기 위해서이다.

또한 사전 검증 단계에서 가맹점 주문번호를 백엔드단에서 커스텀해 프론트로 제공하고 있다. (확실한 보안목적..!!)

 

# views.py
class PointCheckoutView(APIView):
    permission_classes = [IsAuthenticated]

    @transaction.atomic
    def post(self, request, *args, **kwargs):
        user = request.user
        amount = request.data.get('amount')
        payment_type = request.data.get('payment_type')

        try:
            trans = PayTransaction.objects.create_new(
                user=user,
                amount=amount,
                payment_type=payment_type
            )
        except:
            trans = None

        if trans is not None:
            data = {
                "works": True,
                "merchant_id": trans
            }

            return JsonResponse(data)
        else:
            return JsonResponse({}, status=status.HTTP_400_BAD_REQUEST)
# models.py Paytransaction
class PayTransaction(CommonModel):
    """결제 정보가 담기는 모델"""

    user = models.ForeignKey(
        "users.User", related_name="point_data", on_delete=models.CASCADE
    )
    transaction_id = models.CharField(verbose_name="imp결제고유번호", max_length=120, null=True, blank=True)
    order_id = models.CharField(verbose_name="주문번호", max_length=120, unique=True)
    amount = models.PositiveIntegerField(default=0)
    # 해외 payment 쓸거면 DecimalField으로 바꿔야함..!!
    # amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    success = models.BooleanField(default=False)
    transaction_status = models.CharField(max_length=220, null=True, blank=True)
    payment_type = models.CharField(max_length=120)

    objects = TransactionManager()

    def __str__(self):
        return self.order_id

    class Meta:
        ordering = ["-created_at"]



class TransactionManager(models.Manager):
    # 새로운 트랜젝션 생성
    def create_new(self, user, amount, payment_type, success=None, transaction_status=None):

        if not user:
            raise ValueError("유저가 확인되지 않습니다.")

        # 암호화 => 유니크한 주문번호 생성
        short_hash = hashlib.sha1(str(random.random()).encode()).hexdigest()[:2]
        time_hash = hashlib.sha1(str(int(time.time())).encode()).hexdigest()[-3:]
        base = str(user.email).split("@")[0]
        key = hashlib.sha1((short_hash + base + time_hash).encode()).hexdigest()[:10]
        new_order_id = str(key)  # "%s" % (key)

        # 아임포트 결제 사전 검증 단계
        # iamport.py에서의 validation_prepare 함수 호출
        validation_prepare(new_order_id, amount)

	# db에 정보 저장
        new_trans = self.model(
            user=user,
            order_id=new_order_id,
            amount=amount,
            payment_type=payment_type
        )

        if success is not None:
            new_trans.success = success
            new_trans.transaction_status = transaction_status

        try:
            new_trans.save()
        except Exception as e:
            print("저장 오류", e)

        return new_trans.order_id

    # 생성된 트랜잭션 검증
    def validation_trans(self, imp_id):
        result = get_transaction(imp_id)

        if result["status"] == "paid":
            return result
        else:
            return None

    def all_for_user(self, user):
        return super(TransactionManager, self).filter(user=user)

    def get_recent_user(self, user, num):
        return super(TransactionManager, self).filter(user=user)[:num]

결제 사전검증 url로 요청이 오면, 유니크한 가맹점주문번호를 생성하고, user, amount, payment_type을 담아서 db에 저장한다. validation_prepare() 함수로 https://api.iamport.kr/payments/prepare 사전등록을 할 수 있다.

# iamport.py

import requests
import json

from django.conf import settings

# get_access_token: 아임포트 서버에 접근할 수 있는 토큰을 발급
def get_access_token():
    access_data = {
        'imp_key': settings.IAMPORT_KEY,
        'imp_secret': settings.IAMPORT_SECRET
    }

    url = "https://api.iamport.kr/users/getToken"
    req = requests.post(url, data=access_data)
    access_res = req.json()

    if access_res['code'] == 0:
        return access_res['response']['access_token']
    else:
        return None


# 결제를 검증하는 단계
def validation_prepare(merchant_id, amount, *args, **kwargs):
    access_token = get_access_token()

    if access_token:
        access_data = {
            'merchant_uid': merchant_id,
            'amount': amount
        }
        url = "https://api.iamport.kr/payments/prepare"
        headers = {
            'Authorization': access_token
        }

        req = requests.post(url, data=access_data, headers=headers)
        res = req.json()

        if res['code'] != 0:
            raise ValueError("API 통신 오류")
    else:
        raise ValueError("토큰 오류")

 

 

 

- 결제 사후검증하기

# 결제 사후검증
class PointImpAjaxView(APIView):
    permission_classes = [IsAuthenticated]

    @transaction.atomic
    def post(self, request, *args, **kwargs):
        user = request.user
        merchant_id = request.data.get('merchant_id')
        imp_id = request.data.get('imp_id')
        amount = request.data.get('amount')

        try:
        # db에 정보가 존재하는지 확인
            trans = PayTransaction.objects.get(
                user=user,
                order_id=merchant_id,
                amount=amount
            )
        except:
            trans = None

        if trans is not None:
            try:
            # 스크립트에서 정보 조작을 할 경우를 대비하여
            # 포인트는 db에 저장되어있는 금액으로 생성한다.
                Point.objects.create(user=user, point_type_id=5, point=trans.amount)

                trans.transaction_id = imp_id
                trans.transaction_status = "paid"
                trans.success = True
                trans.save()

                data = {
                    "works": True
                }
                return JsonResponse(data)
            except:
                return JsonResponse({}, status=status.HTTP_400_BAD_REQUEST)
        else:
            return JsonResponse({}, status=status.HTTP_400_BAD_REQUEST)

 

이렇게 결제에 대한 백엔드처리과정을 살펴보았다.

결제는 보안에 주의해야하기 때문에 꼭 사전검증과 사후검증 과정을 구현하도록 하자!!

 

아래 참고자료 중 포트원 결제연동 Docs가 있다.

결제 테스트계정 만드는 법부터 결제 검증까지 가이드가 잘 나와있다.

 

 

앞으로 개선할 점:

  • 코드 가독성 높이기
  • 코드를 작성한 나로서는.. 내 코드를 이해하기가 어렵지 않지만,
    코드리뷰를 하며 코드가 많이 복잡하구나 싶었다ㅜㅜ
  • 아무래도 파일 3곳에 결제코드가 작성되어있다보니 보기 불편한 것 같아서 코드 가독성을 높여야겠다.
    (코드를 작성할 때만 해도 확장성 고려 및 사용목적에 따라 분류한 것이었지만, 합체해도 문제없을 것 같다.)