Ssul's Blog

아임포트로 구독결제 구현하기(django, react, restframework) 본문

dev/기능구현

아임포트로 구독결제 구현하기(django, react, restframework)

Ssul 2023. 2. 3. 11:04

한달 단위로 정기결제되는 구독서비스를 개발중이다.

가장 두려운 부분중 하나인 결제파트. 그 개발이 1차 마무리 되어서, 이렇게 기록으로 남겨놓는다.

PG사 신청, 등록부터, 설정까지 쉬운 과정이 없었던것 같다ㅜㅜ

 

PG사 개념 잡기

우선 온라인 결제가 보안이 중요한 사항인만큼 PG사를 이용할수 밖에 없다.

그래서 온라인 결제를 진행하기 위해서는 회사가 PG사에서 회사 고유의 MID를 발급받아야 한다. 자세한 내용은 다른 블로그에서 정리되어 있기에, 내가 서비스를 만들며 직면했던 PG관련 이슈 사항만 코멘트 남겨 놓는다. 우리는 KG이니시스를 사용하였다.

 

기억#1. 서비스를 직접 개발한다면 그 전용 MID를 발급받아야 함

회사내 결제되는 온라인 홈페이지(imweb)가 있었고, 그 과정에서 PG사에서 발급받은 MID가 있어서, 그걸 그대로 사용하면 될줄알았다. 하지만 불가. PG사에 문의결과 호스팅사(imweb 등)에서 발급되는 MID가 있고, 자체 개발을 위한 MID가 따로 있다는 사실. 그리고 이건 보통 유료. 보통 호스팅사 연결해서 하면, 무료일 경우가 많다. 그래서 보증보험 등을 가입하고 비용을 지급하여 PG사 MID발급

 

기억#2. 정기구독을 위한 빌링결제 신청해야 함

일회성 결제라면 일반적인 MID로도 괜찮다. 하지만, 한달단위로 결제가 이뤄지는 구독 서비스이기 때문에 일반MID로는 안됨. 그래서 빌링결제용 MID로 PG사에 신청 해야 함.

 

 

KG이니시스-아임포트 연결 설정

KG이니시스-서비스 형태로 결제를 구현하기도 하지만, 아직은 실력이 부족하니 KG이니시스-아임포트-서비스 형태로 결제를 구현하겠다.

그러려면 당연히 KG이니시스-아임포트 연결 설정

1. KG이니시스 화면(KG이니시스 상점관리자 > 로그인 > 상점정보 > 계약정보 > 부가정보)

KG이니시스 설정화면

2. 아임포트 화면

아임포트에 로그인하여서 > 결제연동에 들어가서 아래와 같이 넣어주면 됩니다.

결제채널 이름: 알아서

PG상정아이이디(MID): 발급받은 MID(KG로그인 아이디)

웹표준결제 signKey: 웹결제signKey생성 조회

빌링용 merchanKey: INILite key 생성 갱신 조회

아임포트 PG사 설정

그리고, 아임포트 api를 활용하기 위한 키들도 챙겨두기

로그인 후 > 내식별코드|API Keys에 있습니다.

가맹점 식별코드: impxxxxxxxx, REST API Key: 숫자들, REST API Secret: 영어&숫자

 

 

이제 설정은 마쳤다. 구독 결제 개발을 시작해보자!!

 

전체 결제 프로세스(비지니스 로직)

(1) 카드 등록(구독 전 카드 등록이 되어야 함)

(2) 구독 결제(카드 등록이 되지 않았으면, 구독결제버튼 비활성화)

(3) 구독결제 성공시, 구독정보를 담은 구독카드(컴포넌트) 생성

 

 

부분별 세부 프로세스

(1) 카드 등록

*아임포트 카드등록방법은 rest api 방식과 PG사 창 방식이 있다. 우리는 PG사 창방식 선택

