본문으로 건너뛰기

카카오페이 결제 처리 안정성 개선

PHP 7 · GODO25
변경 파일 수: 3개
변경 파일 목록
총 3개
변경 파일 코드
추가삭제
Component/Payment/Kakaopay/PgNoti.php+46-4
변경 전PgNoti.php
private $pgClass = '';
private $orderNo = '';
private $pgMobileCode = '';
 
 
 
 
 
/**
* 생성자
$this->orderNo = $requestValue['partner_order_id'];
 
// 노티 중복 수신 방지 — 중계서버는 나중 응답만 보고 카카오 결제 취소 여부를 판단하므로,
// 락 대기 후 실제 확정된 결과로 응답한다(즉시 낙관적 OK 금지). 대기해도 못 얻으면 안전하게 FAIL.
$lockGuard = new PaymentNotiLockGuard(App::getInstance('DB'), 'kakaopay');
if ($lockGuard->lock($this->orderNo, self::NOTI_LOCK_WAIT_SECONDS) === false) {
Logger::channel('pgKakaopayResult')->error('Noti Lock Failed :' . $this->pgMobileCode, [$this->orderNo]);
echo '{"result":"FAIL"}';
exit;
 
 
 
 
 
 
 
 
 
}
register_shutdown_function(function () use ($lockGuard) {
 
$lockGuard->unlock($this->orderNo);
});
 
// 결과 정보 로그
Logger::channel('pgKakaopayResult')->info('PG Settle Data :' . $this->pgMobileCode, [$this->orderNo . ' : Settle Success']);
 
 
 
 
 
 
 
// 결제 결과 저장
$saveResult = $this->pgClass->setPgData($this->orderNo, $getPg, null);
 
// 결과 DB 처리
if ($saveResult === true) {
 
Logger::channel('pgKakaopayResult')->info('Set Pg Data :' . $this->pgMobileCode, ['OK']);
echo '{"result":"OK"}';
exit;
 
$errorData = $this->pgClass->setPgResultCode($e->getMessage());
Logger::channel('pgKakaopayResult')->info('Noti Exception Data :' . $errorData['message'], ['FAIL']);
 
 
if ($e->getMessage() == 'PG_STOP_ORDER_DATA') {
Logger::channel('pgKakaopayResult')->info('Stop Pg Data : ' . $errorData['message'], ['FAIL']);
}
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
/**
* 결제 결과 정보 데이터 설정
*
 
변경 후PgNoti.php
private $pgClass = '';
private $orderNo = '';
private $pgMobileCode = '';
private $accepted = false;
private $saved = false;
/** @var \Exception|null */
private $saveException;
 
