| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- AI자격증
- it #응집도 #결합도 #소프트웨어
- 토스페이
- 프로그래머스
- supabase
- 정렬
- 포트원
- SQL
- Node
- Ai
- ETF
- 프론트개발자
- OpenCV
- 네이버
- 티스토리챌린지
- 리액트
- 웹훅
- 토스
- sw
- 코딩
- 입점심사
- 오블완
- 개발
- OCR
- 프론트
- 결제
- 구글
- 개발자
- openai
- 데이터베이스 #백엔드 #데이터
- Today
- Total
croissant_code
결제 시스템 본문


자사 플랫폼에 결제 시스템을 개발하며 깨닫고 느낀 것들을 정리하려고 합니다.
일단 현재 자사의 플랫폼에는 카드결제, 간편결제만 연동이 되어 있습니다. 그리고 간편결제의 경우에는 카카오페이, 토스페이까지 연결되어 있으며 앞으로 네이버페이까지 연동을 할 계획입니다.
대한민국 결제 흐름
대한민국은 유저의 카드 정보를 함부로 저장할 수 없도록 법이 규정되어 있습니다. 따라서 Stripe 같은 해외 서비스의 경우에는 아래 그림처럼 유저가 카드정보를 입력하면 해당 정보들을 가맹점, Strip, 카드사 까지 단계적으로 이동을 합니다.

즉, 결제의 구조가 매우 직관적이 단순합니다. 하지만 중간 과정에서 사용자 카드 정보를 언제든지 저장할 수 있어 좋지 않습니다.
하지만 대한민국의 경우에는 다릅니다. 한국에서는 중간 과정의 다리에서 유저의 카드 정보를 카드사가 지정한 PG사를 제외하고는 저장할 수 없기 때문에 아래와 같은 그림이 나오게 됩니다.

유저는 앞에서 가장 먼저 카드사에게 카드정보를 요청하고 요청받은 결과를 가지고 이동을 하게됩니다. 따라서 중간과정에서는 인증 결과 정보 외에는 카드정보나 유저정보에 접근할 수 없도록 되어 있습니다.
자사 플랫폼 결제 흐름
현재 자사의 경우에는 토스페이먼츠 PG를 사용했습니다. 이유는 간편결제를 전부 지원을 해주며, 카드까지 복합적으로 제공하기 때문입니다. 하지만 직접적인 토스페이먼츠 사용이 아닌 중간의 포트원이라는 호스팅 업체를 끼고 사용하고 있습니다.
포트원의 중간 호스팅을 끼면서, 간단하게 코드를 구현할 수 있고, 토스페이먼츠의 연회비인 20만원을 지불할 필요가 없었기 때문입니다. 초기에 가입비 20만원을 제외하고 연회비를 지불할 필요가 없는 부분이 가장 컸습니다.
만약 PG를 직접적으로 연동했으면 아래와 같은 결제 흐름도가 만들어졌을 것입니다.

특히 PG 사의 경우에는 2-Transaction으로 처리됩니다.
- 결제 요청을 위한 인증키 획득 : PG와 첫 번째 통신을 통해 인증키(결제토큰)을 받습니다. merchant_id, amount, user_info
- 실제 결제 요청 : 이 정보를 기반으로 실제 결제 요청을 처리합니다. 결과로 imp_uid, transaction_id를 통해 조회 및 검증을 합니다.
따라서 결제 비즈니스 로직에 시간을 할애 하기보다는 여러 어려운 트랜잭션 개발에 시간이 할애됩니다. 따라서 직접적으로 PG를 연동하는 것이 아닌 포트원을 사용하면 아래와 같은 결과가 나옵니다.

