본문으로 건너뛰기

소스 변경 전체 보기

PHP 7 · GODO25
변경 파일 수: 8개
변경 파일 목록
총 8개
변경 파일 코드
추가삭제
카카오페이 결제 처리 안정성 개선
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;
}
}
 
카카오페이 결제 취소 시 주문 상태 처리 개선
Component/Payment/Kakaopay/KakaopayConfig.php+5-2
변경 전KakaopayConfig.php
*/
class KakaopayConfig
{
 
 
 
public $kakaopayApiDomain = '';
public $kakaopayReadyUrl = '';
public $kakaopayShopDomain = '';
// kakaopay 결제 취소 url
$this->kakaopayCancelUrl = $this->kakaopayApiDomain . '/api/KakaoCancel';
 
// kakaopay 결과 노티 url
$this->kakaopayNotiUrl = $this->kakaopayPaymentDomain . DS . 'pg_noti.php';
 
// kakaopay 결과 회신 url
$this->kakaopayReturnUrl = $this->kakaopayPaymentDomain . DS . 'pg_return.php';
 
변경 후KakaopayConfig.php
*/
class KakaopayConfig
{
// 노티 url 표식 — 이 표식이 있는 주문만 중계서버가 결제 취소 직전 확인(PgNoti mode=cancel)을 보낸다
const RELAY_CANCEL_CHECK_QUERY = 'relayCancelCheck=1';
 
public $kakaopayApiDomain = '';
public $kakaopayReadyUrl = '';
public $kakaopayShopDomain = '';
// kakaopay 결제 취소 url
$this->kakaopayCancelUrl = $this->kakaopayApiDomain . '/api/KakaoCancel';
 
// kakaopay 결과 노티 url — 중계서버는 이 표식이 있을 때만 결제 취소 전 확인을 보낸다(구버전 쇼핑몰은 기존 동작 유지)
$this->kakaopayNotiUrl = $this->kakaopayPaymentDomain . DS . 'pg_noti.php?' . self::RELAY_CANCEL_CHECK_QUERY;
 
// kakaopay 결과 회신 url
$this->kakaopayReturnUrl = $this->kakaopayPaymentDomain . DS . 'pg_return.php';
 
Component/Payment/Kakaopay/NotiCancelReservation.php+149
변경 전NotiCancelReservation.php
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
변경 후NotiCancelReservation.php
<?php
/*
* Copyright (C) 2026 NHN COMMERCE. - All Rights Reserved
*
* Unauthorized copying or redistribution of this file in source and binary forms via any medium
* is strictly prohibited.
*/
 
namespace Bundle\Component\Payment\Kakaopay;
 
use Bundle\Component\Order\OrderAdmin;
use Bundle\Component\Payment\PaymentNotiLockGuard;
use Framework\Database\DBManager;
use Framework\Debug\Exception\DatabaseException;
use Logger;
use Origin\Enum\Order\OrderStatusCode;
use Origin\Enum\Order\OrderStatusGroup;
 
/**
* 카카오페이 중계서버(commerce-relay-kakaopay)가 결제를 취소하기 직전에 쇼핑몰과 결과를 확정하는 취소 예약.
*
* 중계서버는 노티 응답이 정상(OK/PAYED)이 아니면 결제를 취소한다. 취소 후에 알리면 그사이 저장 중인 노티가
* 결제완료로 덮어쓰므로, 취소 전에 승인 확정 표시(PaymentNotiLockGuard::markAccepted)를 대기 없이 잡아
* 노티의 확정과 상호배제한다. 표시를 잡으면 결제시도 주문을 결제실패로 바꾸고(취소 예약), 못 잡으면 노티가
* 이미 확정해 저장 중이므로 결제를 유지시킨다. 노티는 확정 표시 후 isPaymentAttempt()로 예약 여부를 다시 확인한다.
*/
class NotiCancelReservation
{
const RESULT_PAYED = 'PAYED';
const RESULT_CANCEL = 'CANCEL';
// 암호화 노티 데이터를 다시 보내 결제시도 주문을 실패로 바꾸지 못하도록 승인 직후 요청만 받는다
const REQUEST_VALID_SECONDS = 600;
 
// (private const 는 7.1+ 전용이라 가시성 없이 선언)
const RESERVE_REASON = '중계서버 결제 취소로 인해';
// 카카오페이 결제수단 코드 — 다른 결제수단의 결제시도 주문은 취소 예약하지 않는다
const SETTLE_KIND_KAKAOPAY = 'pk';
 
/** @var DBManager */
private $db;
/** @var PaymentNotiLockGuard */
private $lockGuard;
 
/**
* @param DBManager $db 노티 처리에 사용하는 DB 커넥션과 동일한 인스턴스
* @param PaymentNotiLockGuard $lockGuard
*/
public function __construct(DBManager $db, PaymentNotiLockGuard $lockGuard)
{
$this->db = $db;
$this->lockGuard = $lockGuard;
}
 
/**
* @param string $orderNo
* @param OrderAdmin $orderAdmin 결제실패 상태 변경(복귀 결제실패 처리와 동일 경로)
* @return string RESULT_PAYED = 결제 유지, RESULT_CANCEL = 취소 진행
* @throws DatabaseException
*/
public function reserve(string $orderNo, OrderAdmin $orderAdmin): string
{
if (!$this->lockGuard->markAccepted($orderNo)) {
return self::RESULT_PAYED;
}
 
try {
$orderData = $this->getOrderData($orderNo);
$orderStatus = (string)($orderData['orderStatus'] ?? '');
// 노티가 확정 표시를 풀기 전에 결제완료 저장까지 끝낸 경우
if ($orderStatus !== '' && substr($orderStatus, 0, 1) !== OrderStatusGroup::FAIL) {
return self::RESULT_PAYED;
}
if ($orderStatus !== OrderStatusCode::PAYMENT_ATTEMPT) {
return self::RESULT_CANCEL;
}
// 카카오페이 주문이 아니면 이 결제와 맞지 않는 주문이므로 건드리지 않고 결제만 취소되게 한다
if (($orderData['settleKind'] ?? '') !== self::SETTLE_KIND_KAKAOPAY) {
Logger::channel('pgKakaopayResult')->warning(__METHOD__ . ' 카카오페이 주문 아님, 취소 예약 생략', ['orderNo' => $orderNo, 'settleKind' => $orderData['settleKind'] ?? '']);
 
return self::RESULT_CANCEL;
}
 
// 상태변경이 화면용 스크립트 등을 출력해도 중계서버 응답 JSON 과 섞이지 않게 버린다
ob_start();
try {
$orderAdmin->updateStatusPreprocess(
$orderNo,
$orderAdmin->getOrderGoods($orderNo),
OrderStatusGroup::FAIL,
OrderStatusCode::PAYMENT_FAILED,
self::RESERVE_REASON,
true
);
} finally {
ob_end_clean();
}
 
return self::RESULT_CANCEL;
} finally {
$this->lockGuard->releaseAccepted($orderNo);
}
}
 
/**
* 노티가 확정 표시를 남긴 뒤 저장 직전에 호출한다 — 취소 예약됐으면 false.
*
* @param string $orderNo
* @return bool
* @throws DatabaseException
*/
public function isPaymentAttempt(string $orderNo): bool
{
return ($this->getOrderData($orderNo)['orderStatus'] ?? '') === OrderStatusCode::PAYMENT_ATTEMPT;
}
 
/**
* @param string $approvedAt 카카오 승인 시각(Y-m-d H:i:s)
* @return bool
*/
public function isFreshApproval(string $approvedAt): bool
{
$approvedTime = strtotime($approvedAt);
if ($approvedTime === false) {
return false;
}
 
return abs(time() - $approvedTime) <= self::REQUEST_VALID_SECONDS;
}
 
/**
* 분산서버(read replica) 상점에서 복제 지연으로 방금 바뀐 상태를 놓치지 않도록 primary에서 조회한다.
*
* @param string $orderNo
* @return array orderStatus·settleKind (주문이 없으면 빈 배열)
* @throws DatabaseException
*/
private function getOrderData(string $orderNo): array
{
if (method_exists($this->db, 'stickyPrimary')) {
$this->db->stickyPrimary();
}
 
$arrBind = [];
$this->db->bind_param_push($arrBind, 's', $orderNo);
$orderData = $this->db->query_fetch('SELECT orderStatus, settleKind FROM ' . DB_ORDER . ' WHERE orderNo = ?', $arrBind, false);
 
return is_array($orderData) ? $orderData : [];
}
}
 
Component/Payment/Kakaopay/PgNoti.php+58-5
변경 전PgNoti.php
namespace Bundle\Component\Payment\Kakaopay;
 
use App;
 
use Component\Payment\PaymentNotiLockGuard;
use Globals;
use Request;
// 중계서버가 1차 노티를 10초 만에 포기하고 재시도하므로, 뒤에 온 노티는 앞선 처리가 끝날 때까지 대기한다.
// (private const 는 7.1+ 전용이라 가시성 없이 선언)
const NOTI_LOCK_WAIT_SECONDS = 20;
 
 
 
 
 
private $pgConfig = '';
private $pgSetting = '';
// 주문번호
$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]);
exit;
}
// 대기가 끝나는 사이 앞선 처리가 끝났을 수 있어 한 번 더 시도한다
if ($lockGuard->lock($this->orderNo) === false) {
 
Logger::channel('pgKakaopayResult')->error('Noti Lock Failed :' . $this->pgMobileCode, [$this->orderNo]);
echo '{"result":"FAIL"}';
exit;
}
}
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]);
 
 
 
 
 
 
 
 
 
}
 