/**
* 생성자
$this->orderNo = $requestValue['partner_order_id'];
 
// 노티 중복 수신 방지 — 중계서버는 나중 응답만 보고 카카오 결제 취소 여부를 판단하므로,
// 락 대기 후 실제 확정된 결과로 응답한다(즉시 낙관적 OK 금지).
$lockGuard = new PaymentNotiLockGuard(App::getInstance('DB'), 'kakaopay');
if ($lockGuard->lock($this->orderNo, self::NOTI_LOCK_WAIT_SECONDS) === false) {
// 앞선 노티가 검증을 통과해 저장 중이면 FAIL 응답 시 정상 결제가 취소되므로 PAYED로 응답한다
if ($lockGuard->isAccepted($this->orderNo)) {
Logger::channel('pgKakaopayResult')->warning('Noti Lock Wait Timeout, Accepted :' . $this->pgMobileCode, [$this->orderNo]);
echo '{"result":"PAYED"}';
exit;
}
// 대기가 끝나는 사이 앞선 처리가 끝났을 수 있어 한 번 더 시도한다
if ($lockGuard->lock($this->orderNo) === false) {
Logger::channel('pgKakaopayResult')->error('Noti Lock Failed :' . $this->pgMobileCode, [$this->orderNo]);
echo '{"result":"FAIL"}';
exit;
}
}
register_shutdown_function(function () use ($lockGuard) {
$this->logAcceptedSaveFailed();
$lockGuard->unlock($this->orderNo);
});
 
// 결과 정보 로그
Logger::channel('pgKakaopayResult')->info('PG Settle Data :' . $this->pgMobileCode, [$this->orderNo . ' : Settle Success']);
 
// 저장이 늦어져 재시도 노티가 들어와도 결제가 취소되지 않도록 승인 확정을 표시한다
$this->accepted = $lockGuard->markAccepted($this->orderNo);
if (!$this->accepted) {
Logger::channel('pgKakaopayResult')->warning('Noti Accepted Mark Failed :' . $this->pgMobileCode, [$this->orderNo]);
}
 
// 결제 결과 저장
$saveResult = $this->pgClass->setPgData($this->orderNo, $getPg, null);
 
// 결과 DB 처리
if ($saveResult === true) {
$this->saved = true;
Logger::channel('pgKakaopayResult')->info('Set Pg Data :' . $this->pgMobileCode, ['OK']);
echo '{"result":"OK"}';
exit;
 
$errorData = $this->pgClass->setPgResultCode($e->getMessage());
Logger::channel('pgKakaopayResult')->info('Noti Exception Data :' . $errorData['message'], ['FAIL']);
$this->saveException = $e;
 
if ($e->getMessage() == 'PG_STOP_ORDER_DATA') {
Logger::channel('pgKakaopayResult')->info('Stop Pg Data : ' . $errorData['message'], ['FAIL']);
}
}
 
/**
* 승인 확정 표시 후 저장되지 않고 끝나면(실패 응답·예외·치명 오류) 재시도 노티가 PAYED로 응답해
* 카카오 결제가 유지됐을 수 있어 대사용으로 남긴다. 종료 시점 한 곳에서 판정한다.
*
* @return void
*/
private function logAcceptedSaveFailed()
{
if (!$this->accepted || $this->saved) {
return;
}
 
$context = ['orderNo' => $this->orderNo];
if ($this->saveException !== null) {
$context['exception'] = $this->saveException;
$context['errorMessage'] = $this->saveException->getMessage();
}
Logger::channel('pgKakaopayResult')->error('Noti Accepted But Save Failed :' . $this->pgMobileCode, $context);
}
 
/**
* 결제 결과 정보 데이터 설정
*
 
Component/Payment/Kakaopay/PgReturn.php+74
변경 전PgReturn.php
 
namespace Bundle\Component\Payment\Kakaopay;
 
 
 
use Request;
use Logger;
 
use Origin\Traits\Payment\PaymentProcessingSessionTrait;
 
/**
*/
const KAKAOPAY_PG_DB_ERROR = '99';
 
 
 
 
 
/**
* 생성자
*
$getPg['pgFailReason'] = $setPayData['retmsg'];
$this->pgClass->setPgErrorData($this->orderNo, $getPg, null);
\Logger::channel('pgKakaopayResult')->info(__METHOD__ . " ORDER NO : {$this->orderNo} ", $setPayData);
 
 
 
 
} else {
$script .= "location.href='../../order/order_end.php?orderNo=" . $this->orderNo . "';";
}
<?php
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
/**
* 인증 정보 데이터 설정
*
 
변경 후PgReturn.php
 
namespace Bundle\Component\Payment\Kakaopay;
 
use Component\Member\Util\MemberUtil;
use Framework\Database\DBManager;
use Request;
use Logger;
use Origin\Enum\Order\OrderStatusCode;
use Origin\Traits\Payment\PaymentProcessingSessionTrait;
 
/**
*/
const KAKAOPAY_PG_DB_ERROR = '99';
 
// 결제 성공 복귀 시 노티의 결제완료 저장을 기다리는 최대 시간(초)과 주문상태 확인 간격(마이크로초)
private $notiWaitSeconds = 25;
private $notiPollMicroseconds = 500000;
 
