| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- contextBridge
- electron
- 프론트엔드
- 온디바이스AI
- 백엔드
- 보안
- python
- typescript
- playwright
- javascript
- 마이그레이션
- JWT
- Vite
- sqlite
- 메일자동화
- 상태관리
- REST API
- next.js
- php
- 아키텍처
- 배포
- Kotlin
- github actions
- 인증
- 크롤링
- Android
- 도메인설계
- React
- FastAPI
- 소셜로그인
- Today
- Total
HM소프트
교육 플랫폼 개발기 (3) — 결제 서버 검증, 영수증 PDF, 계약서 AI 검토 본문

결제는 클라이언트 금액을 믿지 않고 서버가 PG에 직접 물어 상태·실제 금액을 세 겹으로 대조해 위변조를 막는다. 완료 처리는 트랜잭션으로 묶고 웹훅을 안전망으로 둔다. mPDF 재사용 영수증과 현금영수증 검증, 계약서 조항 분할·비용 보호 AI 검토, 판례 KB 적재까지 다룬다.
3편: 돈과 신뢰가 오가는 곳
1편 LMS 토대, 2편 AI 챗봇에 이어, 이번엔 플랫폼이 실제로 매출을 내고 신뢰를 만드는 부분이다. 결제, 영수증, 그리고 이 산업의 특성상 가치가 컸던 계약서 AI 검토·판례 검색. 결제는 "붙이면 끝"이 아니라 금액 위변조를 서버에서 어떻게 막느냐가 전부였고, 영수증·법률 기능은 "있으면 좋은 것"이 아니라 유료 서비스의 필수 신뢰 요소였다.

결제 — 클라이언트 금액을 절대 믿지 않는다
결제 흐름의 출발점은 prepare()다. 강의 가격과 쿠폰 할인을 서버에서 계산해 pending 상태의 주문 행을 만들고, 브라우저 SDK가 쓸 파라미터만 돌려준다. 여기서 금액은 클라이언트가 아니라 서버 DB에 박힌다.
// 결제 준비 — 금액은 서버에서 계산해 DB에 박는다
public static function prepare(int $user_id, int $course_id, ?string $coupon_code = null): array|WP_Error {
$course = HM_DB::get_row('SELECT * FROM ' . HM_DB::table('courses') . " WHERE id = %d AND status = 'published'", [$course_id]);
if (!$course) return HM_Helpers::json_error('course_not_found', '강의를 찾을 수 없습니다.', 404);
$original_amount = (int) ($course['sale_price'] ?: $course['price']);
$discount_amount = 0;
if ($coupon_code) { // 쿠폰 검증도 서버에서
$coupon = HM_Coupon_Manager::validate($coupon_code, $user_id, $course_id, $original_amount);
if (is_wp_error($coupon)) return $coupon;
$discount_amount = (int) $coupon['discount'];
}
$amount = max(0, $original_amount - $discount_amount);
$order_id = HM_Helpers::generate_order_id();
HM_DB::insert('payments', [
'user_id' => $user_id, 'course_id' => $course_id, 'order_id' => $order_id,
'amount' => $amount, 'discount_amount' => $discount_amount, 'status' => 'pending',
]);
return ['order_id' => $order_id, 'amount' => $amount, /* SDK 파라미터 ... */];
}
브라우저가 결제창을 띄워 결제를 마치면 paymentId를 받아 서버 confirm()을 부른다. 여기가 결제의 심장이다. 브라우저가 보낸 성공 신호는 믿지 않는다. 서버가 직접 PG의 조회 API(GET /payments/{id})를 때려 실제 상태와 금액을 확인하고, DB에 박아둔 금액과 PG가 알려준 실제 결제 금액이 일치할 때만 결제를 완료 처리한다.

세 겹의 검증이 들어 있다 — confirm 파라미터 금액 == DB 금액, PG 상태 == PAID, PG가 알려준 실제 금액 == DB 금액. 마지막 검증이 핵심이다. 악의적 사용자가 브라우저에서 결제 금액을 1원으로 조작해도, 서버가 PG에 직접 물은 실제 결제액과 대조하므로 amount_tampered로 막힌다. 결제 연동에서 가장 흔하고 치명적인 사고가 바로 이 금액 위변조인데, "클라이언트가 보낸 모든 것은 거짓일 수 있다"를 전제로 서버 재검증을 강제했다.
결제 완료는 트랜잭션으로 — 부분 실패를 남기지 않기
결제가 검증을 통과하면 할 일이 여럿이다. 주문을 paid로 바꾸고, 수강 등록을 만들고, 쿠폰을 사용 처리한다. 이 중 하나라도 실패하면 "결제는 됐는데 수강 등록이 안 된" 끔찍한 상태가 남는다. 그래서 이 묶음을 DB 트랜잭션으로 감쌌다.

