Skip to content
isdnetworks
Go back

쿠폰이 두 번 쓰였다

한 번만 쓸 수 있는 쿠폰이 두 번 쓰인 건이 나왔다.

Table of contents

Open Table of contents

확인과 표시 사이에 틈이 있었다

코드에 확인은 있었다.

if ($coupon->used) { 오류; }
// 결제 진행
$coupon->used = 1;
$coupon->save();

SELECT 로 확인하고 UPDATE 로 표시하는 사이에 두 요청이 들어오면 둘 다 통과한다. 재현해 보니 결제 창을 두 개 열고 각각 진행하면 됐고 빠르게 할 필요도 없었다.

결제는 외부를 거치기 때문이다. 쿠폰을 확인한 뒤 결제 화면이 뜬다. 사용자가 카드 정보를 입력하고 승인이 돌아와야 used 가 찍힌다. 그 사이가 수십 초라 틈이 아주 넓었다.

확인할 때 바로 잡았다

확인하고 나중에 표시하는 대신 확인하는 그 자리에서 잡기로 했다.

UPDATE coupon SET status = 'holding', held_at = NOW()
 WHERE id = ? AND status = 'available';

ROW_COUNT() 가 1 이면 내가 잡은 것이다. 0 이면 남이 먼저 잡았다. 조건과 갱신이 한 문장 안에 있으니 그 사이에 끼어들 자리가 없다. 두 문장을 한 문장으로 접은 것이 요점이었다.

잡아 둔 것을 푸는 문제

잡아 두면 풀어야 한다. 결제가 실패하면 available 로 되돌리는 코드를 넣었는데 사용자가 창을 그냥 닫으면 그 코드가 안 돈다. 잡힌 채로 영영 남는다.

held_at 을 함께 저장하고 crontab 에 건 배치가 오래된 것을 풀게 했다.

UPDATE coupon SET status = 'available'
 WHERE status = 'holding' AND held_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE);

시간을 얼마로 둘지가 판단이었다. 짧으면 결제 중에 풀려서 두 번 쓰인다. 길면 실수로 창을 닫은 사람이 오래 못 쓴다. 결제에 걸리는 시간보다 넉넉하게 잡아 INTERVAL 30 MINUTE 로 뒀다.

이 배치가 도는 쿼리에는 (status, held_at) 복합 인덱스를 걸었다. 쿠폰이 쌓이면 status 만으로는 holding 아닌 것까지 훑는다.

상태가 셋이어야 했다

처음에는 availableused 두 상태로 하려 했다. 그러면 잡은 것과 쓴 것이 구분이 안 된다. 자동으로 풀 때 이 구분이 필요했다. holding 은 풀어도 되지만 used 는 풀면 안 된다.

상태가 바뀔 때마다 coupon_log 에 행을 남겼다. 쿠폰이 사라졌다는 문의가 오면 언제 잡혔고 언제 풀렸고 누가 썼는지가 보인다. status 만 보면 지금 쓸 수 있다는 것만 알 뿐 왜 그런지는 모른다.

같은 구조를 한정 수량 상품과 좌석 예약에도 썼다. 먼저 잡고 시간이 지나면 푸는 방식이 같다. 공통으로 빼려 했는데 표마다 컬럼 이름이 달라 인자로 받아야 했다. 설정이 늘어나는 것을 보고 statusheld_at 으로 이름을 맞추는 쪽으로 갔다. 이름이 같으면 인자가 필요 없다.

정리


Share this post on:

Previous Post
상대가 실제로 보내는 것을 재서 고정했다
Next Post
채널마다 형식이 달랐다