/**
* 생성자
*
$getPg['pgFailReason'] = $setPayData['retmsg'];
$this->pgClass->setPgErrorData($this->orderNo, $getPg, null);
\Logger::channel('pgKakaopayResult')->info(__METHOD__ . " ORDER NO : {$this->orderNo} ", $setPayData);
} elseif ($this->isNotiProcessing()) {
// 재시도 노티의 PAYED 응답으로 결제가 유지됐는데 앞선 노티가 아직 저장 중이면, 주문완료 화면은 결제시도 상태라
// '다시 결제' 안내가 나와 이중결제로 이어질 수 있어 안내 후 주문내역으로 보낸다(비회원은 로그인·비회원 주문조회로 이동)
$script = $this->getOrderListGuideScript($script);
} else {
$script .= "location.href='../../order/order_end.php?orderNo=" . $this->orderNo . "';";
}
<?php
}
 
/**
* 결제는 됐는데 주문 확인이 필요한 경우 안내 후 주문내역으로 보내는 스크립트
* 비회원은 주문내역 대신 로그인 화면으로 이동(PageAccess)해 비회원 주문조회에 쓸 주문번호를 함께 안내한다.
*
* @param string $script 창 위치 접두(opener.parent. 등)
* @return string
*/
private function getOrderListGuideScript(string $script): string
{
if (MemberUtil::isLogin()) {
$message = __('결제는 완료되었으며 주문 정보를 확인하고 있습니다. 잠시 후 주문내역에서 확인해 주세요.');
} else {
$message = sprintf(__('결제는 완료되었으며 주문 정보를 확인하고 있습니다. 잠시 후 주문내역에서 확인해 주세요. (주문번호: %s)'), $this->orderNo);
}
 
return 'alert(' . json_encode($message) . ');' . $script . "location.href='../../mypage/order_list.php';";
}
 
/**
* 결제 성공으로 돌아왔는데 주문이 아직 결제시도(f1)면 노티가 결제완료를 저장할 때까지 주문상태를 짧은 간격으로 다시 확인한다.
* 노티 락은 메일·SMS 등 후처리까지 끝나야 풀려서, 락 대신 주문상태로 판정해 저장 즉시 빠져나온다.
*
* @return bool true = 최대 대기 후에도 결제시도 상태(또는 확인 실패)
*/
private function isNotiProcessing(): bool
{
try {
$db = \App::getInstance('DB');
// 분산서버(read replica) 상점에서 복제 지연으로 방금 저장된 결제완료를 놓치지 않도록 primary에서 조회한다
if (method_exists($db, 'stickyPrimary')) {
$db->stickyPrimary();
}
 
$deadline = microtime(true) + $this->notiWaitSeconds;
while ($this->getOrderStatus($db) === OrderStatusCode::PAYMENT_ATTEMPT) {
if (microtime(true) >= $deadline) {
return true;
}
usleep($this->notiPollMicroseconds);
}
 
return false;
} catch (\Throwable $e) {
// 확인할 수 없으면 결제시도로 보고 주문내역 안내로 보낸다(주문완료 화면의 '다시 결제' 안내 방지)
\Logger::channel('pgKakaopayResult')->warning(__METHOD__ . ' 주문상태 확인 실패 : ' . $this->orderNo, ['exception' => $e]);
 
return true;
}
}
 
/**
* @param DBManager $db
* @return string
*/
private function getOrderStatus(DBManager $db): string
{
$arrBind = [];
$db->bind_param_push($arrBind, 's', $this->orderNo);
$orderData = $db->query_fetch('SELECT orderStatus FROM ' . DB_ORDER . ' WHERE orderNo = ?', $arrBind, false);
 
return (string)($orderData['orderStatus'] ?? '');
}
 