// pages/cards/new.tsx
export default function CardRegsiterPage() {
  const router = useRouter();
  const { user } = useAuth();
  const [buyerEmail, setBuyerEmail] = useState('');
  const [buyerPhone, setBuyerPhone] = useState('');
  const [temporaryCard, setTemporaryCard] = useState<TCard>();

  useEffect(() => {
    instance.post<undefined, TCard>('/cards/parking').then((res) => {
      setTemporaryCard(res);
    });
  }, []);

  const requestPay = useCallback(async () => {
    if (!temporaryCard) {
      alert('새로고침이 필요합니다.');
      return;
    }
    const params = {
      //아임포트 실계정 설정
      pg: 'html5_inicis',
      //아임포트 테스트 계정 설정
      // pg: 'html5_inicis.INIBillTst',
      pay_method: 'card',
      customer_uid: temporaryCard.billingKey,
      name: '구독결제용 카드 등록',
      amount: 0,
      buyer_email: buyerEmail,
      buyer_name: user?.nick,
      buyer_tel: buyerPhone,
      m_redirect_url: location.origin + '/subscriptions'
    };
    try {
      // 카드 등록
      await callIamportModule(
        params,
      );
      // 백엔드서버에 카드db 생성
      await instance.post('/cards', {
        key: temporaryCard?.key,
      });
      //카드 등록완료
      router.push('/subscriptions/new');
    } catch (e) {
      console.error(e);
      alert((e as TIamportResponse).error_msg);
    }
  }, [temporaryCard, user, buyerEmail, buyerPhone, router]);
  useEffect(() => {
    initialize();
  }, []);
  return (
    <>
      {/* <!-- jQuery --> */}
      <Script type="text/javascript" src="https://code.jquery.com/jquery-1.12.4.min.js" />
      {/* <!-- iamport.payment.js --> */}
      <Script type="text/javascript" src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js" />
      <DefaultLayout
        title="카드 등록"
        contentProps={{
          alignItems: 'center',
          justifyContent: 'center',
          display: 'flex',
          flexDir: 'column'
        }}
        footer={<PaymentFooter />}
      >
        <TableContainer>
          <Table variant="simple">
            <Tbody>
              <Tr>
                <Th>전화 번호</Th>
                <Td>
                  <Input onChange={e => setBuyerPhone(e.target.value)} placeholder="핸드폰 번호" />
                </Td>
              </Tr>
              <Tr>
                <Th>이메일</Th>
                <Td>
                  <Input onChange={e => setBuyerEmail(e.target.value)} placeholder="이메일" />
                </Td>
              </Tr>
            </Tbody>
          </Table>
        </TableContainer>
        <Text m={2}>*결제/취소 내역이 전송됩니다</Text>
        <Button mt={2} onClick={() => requestPay()} disabled={!temporaryCard}>카드 등록 하기</Button>
      </DefaultLayout>
    </>
  );
}

[코드해석]

1. Front: /cards/parking을 호출

useEffect(() => {
    instance.post<undefined, TCard>('/cards/parking').then((res) => {
      setTemporaryCard(res);
    });
  }, []);

2. Back: 하여 card객체 생성 -> card.billingkey는 user.id_랜덤20자리수

# payments/viewsets.py
@action(["post"], detail=False, url_path="parking", url_name="parking")
    def parking(self, request, *args, **kwargs):
        card = Card(owner=request.user, key=get_random_string(20))
        return Response(self.get_serializer(card).data)

3. Front: 생성된 카드 빌링키를 가지고 아임포트 모듈로 카드 등록. /cards를 호출해서, DB에도 카드 정보 저장

//pages/cards/new.tsx
const requestPay = useCallback(async () => {
    if (!temporaryCard) {
      alert('새로고침이 필요합니다.');
      return;
    }
    const params = {
      //아임포트 실계정 설정
      pg: 'html5_inicis',
      //아임포트 테스트 계정 설정
      // pg: 'html5_inicis.INIBillTst',
      pay_method: 'card',
      customer_uid: temporaryCard.billingKey,
      name: '구독결제용 카드 등록',
      amount: 0,
      buyer_email: buyerEmail,
      buyer_name: user?.nick,
      buyer_tel: buyerPhone,
      m_redirect_url: location.origin + '/subscriptions'
    };
    try {
      // 카드 등록
      await callIamportModule(
        params,
      );
      // 백엔드서버에 카드db 생성
      await instance.post('/cards', {
        key: temporaryCard?.key,
      });
      //카드 등록완료
      router.push('/subscriptions/new');
    } catch (e) {
      console.error(e);
      alert((e as TIamportResponse).error_msg);
    }
  }, [temporaryCard, user, buyerEmail, buyerPhone, router]);

4. Back: card정보 db에 저장

#payments/viewsets.py
def create(self, request, *args, **kwargs):
        instance = (
            Card.objects.get_all_queryset().filter(key=request.data.get("key")).first()
        )

        if instance:
            instance.restore()
            return Response(self.get_serializer(instance).data)

        return super().create(request, *args, **kwargs)

(2) 구독생성 + 이번달 결제진행 + 다음달 결제예약

1. Front: 구독버튼을 클릭하면 /subscriptions을 월 구독료와 함께 호출

