정기 결제 서버 시스템 POC 구축 프로젝트

👉🏻 슬리드에서 한 달동안 진행하게 된 개인 프로젝트입니다.

프로젝트 개요

슬리드는 온라인 강의용 캡쳐 필기 노트를 개발, 서비스하는 스타트업입니다.
정기 결제(구독) 시스템을 실제 서비스에 도입하기 전 검증하기 위한 목적으로 데모 페이지를 만들어 시스템을 테스트해보는 프로젝트를 진행했습니다.

프로젝트 테스크

  1. DB설계
  2. 결제 모듈(Payple)을 사용하여 정기 결제 구현
  3. Django Template 사용하여 페이지 구현
  4. AWS RDS 사용
  5. 반복 결제를 위한 시스템 아키텍처 설계

1. DB설계

  1. User

    • created_at : 회원가입한 시간
    • updated_at : 회원정보가 업데이트 된 시간
    • email : 이메일(아이디)
    • password : 비밀번호
    • name : 유저 이름
    • phone_number : 핸드폰 번호
    • use_status : 유저가 사용중인 상품 정보
  2. Product

    • membership_id : membership_foreign key
    • price : 가격
    • duration : 사용 가능한 기간
    • is_subscription : 정기결제 구분
  3. Membership

    • name : 상품 이름
    • description : 상품 설명
  4. Order

    • user_id : user_foreign key
    • number : 주문 번호
    • product_id : product_foreign key
    • price : 결제할 가격
    • pay_type : 결제 수단
    • created_at : 결제 요청 시간
    • successed_at : 결제 성공 시간
    • expiration_date : 사용 만료일
    • next_order_date : 다음 결제일
    • biliing_key : 페이플에서 받은 빌링키
    • result_status : 결제 상태
    • result_message : 페이플에서 받은 결제 응답 메세지 저장
  5. Refund

    • user_id : user_foreign key
    • order_id : order_foreign key
    • created_at : 환불 요청 시간
    • success_at : 환불 성공 시간
    • price : 환불된 금액
    • result_status : 환불 상태
    • result_message : 페이플에서 받은 결제 응답 메세지 저장

2. 결제 모듈을 사용하여 정기 결제 구현

결제 모듈은 페이플을 이용했습니다. 페이플을 이용한 정기 결제는 페이플의 결제창을 호출해서 카드 등록을 받아 빌링키를 발급 받은 뒤 재결제시에는 발급 받은 빌링키로 결제 요청을 하도록 되어있습니다.

정기 결제에 대한 상세한 설명을 구매 / 결제 / 재결제 / 결제 중지 / 결제 취소로 나누었습니다.


구매

사용자가 상품을 주문하는 것

1) 가능 조건

  • 로그인 된 사용자

  • Free 상품을 사용중인 사용자

    → 사용중인 상품에 대한 업그레이드나 다운그레이드 기능이 없으므로, 멤버쉽 구매 취소 후 새로운 멤버쉽을 구매할 수 있도록 했습니다.


결제

사용자가 결제 모듈을 사용하여 실제로 돈을 지불하는 것

결제는 일회성결제와 구독 결제가 있지만 이 문서에서는 구독 결제에 대해서만 작성했습니다.

1) 가능 조건

  • 구매 조건이 충족된 사용자

2) 순서