/**
* 인증 정보 데이터 설정
*
 
Component/Payment/PaymentNotiLockGuard.php+64-3
변경 전PaymentNotiLockGuard.php
*
* 주의: GET_LOCK은 커넥션 단위 스코프라, lock()/unlock()에는 노티 처리에 쓰는 DB 커넥션과 동일한
* 인스턴스(mysqli 싱글턴, \App::getInstance('DB'))를 넘겨야 한다. 판정 결과만 반환하며 로깅은 호출부 책임이다.
 
 
 
 
 
*/
class PaymentNotiLockGuard
{
const LOCK_PREFIX = 'pgNoti:';
 
 
private $db;
private $pgName;
 
 
 
/**
* @param DBManager $db 노티 처리에 사용하는 DB 커넥션과 동일한 인스턴스
{
$this->db = $db;
$this->pgName = $pgName;
 
}
 
/**
* @throws DatabaseException
*/
public function unlock(string $orderNo)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
{
$arrBind = [];
$this->db->bind_param_push($arrBind, 's', $this->lockName($orderNo));
$this->db->query_fetch('SELECT RELEASE_LOCK(?) AS released', $arrBind, false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
}
 
/**
 
 
 
 
* @param string $orderNo
* @return string
*/
private function lockName(string $orderNo): string
{
return self::LOCK_PREFIX . $this->pgName . ':' . $orderNo;
 
 
 
 
 
 
 
 
 
}
}
 
변경 후PaymentNotiLockGuard.php
*
* 주의: GET_LOCK은 커넥션 단위 스코프라, lock()/unlock()에는 노티 처리에 쓰는 DB 커넥션과 동일한
* 인스턴스(mysqli 싱글턴, \App::getInstance('DB'))를 넘겨야 한다. 판정 결과만 반환하며 로깅은 호출부 책임이다.
*
* 승인 확정 표시(markAccepted/isAccepted): 처리 중인 요청이 주문·금액 검증을 통과했다는 표시를 별도 락으로 남긴다.
* 웹서버가 여러 대여도 같은 DB에서 보이고, 요청이 비정상 종료되면 커넥션과 함께 풀린다.
*
* 락 이름은 상점 단위로 분리한다 — lockName() 참고.
*/
class PaymentNotiLockGuard
{
const LOCK_PREFIX = 'pgNoti:';
const ACCEPTED_LOCK_PREFIX = 'pgNotiAccepted:';
 
private $db;
private $pgName;
/** @var string 락 이름에 쓰는 상점 식별자 — lock()/unlock() 이 같은 이름을 쓰도록 생성 시점에 한 번만 읽는다 */
private $shopNo;
 
/**
* @param DBManager $db 노티 처리에 사용하는 DB 커넥션과 동일한 인스턴스
{
$this->db = $db;
$this->pgName = $pgName;
$this->shopNo = (string)\Globals::get('gLicense.godosno');
}
 
/**
* @throws DatabaseException
*/
public function unlock(string $orderNo)
{
// 주문 락을 먼저 푼다 — 표시를 먼저 풀면 그 사이 대기가 끝난 재시도가 표시 없음·락 점유로 판정해 FAIL 응답할 수 있다
foreach ([$this->lockName($orderNo), $this->acceptedLockName($orderNo)] as $lockName) {
$arrBind = [];
$this->db->bind_param_push($arrBind, 's', $lockName);
$this->db->query_fetch('SELECT RELEASE_LOCK(?) AS released', $arrBind, false);
}
}
 
/**
* 주문·금액 검증을 통과해 결제를 확정 처리 중이라는 표시를 남긴다. lock() 획득 후에만 호출한다.
*
* @param string $orderNo
* @return bool
* @throws DatabaseException
*/
public function markAccepted(string $orderNo): bool
{
$arrBind = [];
$this->db->bind_param_push($arrBind, 's', $this->acceptedLockName($orderNo));
$result = $this->db->query_fetch('SELECT GET_LOCK(?, 0) AS acquired', $arrBind, false);
 
return !empty($result) && (int)$result['acquired'] === 1;
}
 
/**
* 다른 요청이 승인 확정 표시를 들고 처리 중인지 확인한다.
*
* @param string $orderNo
* @return bool
* @throws DatabaseException
*/
public function isAccepted(string $orderNo): bool
{
if (method_exists($this->db, 'stickyPrimary')) {
$this->db->stickyPrimary();
}
 
$arrBind = [];
$this->db->bind_param_push($arrBind, 's', $this->acceptedLockName($orderNo));
$result = $this->db->query_fetch('SELECT IS_USED_LOCK(?) AS holder', $arrBind, false);
 
return !empty($result) && $result['holder'] !== null;
}
 
/**
* GET_LOCK 이름은 스키마가 아니라 MySQL 인스턴스 전역이라, 여러 상점 DB가 한 서버를 공유하면
* 주문번호만으로는 다른 상점의 같은 주문번호와 겹친다(주문번호에 상점 식별자 없음). 예치금 표준
* 락(SimpleCache Redis executeWithLock)과 동일하게 상점 식별자(godosno)를 이름에 포함한다.
*
* @param string $orderNo
* @return string
*/
private function lockName(string $orderNo): string
{
return self::LOCK_PREFIX . $this->shopNo . ':' . $this->pgName . ':' . $orderNo;
}
 
/**
* @param string $orderNo
* @return string
*/
private function acceptedLockName(string $orderNo): string
{
return self::ACCEPTED_LOCK_PREFIX . $this->shopNo . ':' . $this->pgName . ':' . $orderNo;
}
}