// 결제 결과 저장
}
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
/**
* 승인 확정 표시 후 저장되지 않고 끝나면(실패 응답·예외·치명 오류) 재시도 노티가 PAYED로 응답해
* 카카오 결제가 유지됐을 수 있어 대사용으로 남긴다. 종료 시점 한 곳에서 판정한다.
 
변경 후PgNoti.php
namespace Bundle\Component\Payment\Kakaopay;
 
use App;
use Component\Payment\Kakaopay\NotiCancelReservation;
use Component\Payment\PaymentNotiLockGuard;
use Globals;
use Request;
// 중계서버가 1차 노티를 10초 만에 포기하고 재시도하므로, 뒤에 온 노티는 앞선 처리가 끝날 때까지 대기한다.
// (private const 는 7.1+ 전용이라 가시성 없이 선언)
const NOTI_LOCK_WAIT_SECONDS = 20;
// 중계서버 취소 직전 확인이 결제실패 기록을 마칠 때까지 기다린다(그 전에 상태를 읽으면 결제시도로 보여 저장이 진행됨)
const ACCEPT_WAIT_SECONDS = 10;
// 중계서버가 노티 응답을 확정하지 못했을 때 결제를 취소하기 직전에 보내는 확인 요청(암호화 data 안의 mode)
const RELAY_MODE_CANCEL = 'cancel';
 
private $pgConfig = '';
private $pgSetting = '';
// 주문번호
$this->orderNo = $requestValue['partner_order_id'];
 
$lockGuard = new PaymentNotiLockGuard(App::getInstance('DB'), 'kakaopay');
$cancelReservation = new NotiCancelReservation(App::getInstance('DB'), $lockGuard);
 
// 중계서버가 결제를 취소하기 직전 확인 — 노티의 승인 확정과 상호배제해 취소 예약 또는 결제 유지로 응답한다
if (is_scalar($requestValue['mode'] ?? null) && (string)$requestValue['mode'] === self::RELAY_MODE_CANCEL) {
$approvedAt = $requestValue['approved_at'] ?? '';
$this->replyCancelReservation($cancelReservation, is_scalar($approvedAt) ? (string)$approvedAt : '');
}
 
// 노티 중복 수신 방지 — 중계서버는 나중 응답만 보고 카카오 결제 취소 여부를 판단하므로,
// 락 대기 후 실제 확정된 결과로 응답한다(즉시 낙관적 OK 금지).
if (!$lockGuard->lock($this->orderNo, self::NOTI_LOCK_WAIT_SECONDS)) {
 
// 앞선 노티가 검증을 통과해 저장 중이면 FAIL 응답 시 정상 결제가 취소되므로 PAYED로 응답한다
if ($lockGuard->isAccepted($this->orderNo)) {
Logger::channel('pgKakaopayResult')->warning('Noti Lock Wait Timeout, Accepted :' . $this->pgMobileCode, [$this->orderNo]);
exit;
}
// 대기가 끝나는 사이 앞선 처리가 끝났을 수 있어 한 번 더 시도한다
// 그래도 처리 중이면 결과를 모르므로 WAIT로 응답해 중계서버가 취소 전 확인을 거치게 한다(구버전 중계서버는 실패로 봄)
if (!$lockGuard->lock($this->orderNo)) {
Logger::channel('pgKakaopayResult')->error('Noti Lock Failed :' . $this->pgMobileCode, [$this->orderNo]);
echo '{"result":"WAIT"}';
exit;
}
}
Logger::channel('pgKakaopayResult')->info('PG Settle Data :' . $this->pgMobileCode, [$this->orderNo . ' : Settle Success']);
 
// 저장이 늦어져 재시도 노티가 들어와도 결제가 취소되지 않도록 승인 확정을 표시한다
$this->accepted = $lockGuard->markAccepted($this->orderNo, self::ACCEPT_WAIT_SECONDS);
// 확정 표시를 못 잡으면 취소 직전 확인과 상호배제가 깨지므로 저장하지 않고 결과 미확정으로 응답한다
if (!$this->accepted) {
Logger::channel('pgKakaopayResult')->warning('Noti Accepted Mark Failed :' . $this->pgMobileCode, [$this->orderNo]);
$this->pgClass->failDoRollbackQueryPG();
echo '{"result":"WAIT"}';
exit;
}
// 중계서버가 취소 직전 확인에서 결제실패로 예약했으면 카카오 결제가 취소되므로 결제완료로 저장하지 않는다
if (!$cancelReservation->isPaymentAttempt($this->orderNo)) {
$this->accepted = false;
Logger::channel('pgKakaopayResult')->warning('Noti Cancel Reserved, Skip Save :' . $this->pgMobileCode, [$this->orderNo]);
throw new Exception('PG_STOP_ORDER_DATA');
}
 
// 결제 결과 저장
}
}
 
/**
* 중계서버 취소 직전 확인에 응답하고 종료한다.
*
* @param NotiCancelReservation $cancelReservation
* @param string $approvedAt
* @return void
*/
private function replyCancelReservation(NotiCancelReservation $cancelReservation, string $approvedAt)
{
// 예약하지 못하면 결과를 모르는 것이므로 WAIT로 응답해 중계서버가 취소하지 않고 결제 확인 필요로 처리하게 한다
if (!$cancelReservation->isFreshApproval($approvedAt)) {
Logger::channel('pgKakaopayResult')->warning('Relay Cancel Check Expired :' . $this->pgMobileCode, [$this->orderNo, $approvedAt]);
echo '{"result":"WAIT"}';
exit;
}
 
try {
$result = $cancelReservation->reserve($this->orderNo, App::load('\\Component\\Order\\OrderAdmin'));
} catch (\Throwable $e) {
Logger::channel('pgKakaopayResult')->error('Relay Cancel Reserve Failed :' . $this->pgMobileCode, ['orderNo' => $this->orderNo, 'exception' => $e, 'errorMessage' => $e->getMessage()]);
echo '{"result":"WAIT"}';
exit;
}
 
Logger::channel('pgKakaopayResult')->info('Relay Cancel Check :' . $this->pgMobileCode, [$this->orderNo, $result]);
echo '{"result":"' . $result . '"}';
exit;
}
 
