결제 완료 통지를 받아 처리하는 부분을 맡았는데 주문이 두 번 확정된 건이 나왔다.
Table of contents
Open Table of contents
같은 통지가 두 번 왔다
통지 로그를 보니 같은 내용이 30초 간격으로 두 번 와 있었다.
10:00:01 결제 완료 통지
10:00:31 결제 완료 통지 (같은 주문)
문서를 보니 응답을 못 받으면 일정 시간 뒤에 다시 보낸다고 적혀 있었다. 우리 처리가 여러 단계를 거치느라 30초를 넘었고 그동안 응답이 없으니 저쪽이 재전송한 것이다. 두 번째 통지도 똑같이 처리해서 주문 확정과 재고 차감과 mail() 발송이 전부 두 번 일어났다.
두 번 와도 한 번만 처리하게 했다
고칠 것이 둘이었다. 응답이 느린 것과 두 번 와도 한 번만 처리해야 하는 것인데 뒤쪽을 먼저 했다. 응답을 빠르게 해도 언젠가 두 번 올 수 있기 때문이다. 앞쪽만 고치면 확률이 줄 뿐이고 뒤쪽을 고치면 두 번 와도 안전하다.
받은 통지를 처리하기 전에 먼저 payment_callback 에 기록하고 tid 에 UNIQUE KEY 를 걸었다.
try {
기록($tid, $payload);
} catch (중복오류 $e) {
respond_ok(); // 이미 처리함
return;
}
처리();
respond_ok();
두 번째 통지는 기록 단계에서 ER_DUP_ENTRY 로 걸린다. 코드가 SELECT 로 확인하는 것이 아니라 DB 가 막는다. 확인과 삽입 사이에 틈이 없다는 것이 요점이다.
CodeIgniter 2.x 의 DB 드라이버는 예외를 안 던지므로 _error_number() 가 1062 인지를 봐야 했다. db_debug 가 켜져 있으면 그 자리에서 show_error 로 화면이 죽으니 이 구간만 껐다.
응답을 먼저 주고 처리를 미뤘다
응답이 느린 것도 고쳤다. 기록하고 바로 응답한 뒤에 실제 처리는 status 를 wait 로 두고 crontab 배치가 가져가게 했다. 응답이 즉시 나가니 재전송이 안 온다.
여기서 걱정이 하나 생겼다. 응답은 했는데 처리가 실패하면 저쪽은 정상으로 알고 끝내므로 다시 안 보낸다. 처리 실패를 우리가 챙겨야 한다.
통지마다 상태를 두고 실패한 것과 오래 대기 중인 것을 매일 확인하게 했다.
SELECT COUNT(*) FROM payment_callback WHERE status = 'fail';
SELECT COUNT(*) FROM payment_callback
WHERE status = 'wait' AND created_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE);
wait 가 더 안 보이는 문제였다. fail 은 실패라고 표시되니 눈에 띄지만 wait 는 정상 상태처럼 보인다. created_at 에 시간 조건을 붙여야 처리가 안 돌고 있다는 것이 드러난다.
통지의 출처 검증
또 하나 걸린 것이 있었다. 통지를 누가 보냈는지 안 보고 $_POST 값만 읽고 있었다. 그 주소로 아무나 요청을 보내면 결제가 완료된 것으로 처리된다.
문서를 보니 signature 가 함께 온다. 우리만 아는 값으로 같은 hash('sha256', ...) 를 만들어 비교했다.
$expected = hash('sha256', $tid . $amount . $secret);
if ($expected !== $_POST['signature']) { 거부 }
비교는 hash_equals 로 했다. 한 글자씩 끊어 보는 비교는 걸린 시간으로 어디까지 맞았는지가 새어 나간다.
서명이 맞아도 amount 가 주문 금액과 같은지 따로 봤다. 다르면 서명과 무관하게 뭔가 잘못된 것이다.
통지를 받고 저쪽에 tid 로 다시 조회해 실제 상태를 확인하는 방법도 문서에 있었다. 서명은 빠르고 조회는 느린 대신 확실하다. 금액이 큰 건은 조회까지 하게 했다.
정리
- 응답이 느리면 저쪽이 다시 보낸다
- 두 번 와도 한 번만 처리되게 하는 것이 근본이다
payment_callback에 먼저 기록하고tid의UNIQUE KEY로 막는다SELECT로 확인하지 않고ER_DUP_ENTRY를 받는 것이 요점이다- 응답을 먼저 주고 처리는
status = 'wait'로 미룬다 fail과 오래된wait를 둘 다 본다.wait가 더 안 보인다signature를hash_equals로 비교하고amount도 주문과 대조한다- 금액이 큰 건은
tid로 저쪽에 다시 조회해 확인한다