- Published on
DjangoRestFramework 토스페이먼츠 결제 연동하기
- Authors
- Name
- hongreat
- ✉️hongreat95@gmail.com
이번 글에서는 토스페이먼츠 개발가이드 문서의 보충 느낌으로다가.. DRF(DjangoRestFramework)에서 토스페이먼츠 백엔드 연동하는 방법과 다이어그램에서 실제 결제 요청과 응답에 대한 부분을 중점적으로 기록합니다.
결제 방식(가상계좌, 세금계산서)에 따라 비즈니스 로직이 상이 하지만, 큰 흐름을 보면 거의 유사한 것 같습니다.
결제를 위한 준비 과정
back → client
참고
purchase == order == 주문
payment == 결제
- client에서 결제를 할 상품에 대해 order_id 를 생성합니다. 주문 자체에 대한 고유값으로 어떤 주문 값에 대한 결제를 진행할 것인지 알아야합니다.
- 주문을 만들었지만 마음에 들지않아 뒤로가기/수정하기/취소하기 등의 UX를 고려해 프론트엔드에서 백엔드API를 거쳐 주문을 생성하고 이에 대해 결제를 진행하거나 취소하는 로직으로 만들었습니다.
order id
- 앞단에서 백엔드를 통해 purchase(order) 를 생성하도록 합니다. order_id 는 django 에서 uuid를 사용해 기본 값으로 사용할 수도 있습니다.
- 이곳에 비즈니스 로직을 추가하는 것 또한 가능하지만, 1,2,3… pk 같은 단순 숫자 나열은 지양하고 있습니다.
- 이렇게 하는 이유는 토스에서는 결제금액 무결성을 위해 orderId와 amount를 백엔드 서버에 (임시)저장할 것을 권하고 있기 때문입니다.
import uuid
class Purchase(BaseModel): # or OrderSomething
order_id = models.UUIDField(verbose_name="토스페이먼츠에 사용할 orderId 개념", default=uuid.uuid4)
Payment 객체
Payment 객체는 토스페이먼츠 결제 정보의 핵심 객체입니다. 하단 참고
결제 한 건의 결제 상태, 결제 취소 기록, 매출 전표, 현금영수증 정보 등을 자세히 알 수 있습니다.
객체의 구성은 결제수단에 따라 조금씩 달라질 수 있지만, 결제가 승인됐을 때 응답은 Payment 객체로 항상 동일합니다.
Toss → Frontend 브라우저 응답
결제가 승인이 된다면, successURL 이 callback으로 넘어옵니다. 이 callbackURL 을 파싱해서 backend에 승인(confirm) API를 요청하도록 진행합니다.
https://domain.com/tosscallback/success?orderId={order_id}&paymentKey={payment)key}&amount={amount}
Backend → Toss confirm 실 결제 요청 및 승인
위 시퀀스 다이어그램의 실결제 요청 부분입니다.
클라이언트가 백엔드에 요청하는 실제 결제컨펌(승인)이 마무리되어야 결제가 마무리 됩니다.
API키(시크릿 키 등)에 대한 에러
- 클라이언트 백엔드 모두 API 키 값이 필요합니다.
- 이 과정에서 키값이 잘못되었다면 에러가 발생하기 때문에 원활하게 진행이 되지 않습니다.
Tosspayments 요청과 승인이 따로인 이유 정리
tosspayments는 요청-승인 이 구분되어있는 결제 로직을 사용합니다.
- 데이터 정합성과 가맹점의 연동이 편하다는 장점이 있습니다.
요청-승인 한번에 처리하는 로직
- 흐름자체는 간단합니다.
- 승인 데이터 정합성 보장을 위해 가맹점에서 여러 작업을 해야 합니다.
- 언제 승인 결과가 돌아올 지 알 수 없다는 단점 때문에 가맹점 서버에서 승인 결과를 받으려면 반드시 웹훅 연동이 필요합니다.
- 웹훅의 단점
- 사용자가 결제창을 닫아 버리면 처리가 불가능 합니다.
- 가맹점 서버에 트래픽이 몰린다면 웹훅으로 승인 완료 결과를 처리하기 어렵습니다.
- 웹훅 단점 보완
- 웹훅을 여러 번 재전송 하면 단점을 보완할 수 있습니다.
- 하지만 가맹점 서버가 받아줄 수 있는 상태가 아닌 경우, [가맹점에서의 상태 는 결제 실패 ≠ PG에는 결제 완료 상태] 가 됩니다.
- 웹훅을 여러 번 재전송 하면 단점을 보완할 수 있습니다.
그래서 토스페이먼츠에서는 결제 요청과 승인을 따로 하는 방식으로 데이터 정합성을 보장하고, 가맹점에서 해야 할 일을 줄이고자 이렇게 구현했다고 설명합니다.
successURL을 보내서 서버에서 계속 응답 결과를 하지 않아도 되고, URL로 돌려준 정보를 가맹점에서 직접 받아 그 정보로 승인을 요청하기 때문입니다.
request utils
confirm API 를 토스 측에 보내기 위한 유틸함수 예제코드를 기록합니다.
- camel_to_snake : 토스 페이먼츠의 컨벤션이 카멜케이스 였기에, 스네이크 케이스를 사용하는 백엔드 컨벤션에 맞추기 위한 함수 입니다.
- get_headers_from_secret_key : header에 API키를 적용하기 위한 함수입니다.
참고) shell에서 base64 인코딩하는 명령어
echo -n 'api_key' | base64
def camel_to_snake(name):
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
def get_headers_from_secret_key():
secret_key = settings.TOSS_PAYMENT_SECRET
userpass = secret_key + ":"
encoded_secret = base64.b64encode(userpass.encode()).decode()
headers = {"Authorization": "Basic %s" % encoded_secret, "Content-Type": "application/json"}
return headers
def request_payment_confirm(order_id, amount, payment_key):
headers = get_headers_from_secret_key()
request_url = "https://api.tosspayments.com/v1/payments/confirm"
response = requests.post(
request_url,
data=json.dumps(
{
"orderId": order_id,
"amount": amount,
"paymentKey": payment_key,
}
),
headers=headers,
)
response_json = response.json()
return response, response_json
- 백엔드에서는 콜백으로 넘어온 데이터를 프론트에서 전달받아
https://api.tosspayments.com/v1/payments/confirm
에 요청합니다. - confirm 이 문제가 없다면, 결제로직이 완전하게 끝났습니다. confirm에 대한 성공여부 또한 프론트엔드로 내려보내게 되면 로직이 완료됩니다.
payment model
성공하면 전달되는 응답 값 스키마입니다. 내용이 적지 않지만, 문서에 타입이 잘 나와있어 모델로 만들기에 (엄청..)오래걸리지는 않았습니다.
{
'm_id': '(상점아이디)',
'last_transaction_key': '(거래키값)',
'payment_key': '(결제키 값)',
'order_id': '(주문 id)',
'order_name': '(주문 명)',
'tax_exemption_amount': '(과세 제외 결제 금액)',
'status': '(결제 처리 상태)',
'requested_at': '(결제가 일어난 시간)',
'approved_at': '(결제가 승인된 시간)',
'use_escrow': '(에스크로 사용여부)',
'culture_expense': '(문화비 지출여부)',
'card': {
'issuerCode': '(카드 발급사 코드)',
'acquirerCode': '(카드 매입사 코드)',
'number': '(카드 번호)',
'installmentPlanMonths': '(할부 개월 수)',
'isInterestFree': '(무이자 여부)',
'interestPayer': '(이자 납부자)',
'approveNo': '(승인 번호)',
'useCardPoint': '(카드 포인트 사용 여부)',
'cardType': '(카드 종류)',
'ownerType': '(소유자 종류)',
'acquireStatus': '(카드 승인 상태)',
'amount': '(결제 금액)'
},
'virtual_account': '(가상계좌 정보)',
'transfer': '(계좌이체 결제 시 이체 정보)',
'mobile_phone': '(휴대폰 결제 정보)',
'gift_certificate': '(문화비 지출 여부)',
'cash_receipt': '(현금영수증 결제 정보)',
'cash_receipts': '(현금영수증 발행 및 취소 정보)',
'discount': '(카드사 즉시 할인 프로모션 정보)',
'cancels': '(결제 취소 이력)',
'secret': '(가상계좌 웹훅 확인)',
'type': '(결제 타입 정보)',
'easy_pay': {
'provider': '(간편결제 제공자)',
'amount': '(간편결제 금액)',
'discountAmount': '(간편결제 할인 금액)'
},
'country': '(결제 국가)',
'failure': '(결제 승인 실패)',
'is_partial_cancelable': '(부분 취소 가능 여부)',
'receipt': {
'url': '(영수증 URL)'
},
'checkout': {
'url': '(결제창 URL)'
},
'currency': '(결제 통화)',
'total_amount': '(총 결제 금액)',
'balance_amount': '(취소 가능한 금액)',
'supplied_amount': '(공급가액)',
'vat': '(부가세)',
'tax_free_amount': '(면세 금액)',
'method': '(결제수단)',
'version': '(Payment 응답 버전)',
'metadata': '(메타데이터)'
}
# *last_update(240913)* - metadata 추가
결제 승인로직 payment serializer
결제 승인 로직을 포함해 비즈니스 로직을 담고있는 곳 입니다.
이 계층에서 알림톡과 결제방식에 대한 비즈니스 로직 분기 등을 처리했으며, 해당 세부내용은 모두 생략했습니다.
큰 흐름만 기록합니다.
logger = logging.getLogger("request")
class PaymentSerializer(serializers.ModelSerializer):
user = serializers.CharField(read_only=True)
payment_key = serializers.CharField()
order_id = serializers.CharField(label="purchase 의 order id")
total_amount = serializers.IntegerField(label="결제 금액")
class Meta:
model = Payment
fields = [
"id",
"user",
"payment_key",
"order_id",
(생략)
]
read_only_fields = [
(생략)
]
def validate(self, attrs):
attrs = super().validate(attrs)
validate_request_user(self, attrs)
# 프론트엔드에서 전달받은 값
order_id = attrs.get("order_id")
total_amount = attrs.get("total_amount")
payment_key = attrs.get("payment_key")
...(생략)...
# confirm API 요청
response, response_data = request_payment_confirm(
order_id,
total_amount,
payment_key,
)
if response.status_code != 200:
self._handle_payment_failure(purchase_obj, response_data)
raise ValidationError(response_data)
attrs["purchase_obj"] = purchase_obj
attrs["response_data"] = response_data
return attrs
@transaction.atomic()
def create(self, validated_data):
purchase_obj = validated_data["purchase_obj"]
user_obj = validated_data["user"]
response_data = validated_data["response_data"]
# confirm 이 완료된 경우 payment 저장 및 프론트엔드로 전달
response_data_snake = {camel_to_snake(key): value for key, value in response_data.items()}
...(생략)...
filtered_data = {key: value for key, value in response_data_snake.items() if key in payment_fields}
instance = Payment.objects.create(purchase=purchase_obj, user=user_obj, **filtered_data)
...(생략)...
return instance
api error
Confirm 과정이 성공했으나 백엔드로직에서 일부 실패과정을 테스트를 통해 발견했습니다.
응답 스키마를 전부 저장하는 과정에서 field가 일부 다른 것이 원인이었고, 서버에러에 대해, 프론트엔드에서는 결제 최종완료 값을 받지 못해 결제 성공 화면을 보여주지 못했습니다.
(결제완료 처리가 되지 않는 다면 클라이언트에서도 어떻게 표시해야할지 난감한 500에러에 대해 다음과 같은 에러가 표시 됩니다.)
결론
토스페이먼츠 연동에서 평균적으로 계약 컨펌 기간 1주
, PG 사 승인 기간은 약 1~2주 정도
예상하는 것이 바람직 하겠습니다. (참고로 진행한 프로젝트와 회사 규모는 중소기업 수준입니다.)
회사 규모나 비즈니스 도메인 특성, 계약방식에 따라 상이하니 토스페이먼츠 팀에 문의하는 것이 가장 정확합니다.
사내에서 토스페이먼츠와 직접 계약하는 방식으로 처음 뒷단(백엔드) 개발을 하며, 짧은기간에 새로운 결제모듈 연동을 해야했습니다.
개발일정에 차질이 생기지 않을까 하는 우려가 무색해지게, 토스페이먼츠 팀의 개발가이드 문서를 보며 무리없이 연동할 수 있었습니다.
토스페이먼츠를 백엔드에 연동하기위해, 프론트엔드 단을 직접 테스트로 구현해보며 재미있었습니다. 이 과정에서 수많은 레퍼런스 링크 덕분에 재미있게 개발할 수 있었는데, 토스페이먼츠 팀에서 얼마나 문서 제작에 심혈을 기울였는지 알 수 있었습니다.
아래는 제가 개발에 참고하며 정독했던 주요 레퍼런스 링크 입니다. 개발에 도움되기를 바랍니다.
레퍼런스 링크
가상계좌 참고
페이먼츠 백오피스의 테스트 결제내역에서 테스트 입금처리 가능