웹훅 — 가상계좌·해외카드의 다른 길
브라우저 confirm 경로가 정상 흐름이지만, 모든 결제가 그 길로 오지 않는다. 가상계좌 입금은 며칠 뒤 사용자가 브라우저 없이 입금하고, 일부 결제 수단은 콜백이 누락된다. 그래서 PG 웹훅을 안전망으로 받았다. 웹훅은 신뢰 경계 밖에서 오므로, 처리하기 전에 서명 검증부터 한다.

웹훅에서 한 가지 더 챙긴 건 PG 수수료 누락 방지다. 가상계좌처럼 confirm 경로를 안 거친 결제는 수수료(pg_fee)가 0으로 남는다. 그래서 웹훅이 paid를 받았을 때 수수료가 0이고 금액이 있으면, 그 자리에서 수수료를 다시 계산해 채운다. 결제 완료 후 발화하는 hm_edu_payment_completed 액션은 confirm과 웹훅 양쪽에서 같은 모양으로 쏘되, 수강 등록은 "이미 active면 noop"이라 중복 등록이 생기지 않는다. 두 경로가 같은 종착점으로 모이게 설계한 것이다.
영수증 — 거래내역 PDF와 현금영수증
결제 후 증빙이 필요하다. 두 가지를 구현했다. 거래내역 PDF는 별도 PDF 라이브러리를 새로 들이지 않고, 수료증 발급에 이미 쓰던 mPDF + 한글 폰트(NotoSansKR)를 재사용했다. 다른 플러그인의 vendor 경로만 require하고, 그 플러그인 파일은 건드리지 않았다.
// 거래내역 PDF — 기존 mPDF·한글폰트 재사용
public static function stream_receipt_pdf(int $payment_id, int $user_id, bool $download = false): ?WP_Error {
$payment = self::authorize($payment_id, $user_id); // 본인 결제만 (또는 관리자)
if (is_wp_error($payment)) return $payment;
if ($payment['status'] !== 'paid') return HM_Helpers::json_error('not_paid', '완료된 결제만 발급 가능합니다.');
$autoload = WP_PLUGIN_DIR . '/hm-edu-certificates/vendor/autoload.php'; // 수료증 플러그인의 mPDF
if (!file_exists($autoload)) return HM_Helpers::json_error('mpdf_missing', 'PDF 모듈 미설치', 500);
require_once $autoload;
$mpdf = new \Mpdf\Mpdf(self::mpdf_config()); // NotoSansKR 폰트, 없으면 graceful fallback
$mpdf->WriteHTML(self::render_receipt_html($payment));
$mpdf->Output('receipt-' . $payment['order_id'] . '.pdf', $download ? 'D' : 'I'); // I=인라인, D=다운로드
exit;
}
mPDF 설정에서 한글 폰트 파일이 있으면 그걸 쓰고, 없으면 기본 폰트로 graceful fallback(한글이 깨질 순 있어도 PDF 자체는 생성)하게 했다. 운영 환경에서 composer install이 안 돌아 vendor가 비어 있을 가능성을 염두에 둔 방어다. PDF 하단에는 "본 문서는 참고용이며 세금계산서·현금영수증의 법적 증빙 효력은 없다"는 면책을 명시했다 — 거래내역서와 법적 증빙을 혼동하지 않게.
현금영수증은 별개다. 이건 세무 처리용 법적 증빙이라 PG의 현금영수증 발급 API를 직접 호출한다. 입력 검증을 까다롭게 했다 — 유형은 화이트리스트(personal/business), 식별번호는 숫자만 추출해 휴대폰(10~11자리)·사업자번호(10자리) 형식을 검사하고, 중복 발급을 막는다.

여기서 일정의 큰 부분을 차지한 건 코드가 아니라 PG 심사 대응이었다. PG사는 실제 서비스가 약관·환불정책·상품 표기 등을 갖췄는지 심사한다. 환불정책·이용약관·개인정보 페이지를 정비하고, 상품(강의) 표기를 규정에 맞추는 작업이 코드 작성만큼 걸렸다. 진행 중에 PG도 한 번 바뀌어서(초기 검토하던 곳에서 다른 PG 애그리게이터로) 연동·문서를 그에 맞춰 정정했다. "붙이면 끝"이 아니라 심사·정책까지 통과해야 실제로 돈을 받을 수 있다는 게 결제 연동의 현실이다.
계약서 AI 검토 — RAG 위에 얹은 법무 데스크
이 산업의 종사자에게 가장 가치 있던 기능은 가맹계약서 AI 조항 검토였다. 사용자가 계약서 텍스트를 붙여넣으면(또는 브라우저에서 PDF 텍스트를 추출하면), AI가 조항별로 가맹점주 관점의 위험을 평가하고, 법정 필수항목 누락을 점검한다. 2편의 챗봇 KB·법령 임포터를 그대로 근거로 활용한다.
설계 원칙은 토큰·비용 보호였다. 긴 계약서를 통째로 LLM에 넣으면 비용이 폭발하고 응답도 불안정하다. 그래서 "제N조" 패턴으로 조항을 분할하고(구조가 없으면 문단 길이로 폴백), 조항을 묶음(batch)으로 호출하며, 조항 수·길이에 상한을 뒀다.