//pages/subscriptions/new.tsx
// 구독 생성 로직(일반)
  const createNewSubscription = useCallback(async () => {
    const subscription = await instance.post<any, TEducatorSubscription>('/subscriptions', {
      method: 'card',
      //결제 2nd: (중요)일반가격 설정
      monthly_fee: 100,
    });
    router.push(`/subscriptions/${subscription.id}/complete`);
  }, [router]);

2. Back: 구독을 생성하는데, 요금제에 따라 구분하여 생성(pro버전, lite버전)

더보기
(참고: restframework의 역직렬화 순서)
클라이언트에서 받은 요청(bytes)을 dict로 변환(viewsets또는 views.py에 있음)
tmpdata = JSONParser().parse(BytesIO(클라이언트요청))
#또는 보통 밑에 request 해결됨
tmpdata = request.data
serializer에 넣어서 instance 생성 준비
dsr = CommentSerializer(data=tmpdata) #(중요)data에 넣는다, instance,data둘다 이용시 update
#해당 과정을 조금 더 디테일하게 들어가면, 댓글을 다는 과정에서
#CommentsModelViewSet.create() > get_serializer(data=request.data) 여기까지 진행
유효성 검사를 해서, 성공하면 validated_data생성
dsr.is_valid()
#통과하면 dsr.is_valid()는 True, dsr.errors는 {}
#그리고 dsr.validated_data에 통과된 최종 데이터들이 dict형태로 담김
인스턴스 생성(*인스턴스는 validated_data로 만든다)
instance = Comment(**dsr.validated_data)
instance.save()
#해당 과정을 조금 더 디테일하게 들어가면, 댓글을 다는 과정에서
#views.perform_create(serializer) -> serializer.save() -> serializer.create

- viewset에서 dict형태로 전환하면서, 해당 구독의 manager 설정(이건 우리 비즈니스 모델의 특수성이니..참고만)

#subscriptions/viewsets.py
    def create(self, request, *args, **kwargs):
        # 결제 3rd: 구독 생성시, manager에 결제자는 기본으로 추가
        # serializer의 create로 이동
        # 구독 생성
        if not request.user.is_educator:
            raise PermissionDenied(_("cannot create subscription"))
        if not request.user.cards.first():
            raise ValidationError(_("there is no card"))
        request.data.update(
            {
                "owner_id": request.user.id,
                "managers": [request.user,],
            }
        )
        return super().create(request, *args, **kwargs)

- 유효성검사까지 성공하고(is_valid) 구독 객체를 생성할때, 가격을 검사해서 Pro버전(500원적립), Lite버전(100원)적립으로 구분하여 구독생성

viewset.create() > get_serializer() > is_valid() > views.perform_create() > serializer.save() -> serializer.create

#subscriptions/serializers.py
    def create(self, validated_data):
        # 결제 4th: (중요) 일반버전, lite버전별 구성
        monthly_fee = validated_data['monthly_fee']

        # 결제 5th: (중요)lite버전일 경우, 금액, 적립금 수정(10,000원 1백원페이백)
        if monthly_fee == 10000:
            validated_data.update(
                {
                    "expired_at": timezone.now() + relativedelta(months=1),
                    "type": Subscription.Types.Personal,
                    "next_billed_at": timezone.now()
                                      + relativedelta(months=1)
                                      - relativedelta(days=1),
                #     lite버전이니, 용돈 100원
                    "habit_value": 100,
                    "diary_value": 100,
                }
            )
        else:
            # 결제 6th: 일반버전일 경우, 금액, 적립금 수정(30,000원 5백원 페이백)
            validated_data.update(
                {
                    "expired_at": timezone.now() + relativedelta(months=1),
                    # 결제금액 임시수정
                    # "monthly_fee": 100,
                    "type": Subscription.Types.Personal,
                    "next_billed_at": timezone.now()
                                      + relativedelta(months=1)
                                      - relativedelta(days=1),
                }
            )
        return super().create(validated_data)