/**
* 승인 확정 표시 후 저장되지 않고 끝나면(실패 응답·예외·치명 오류) 재시도 노티가 PAYED로 응답해
* 카카오 결제가 유지됐을 수 있어 대사용으로 남긴다. 종료 시점 한 곳에서 판정한다.
 
Component/Payment/Kakaopay/PgReturn.php+45-3
변경 전PgReturn.php
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;
 
 
 
/**
* 생성자
// 인증 처리 체크
if (empty($setPayData['retcode']) === true || $this->orderNo == '') {
$script .= "location.href='../../order/order_end.php?orderNo='";
 
 
 
 
 
 
 
 
 
 
 
} elseif ($setPayData['retcode'] != '0') {
$script .= "location.href='../../order/order_end.php?orderNo=" . $this->orderNo . "&mode=";
// 카카오페이 결제 실패
 
switch ($setPayData['retcode']) {
case self::KAKAOPAY_PG_USER_STOP: // 결제 중단
$script .= "pgUserStop';";
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');
$db->stickyPrimary();
}
 
$deadline = microtime(true) + $this->notiWaitSeconds;
while ($this->getOrderStatus($db) === OrderStatusCode::PAYMENT_ATTEMPT) {
if (microtime(true) >= $deadline) {
return true;
 
변경 후PgReturn.php
use Request;
use Logger;
use Origin\Enum\Order\OrderStatusCode;
use Origin\Enum\Order\OrderStatusGroup;
use Origin\Traits\Payment\PaymentProcessingSessionTrait;
 
/**
*/
const KAKAOPAY_PG_DB_ERROR = '99';
 