1. 사용자가 서비스 주문
2. 사용자가 유저 정보 입력과 결제 방식 선택
- 유저 정보 입력 내용 결제 방식

    이름 :

    핸드폰 :

    이메일 :

    결제 수단 : 카드 / 현금
  1. 사용자가 입력한 2번의 내용 확인 후 결제 진행 요청시,

  2. 페이플에 가맹점 인증 요청

  3. 가맹점 인증 성공시, 페이플의 결제창을 호출하여 사용자가 카드 정보 입력

  4. 사용 가능한 카드일 때 페이플에서 빌링키 반환

  5. 결제 요청 재컨펌 요청과 자사 서버 order 데이터 생성

    • 결제 요청 재컨펌 요청 코드

      결제 요청 재컨펌 요청이란, 최종 결제 요청입니다. 결제 요청시 work_type : "CERT"로 입력하면 재컨펌 요청이 됩니다.

      ( github → payment-system/slid/payment/views.py )

      # POST /payconfirm, 결제요청 재컨펌 (PCD_PAY_WORK : CERT)
      def pay_confirm(request):
          if request.method == 'POST':
              print("pay_confim입니다-------------------")
              body = json.loads(request.body)
      
              pay_confirm_url = body.get('PCD_PAY_COFURL')  # (필수) 결제승인요청 URL
              data = { 
                  'PCD_CST_ID': os.environ.get('CST_ID'),  # (필수) 가맹점 ID (실결제시 .env 파일내 발급받은 운영ID를 작성하시기 바랍니다.)
                  'PCD_CUST_KEY': os.environ.get('CUST_KEY'),  # (필수) 가맹점 Key (실결제시 .env 파일내 발급받은 운영Key를 작성하시기 바랍니다.)
                  'PCD_AUTH_KEY': body.get('PCD_AUTH_KEY', ''),  # (필수) 결제용 인증키
                  'PCD_PAYER_ID': body.get('PCD_PAYER_ID', ''),  # (필수) 결제자 고유ID
                  'PCD_PAY_REQKEY': body.get('PCD_PAY_REQKEY', ''),  # (필수) 결제요청 고유KEY
              }
              
              headers = {'Content-Type': 'application/json;', 'referer': os.environ.get('PCD_HTTP_REFERRER')}
      
              response = requests.post(pay_confirm_url, data=json.dumps(data), headers=headers).json()
      
              print('결제승인요청 결과 :', response)
      
              order_number = response['PCD_PAY_OID']
              result = test_check_transaction(order_number)
      
              if result['PCD_PAY_RST'] == 'success' :
                  data = test_db_update(result)
      
                  if data is not None : 
                      return HttpResponse(json.dumps(response), content_type="application/json")
          return redirect('/')
  6. 결제 성공 메세지 반환시, 결제 결과 조회

  7. 결제 성공 내역 확인되면 3) 결제 성공

  8. 결제 성공 내역 확인 안되면 4) 결제 실패

3) 결제 성공

결제 성공시 결제 요청하면서 생성했던 table의 data를 update합니다.

  • 결제 완료 날짜(successed_at) : 현재 시간으로 업데이트
  • 사용 만료일 (expiration_date) : successed_at + 구매한 product의 duration
  • 다음 결제일 (next_order_date) : expiration_date - 1일
  • 결과 상태 (result_status) : 페이플에서 받은 '결제 성공' 저장
  • 결과 메세지 (result_message) : 페이플에서 받은 결제 메세지 저장

table의 data를 update합니다.

  • 사용 상태 (use_status) : 구매한 상품의 멤버쉽 이름('basic' 혹은 'pro')

4) 결제 실패

(코드 구현 안됨)

  • 결과 상태(result_status) : 페이플에서 받은 '결제 실패' 저장
  • 결과 메세지(result_message) : 페이플에서 받은 결제 메세지 저장

재결제

정기적으로 페이플로 REST API로 결제를 요청하는 것

1) 가능 조건

  • 사용자가 정기 결제 상품의 결제를 성공했을 때