결제창과 PG 사이에서 호스팅을 하고 있습니다. 따라서 해당 결제의 PG 결제 결과를 status라는 결제 상태를 이용해서 처리했습니다.
토스페이먼츠, 기본 심사와 카드사 심사
신규 플랫폼을 만들면 포트원이든 PG쪽이든 심사를 받게 됩니다. 이 때 심사에는 2가지가 존재합니다.
- 일반 심사(평균 2 ~ 4일)
- 카드사 심사(평균 7일 ~ 10일)
일반 심사의 경우에는 PG에서 요구하는 사업자등록증 확인, 통신 판매업 신고증 확인 등등 해당 회사에서 요구하는 기본적인 UI나 시스템을 자사 플랫폼에 보여지게 만들어야 합니다. 아래가 제가 경험한 토스페이먼츠에서 요구하는 일반 심사 내용입니다.

- 판매할 상품
- 배송주기와 환불 정책 고지
- 금액
- 충전 서비스 : 1일 최대 5만원 미만
- 상품권 : 1개월 최대 결제 금액 100만원 미만
- 게임 : 1일 최대 결제 금액 5만원 미만
- 온라인 판매가 가능한 업종
- 자사 플랫폼의 경우에는 코인 충전형이기 때문에 아래의 정보가 꼭 필요합니다.
- 충전된 포인트에 대한 환불 정책 기재 필수
- 환불 정책 내 환불 시 현금화 불가에 대한 안내 필수
- 결제금액 임의입력 불가
- 자사 플랫폼의 경우에는 코인 충전형이기 때문에 아래의 정보가 꼭 필요합니다.
- 홈페이지 하단에 내용 기입
- 상호명
- 대표자명
- 사업자등록번호
- 사업장 주소
- 유선번호
- 통신판매업 신고번호
위와 같은 내용이 필수로 있어야, 3 ~ 5일이 지나면 카드사 심사로 넘어갑니다. 만약 기존에 운영하고 있던 플랫폼이 이미 존재하고 이전에 카드사 심사를 받은 기록이 있으면 카드사 심사를 하지 않고 심사가 끝이나며 상점 아이디를 제공받고 끝이 납니다.
하지만 완전 새로운 경우에는 기본 심사가 끝나고 카드사 심사로 넘어가면서 각 카드사 별로 심사를 진행하게 됩니다.

따라서 실제 결제가 가능한 상점 아이디를 받은 경우에는 실제 연동을 해서 매출을 올리는 경우, 위의 사진에서 심사중을 제외하고는 실제 결제가 가능합니다.
즉, 심사는 최대한 빠르게 진행하는 방법이 좋습니다. 저의 경우에는 처음에 카카오페이를 PG를 연동하지 않고 따로 진행했는데, 실제 결제 가능이라는 답변의 이메일을 받기까지 피드백 반영과 수정을 포함해서 1.5달 넘는 기간이 걸렸습니다. 개인적으로는 토스에 신청을 해서 돈을 지불하고 빠르게 오픈을 한 뒤 매출을 올리는 방법이 훨씬 효율적이라고 생각합니다. 저의 경우에는 실 결제까지 3주도 걸리지 않았습니다.
토스페이먼츠, 보증보험
처음에 토스페이먼츠를 사용하면 보증보험을 얼마로 설정할 것인지를 CS직원으로부터 질문 받게됩니다.
보증보험이란, 온라인 사업자가 소비자에게 환불을 해주지 못하거나, 부도 등으로 거래에 문제가 생겼을 경우 소비자의 피해를 대신 보상하기 위한 금융 보증 수단입니다. 추가적으로 앞서 말한 문제가 발생하는 경우 보증보험이 없는 경우 PG가 피해를 전부 받기 때문에 보증보험은 반드시 필요합니다.
따라서 각 상점 아이디마다 보증보험이 필요하게 됩니다. 왜냐하면 보증보험의 금액 설정에 따라서 해당하는 상점 아이디를 연동시킨 결제의 월 최대 결제 금액이 결정되기 때문입니다.
만약 1개의 상점 아이디의 월 최대 매출 금액(정산한도)을 1000만원으로 설정한 경우, 보증보험이 1000만원 필요합니다. 만약 정산한도를 1000만원 이상인 2000만원으로 하는 경우에는 정산한도(1) : 보증보험(2) 의 비율로 보증보험은 4000만원이 필요합니다.
즉, 정산한도가 1000만원 전까지는 정산한도(1) : 보증보험(1) 비율을 가집니다. 추가로 최소 정상한도 금액은 250만원이기 때문에 반드시 정산한도는 250만원 이상이라는 뜻입니다.
충전형 상품의 보증보험 케이스
자사의 플랫폼은 충전형 상품을 기반으로 서비스가 돌아가고 있습니다. 따라서 보증보험의 케이스가 다릅니다. 위에 설명한 보증보험 케이스는 일반적인 케이스 이기 때문에 동일한 비율로 정산한도와 보증보험이 들어갑니다.
하지만, 충전형 상품의 경우에는 일반적인 상품 판매와 다른 형태이기 때문에 1/2한 비율이 적용됩니다. 따라서 1000만원의 보증보험을 신청한 경우 정산한도는 500만원이 되는 것입니다. 아래 사진을 보시면 이해할 수 있습니다.


