클라이언트 사이드에서 PG사 결제 창을 호출하고 결제를 진행할 때, 악의적인 사용자가 개발자 도구를 통해 결제 금액을 10원이나 100원으로 위변조하는 문제를 경험해봤을까?
웹 서비스 개발에서 PG(결제대행사) 연동은 필수적이지만, 많은 개발자가 클라이언트 자바스크립트 응답만 신뢰하고 DB를 업데이트하는 치명적인 실수를 저지른다.
다만 정확한 서버 대 서버(Server to Server) 검증 메커니즘이나 API 호출 순서를 알지 못해 보안 취약점에 노출되는 경우가 많다.
이번에는 Portone(구 아임포트) REST API가 정확히 뭔지, 왜 사후/사전 검증 프로세스가 필수적인지, 그리고 PHP cURL을 통해 금액 위변조 방지 로직을 어떻게 안전하게 구현하는지 완벽하게 정리해서 소개하겠다.
Portone(구 아임포트)은 다양한 PG사(KG이니시스, 토스페이먼츠, 카카오페이 등)의 결제 모듈을 하나로 통합해 주는 API 서비스다.
브라우저 자바스크립트 SDK로 결제 창을 띄우는 것만으로는 보안이 완벽하지 않다. 자바스크립트 변수는 사용자의 브라우저 메모리상에 존재하므로 언제든 임의로 조작될 수 있기 때문이다.
따라서 반드시 결제가 완료된 후 또는 결제 요청 전에 Portone REST API를 통해 실제 PG사에 승인된 결제 금액과 웹 서비스 내부 DB의 주문 금액이 일치하는지 비교하는 서버 검증 process가 들어가야 한다.
| 구분 | 클라이언트 결제 단독 처리 | Portone REST API 서버 검증 |
|---|---|---|
| 위변조 위험성 | 개발자 도구로 결제 금액 임의 변경 가능 (매우 위험) | 서버 간 통신으로 승인 금액을 검증하므로 조작 불가능 (안전) |
| 데이터 신뢰성 | 브라우저 전송 값만 신뢰 | PG사 및 Portone 원장 데이터 직접 조회 및 검증 |
| 에러 처리 | 이탈/중단 시 결제 상태 확인 불분명 | REST API 조회를 통해 미결제/취소/승인 상태 명확 확인 |
Portone REST API를 활용한 안전한 결제 승인 절차는 다음과 같은 3단계 프로세스를 거친다.
1) 인증 토큰(Access Token) 발급: Portone 관리자 콘솔에서 확인한 REST API Key와 API Secret을 사용하여 서버 간 통신용 Bearer 토큰을 발급받는다.
2) 결제 사전 등록 (선택/권장): 결제 창을 띄우기 전, 서버에서 주문번호(merchant_uid)와 결제 예정 금액을 Portone 서버에 미리 등록한다.
3) 결제 사후 검증 및 DB 승인 (필수): 브라우저 결제 완료 후 전달받은 아임포트 고유번호(imp_uid)로 API를 호출하여 실제로 결제된 금액과 DB의 주문 금액을 대조한 뒤 최종 주문 상태를 변경한다.
다만 대부분의 개발자들은 프론트엔드에서 전달된 `amount` 값을 그대로 믿고 DB 업데이트 쿼리를 실행하는 실수를 한다.
✗ 브라우저에서 넘겨받은 파라미터를 그대로 사용하여 데이터베이스 상태를 변경하면 결제 금액 위변조 공격에 완전히 노출된다.
<?php
// 프론트엔드에서 폼(Form) 전송으로 들어온 데이터
$merchant_uid = $_POST['merchant_uid'];
$amount = $_POST['amount']; // ✗ 공격자가 10,000원짜리 상품을 100원으로 조작해서 보낼 수 있음!
// 검증 없이 곧바로 결제 완료 처리
$stmt = $pdo->prepare("UPDATE orders SET status = 'paid' WHERE merchant_uid = :merchant_uid");
$stmt->execute([':merchant_uid' => $merchant_uid]);
echo "결제 완료";
?>
✓ REST API 토큰을 발급받아 Portone 중앙 서버의 결제 원장 정보를 직접 조회하고, DB에 기록된 실제 상품 가격과 대조한다.
<?php
$api_key = "YOUR_PORTONE_API_KEY";
$api_secret = "YOUR_PORTONE_API_SECRET";
$imp_uid = $_POST['imp_uid'] ?? '';
$merchant_uid = $_POST['merchant_uid'] ?? '';
if (empty($imp_uid) || empty($merchant_uid)) {
die(json_encode(['success' => false, 'message' => '유효하지 않은 요청입니다.']));
}
// 1. Portone REST API Access Token 발급
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://api.iamport.kr/users/getToken",
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
"imp_key" => $api_key,
"imp_secret" => $api_secret
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"]
]);
$token_response = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($token_response['response']['access_token'])) {
die(json_encode(['success' => false, 'message' => 'API 토큰 발급 실패']));
}
$access_token = $token_response['response']['access_token'];
// 2. Portone 서버에서 실제 결제 내역 조회
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "https://api.iamport.kr/payments/" . urlencode($imp_uid),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: " . $access_token,
"Content-Type: application/json"
]
]);
$payment_response = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($payment_response['code']) && $payment_response['code'] === 0) {
$payment_data = $payment_response['response'];
// DB에서 실제 주문해야 할 금액 조회
$stmt = $pdo->prepare("SELECT amount, status FROM orders WHERE merchant_uid = :merchant_uid");
$stmt->execute([':merchant_uid' => $merchant_uid]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
$amount_to_be_paid = (int)$order['amount']; // DB상 원래 결제되어야 할 금액
$actual_paid_amount = (int)$payment_data['amount']; // Portone PG 승인 금액
$status = $payment_data['status']; // paid, cancelled 등
// 3. 결제 상태 및 금액 위변조 여부 검증
if ($status === 'paid' && $actual_paid_amount === $amount_to_be_paid) {
// ✓ 위변조 없음: DB 결제 완료 처리
$update_stmt = $pdo->prepare("UPDATE orders SET status = 'paid', imp_uid = :imp_uid WHERE merchant_uid = :merchant_uid");
$update_stmt->execute([':imp_uid' => $imp_uid, ':merchant_uid' => $merchant_uid]);
echo json_encode(['success' => true, 'message' => '결제 검증 및 완료 처리 성공']);
} else {
// ✗ 위변조 시도 감지 또는 금액 불일치: 결제 취소 API 호출 등의 후속 조치 필요
echo json_encode(['success' => false, 'message' => '결제 금액 위변조가 의도되었거나 금액이 일치하지 않습니다.']);
}
} else {
echo json_encode(['success' => false, 'message' => '결제 정보 조회 실패']);
}
?>
정상적인 요청이 들어오면 JSON 형태로 `{"success": true, "message": "결제 검증 및 완료 처리 성공"}`이 반환된다.
만약 악의적인 사용자가 결제 금액을 변조하여 PG사에 100원만 결제했다면, `$actual_paid_amount === $amount_to_be_paid` 조건문에서 거러져 DB 조작이 즉시 차단된다.
실무에서 Portone API를 연동할 때 빈번히 발생하는 보안 및 로직 실수를 체크해 보자.
✗ API Key / Secret 코드 노출: REST API Secret을 자바스크립트 파일이나 클라이언트 코드에 직접 하드코딩하여 노출하는 것.
✓ 서버 환경변수 관리: API Key와 Secret은 반드시 PHP 서버 환경변수(.env)나 보안 파일로 관리하고 서버 간 HTTP 통신에서만 사용해야 한다.
✗ 중복 처리 방지 누락: 웹훅(Webhook)과 프론트엔드 완료 콜백이 동시에 서버로 유입되어 DB 처리가 2번 실행되는 현상.
✓ 트랜잭션 및 상태 체크: DB 업데이트 전 `WHERE status = 'ready'`와 같이 미결제 상태인 경우에만 승인 처리가 되도록 조건 검색을 강화해야 한다.
✗ 토큰 재사용 미흡: 매 요청마다 토큰을 새로 발급받아 API 요청 제한에 걸리는 경우.
✓ 토큰 캐싱: 발급받은 access_token은 유효 시간(기본 30분) 동안 세션이나 Redis 등에 캐싱하여 재사용하는 것이 효율적이다.
Portone REST API 연동 시 결제 사전/사후 검증은 웹 서비스 보안과 직결된 핵심 과정이다.
작은 보안 검증 습관 하나가 결제 사고와 재정적 손실을 막는 큰 차이를 만들어 낸다는 점을 잊지 말자.
이 글의 PHP cURL 실전 코드와 3단계 검증 로직을 참고해 프로젝트에 직접 결제 검증을 적용해 보면, 위변조 위험 없는 안전하고 견고한 결제 시스템을 구축할 수 있을 것이다.