정산 집계를 화면에서 돌리는 기능이 있었다. 오래 걸려서 관리자가 창을 닫는 일이 잦았는데 그때마다 데이터가 반만 들어가 있었다.
Table of contents
Open Table of contents
증상 — 창을 닫으면 반만 들어갔다
웹 요청으로 도는 처리는 클라이언트가 끊으면 같이 끝난다.
진행 상황을 중간중간 내보내던 화면이라 PHP 가 그 출력 시점에 끊김을 알아채고 죽는다. 집계가 어디까지 갔는지와 무관하게 그 자리에서 멈추므로 남은 자료는 반쪽이 된다.
관리자 쪽에서는 창을 닫은 것이 처리를 중단시킨다는 감각이 없다. 몇 분을 기다리다 닫고 나중에 다시 눌러 보는 것이 자연스러운 행동이었다.
원인 — 무시하게 켜는 것으로는 부족했다
중단을 무시하게 켜는 방법이 있다.
ignore_user_abort(true);
set_time_limit(0);
ignore_user_abort 를 켜면 창을 닫아도 안 죽는다. 그런데 이걸로 끝낼 문제가 아니었다.
웹 서버가 요청당 프로세스를 잡고 있다
관리자가 다시 누르면 같은 처리가 또 시작된다
어디까지 됐는지 볼 방법이 없다
셋 다 창을 닫는 것과 별개로 남는다.
php-fpm 프로세스를 몇 분씩 잡으면 다른 요청이 쓸 자리가 줄고 여러 개가 겹치면 자원이 모자란다. 응답이 없다고 다시 누르면 같은 집계가 둘 돌고 어느 쪽이 먼저 끝날지도 모른다.
조치 — 요청과 처리의 분리
화면은 요청만 남기고 끝내게 했다.
public function requestSettle(Request $req)
{
$jobNo = $this->jobs->create([
'type' => 'settle',
'params' => json_encode(['from' => $req->from, 'to' => $req->to]),
'state' => 'queued',
]);
return redirect("/admin/job/{$jobNo}");
}
job 에 행 하나를 만들고 $jobNo 로 보내는 것이 전부라 몇 밀리초에 끝난다.
실제로 돌리는 것은 cron 에 건 job:run 이 한다.
* * * * * /usr/bin/php /app/artisan job:run --type=settle
창을 닫아도 상관없다. 요청은 이미 job 에 남아 있다.
끊김과 처리가 이어져 있던 것을 떼어 놓은 것뿐인데 앞의 세 문제가 함께 사라졌다. php-fpm 을 오래 잡지도 않고 job 행이 진행을 볼 자리가 된다.
설정 — 작업 상태와 중복 방지
작업 상태를 행에 남겼다.
CREATE TABLE job (
no bigint NOT NULL AUTO_INCREMENT,
type varchar(32) NOT NULL,
state varchar(16) NOT NULL, -- queued/running/done/failed
progress int NOT NULL DEFAULT 0,
total int NOT NULL DEFAULT 0,
started_at datetime DEFAULT NULL,
ended_at datetime DEFAULT NULL,
error text,
PRIMARY KEY (no)
);
화면은 이 행을 읽어서 보여 준다.
집계 중 (1,240 / 4,880)
progress 와 total 이 그대로 남으므로 다시 들어와 보면 된다.
UPDATE job SET state='failed', error='시작 후 응답 없음'
WHERE state='running' AND started_at < NOW() - INTERVAL 30 MINUTE;
running 인 채로 오래 있으면 그 처리가 죽은 것으로 판정한다.
중복 실행도 job 표로 막았다.
$dup = $this->jobs->firstWhere([
'type' => 'settle',
'state' => ['queued', 'running'],
'params'=> json_encode(['from' => $req->from, 'to' => $req->to]),
]);
if ($dup) { return redirect("/admin/job/{$dup->no}"); }
같은 조건이 이미 있으면 새로 만들지 않고 그 행으로 보낸다. 몇 번을 눌러도 실제로 도는 것은 하나다.
검증 — 자를 수 있는 단위와 재실행
앞서 반만 들어간 자료도 정리해야 했다.
집계를 덮어쓰는 형태로 바꿨다.
DELETE FROM settle_daily WHERE settle_date BETWEEN ? AND ?;
INSERT INTO settle_daily (...) SELECT ...;
DELETE 와 INSERT 를 한 트랜잭션에 넣으면 중간에 끊겨도 지운 것이 함께 취소된다.
다만 범위가 넓으면 그동안 오래 잠근다. 자를 수 있는 단위를 찾아 나눴다.
foreach ($this->dates($from, $to) as $d) {
$this->db->transaction(fn() => $this->settleOneDay($d));
$this->jobs->progress($jobNo, ++$done, $total);
}
settleOneDay 가 단위라 끊겨도 그 전날까지는 온전하게 남는다.
진행 수를 남겨 두니 다시 돌릴 때 어디부터인지도 정할 수 있었다.
$from = $this->settleDaily->maxDate($from, $to) ?? $from;
maxDate 가 낸 날짜 다음부터 하므로 처음부터 다시 하지 않는다.
이것은 날짜 하나가 통째로 들어갔거나 통째로 안 들어갔거나 둘 중 하나일 때만 맞다. 트랜잭션 단위를 날짜로 맞춘 이유가 여기 있었고 진행 값이 화면 표시가 아니라 재실행의 근거가 된 셈이다.
정리
- 웹 요청으로 도는 처리는 클라이언트가 끊으면 같이 끝난다
- 출력 시점에 끊김을 알아채므로 진행을 내보내는 화면일수록 잘 죽는다
- 중단을 무시하게 켜는 것으로는 끝나지 않는다
- 요청 프로세스를 오래 잡고 다시 누르면 또 돌고 진행을 볼 수 없다
- 요청을 남기는 것과 처리하는 것을 떼어 놓는다
- 작업 상태를 행으로 남기면 끊겨도 값이 남는다
running이 오래가면 죽은 것으로 판정한다- 같은 조건이 이미 있으면 새로 만들지 않고 그 행으로 보낸다
- 집계는 지우고 다시 넣되 자를 수 있는 단위로 나눈다
- 단위가 정해져야 끊긴 지점이 온전한 경계가 된다
- 진행 값이 다시 돌릴 때의 시작 지점을 정한다