토스페이먼츠, 포트원 각각의 상점 아이디
자사의 경우 간편결제와 카드결제를 포트원을 통해서 신청했습니다. (가입비의 경우에는 심사가 모두 끝나면 1개 신청을 제외하고 돌려받습니다. 자사의 경우 3개를 심사 신청을 했고 60만원을 지불했으며 심사 완료 시, 40만원을 돌려받게 됩니다.)

여기서 저는 독특한 이슈가 있었습니다. 기본 심사가 끝나면 실제 결제가 가능한 상점 아이디를 부여받습니다. 바로 실 결제 연동이 가능하며 심사는 카드사 심사로 넘어간 상태입니다. 카드사의 경우에는 심사중을 제외하고 심사승인이 뜬 카드사를 통해서 결제가 가능했습니다.
이슈는 다음과 같습니다. 저는 간편결제와 카드결제 각각의 심사를 넣었고, 다음과 같은 형태로 설정 창에서 정보를 확인할 수 있었습니다.


간편결제에도 기본적으로 신용*체크카드가 들어가있는 이슈가 있었습니다. 즉, 카드결제에서 신용*체크카드 결제와 간편결제에서 신용*체크카드 결제가 공통적으로 들어가는 이슈였습니다. 그리고 각각의 상점 아이디를 가지고 프론트 UI를 띄운 결과 아래 사진처럼 나왔습니다.


이 부분이 왜 이슈라고 생각을 했냐면, 앞서 보증보험을 말하면서 각 상점 아이디 당 보증보험의 금액에 따른 월 정산한도가 있다고 했습니다. 그러면 유저가 간편결제를 목적으로 간편결제 버튼을 클릭을 했는데 간편결제가 아닌 신용*체크카드 결제를 한 경우에는 어떻게 될 지 의문이 들었습니다.
제가 포트원에서 간편결제와 카드결제 2개로 나눠서 심사를 넣은 이유는 카카오페이, 토스페이, 네이버 페이와 같은 카드결제를 제외한 간편결제는 반드시 간편결제 상점아이디에서 이루어져야 하며, 간편결제를 제외한 모든 카드결제는 카드결제 상점아이디에서 이루어지도록 하는 것이 목표였습니다.
저는 간편결제에 있는 신용*체크카드 결제를 사용해서 결제를 하면 카드결제 상점 아이디에 매출이 집계가 되는 것으로 생각을 했습니다. 하지만 테스트 결과 매출 집계는 간편결제 상점 아이디에 기록이 남게 되었습니다.

이 부분이 매출 관점에서는 문제가 없지만, 데이터베이스를 일괄적으로 처리하기에는 껄끄러운 부분이 있었습니다. 따라서 카카오페이, 토스페이, 네이버페이는 버튼형식으로 따로 빼고 카드결제만 버튼형식으로 바꾸는 방법을 생각했습니다.

