[인프런] 파이썬/장고로 결제 시작하기 (Feat. 아임포트) - 기본편 - 학습 정리 2
* iamport rest client python 메뉴얼 : https://github.com/iamport/iamport-rest-client-python
1. 여러개의 row 데이터를 한번에 처리하는 방법 formset
- modelformset_factory는 Django에서 모델 인스턴스의 집합을 관리할 수 있도록 도와주는 유용한 도구이다. 이를 통해 여러 개의 모델 폼을 한 번에 처리할 수 있으며, 주로 CRUD(Create, Read, Update, Delete) 작업에서 사용된다.
- extra=0의 의미 : extra=0으로 설정하면, 기본적으로 빈 폼이 추가되지 않는다. 즉, 사용자가 새로운 CartProduct 인스턴스를 추가할 수 있는 빈 폼이 생성되지 않는다.
- 이는 기존의 CartProduct 인스턴스만을 수정하거나 삭제하는 데 초점을 맞추겠다는 의미이다.
from django.forms import modelformset_factory
def cart_detail(request):
CartProductFormSet = modelformset_factory(
model=CartProduct,
form=CartProductForm,
extra=0,
can_delete=True,
) // formset을 정의하고 이후에 일반 form처럼 사용하면 된다.
if request.method == "POST":
formset = CartProductFormSet(
data=request.POST,
queryset=cart_product_qs,
)
if formset.is_valid():
formset.save()
messages.success(request, "장바구니를 업데이트했습니다.")
return redirect("cart_detail")
else:
formset = CartProductFormSet(
queryset=cart_product_qs,
)
2. django의 기본 form의 스타일을 변경하는 방법
- django-widget-tweaks : django-widget-tweaks는 Django 템플릿에서 폼 필드에 CSS 클래스나 속성을 동적으로 추가하고 수정할 수 있도록 도와주는 라이브러리로 폼 필드를 보다 쉽게 스타일링하고 사용자 정의할 수 있다.
- 주요 기능 :
- 동적 클래스 추가:
- 폼 필드에 CSS 클래스를 동적으로 추가할 수 있습니다. 이를 통해 레이아웃이나 스타일을 쉽게 조정할 수 있다.
- 속성 수정:
- placeholder, disabled, readonly 등과 같은 HTML 속성을 쉽게 추가하거나 수정할 수 있다.
- 편리한 템플릿 태그:
- Django 템플릿에서 사용할 수 있는 유용한 템플릿 태그와 필터를 제공한다.
{% load widget_tweaks %}
...
<td>
{% render_field form.quantity class+="form-control text-end" %}
{{ form.quantity.errors }}
</td>
<td class="text-center">{% render_field form.DELETE class+="form-check-input" %}</td>
3. javascript를 이용해 api 요청으로 장바구니에 담는 방법
- X-CSRFToken에 csrf token을 정의하고 fetch로 요청하면 된다.
<script>window.csrf_token = "{{ csrf_token }}";</script>
<script>
const alert_modal = new AlertModal("#alert-modal");
document.querySelectorAll(".cart-button").forEach(function(button) {
button.addEventListener("click", function(e) {
e.preventDefault();
const url = e.target.href;
fetch(url, {
method: "POST",
headers: {
"X-CSRFToken": window.csrf_token,
}
}).then(function(response) {
return response.status === 200 ? response.text() : Promise.reject(response)
}).then(function() {
alert_modal.show("장바구니에 담았습니다.");
}).catch(function() {
alert_modal.show("장바구니에 담았습니다.");
});
});
});
</script>
4. IAMPORT의 결제 결과를 받는 첫번째 방법 - callback
- IMP.request_pay함수의 두번째 인자인 콜백함수로 받는 방법으로 지금까지의 프로젝트에 사용된 방법이다.
{{ payment_props|json_script:"payment-props" }}
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>
<script>
(function() {
const IMP = window.IMP;
const next_url = "{{ next_url }}";
IMP.init("{{ portone_shop_id }}");
const json_string = document.querySelector("#payment-props").textContent;
const props = JSON.parse(json_string);
IMP.request_pay(props, function(response) {
if(response.error_msg) {
alert(`${response.error_msg} (${response.error_code})`);
}
location.href = next_url;
});
})();
</script>
5. IAMPORT의 결제 결과를 받는 첫번째 방법 - m_redirect_url
- 안드로이드/아이폰과 같은 모바일 환경에서는 IMP.request_pay 함수를 통해 결제를 진행할 때 PG 사이트로 페이지 이동이 발생한다. 그리고 그 PG 사이트에서 결제 진행을 수행하고 나서 우리 서비스로 페이지 이동을 하는 시나리오로 동작을 하게 된다.
- 이러한 이유로, 두번째 인자인 콜백이 동작을 하지 않는다.
- request_pay의 인자로 m_redirect_url: "{리디렉션 될 URL}" 을 정의해주면 PG 사이트에서 결제 진행 후 정의된 주소로 이동이 된다.
- 이동이 되면, 주소에 쿼리스트링으로 결제 결과가 전달된다.
https://myservice.com/payments/complete?imp_uid=???&merchant_uid=???&imp_success=true
https://myservice.com/payments/complete?imp_uid=???&merchant_uid=???&imp_success=false&error_code=???&error_msg=???
- imp_success 파라미터는 결제 프로세스의 정상종료를 의미한다. 하지만, IMP.request_pay 함수호출은 브라우저 클라이언트 단에서 이루어지기 때문에 제 3자에 의해 악의적인 목적으로 호출되어 결제 금액을 위/변조 할 가능성이 존재한다.
- imp_success 값이 참이라 하여 이를 신뢰해서는 안되고 서버 단에서 결제금액의 위변조 여부를 반드시 검증한 후 최종적으로 결제 성공여부를 판단해야 한다.
- 일부 PG에서는 imp_success 인자가 아닌 success 인자로 전달되거나 아예 인자가 전달되지 않는 경우도 있다.
- 샘플코드 1
- PG 서버에서 우리 서버로 직접 http 요청을 보내는 것이 아니라, 페이지 이동 방식이기에 login_required 장식자를 붙여 뷰 호출 시 로그인 여부를 확인할 수 있다.
# mall/urls.py
path(
"orders/check/redirect/",
views.order_check_by_redirect,
"order_check_by_redirect",
)
# mall/views.py
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from mall.models import OrderPayment
@login_required
def order_check_by_redirect(request):
merchant_uid = request.GET.get("merchant_uid")
if not merchant_uid
raise Http404("merchant_uid가 없습니다.")
payment = get_object_or_404(OrderPayment, uid=merchant_uid)
payment.update()
return redirect(payment.order)
@ login_required
def order_pay(request, pk)
...
payment_props = {
...
# 다른 사이트로 이동 후 다시 돌아와야 하기 때문에 hostname을 비롯한 전체 주소로 지정해야 한다.
# 그렇지 않으면 포트원 페이지로 이동하게 된다.
"m_redirect_url" : request.build_absolute_uri(reverse("order_check_by_redirect")),
}
return render(
request,
"mall/order_pay.html",
{
"portone_shop_id" : ...
"payment_props" : payment_props,
"next_url" : reverse("order_check", args=[order.pk, payment.pk]),
},
}
- 매번 결제세부내역을 직접 조회하게 되면, PG사에 따른 imp_success 인자가 누락되는 경우에 대해서도 일괄적인 처리가 가능해져 코드가 심플하고 관리성이 좋아진다.
- 샘플코드 2 - 리팩토링
@login_required
def order_pay(request, pk):
order = get_object_or_404(Order, pk=pk, user=request.user)
if not order.can_pay():
messages.error(request, "현재 결제를 할 수 없는 주문입니다.")
return redirect(order)
payment = OrderPayment.create_by_order(order)
check_url = reverse("order_check", args=[order.pk, payment.pk])
payment_props = {
"pg": settings.PORTONE_PG,
"merchant_uid": payment.merchant_uid,
"name": payment.name,
"amount": payment.desired_amount,
"buyer_name": payment.buyer_name,
"buyer_email": payment.buyer_email,
"m_redirect_url": request.build_absolute_uri(check_url),
}
return render(
request,
"mall/order_pay.html",
{
"portone_shop_id": settings.PORTONE_SHOP_ID,
"payment_props": payment_props,
"next_url": check_url,
},
)
@login_required
def order_check(request, order_pk, payment_pk):
payment = get_object_or_404(OrderPayment, pk=payment_pk, order__pk=order_pk)
payment.update()
# return redirect(payment.order)
return redirect("order_detail", order_pk)
6. IAMPORT의 결제 결과를 받는 첫번째 방법 - webhook
- 웹훅을 사용하는 목적 : JS 결제성공 콜백을 받았지만, 네트워크 장애, 브라우저 새로고침 등으로 가맹점 서버로 전달이 안될 수 있다.
- 가맹점 서버에서 불필요하게 포트원 서버를 폴링하지 않고, 웹훅으로 실시간 수신할 수 있다.
- 웹훅 요청 내역
- 포트원 관리자 콘솔(결제연동)에서 지정한 주소나, 결제 요청 시 notice_url 인자로 지정된 주소로 post 요청
- 지원 content-Type
- appliocation/x-www-form-urlencoded (추천) : 장고 기본에서 request.POST를 통해 수신 데이터 접근
- application/json : 장고 기본에서는 request.body를 JSON 역직렬화(json.loads) 수동 처리 필요, DRF에서는 기본적인 처리 방식
- 수신 데이터
- imp_uid : 포트원 결제 식별자
- merchant_uid : 쇼핑몰 결제 식별자
- status : 결제 상태
- 웹훅 이벤트는 언제 발생하는가?
- 결제가 승인되었을 때 (모든 결제 수단) -> status는 "paid"
- 관리자 콘솔에서 환불되었을 때 -> status는 "cancelled"
- 가상계좌가 발급되었을 때 -> status는 "ready"
- 가상계좌에 결제 금액이 입금되었을 때 -> status는 "paid"
- 예약결제가 시도되었을 때 -> status는 "paid" 또는 "failed"
- 결제 정보 전달(웹훅)과 브러우저에서 콜백 등으로 결제 정보가 전달되는 것의 순서는 보장되지 않는다.
- 순서 보장을 원한다면 포트원에 요청할 수 있다.
- 웹훅 만들기
# settings.py
PORTONE_WEBHOOK_IPS = env.list(
"PORTONE_WEBHOOK_IPS", default=["52.78.100.19", "52.78.48.223", "52.78.5.241"]
)
# urls.py
path("webhook/", views.portone_webhook, name="webhook"),
# views.py
@require_POST
@csrf_exempt
@deny_from_untrusted_hosts(settings.PORTONE_WEBHOOK_IPS)
def portone_webhook(request):
print("request.body :", request.body)
print("request.POST :", request.POST)
print("merchant_uid :", merchant_uid)
if request.META["CONTENT_TYPE"] == "application/json":
payload = json.loads(request.body)
merchant_uid = payload.get("merchant_uid")
else:
merchant_uid = request.POST.get("merchant_uid")
if not merchant_uid:
return HttpResponse("merchant_uid 인자가 누락되었습니다.", status=400)
elif merchant_uid == "merchant_1234567890": # 테스트시 uid 동일한 값으로 고정
return HttpResponse("test ok")
payment = get_object_or_404(OrderPayment, uid=merchant_uid)
payment.update()
return HttpResponse("ok")
- 결제 연동 -> 결제알림(webhook)관리에 endpoint url을 정의해준다.
- 관리자모드에서 테스트를 할 경우 merchant_uid 값은 merchant_1234567890로 고정되어 있다.
- 포트원에서는 상태코드만 의미가 있지, 응답 body에는 의미가 없다.
- 웹훅 요청 아이피
- 웹훅 핸들러에서는 포트원 웹훅 요청 아이피를 제외한 요청은 반드시 무시해야 한다.
- 52.78.100.19, 52.78.48.223, 52.78.5.241 (웹훅 테스트 발송 버튼으로 전송되는 경우, 개발용 settings에서만 활성화 해야한다)
- 데코레이터를 이용해 허가된 아이피만 접근하게 만들기
- 웹요청에서는 서버에서 REMOTE_ADDR 헤더를 통해 요청 클라이언트의 IP를 알 수 있다.
- 하지만 중간에 경유하는 서버가 있따면, REMOTE_ADDR 정보는 변경된다.
- 대신 클라이언트 IP는 X-Forwarded-For 헤더에 추가되어 요청이 전달된다.
- 헤더에 저장된 아이피 중 첫번째 아이피를 사용하면 된다.
import functools
from django.http import HttpRequest, HttpResponseBadRequest
def deny_from_untrusted_hosts(allowed_ip_list):
def get_client_ip(request: HttpRequest) -> str:
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
# ex) X-Forwarded-For: client, proxy1, proxy2
# 가장 좌측이 실제 클라이언트 IP이며, 우측으로 갈수록 경유하는 프록시 서버 IP
ip = x_forwarded_for.split(",")[0]
else:
ip = request.META.get("REMOTE_ADDR")
return ip
def decorator(view_function):
@functools.wraps(view_function)
def _wrapped_view(request, *args, **kwargs):
ip = get_client_ip(request)
if ip not in allowed_ip_list:
return HttpResponseBadRequest("허용되지 않은 IP에서의 요청입니다.")
return view_function(request, *args, **kwargs)
return _wrapped_view
return decorator
7. 추가 학습 및 토이 프로젝트
1) 결제정보 사전 검증하기 :
- 클라이언트의 변조를 원천차단 하기 위해, 유저에게 결제를 요청하기 전, merchant_uid 결제금액을 사전 등록할 수 있다.
- IMP.request_pay 호출을 통한 결제시, 사전에 등록한 금액과 일치하지 않으면 SDK 수준에서 결제 요청이 차단된다.
- iamport-rest-client 라이브러리에서 이를 위한 .prepare 메서드와 .prepare_validate 메서드가 지원된다.
- 포트원 공식문서 : https://portone.gitbook.io/docs/auth/guide/5/pre
2) 로그아웃 상태에서 장바구니 담기 버튼을 클릭하면, 장바구니에 담았다는 메세지는 뜨지만 실제로 장바구니에 담기지는 않는 버그 수정
- 로그아웃 상태에서 login_required 장식자에 의해 302 Redirect 응답을 받지만 이동한 로그인 페이지에서는 200 응답을 받기 때문에, fetch 메서드에서는 200 응답으로 처리하여, 장바구니 담기 성공으로 처리된다.
- 로그아웃 상태에서는 버튼을 비활성화 하거나 클릭시 로그인 안내를 하는 방법이 있다.
- 로그아웃 상태에서도 장바구니 담기를 수행하기 위해서는 세션 장바구니 등을 구현해야 한다.
3) 실서비스 결제를 연동하기 위한 PG 연동
- 공식문서 : https://portone.gitbook.io/docs/ready/2.-pg/payment-gateway
- 단일 PG만 계약한다면 코드 레벨에서는 추가 진행할 부분 없이, PG 계약과 포트원 관리자페이지에서 PG 설정만 하면 된다.
- 2개 이상의 PG를 연동한다면, 코드 레벨에서 IMP.request_pay 함수 호출 시에 pg 인자를 지정해 주어야 한다.
* 추가 질문에 대한 답변 정리 :
- 전송 보장에 대한 사항 : 웹훅은 기본 1회만 전송하고, 포트원 측에 따로 요청하면 최대 5회까지 1분 간격으로 재시도토록 설정할 수 있다고 합니다.
- 결제 결과를 확인에 대해 신뢰성 있는 시나리오 : 현재의 결제 구현에서 웹훅은 "결제 프로세스"가 끝났음을 알려주는 알림 메시지일 뿐이구요. 웹훅이 없더라도 order.update() 메서드를 호출하여 직접 결제 상태를 갱신하실 수도 있습니다. 결제가 끝난 유저의 주문 페이지에서 "결제상태 새로고침" 버튼을 노출하여, 유저가 그 새로고침 버튼을 클릭하면 결제상태를 갱신토록 하셔도 좋겠구요. 혹은 유저에게 그 버튼을 노출하기 전에, 서비스 내부적으로 새로고침을 한 번 수행해보는 접근도 좋을 듯 합니다. 혹은 주문페이지의 프론트 단에서 JS로 수 초 단위로 새로고침 요청을 해도 좋겠죠.
- reference :
https://portone.gitbook.io/docs/result/webhook
https://developers.portone.io/opi/ko/integration/webhook/readme-v2?v=v2
https://faq.portone.io/74c9f8b4-e181-47bb-bfdb-7ca20a6e2466
https://faq.portone.io/7e681c22-7bd1-4882-b009-90fdb1fa7903
https://faq.portone.io/647eae49-9ba5-4d26-bd45-35702ed92c16
https://faq.portone.io/3e6d7f12-6e65-4e54-ab38-1f84c4c9121b