2) 순서

  1. Order table에 있는 다음 결제일(next_order_date)가 오늘인 날짜인 리스트 목록을 생성한 뒤 for문으로 payment_list에 있는 order 요소들을 차례로 결제 요청을 준비

    • 결제 요청 준비 코드

      def recurring_order_list(request) :
          
          # 오늘 날짜
          day = datetime.now()
          today = day.strftime('%Y-%m-%d')
      
          payment_list = Order.objects.filter(next_order_date = today)
          result_message = 'RECURRING_ORDER_REQUEST'
      
          # 테스트용 날짜
          test_list = Order.objects.filter(next_order_date = '2021-08-11')
      
          if test_list :
              for order in test_list:
                  result_message = request_subscription(order)
                  
              if result_message == 'recurring_order_success' :
                  # result_message = 'RECURRING_ORDER_SUCCESS'
                  return HttpResponse(result_message, status=201)
      
          result_message = 'RECURRING_ORDER_LIST_IS_NOT_EXISTS'
          return HttpResponse(result_message, status = 400)
  2. 가맹점 인증 요청

  3. 가맹점 인증 응답 확인

  4. 가맹점 인증 성공시 결제 요청 + 새로운 order data 생성

    • b~d 과정의 코드

      def request_subscription(order) :
      
          order_id = order.id
          order = Order.objects.get(id=order_id)
      
          # 가맹점 인증 함수 호출 
          response = test_authenticate('sub_request')
      
          print('정기결제 요청 전에 가맹점인증 결과 :', response)
      
          if response['result'] == 'success' : 
              request_url = response['PCD_PAY_HOST']+response['PCD_PAY_URL']
              data = {
                  "PCD_CST_ID": response['cst_id'],
                  "PCD_CUST_KEY": response['custKey'],
                  "PCD_AUTH_KEY": response['AuthKey'],
                  "PCD_PAY_TYPE" : "card",
                  "PCD_PAYER_NO" : order.user_id.id,
                  "PCD_PAYER_ID" : order.billing_key,
                  "PCD_PAY_GOODS" : order.product_id.membership.name,
                  "PCD_SIMPLE_FLAG" : "Y",
                  "PCD_PAY_TOTAL" : order.price,
                  "PCD_PAY_OID": str('test' + datetime.utcnow().strftime('%Y%m%d%H%M%S%f'))
              }
      
              headers = {'Content-Type': 'application/json;'}
      
              find_response = requests.post(request_url, data=json.dumps(data), headers=headers).json()
      
              print('결제 결과 조회 결과 :', find_response)
      
              # 새로운 결제 데이터 생성
              create_new_order_db(find_response)
      
  5. 결제 성공시 3) 결제 성공

  6. 결제 실패시 4) 결제 실패

3) 결제 성공

결제 성공시 결제 요청하면서 생성했던 table의 data를 update합니다.

  • 결제 완료 날짜(successed_at) : 현재 시간으로 업데이트
  • 사용 만료일 (expiration_date) : successed_at + 구매한 product의 duration
  • 다음 결제일 (next_order_date) : expiration_date - 1일
  • 결과 상태 (result_status) : 페이플에서 받은 '결제 성공' 저장
  • 결과 메세지 (result_message) : 페이플에서 받은 결제 메세지 저장

4) 결제 실패

(코드 구현 안됨)

유저가 결제 중지를 요청하지 않았지만 잔액부족, 한도초과, 카드유효기간 만료 등의 이유로 실패할 경우 익일 오후 6시에 재결제 요청을 보내도록 합니다. 만일 6시에 시도한 재결제도 실패할 경우,

table data를 update합니다.

  • 유저의 상품 이용 권한(use_status) : Free로 변경

table의 data를 update합니다.

  • 다음 결제 예정일 (next_order_date) : None으로 변경

  • 결과 상태(result_status) : 페이플에서 받은 '결제 실패' 저장

  • 결과 메세지(result_message) : 페이플에서 받은 결제 메세지 저장

  • 다른 서비스들의 결제 실패시 처리 방법

    결제 실패시 처리 방법이 다양했습니다. 참고하면 좋을 것 같아 아래 링크로 첨부합니다.

    • 1) 유튜브
    • 2) 리디 셀렉트
    • 3) 캡슐투도어

결제 중지

(코드 구현 안됨)