그 결과 간편결제에서 신용*체크카드 결제가 이루어지지 않고 오직 간편결제 목록들만 구성되는 결과를 확인할 수 있었습니다.
그러면 당연하게 이런 생각을 할 수 있습니다. 2개의 상점 아이디를 1개로 합치면 되는 거 아니야? 간편결제 상점아이디로 합치면 끝이잖아?
저도 동일한 생각을 했습니다. 만약 제가 포트원을 통하지 않고 토스로 바로 연동을 했으면 그 부분이 가능합니다. 하지만 포트원에서는 간편결제와 카드결제는 완전히 독립적으로 매출 집계가 이루어지기 때문에 각각의 상점 아이디가 반드시 필요합니다. 저도 이 부분에서 토스와 포트원에 많은 질문과 전화를 하며 답변을 얻었습니다.
포트원, 동기화의 꽃 '웹 훅'
웹훅은 특정 이벤트가 발생했을 때 외부에 알림을 보내는 기능입니다. 웹 훅 프로바이더가 이벤트가 발생하면 HTTP POST를 통해서 요청을 생성하고 callback URL로 이벤트 정보를 보냅니다.
처음에는 웹 훅의 필요성을 느끼지 못 했습니다. 하지만 테스트 도중 클라이언트가 결제 중 갑자기 창을 끄거나 종료 시, 결제 건이 포트원과 DB가 동기화가 되어야 하는데 동기화가 되지 않는 문제를 보면서, 지속적으로 결제 관련 데이터가 변경이 되면 웹 훅을 적용해 안정성을 위해서 동기화를 해야겠다고 생각했습니다.
포트원에서는 일반적으로 발생할 수 있는 아래 케이스가 보편적입니다.
- 결제준비 READY : 결제창이 열렸을 때
- 결제완료 PAID : 결제가 승인된 상태
- 결제취소 CANCELED : 결제를 취소한 상태
- 결제요청 CANCELPENDING : 결제 취소를 요청했을 때
- 결제실패 FAILED : 결제가 완전 취소되었을
만약 웹 훅이 성공적이거나 실패하면 아래와 같은 모양으로 나오게 됩니다. 현재 결제실패 부분의 웹훅이 구현되어 있지 않아 지속적으로 발송 실패가 발생하는 것을 알 수 있습니다.

웹 훅 의 구현은 아래와 같습니다.
@app.post("/api/payment/webhook")
def receive_webhook(request: Request, body=Depends(get_raw_body)):
try:
webhook = portone.webhook.verify(
os.environ["V2_WEBHOOK_SECRET"],
body.decode("utf-8"),
request.headers,
)
except portone.webhook.WebhookVerificationError:
return "Bad Request", 400
if not isinstance(webhook, dict) and isinstance(
webhook.data, portone.webhook.WebhookTransactionData
):
sync_payment(webhook.data.payment_id)
return "OK", 200
WEBHOOK_SECRET에 해당하는 키 값을 포트원 설정에서 가져오고, 넣어주면 됩니다. 그러면 순서는 아래와 같이 돌아갑니다.
- 먼저 웹 훅은 변경이 되면 그때그때 이벤트가 발생합니다. 따라서 결제성공의 경우, 포트원에 기록이 남고 포트원에 웹 훅 설정에 저장한 서버쪽 API로 POST 요청을 보냅니다.
- 자사 서버는 요청을 받고 웹흑 SECRET키를 가지고 verify를 합니다. 결과 값에는 위에서 언급한 5가지 status가 type으로 존재하고 해당 값을 sync_payment로 넘깁니다.
- sync_payment에서 1번더 webhook에 해당하는 payment_id와 해당 값으로 진짜 일치하는지 확인을 합니다.
- 만약 틀리는 경우에는 올바른 상태로 업데이트하는 로직을 넣어주면 됩니다.
자사 서비스에 결제를 구축하면서 발생했던 이슈들을 정리했습니다. 다음 번에는 빌링키(정기 구독) 시스템을 잘 정리해서 오겠습니다.
'SW' 카테고리의 다른 글
| Clean Architecture (4) | 2025.08.07 |
|---|---|
| IT 이직하기 위한 단어 정리 (2) | 2025.07.12 |
| 구글 SSO 인증과 이메일 기반 인증 (0) | 2025.07.02 |
| QA 엔지니어 (0) | 2025.05.28 |
| supabase - auth.users (0) | 2025.05.26 |