조항 묶음을 LLM에 보낼 때, KB 검색으로 관련 법령 근거를 선택적으로 첨부한다(묶음당 상위 2청크만). 그리고 응답을 JSON 배열로 강제하되, LLM이 순서를 흐트러도 안전하게 index 키로 재정렬한다. 위험등급은 상/중/하로 정규화하고, 파싱이 실패하면 보수적으로 "중"으로 떨어뜨린다.
// LLM 응답 — index 키로 재정렬, 실패 시 보수 처리
$parsed = self::parse_json_array($resp['content'] ?? ''); // 코드펜스 제거 + 첫'['~마지막']' 추출
$by_index = [];
foreach ($parsed as $row) {
if (!is_array($row) || !isset($row['index'])) continue;
$by_index[(int) $row['index']] = $row; // LLM 이 순서 흐트러도 인덱스로 정렬
}
if (empty($by_index) && $parsed) { // index 가 전혀 없으면 순서대로 매핑
foreach (array_values($parsed) as $i => $row) if (is_array($row)) $by_index[$i] = $row;
}
법정 필수항목 체크리스트는 가맹사업법 기준 12개 항목(정보공개서 14일 숙고기간, 가맹금 정의·예치, 영업지역 보호, 갱신요구권 10년, 위약금, 불공정거래행위 금지 등)을 상수로 고정하고, LLM에는 "각 항목이 이 계약서에 포함됐는지"만 판단시켰다. 항목 자체를 LLM이 만들게 하면 매번 달라지므로, 점검 기준은 코드가 쥐고 판단만 위임한 것이다. 총평은 위험등급 통계로 LLM 호출 없이 규칙 기반으로 생성해 비용을 한 번 더 아꼈다. 그리고 결과·UI 전반에 *"AI 참고용이며 변호사 자문을 대체하지 않는다"*는 면책을 노출했다.

판례 검색 — 외부 API를 우리 KB로 적재
마지막은 관련 분쟁 판례 검색이다. 매 검색마다 외부 공개 API를 호출하면 느리고 불안정하다. 그래서 가맹분쟁 키워드(가맹사업, 가맹점, 프랜차이즈, 정보공개서 등)로 판례를 한 번 긁어와 KB에 precedent 타입으로 적재하고, 이후엔 2편의 IDF 검색을 그대로 재사용한다. 법령 임포터(2편)와 호출·정규화·레이트리밋·재적재 방식을 통째로 공유했다.

판례 본문은 판시사항·판결요지·참조조문·참조판례를 묶고, 긴 전문은 800자 청킹해 적재한다. 메타에 법원·사건번호·선고일·원문 링크를 넣어, 챗봇이 답할 때 "○○법원 ○○선고 (사건번호)" 라벨과 원문 링크까지 출처로 보여준다. 이렇게 2편의 법령 + 이번 판례가 하나의 법률 KB로 합쳐져, 챗봇·계약서 검토·판례 검색이 같은 토대를 공유하게 됐다.
정리하며
- 결제는 클라이언트 금액을 절대 신뢰하지 않는다 — 서버가 PG에 직접 물어 상태·실제 금액을 대조하고, 세 겹으로 위변조를 막았다.
- 완료 처리(
paid+수강등록+쿠폰)는 트랜잭션으로 묶고, 웹훅을 안전망으로 둬 가상계좌·콜백 누락 경로까지 같은 종착점으로 모았다. - 영수증은 기존 mPDF·한글폰트를 재사용한 거래내역 PDF와, 형식 검증을 갖춘 현금영수증으로 나눴다. 일정의 큰 부분은 코드가 아니라 PG 심사·정책이었다(PG 교체도 겪음).
- 계약서 AI 검토는 조항 분할 + 묶음 호출 + 상한으로 비용을 보호하고, 점검 기준은 상수로 고정·판단만 LLM에 위임했으며 면책을 명시했다.
- 판례는 외부 API를 KB로 적재해 2편 검색을 재사용 — 법령 임포터의 패턴을 그대로 물려받았다.
다음 마지막 4편에서는 사람을 잇는 멘토링·알림 폴백·서버사이드 소셜 로그인·배포의 함정으로 시리즈를 마친다.
'외주 개발일지' 카테고리의 다른 글
| 과제 채점 플랫폼 개발기 (4) — 랜딩 첫인상 다듬기: 스크롤 리빌, 날짜 피커, 가입 페이지 (0) | 2026.06.23 |
|---|---|
| 교육 플랫폼 개발기 (4) — 멘토링·알림 폴백·서버사이드 소셜 로그인·배포의 함정 (0) | 2026.06.23 |
| 교육 플랫폼 개발기 (2) — 한국어 RAG 챗봇과 법령 임포터의 검색 정밀도 (0) | 2026.06.21 |
| 교육 플랫폼 개발기 (1) — WordPress 위에 LMS를 얹고 강사 권한 경계를 긋다 (0) | 2026.06.21 |
| 웹·앱 공용 풀스택 시스템 개발기 (2) — 알림(alerts) 시스템 설계 (1) | 2026.06.21 |