/**
* 중계서버가 결제 결과를 확정하지 못해 카카오 결제를 유지함 (쇼핑몰 응답 지연·결제 취소 실패·결제창 재진입)
*/
const KAKAOPAY_PG_CONFIRM_REQUIRED = '98';
 
// 결제 성공 복귀 시 노티의 결제완료 저장을 기다리는 최대 시간(초)과 주문상태 확인 간격(마이크로초)
private $notiWaitSeconds = 25;
private $notiPollMicroseconds = 500000;
// 결제 확인 필요(98) 복귀는 고객이 이미 오래 기다린 뒤라 노티 저장을 짧게만 기다린다
private $confirmRequiredWaitSeconds = 10;
 
/**
* 생성자
// 인증 처리 체크
if (empty($setPayData['retcode']) === true || $this->orderNo == '') {
$script .= "location.href='../../order/order_end.php?orderNo='";
} elseif ($setPayData['retcode'] === self::KAKAOPAY_PG_CONFIRM_REQUIRED) {
// 카카오 결제는 유지됐으므로 결제실패로 바꾸지 않는다. 노티가 저장을 끝내는 중일 수 있어 잠시 기다린 뒤
// 결제완료면 주문완료로, 아니면 사유를 남기고 주문내역 안내로 보낸다(대사 대상)
if (!$this->isNotiProcessing($this->confirmRequiredWaitSeconds) && $this->isPaid()) {
$script .= "location.href='../../order/order_end.php?orderNo=" . $this->orderNo . "';";
} else {
\Logger::channel('pgKakaopayResult')->error(__METHOD__ . ' Relay Confirm Required : ' . $this->orderNo, $setPayData);
$getPg['pgFailReason'] = is_scalar($setPayData['retmsg'] ?? null) ? (string)$setPayData['retmsg'] : '';
$this->pgClass->setPgErrorData($this->orderNo, $getPg, null);
$script = $this->getOrderListGuideScript($script);
}
} elseif ($setPayData['retcode'] != '0') {
 
// 카카오페이 결제 실패
$script .= "location.href='../../order/order_end.php?orderNo=" . $this->orderNo . "&mode=";
switch ($setPayData['retcode']) {
case self::KAKAOPAY_PG_USER_STOP: // 결제 중단
$script .= "pgUserStop';";
return 'alert(' . json_encode($message) . ');' . $script . "location.href='../../mypage/order_list.php';";
}
 
/**
* 노티가 결제완료를 이미 저장했는지 primary에서 확인한다. 확인할 수 없으면 false(주문내역 안내로 보냄).
*
* @return bool
*/
private function isPaid(): bool
{
try {
$db = \App::getInstance('DB');
if (method_exists($db, 'stickyPrimary')) {
$db->stickyPrimary();
}
$orderStatus = $this->getOrderStatus($db);
} catch (\Throwable $e) {
\Logger::channel('pgKakaopayResult')->warning(__METHOD__ . ' 주문상태 확인 실패 : ' . $this->orderNo, ['exception' => $e, 'errorMessage' => $e->getMessage()]);
 
return false;
}
 
return $orderStatus !== '' && substr($orderStatus, 0, 1) !== OrderStatusGroup::FAIL;
}
 