3. Back: 성공적으로 구독이 생성되면, user와 연결된 카드정보를 가져와서, 결제 함수를 호출(card.pay(instance)

(장고의 signal 기능을 사용하여, 구독에서 post_save가 발생할때 실행, subscription수정(not created)일때는 결제 없음)

#subscriptions.receivers.py
@receiver(post_save, sender=Subscription)
def pay_first(instance: Subscription, created, **kwargs):
    is_no_paid_plan = instance.type in [
        Subscription.Types.Free,
        Subscription.Types.Company,
    ]
    # 유료결제 구독이면, 카드 정보 가져와서, pay 호출. 그리고 subscription 수정일 경우 결제 호출없음
    if created and not is_no_paid_plan and instance.owner:
        card = instance.owner.cards.first()
        card.pay(instance)

 

4. Back:

- 아임포트 모듈로 결제하고,(아임포트 문서 참고: https://portone.gitbook.io/docs/auth/guide-1/undefined)

#payments/models.py
    def pay(self, subscription: Subscription):
        client = IamportClient()
        if not client.is_valid_suscription(self.billing_key):
            self.delete()
            raise ValidationError(_("card is not valid"))
        owner: User = self.owner
        # 결제 생성 receiver의 시그널이 호출되지만, created이기에 적용안됨
        payment: Payment = Payment.objects.create(
            buyer=self.owner,
            subscription=subscription,
            paid_card=self,
            amount=subscription.monthly_fee,
        )
        # 아임포트에서 결제 진행
        res = client.pay_again(
            customer_uid=self.billing_key,
            merchant_uid=str(payment.id),
            amount=payment.amount,
            name=subscription.name,
            buyer_name=owner.name,
            buyer_email=owner.email,
            buyer_tel=owner.phone,
        )
        
#subscriptions/service/iamport.py
    def pay_again(
        self,
        customer_uid: str,
        merchant_uid: str,
        amount: int,
        name: str,
        *args,
        **kwargs,
    ):
        print(customer_uid, merchant_uid, amount, name, kwargs)
        res = self._post(
            "/subscribe/payments/again",
            data={
                "customer_uid": customer_uid,
                "merchant_uid": merchant_uid,
                "amount": amount,
                "name": name,
                **kwargs,
            },
        )
        return res

- 결제를 성공하면 payment 객체 생성, 실패시 구독의 상태를 unpaid로 설정

#payments/models.py
    def pay(self, subscription: Subscription):
        
        #위에서 언급
        
        # 결제 성공시 결제 일자 기록 후 페이먼트 업데이트
        if res.status_code == 200 and res.json().get("code") == 0:
            payment.paid_at = timezone.now()
            payment.save()
        #     receivers.py로 다음스케쥴 예약하러 이동
        else:
        	# 시그널이 호출은 되지만, 수정이기 때문에 card.pay 호출되지는 않음
            subscription.status = subscription.Statuses.Unpaid
            subscription.save()

 

5. Back: 다음달 결제 예약하러 가기. payment.save가 수정 저장되면(=결제성공), 또 signal을 활용하여 다음 결제 예약

#payments/receivers.py
@receiver(post_save, sender=Payment)
def pay_first(instance: Payment, created, **kwargs):
    if created:
        return
    if instance.paid_at is not None:
        # 구독 활성화
        subscription: Subscription = instance.subscription
        # subscription.next_billed_at = instance.paid_at + relativedelta(
        #     months=1, days=-1
        # )

        # 잠시 하루단위
        subscription.next_billed_at = instance.paid_at + relativedelta(days=1)


        subscription.expired_at = instance.paid_at + relativedelta(months=1)
        subscription.status = subscription.Statuses.Active
        subscription.save()

        # 다음 결제 예약, 여기서도 시그널이 호출되지만 created이기 때문에 한번호출후 더이상 안됨
        payment = Payment.objects.create(
            buyer=instance.buyer,
            subscription=subscription,
            paid_card=instance.paid_card,
            amount=subscription.monthly_fee,
        )
        client = IamportClient()
        client.create_schedule(
            customer_uid=instance.paid_card.billing_key,
            merchant_uid=str(payment.id),
            amount=payment.amount,
            name=subscription.name,
            scheduled_at=subscription.next_billed_at.timestamp(),
            buyer_name=instance.buyer.name,
            buyer_email=instance.buyer.email,
            buyer_tel=instance.buyer.phone,
        )
        
#subscriptions/services/iamport.py
    def create_schedule(
        self,
        customer_uid: str,
        merchant_uid: str,
        amount: int,
        name: str,
        scheduled_at: int,
        *args,
        **kwargs,
    ):
        print(customer_uid, merchant_uid, amount, name, scheduled_at, kwargs)
        res = self._post(
            "/subscribe/payments/schedule",
            data={
                "customer_uid": customer_uid,
                "schedules": [
                    {
                        "merchant_uid": merchant_uid,
                        "schedule_at": scheduled_at,
                        "amount": amount,
                        "tax_free": 0,
                        "name": name,
                        **kwargs,
                    }
                ],
            },
        )
        return res

이렇게 하면, paid_at이 없는 payment객체가 저장(다음달 결제용). signal내의 함수 실행이 안됨.

 

 

(3) 다음달 결제 진행 > 성공시 아임포트가 웹훅 > 해당 호출을 받아, 결제하고, 다음달 결제 예약

1. 다음달 결제진행(성공적으로 예약된 빌링결제를 아임포트가 결제): 아임포트 관리자 페이지가서 결제>정기결제캘린더 가서 성공적으로 결제 예약 되어있는지 확인

 

2. 성공적으로 예약결제가 진행되면 > 아임포트가 웹훅 호출 > 호출받은 웹훅으로 구독정보 업데이트

(예약결제성공, 첫번째 결제성공 모두 status를 paid로 주기 때문에 첫번째 결제성공 웹훅은 예외처리 해줘야 함)

#payments/urls.py
urlpatterns = [
    path("", include(router.urls)),
    path("providers/iamport/callback", iamport_callback),
]

#payments/views.py
@api_view(["post"])
def iamport_callback(request):
    merchant_uid = request.data.get("merchant_uid")
    status = request.data.get("status")

    # 예약결제 성공인 경우
    if status == "paid":
        # 결제 성공 아임포트 webhook 일 경우, payment.paid_at이 있으니 예외처리
        if payment.paid_at:
            return Response("결제 성공 웹훅")
        # 예약결제 성공일 경우, 결제일 기록후, 업데이트하고, 다시 시그널로 다음달 예약
        payment.paid_at = timezone.now()
        payment.save()
        
    # 예약 결제가 안이뤄졌을 경우, 관리자 취소의 경우
    elif status == "failed" or status == "cancelled":
        subscription = payment.subscription
        subscription.canceled_at = timezone.now()
        subscription.status = subscription.Statuses.Unpaid
        # 업데이트이기 때문에 결제호출은 안됨
        subscription.save()
    else:
        return Response("다른 아임포트 웹훅입니다")
    return Response("ok")

3. payment.save()로 업데이트 되었으므로, signal호출하여 > 구독정보 업데이트 하고 > 다음달 결제 예약 진행

#payments/receivers.py
@receiver(post_save, sender=Payment)
def pay_first(instance: Payment, created, **kwargs):
    if created:
        return
    if instance.paid_at is not None:
        # 구독 활성화
        subscription: Subscription = instance.subscription
        # subscription.next_billed_at = instance.paid_at + relativedelta(
        #     months=1, days=-1
        # )

        # 잠시 하루단위
        subscription.next_billed_at = instance.paid_at + relativedelta(days=1)


        subscription.expired_at = instance.paid_at + relativedelta(months=1)
        subscription.status = subscription.Statuses.Active
        subscription.save()

        # 다음 결제 예약, 여기서도 시그널이 호출되지만 created이기 때문에 한번호출후 더이상 안됨
        payment = Payment.objects.create(
            buyer=instance.buyer,
            subscription=subscription,
            paid_card=instance.paid_card,
            amount=subscription.monthly_fee,
        )
        client = IamportClient()
        client.create_schedule(
            customer_uid=instance.paid_card.billing_key,
            merchant_uid=str(payment.id),
            amount=payment.amount,
            name=subscription.name,
            scheduled_at=subscription.next_billed_at.timestamp(),
            buyer_name=instance.buyer.name,
            buyer_email=instance.buyer.email,
            buyer_tel=instance.buyer.phone,
        )
        
#subscriptions/services/iamport.py
    def create_schedule(
        self,
        customer_uid: str,
        merchant_uid: str,
        amount: int,
        name: str,
        scheduled_at: int,
        *args,
        **kwargs,
    ):
        print(customer_uid, merchant_uid, amount, name, scheduled_at, kwargs)
        res = self._post(
            "/subscribe/payments/schedule",
            data={
                "customer_uid": customer_uid,
                "schedules": [
                    {
                        "merchant_uid": merchant_uid,
                        "schedule_at": scheduled_at,
                        "amount": amount,
                        "tax_free": 0,
                        "name": name,
                        **kwargs,
                    }
                ],
            },
        )
        return res

 

이후에, 12월 구독결제 후, 다음달 예약의 경우 내년 1월로 결제일을 설정해줘야 해서 또 수정.

#payments/receivers.py
...생략

    if instance.paid_at is not None:
        # 결제 성공했으니, 구독 활성화, 12월 체크 필요
        subscription: Subscription = instance.subscription
        if instance.paid_at.month == 12:
            subscription.next_billed_at = instance.paid_at + relativedelta(
                years=1, months=-11, days=-1
            )
        else:
            subscription.next_billed_at = instance.paid_at + relativedelta(
                months=1, days=-1
            )
            ....생략

아마 계속 수정사항이 나오지 않을까 예상해 본다 ㅠ.ㅠ

그래도, 장고 signal은 아주 좋은 기능 같다. 무한 루프에 빠지지 않게 조심하자!!