결제 연동을 붙이고 확인했다. 결제가 되고 주문이 만들어지는 것까지 봤다. 운영에 나간 뒤에 결제가 실패하면 화면이 깨진다는 문의를 받았다.
Table of contents
Open Table of contents
실패를 한 번도 안 만들어 봤다
시험할 때 정상 결제만 했다. 실패하는 경우를 만들 방법을 몰랐기 때문이다. code 가 0000 이 아닐 때를 처리하는 코드 자체는 그 자리에 있었다.
if ($res['code'] !== '0000') {
$this->load->view('order/fail', ['msg' => $res['msg']]);
return;
}
order/fail 화면이 없었다. 파일 이름이 order/failed.php 였다. load->view 는 없는 파일을 만나면 그때 죽는다. 한 번도 안 돌려 봤으니 알 수가 없었다.
실패를 만드는 방법
상대 문서를 다시 읽으니 시험용 카드번호가 적혀 있었다.
카드번호 4000-0000-0000-0002 → 한도 초과
카드번호 4000-0000-0000-0069 → 만료된 카드
금액 1004원 → 강제 실패
문서에 적혀 있었는데 정상 흐름만 확인하고 넘어가느라 그 대목을 안 읽었다. 상대가 주는 값 말고 우리 쪽에서도 실패를 만들 수 있게 force_fail 인자를 하나 넣었다.
if (ENVIRONMENT !== 'production' && $this->input->get('force_fail')) {
$res = ['code' => '9999', 'msg' => '강제 실패 (시험)'];
} else {
$res = $this->pg->approve($data);
}
주소에 force_fail 을 붙이면 승인 응답 대신 실패 응답이 돌아온다. 운영에서는 ENVIRONMENT 조건에 걸려 이 분기가 아예 안 돈다. 이걸로 실패 화면을 처음 띄워 봤고 파일 이름이 틀린 것도 그때 나왔다.
무엇이 실패할 수 있나
실패를 만들 수 있게 되고 나서 무엇이 실패할 수 있는지를 종류별로 적어 봤다.
결제 실패 한도 초과 / 만료된 카드 / 잔액 부족 / 카드사 거절
통신 실패 응답 없음(시간 초과) / 응답이 왔는데 형식이 다름
처리 실패 결제는 됐는데 주문 저장 실패 / 중복 요청
사용자 이탈 결제 창에서 취소 / 창을 닫음
통신 실패 쪽은 CURLOPT_TIMEOUT 에 걸리는 경우와 응답이 왔는데 json_decode 가 실패하는 경우로 갈렸다. 뒤의 두 부류가 특히 안 다뤄져 있었다. 그중에서도 결제는 됐는데 주문 저장이 실패하는 경우가 제일 나빴다. trans_status 가 거짓인데 tid 는 이미 승인된 상태다. 돈은 나갔는데 주문이 없다.
가장 나쁜 경우부터 다뤘다
주문 저장 실패를 먼저 고쳤다. trans_start 와 trans_complete 로 묶는다. trans_status 가 거짓이면 결제 취소를 시도한다. 취소도 실패하면 사람에게 알린다.
$this->db->trans_start();
$orderNo = $this->order_model->create($data, $res);
$this->db->trans_complete();
if ($this->db->trans_status() === false) {
log_message('error', "주문 저장 실패. 결제 취소 시도 tid={$res['tid']}");
$cancel = $this->pg->cancel($res['tid']);
if (!$cancel['ok']) {
log_message('error', "결제 취소도 실패 tid={$res['tid']} — 수동 확인 필요");
$this->notifyOps("결제 취소 실패", $res);
}
return $this->fail('주문 처리 중 오류가 발생했습니다');
}
취소까지 실패하면 자동으로 풀 방법이 없어서 사람이 봐야 한다. log_message 에 tid 를 남기고 notifyOps 로 알린다. 그 tid 로 결제사 관리자에서 어느 건인지 바로 찾는다. 알리는 데까지가 코드의 몫이고 그다음은 사람이 이어받는다.
그다음에는 확인 목록에 실패 경우를 전부 적어 넣고 하나씩 지워 나갔다. 정상 하나에 실패 여섯이다. 실제로 코드의 분량도 대략 그 비율이었다.
[ ] 정상 결제 → 주문 생성 확인
[ ] 한도 초과 → 실패 화면 표시
[ ] 카드 만료 → 실패 화면 표시
[ ] 결제 창 취소 → 원래 화면 복귀
[ ] 응답 시간 초과 → 실패 처리, 로그 기록
[ ] 주문 저장 실패(강제) → 결제 취소 확인
[ ] 같은 요청 두 번 → 한 번만 처리
각 실패 경로에 log_message 를 넣고 시험할 때 그것이 찍히는지도 함께 봤다. 로그가 안 찍히면 그 경로를 안 탄 것이다. 화면만 보면 경로를 안 탄 것과 타고도 결과가 같은 것을 구분할 수 없다.
정리
- 코드가 있는 것과 도는 것은 다르다
- 오류 경로는 일부러 만들지 않으면 안 돌아 본다
- 상대 문서에 시험용 카드번호와 강제 실패 금액이 있는지 확인한다
- 통신 실패는
CURLOPT_TIMEOUT과json_decode실패로 갈린다 force_fail장치를 두되ENVIRONMENT조건을 건다- 실패할 수 있는 경우를 목록으로 만든다. 정상 하나에 실패가 여럿이다
- 가장 나쁜 경우부터 다룬다. 돈은 나갔는데 주문이 없는 경우 같은 것
trans_status가 거짓이면 결제 취소를 시도하고 그것도 실패하면 알린다log_message에tid를 남기고notifyOps로 알린다- 각 경로에 로그를 넣고 찍히는지 확인한다