/**
* 결제 성공으로 돌아왔는데 주문이 아직 결제시도(f1)면 노티가 결제완료를 저장할 때까지 주문상태를 짧은 간격으로 다시 확인한다.
* 노티 락은 메일·SMS 등 후처리까지 끝나야 풀려서, 락 대신 주문상태로 판정해 저장 즉시 빠져나온다.
*
* @param int|null $waitSeconds 최대 대기 시간(초), null 이면 기본값(notiWaitSeconds)
* @return bool true = 최대 대기 후에도 결제시도 상태(또는 확인 실패)
*/
private function isNotiProcessing(int $waitSeconds = null): bool
{
try {
$db = \App::getInstance('DB');
$db->stickyPrimary();
}
 
$deadline = microtime(true) + ($waitSeconds ?? $this->notiWaitSeconds);
while ($this->getOrderStatus($db) === OrderStatusCode::PAYMENT_ATTEMPT) {
if (microtime(true) >= $deadline) {
return true;
 
Component/Payment/PaymentNotiLockGuard.php+25-3
변경 전PaymentNotiLockGuard.php
}
 
/**
* 주문·금액 검증을 통과해 결제를 확정 처리 중이라는 표시를 남긴다. 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;
}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
/**
* 다른 요청이 승인 확정 표시를 들고 처리 중인지 확인한다.
*
 
변경 후PaymentNotiLockGuard.php
}
 
/**
* 주문·금액 검증을 통과해 결제를 확정 처리 중이라는 표시를 남긴다.
* 노티는 lock() 획득 후 호출하고, 중계서버 취소 직전 확인은 노티의 확정과 상호배제하려고 대기 없이 호출한다.
*
* @param string $orderNo
* @param int $waitSeconds GET_LOCK 대기 시간(초). 0이면 즉시 실패.
* @return bool
* @throws DatabaseException
*/
public function markAccepted(string $orderNo, int $waitSeconds = 0): bool
{
// 중계서버 취소 직전 확인은 lock()을 거치지 않으므로 여기서도 primary에 고정한다(복제 DB에 표시가 잡히면 상호배제가 깨짐)
if (method_exists($this->db, 'stickyPrimary')) {
$this->db->stickyPrimary();
}
 
$arrBind = [];
$this->db->bind_param_push($arrBind, 's', $this->acceptedLockName($orderNo));
$this->db->bind_param_push($arrBind, 'i', $waitSeconds);
$result = $this->db->query_fetch('SELECT GET_LOCK(?, ?) AS acquired', $arrBind, false);
 
return !empty($result) && (int)$result['acquired'] === 1;
}
 
/**
* markAccepted()로 잡은 승인 확정 표시만 해제한다(주문 락을 잡지 않은 중계서버 취소 직전 확인용).
*
* @param string $orderNo
* @return void
* @throws DatabaseException
*/
public function releaseAccepted(string $orderNo)
{
$arrBind = [];
$this->db->bind_param_push($arrBind, 's', $this->acceptedLockName($orderNo));
$this->db->query_fetch('SELECT RELEASE_LOCK(?) AS released', $arrBind, false);
}
 
/**
* 다른 요청이 승인 확정 표시를 들고 처리 중인지 확인한다.
*