다음 결제일의 결제를 중단하는 것

1) 가능 조건

  • 정기 결제 상품을 사용중인 유저
  • 가장 최근 결제한 내역을 중지

2) 순서

  1. 사용자가 결제 중지 요청

  2. < Order > table의 data를 update

    • 다음 결제일 (next_order_date) : None으로 변경

    • 결과 상태 (result_status) : '결제 중지'로 변경

    • 수정일(updated_at) : 결제 중지 요청한 시각을 저장

      현재 DB에는 없는 Column입니다. 문서를 정리하면서 필요한 Column이라고 생각되어 문서에 추가했습니다.


결제 취소

(코드 구현 안됨)

유저가 결제한 내역을 취소하고 돈을 일정부분 돌려 받는 것

1) 가능 조건

  • 유료 상품을 사용중인 유저

2) 순서

  1. 사용자가 결제 취소 요청
  2. 가맹점 인증 요청 (승인취소 API의 가맹점 인증 요청)
  3. 가맹점 인증 성공시 승인 취소 요청
  4. < Refund > data 생성
    • 유저 (user_id ) : 결제 취소 요청한 유저
    • 주문 (order_id) : 가장 최근 결제한 주문
    • 생성시각(created_at) : 결제 취소 요청한 시각
    • 성공시각(successed_at) : 결제 취소 완료된 시각
    • 금액(price) : 결제 취소 된 금액
    • 결과 상태(result_status) : 페이플에서 받은 '취소 성공' 저장
    • 결과 메세지(result_message) : 페이플에서 받은 결제 메세지 저장

3. Django Template

  • 템플릿

    서로 다른 데이터를 일정한 형태로 표시하기 위해 재사용 가능한 파일을 말한다. Django 템플릿 양식은 HTML을 사용하며, View로부터 데이터를 전달받아 HTML 템플릿에 동적으로 치환해서 사용한다.

  • 템플릿 렌더링

    views.py에서 render 함수를 사용하면 템플릿을 렌더링해줄 수 있다. render는 django.shortcuts 패키지에 있는 함수로 첫번째 파라미터로 request를, 그리고 두번째 파라미터로 템플릿을 받아들인다.

    from django.shortcus import render
    
    def base(request):
    	context = {
                'name' : 'slid',
                'des' : 'description'
    						}
    	return render(request,'base.html', context)

    view에서 render를 사용해서 (request, 'base.html', context)를 반환한다.

    request는 응답을 생성하는데 사용되는 요청 개체이고, 'base.html'은 사용할 템플릿의 이름이다. context는 view에서 템플릿에 전달할 데이터를 dictionary 형태로 담아서 전달한다.

  • 템플릿 언어

    Django 템플릿에서 사용하는 언어가 있는데 크게 템플릿 변수, 템플릿 태그, 템플릿 필터, 코멘트 등으로 나눌 수 있다.

    • 템플릿 변수 : {{ 변수 }} 안에 싸여있으며, 템플릿 변수를 사용하면 view에서 템플릿으로 객체를 전달할 수 있으며, view에서 context로 전달된 변수의 값을 해당 위치에 치환시킬 수 있다.
    • 템플릿 태그 : {% 태그 %} 로 감싸져있다. HTML자체는 프로그래밍 로직을 구현할 수 없지만 템플릿 태그를 사용하면 if, for문을 사용해서 흐름을 제어할 수 있다.
  • 템플릿 상속

    HTML중 기본 뼈대가 되는 문서를 기본 템플릿으로 정하고, 다른 문서에서 기본 템플릿의 코드가 필요할 경우 상속해서 가져다 쓰는 것을 말한다.

    1) 기본 뼈대가 될 base.html 생성

    // base.html 생성 
    
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Slid</title>
    </head>
    <body>
      	<div class="navbar">
          모든 페이지에 공통으로 들어갈 요소
      	</div>
        {% block content %} 
      		// base.html을 상속한 템플릿에서 구현해야 하는 영역
        {% endblock %}
    </body>
    </html>

    2) product.html에서 base.html 상속

    {% extends 'base.html' %} // html 파일 최상단에 상속받을 파일을 명시한다. 
    
    {% block content %}
    <div>
      //product에 관한 내용들 
    </div>
    {% endblock %}

