공지를 회원에게 SMTP 로 보내는 작업이 있는데 한 번에 다 못 보내서 500명씩 나눠 보낸다. 중간에 실패해서 다시 돌렸더니 앞의 1,500명이 두 번 받았다.
Table of contents
Open Table of contents
원인 — 단위가 어긋나 있었다
발송 코드는 이랬다.
$members = $this->member_model->get_targets($noticeNo);
$chunks = array_chunk($members, 500);
foreach ($chunks as $c) {
$this->mailer->send_bulk($c, $subject, $body);
}
$this->db->update('notice', ['sent_yn' => 'Y'], ['notice_no' => $noticeNo]);
array_chunk 로 나눠 보내고 전부 끝난 뒤에 sent_yn 을 한 번 표시한다. 중간에 실패하면 표시가 안 되고 다시 돌리면 처음부터 다시 보낸다. 이미 보낸 조각인지 아닌지를 아는 자리가 없었다.
전체가 하나의 단위인데 실제 전송은 여러 조각으로 나간다. 표시는 전체 단위이고 전송은 조각 단위였다. 이 어긋남이 중복의 원인이었다.
조각마다 결과를 남겼다
발송 대상을 행으로 만들었다.
CREATE TABLE notice_send (
send_no INT NOT NULL AUTO_INCREMENT,
notice_no INT NOT NULL,
member_no INT NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'ready',
send_date DATETIME NULL,
PRIMARY KEY (send_no),
UNIQUE KEY uk_notice_member (notice_no, member_no),
KEY idx_status (notice_no, status)
);
발송 전에 대상을 행으로 만들고 보낸 것만 상태를 바꾼다.
foreach ($chunks as $c) {
$ok = $this->mailer->send_bulk($c, $subject, $body);
if ($ok) {
$nos = array_column($c, 'member_no');
$this->db->where('notice_no', $noticeNo)
->where_in('member_no', $nos)
->update('notice_send', ['status' => 'sent', 'send_date' => date('Y-m-d H:i:s')]);
}
}
다시 돌릴 때는 status 가 ready 인 것만 대상이라 앞의 조각은 다시 나가지 않는다.
uk_notice_member 를 걸어 둔 것이 두 번째 방어선이었다. 대상을 만드는 코드를 두 번 돌려도 같은 회원이 두 행이 되지 않는다.
$this->db->query(
"INSERT IGNORE INTO notice_send (notice_no, member_no, status)
SELECT ?, member_no, 'ready' FROM member WHERE use_yn='Y'",
[$noticeNo]
);
INSERT IGNORE 라 이미 있으면 무시된다. 몇 번을 돌려도 결과가 같다.
조각 안에서 실패하면
send_bulk 가 500명을 한 번에 보내는데 그중 일부만 실패할 수 있다. 전체 성공 여부만 받으면 어느 사람이 실패했는지 모른다.
$results = $this->mailer->send_bulk($c, $subject, $body);
// ['12': true, '13': false, ...]
foreach ($results as $memberNo => $ok) {
$this->db->where('notice_no', $noticeNo)
->where('member_no', $memberNo)
->update('notice_send', [
'status' => $ok ? 'sent' : 'failed',
'send_date' => date('Y-m-d H:i:s'),
]);
}
member_no 별로 결과를 받아 sent 와 failed 를 따로 적으니 실패한 사람만 다시 대상이 된다.
보냈는지 모르는 구간
한 가지 남은 것이 있다. send_bulk 가 도는 중에 죽으면 그 조각의 결과를 알 수 없다. 상태는 ready 인데 실제로는 나갔을 수 있다.
이 구간은 어느 쪽으로 정해도 완벽하지 않았다. 두 번 보내는 것과 안 보내는 것 중 무엇이 나은지를 물어봤고 공지는 두 번 받는 편이 낫다고 해서 다시 보내는 쪽으로 정했다. 결제 같은 것이었으면 반대로 정했을 것이다.
결과 — 진행 상황이 보이게 됐다
행으로 남기니 지금 어디까지 갔는지 조회할 수 있게 됐다.
SELECT status, COUNT(*) FROM notice_send WHERE notice_no = 12 GROUP BY status;
ready 1240
sent 3760
failed 12
이전에는 작업이 끝날 때까지 아무것도 몰랐다. 발송이 오래 걸릴 때 물어보는 사람에게 숫자로 답할 수 있게 됐고 failed 만 따로 뽑는 것도 같은 기록으로 됐다.
정말 두 번 안 가는지 확인하려고 일부러 두 번 돌려 봤다.
1회차: sent 3760
2회차: sent 3760 (변화 없음)
두 번째 실행에서 대상이 0건이면 정상이다. 대상이 0건인 것과 조회가 안 된 것을 구분하려고 개수도 찍었다.
log_message('info', "발송 대상 {$total}건 (notice {$noticeNo})");
정리
- 표시 단위와 전송 단위가 어긋나면 중복이 난다
- 전체 단위 표시는 중간 실패를 담지 못한다
- 조각마다
UPDATE로 결과를 남기면 남은 것만 다시 보낸다 uk_notice_member같은 유일 키로 대상 중복을 막는다INSERT IGNORE면 대상을 여러 번 만들어도 결과가 같다- 묶음 전송의 결과를 개별로 받아야 일부 실패를 다룰 수 있다
- 보내는 도중에 죽은 구간은 알 수 없다
- 두 번 보내는 것과 안 보내는 것 중 무엇이 나은지 물어본다
- 종류에 따라 답이 반대가 된다
- 그 기록을
GROUP BY하면 진행 상황과 실패 목록이 같이 나온다 - 다시 돌려서 대상이 0건인지 확인하고 개수를 같이 찍어 미조회와 구분한다