시스템 아키텍처

  • 이번 프로젝트에서는 local에서 django manage runserver와 DB서버로는 AWS RDS(MySQL)를 사용했습니다.

Crontab 사용하기

  • 서버에서 자동으로 페이플에 정기 결제를 요청할 수 있도록, 서버 스스로 정해둔 명령을 일정 기간마다 실행하도록 해주는 crontab을 사용하고자 했습니다.

1) 서버에 모듈 설치

pip install django-crontab

2) settings.py에 앱 추가

INSTALLED_APPS = (
    ...,
    'django_crontab',
)

3) Project/App/cron.py 생성 후 실행시킬 함수를 생성합니다.

4) settings.py에 cronjobs 등록

CRONJOBS = [
    ('*/5 * * * *', 'app.cron.crontab_job')
]

('분 시 일 월 요일' , '앱이름.파일명.실행시킬 함수명')

5) 서버에서 crontab 작업 처리

python manage.py crontab add 		# 등록된 job들을 모두 실행
python manage.py crontab remove 	# 등록된 job들을 모두 제거
python manage.py crontab show		# 등록된 job들을 모두 보기

위의 순서에 따라 cron.py 생성 후 함수를 생성하고 함수가 제대로 실행되는지 테스트를 진행했으나 환경 변수에 저장된 가맹점 인증 키를 포함한 다른 변수들을 모두 읽어오지 못하는(python manage.py shell을 실행시켰을 때는 환경 변수가 잘 조회되었지만 python script file에서는 읽어오지 못했습니다. ) 문제가 발생했습니다.

  • 문제 내용

    같은 문제에 대한 글을 발견했고,

    • 이슈 링크1

    • 이슈 링크2

      export를 사용하여 환경변수를 셸에서 환경으로 내보내는 방법으로 해당 이슈를 해결이 가능할 것 같다고 생각됩니다.

때문에 이번 프로젝트에서는 crontab으로 서버에서 재결제를 자동으로 실행하는 것이 아닌, 페이지에 버튼을 만들어 재결제 요청을 실행하는 함수를 POST 요청으로 실행시켰습니다.

  • 재결제 실행 코드

    payment-system/slid/product/templates/product/test_recurring_order.html

    {% extends 'base.html' %} 
    {% block content %}
    
    <script>
    
    </script>
    
    <form action = "{% url 'recurring' %}" method="POST">
      {% csrf_token %}
      <button type='submit' name='detail' value='recurring'>정기 결제</button>
    
    </form>
    
    {% endblock %}

    위 코드에 따라 정기결제 버튼을 클릭시, 재결제 하도록 만든 함수가 작동하게 됩니다.

    payment-system/slid/payment/views.py

    def recurring_order_list(request) :
        
        # 오늘 날짜
        day = datetime.now()
        today = day.strftime('%Y-%m-%d')
    
        payment_list = Order.objects.filter(next_order_date = today)
        result_message = 'RECURRING_ORDER_REQUEST'
    
        # 테스트용 날짜
        test_list = Order.objects.filter(next_order_date = '2021-08-11')
    
        if test_list :
            for order in test_list:
                result_message = request_subscription(order)
                
            if result_message == 'recurring_order_success' :
                # result_message = 'RECURRING_ORDER_SUCCESS'
                return HttpResponse(result_message, status=201)
    
        result_message = 'RECURRING_ORDER_LIST_IS_NOT_EXISTS'
        return HttpResponse(result_message, status = 400)

좋은 웹페이지 즐겨찾기